diff options
59 files changed, 7832 insertions, 1373 deletions
@@ -44,6 +44,8 @@ async-std = ["iced_futures/async-std"] smol = ["iced_futures/smol"] # Enables advanced color conversion via `palette` palette = ["iced_core/palette"] +# Enables pure, virtual widgets in the `pure` module +pure = ["iced_pure"] [badges] maintenance = { status = "actively-developed" } @@ -57,6 +59,7 @@ members = [ "glutin", "lazy", "native", + "pure", "style", "wgpu", "winit", @@ -87,6 +90,11 @@ members = [ "examples/tooltip", "examples/tour", "examples/url_handler", + "examples/pure/component", + "examples/pure/counter", + "examples/pure/pick_list", + "examples/pure/todos", + "examples/pure/tour", "examples/websocket", ] @@ -96,6 +104,7 @@ iced_futures = { version = "0.3", path = "futures" } iced_winit = { version = "0.3", path = "winit" } iced_glutin = { version = "0.2", path = "glutin", optional = true } iced_glow = { version = "0.2", path = "glow", optional = true } +iced_pure = { version = "0.1", path = "pure", optional = true } thiserror = "1.0" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/examples/pure/component/Cargo.toml b/examples/pure/component/Cargo.toml new file mode 100644 index 00000000..a15f134f --- /dev/null +++ b/examples/pure/component/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "pure_component" +version = "0.1.0" +authors = ["Héctor Ramón Jiménez <hector0193@gmail.com>"] +edition = "2021" +publish = false + +[dependencies] +iced = { path = "../../..", features = ["debug", "pure"] } +iced_native = { path = "../../../native" } +iced_lazy = { path = "../../../lazy", features = ["pure"] } diff --git a/examples/pure/component/src/main.rs b/examples/pure/component/src/main.rs new file mode 100644 index 00000000..0de7bdd9 --- /dev/null +++ b/examples/pure/component/src/main.rs @@ -0,0 +1,168 @@ +use iced::pure::widget::container; +use iced::pure::{Element, Sandbox}; +use iced::{Length, Settings}; + +use numeric_input::numeric_input; + +pub fn main() -> iced::Result { + Component::run(Settings::default()) +} + +#[derive(Default)] +struct Component { + value: Option<u32>, +} + +#[derive(Debug, Clone, Copy)] +enum Message { + NumericInputChanged(Option<u32>), +} + +impl Sandbox for Component { + type Message = Message; + + fn new() -> Self { + Self::default() + } + + fn title(&self) -> String { + String::from("Component - Iced") + } + + fn update(&mut self, message: Message) { + match message { + Message::NumericInputChanged(value) => { + self.value = value; + } + } + } + + fn view(&self) -> Element<Message> { + container(numeric_input(self.value, Message::NumericInputChanged)) + .padding(20) + .height(Length::Fill) + .center_y() + .into() + } +} + +mod numeric_input { + use iced::pure::widget::Element; + use iced::pure::widget::{row, text, text_input}; + use iced_lazy::pure::component::{self, Component}; + use iced_native::alignment::{self, Alignment}; + use iced_native::text; + use iced_native::Length; + + pub struct NumericInput<Message> { + value: Option<u32>, + on_change: Box<dyn Fn(Option<u32>) -> Message>, + } + + pub fn numeric_input<Message>( + value: Option<u32>, + on_change: impl Fn(Option<u32>) -> Message + 'static, + ) -> NumericInput<Message> { + NumericInput::new(value, on_change) + } + + #[derive(Debug, Clone)] + pub enum Event { + InputChanged(String), + IncrementPressed, + DecrementPressed, + } + + impl<Message> NumericInput<Message> { + pub fn new( + value: Option<u32>, + on_change: impl Fn(Option<u32>) -> Message + 'static, + ) -> Self { + Self { + value, + on_change: Box::new(on_change), + } + } + } + + impl<Message, Renderer> Component<Message, Renderer> for NumericInput<Message> + where + Renderer: text::Renderer + 'static, + { + type State = (); + type Event = Event; + + fn update( + &mut self, + _state: &mut Self::State, + event: Event, + ) -> Option<Message> { + match event { + Event::IncrementPressed => Some((self.on_change)(Some( + self.value.unwrap_or_default().saturating_add(1), + ))), + Event::DecrementPressed => Some((self.on_change)(Some( + self.value.unwrap_or_default().saturating_sub(1), + ))), + Event::InputChanged(value) => { + if value.is_empty() { + Some((self.on_change)(None)) + } else { + value + .parse() + .ok() + .map(Some) + .map(self.on_change.as_ref()) + } + } + } + } + + fn view(&self, _state: &Self::State) -> Element<Event, Renderer> { + let button = |label, on_press| { + use iced::pure::widget::button; + + button( + text(label) + .width(Length::Fill) + .height(Length::Fill) + .horizontal_alignment(alignment::Horizontal::Center) + .vertical_alignment(alignment::Vertical::Center), + ) + .width(Length::Units(50)) + .on_press(on_press) + }; + + row() + .push(button("-", Event::DecrementPressed)) + .push( + text_input( + "Type a number", + self.value + .as_ref() + .map(u32::to_string) + .as_ref() + .map(String::as_str) + .unwrap_or(""), + Event::InputChanged, + ) + .padding(10), + ) + .push(button("+", Event::IncrementPressed)) + .align_items(Alignment::Fill) + .spacing(10) + .into() + } + } + + impl<'a, Message, Renderer> From<NumericInput<Message>> + for Element<'a, Message, Renderer> + where + Message: 'a, + Renderer: 'static + text::Renderer, + { + fn from(numeric_input: NumericInput<Message>) -> Self { + component::view(numeric_input) + } + } +} diff --git a/examples/pure/counter/Cargo.toml b/examples/pure/counter/Cargo.toml new file mode 100644 index 00000000..2fcd22d4 --- /dev/null +++ b/examples/pure/counter/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "pure_counter" +version = "0.1.0" +authors = ["Héctor Ramón Jiménez <hector0193@gmail.com>"] +edition = "2021" +publish = false + +[dependencies] +iced = { path = "../../..", features = ["pure"] } diff --git a/examples/pure/counter/README.md b/examples/pure/counter/README.md new file mode 100644 index 00000000..4d9fc5b9 --- /dev/null +++ b/examples/pure/counter/README.md @@ -0,0 +1,18 @@ +## Counter + +The classic counter example explained in the [`README`](../../README.md). + +The __[`main`]__ file contains all the code of the example. + +<div align="center"> + <a href="https://gfycat.com/fairdeadcatbird"> + <img src="https://thumbs.gfycat.com/FairDeadCatbird-small.gif"> + </a> +</div> + +You can run it with `cargo run`: +``` +cargo run --package counter +``` + +[`main`]: src/main.rs diff --git a/examples/pure/counter/index.html b/examples/pure/counter/index.html new file mode 100644 index 00000000..d2e368e4 --- /dev/null +++ b/examples/pure/counter/index.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8" content="text/html; charset=utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Counter - Iced</title> + <base data-trunk-public-url /> +</head> +<body> +<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="z" data-bin="counter" /> +</body> +</html> diff --git a/examples/pure/counter/src/main.rs b/examples/pure/counter/src/main.rs new file mode 100644 index 00000000..9b520347 --- /dev/null +++ b/examples/pure/counter/src/main.rs @@ -0,0 +1,50 @@ +use iced::pure::widget::{button, column, text}; +use iced::pure::{Element, Sandbox}; +use iced::{Alignment, Settings}; + +pub fn main() -> iced::Result { + Counter::run(Settings::default()) +} + +struct Counter { + value: i32, +} + +#[derive(Debug, Clone, Copy)] +enum Message { + IncrementPressed, + DecrementPressed, +} + +impl Sandbox for Counter { + type Message = Message; + + fn new() -> Self { + Self { value: 0 } + } + + fn title(&self) -> String { + String::from("Counter - Iced") + } + + fn update(&mut self, message: Message) { + match message { + Message::IncrementPressed => { + self.value += 1; + } + Message::DecrementPressed => { + self.value -= 1; + } + } + } + + fn view(&self) -> Element<Message> { + column() + .padding(20) + .align_items(Alignment::Center) + .push(button("Increment").on_press(Message::IncrementPressed)) + .push(text(self.value.to_string()).size(50)) + .push(button("Decrement").on_press(Message::DecrementPressed)) + .into() + } +} diff --git a/examples/pure/pick_list/Cargo.toml b/examples/pure/pick_list/Cargo.toml new file mode 100644 index 00000000..c0fcac3c --- /dev/null +++ b/examples/pure/pick_list/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "pure_pick_list" +version = "0.1.0" +authors = ["Héctor Ramón Jiménez <hector0193@gmail.com>"] +edition = "2021" +publish = false + +[dependencies] +iced = { path = "../../..", features = ["debug", "pure"] } diff --git a/examples/pure/pick_list/README.md b/examples/pure/pick_list/README.md new file mode 100644 index 00000000..6dc80bf4 --- /dev/null +++ b/examples/pure/pick_list/README.md @@ -0,0 +1,18 @@ +## Pick-list + +A dropdown list of selectable options. + +It displays and positions an overlay based on the window position of the widget. + +The __[`main`]__ file contains all the code of the example. + +<div align="center"> + <img src="https://user-images.githubusercontent.com/518289/87125075-2c232e80-c28a-11ea-95c2-769c610b8843.gif"> +</div> + +You can run it with `cargo run`: +``` +cargo run --package pick_list +``` + +[`main`]: src/main.rs diff --git a/examples/pure/pick_list/src/main.rs b/examples/pure/pick_list/src/main.rs new file mode 100644 index 00000000..f9d55dd0 --- /dev/null +++ b/examples/pure/pick_list/src/main.rs @@ -0,0 +1,111 @@ +use iced::pure::widget::{ + column, container, pick_list, scrollable, vertical_space, +}; +use iced::pure::{Element, Sandbox}; +use iced::{Alignment, Length, Settings}; + +pub fn main() -> iced::Result { + Example::run(Settings::default()) +} + +#[derive(Default)] +struct Example { + selected_language: Option<Language>, +} + +#[derive(Debug, Clone, Copy)] +enum Message { + LanguageSelected(Language), +} + +impl Sandbox for Example { + type Message = Message; + + fn new() -> Self { + Self::default() + } + + fn title(&self) -> String { + String::from("Pick list - Iced") + } + + fn update(&mut self, message: Message) { + match message { + Message::LanguageSelected(language) => { + self.selected_language = Some(language); + } + } + } + + fn view(&self) -> Element<Message> { + let pick_list = pick_list( + &Language::ALL[..], + self.selected_language, + Message::LanguageSelected, + ) + .placeholder("Choose a language..."); + + let content = column() + .width(Length::Fill) + .align_items(Alignment::Center) + .spacing(10) + .push(vertical_space(Length::Units(600))) + .push("Which is your favorite language?") + .push(pick_list) + .push(vertical_space(Length::Units(600))); + + container(scrollable(content)) + .width(Length::Fill) + .height(Length::Fill) + .center_x() + .center_y() + .into() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Language { + Rust, + Elm, + Ruby, + Haskell, + C, + Javascript, + Other, +} + +impl Language { + const ALL: [Language; 7] = [ + Language::C, + Language::Elm, + Language::Ruby, + Language::Haskell, + Language::Rust, + Language::Javascript, + Language::Other, + ]; +} + +impl Default for Language { + fn default() -> Language { + Language::Rust + } +} + +impl std::fmt::Display for Language { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Language::Rust => "Rust", + Language::Elm => "Elm", + Language::Ruby => "Ruby", + Language::Haskell => "Haskell", + Language::C => "C", + Language::Javascript => "Javascript", + Language::Other => "Some other language", + } + ) + } +} diff --git a/examples/pure/todos/Cargo.toml b/examples/pure/todos/Cargo.toml new file mode 100644 index 00000000..217179e8 --- /dev/null +++ b/examples/pure/todos/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "pure_todos" +version = "0.1.0" +authors = ["Héctor Ramón Jiménez <hector0193@gmail.com>"] +edition = "2021" +publish = false + +[dependencies] +iced = { path = "../../..", features = ["async-std", "debug", "default_system_font", "pure"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +async-std = "1.0" +directories-next = "2.0" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +web-sys = { version = "0.3", features = ["Window", "Storage"] } +wasm-timer = "0.2" diff --git a/examples/pure/todos/README.md b/examples/pure/todos/README.md new file mode 100644 index 00000000..9c2598b9 --- /dev/null +++ b/examples/pure/todos/README.md @@ -0,0 +1,20 @@ +## Todos + +A todos tracker inspired by [TodoMVC]. It showcases dynamic layout, text input, checkboxes, scrollables, icons, and async actions! It automatically saves your tasks in the background, even if you did not finish typing them. + +All the example code is located in the __[`main`]__ file. + +<div align="center"> + <a href="https://gfycat.com/littlesanehalicore"> + <img src="https://thumbs.gfycat.com/LittleSaneHalicore-small.gif" height="400px"> + </a> +</div> + +You can run the native version with `cargo run`: +``` +cargo run --package todos +``` +We have not yet implemented a `LocalStorage` version of the auto-save feature. Therefore, it does not work on web _yet_! + +[`main`]: src/main.rs +[TodoMVC]: http://todomvc.com/ diff --git a/examples/pure/todos/index.html b/examples/pure/todos/index.html new file mode 100644 index 00000000..ee5570fb --- /dev/null +++ b/examples/pure/todos/index.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8" content="text/html; charset=utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Todos - Iced</title> + <base data-trunk-public-url /> +</head> +<body> +<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="z" data-bin="todos" /> +</body> +</html> diff --git a/examples/pure/todos/src/main.rs b/examples/pure/todos/src/main.rs new file mode 100644 index 00000000..e993c598 --- /dev/null +++ b/examples/pure/todos/src/main.rs @@ -0,0 +1,607 @@ +use iced::alignment::{self, Alignment}; +use iced::pure::widget::{ + button, checkbox, column, container, row, scrollable, text, text_input, +}; +use iced::pure::{Application, Element, Text}; +use iced::window; +use iced::{Command, Font, Length, Settings}; +use serde::{Deserialize, Serialize}; + +pub fn main() -> iced::Result { + Todos::run(Settings { + window: window::Settings { + size: (500, 800), + ..window::Settings::default() + }, + ..Settings::default() + }) +} + +#[derive(Debug)] +enum Todos { + Loading, + Loaded(State), +} + +#[derive(Debug, Default)] +struct State { + input_value: String, + filter: Filter, + tasks: Vec<Task>, + dirty: bool, + saving: bool, +} + +#[derive(Debug, Clone)] +enum Message { + Loaded(Result<SavedState, LoadError>), + Saved(Result<(), SaveError>), + InputChanged(String), + CreateTask, + FilterChanged(Filter), + TaskMessage(usize, TaskMessage), +} + +impl Application for Todos { + type Executor = iced::executor::Default; + type Message = Message; + type Flags = (); + + fn new(_flags: ()) -> (Todos, Command<Message>) { + ( + Todos::Loading, + Command::perform(SavedState::load(), Message::Loaded), + ) + } + + fn title(&self) -> String { + let dirty = match self { + Todos::Loading => false, + Todos::Loaded(state) => state.dirty, + }; + + format!("Todos{} - Iced", if dirty { "*" } else { "" }) + } + + fn update(&mut self, message: Message) -> Command<Message> { + match self { + Todos::Loading => { + match message { + Message::Loaded(Ok(state)) => { + *self = Todos::Loaded(State { + input_value: state.input_value, + filter: state.filter, + tasks: state.tasks, + ..State::default() + }); + } + Message::Loaded(Err(_)) => { + *self = Todos::Loaded(State::default()); + } + _ => {} + } + + Command::none() + } + Todos::Loaded(state) => { + let mut saved = false; + + match message { + Message::InputChanged(value) => { + state.input_value = value; + } + Message::CreateTask => { + if !state.input_value.is_empty() { + state + .tasks + .push(Task::new(state.input_value.clone())); + state.input_value.clear(); + } + } + Message::FilterChanged(filter) => { + state.filter = filter; + } + Message::TaskMessage(i, TaskMessage::Delete) => { + state.tasks.remove(i); + } + Message::TaskMessage(i, task_message) => { + if let Some(task) = state.tasks.get_mut(i) { + task.update(task_message); + } + } + Message::Saved(_) => { + state.saving = false; + saved = true; + } + _ => {} + } + + if !saved { + state.dirty = true; + } + + if state.dirty && !state.saving { + state.dirty = false; + state.saving = true; + + Command::perform( + SavedState { + input_value: state.input_value.clone(), + filter: state.filter, + tasks: state.tasks.clone(), + } + .save(), + Message::Saved, + ) + } else { + Command::none() + } + } + } + } + + fn view(&self) -> Element<Message> { + match self { + Todos::Loading => loading_message(), + Todos::Loaded(State { + input_value, + filter, + tasks, + .. + }) => { + let title = text("todos") + .width(Length::Fill) + .size(100) + .color([0.5, 0.5, 0.5]) + .horizontal_alignment(alignment::Horizontal::Center); + + let input = text_input( + "What needs to be done?", + input_value, + Message::InputChanged, + ) + .padding(15) + .size(30) + .on_submit(Message::CreateTask); + + let controls = view_controls(&tasks, *filter); + let filtered_tasks = + tasks.iter().filter(|task| filter.matches(task)); + + let tasks: Element<_> = if filtered_tasks.count() > 0 { + tasks + .iter() + .enumerate() + .filter(|(_, task)| filter.matches(task)) + .fold(column().spacing(20), |column, (i, task)| { + column.push(task.view().map(move |message| { + Message::TaskMessage(i, message) + })) + }) + .into() + } else { + empty_message(match filter { + Filter::All => "You have not created a task yet...", + Filter::Active => "All your tasks are done! :D", + Filter::Completed => { + "You have not completed a task yet..." + } + }) + }; + + let content = column() + .spacing(20) + .max_width(800) + .push(title) + .push(input) + .push(controls) + .push(tasks); + + scrollable( + container(content) + .width(Length::Fill) + .padding(40) + .center_x(), + ) + .into() + } + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Task { + description: String, + completed: bool, + + #[serde(skip)] + state: TaskState, +} + +#[derive(Debug, Clone)] +pub enum TaskState { + Idle, + Editing, +} + +impl Default for TaskState { + fn default() -> Self { + Self::Idle + } +} + +#[derive(Debug, Clone)] +pub enum TaskMessage { + Completed(bool), + Edit, + DescriptionEdited(String), + FinishEdition, + Delete, +} + +impl Task { + fn new(description: String) -> Self { + Task { + description, + completed: false, + state: TaskState::Idle, + } + } + + fn update(&mut self, message: TaskMessage) { + match message { + TaskMessage::Completed(completed) => { + self.completed = completed; + } + TaskMessage::Edit => { + self.state = TaskState::Editing; + } + TaskMessage::DescriptionEdited(new_description) => { + self.description = new_description; + } + TaskMessage::FinishEdition => { + if !self.description.is_empty() { + self.state = TaskState::Idle; + } + } + TaskMessage::Delete => {} + } + } + + fn view(&self) -> Element<TaskMessage> { + match &self.state { + TaskState::Idle => { + let checkbox = checkbox( + &self.description, + self.completed, + TaskMessage::Completed, + ) + .width(Length::Fill); + + row() + .spacing(20) + .align_items(Alignment::Center) + .push(checkbox) + .push( + button(edit_icon()) + .on_press(TaskMessage::Edit) + .padding(10) + .style(style::Button::Icon), + ) + .into() + } + TaskState::Editing => { + let text_input = text_input( + "Describe your task...", + &self.description, + TaskMessage::DescriptionEdited, + ) + .on_submit(TaskMessage::FinishEdition) + .padding(10); + + row() + .spacing(20) + .align_items(Alignment::Center) + .push(text_input) + .push( + button( + row() + .spacing(10) + .push(delete_icon()) + .push("Delete"), + ) + .on_press(TaskMessage::Delete) + .padding(10) + .style(style::Button::Destructive), + ) + .into() + } + } + } +} + +fn view_controls(tasks: &[Task], current_filter: Filter) -> Element<Message> { + let tasks_left = tasks.iter().filter(|task| !task.completed).count(); + + let filter_button = |label, filter, current_filter| { + let label = text(label).size(16); + + let button = button(label).style(if filter == current_filter { + style::Button::FilterSelected + } else { + style::Button::FilterActive + }); + + button.on_press(Message::FilterChanged(filter)).padding(8) + }; + + row() + .spacing(20) + .align_items(Alignment::Center) + .push( + text(format!( + "{} {} left", + tasks_left, + if tasks_left == 1 { "task" } else { "tasks" } + )) + .width(Length::Fill) + .size(16), + ) + .push( + row() + .width(Length::Shrink) + .spacing(10) + .push(filter_button("All", Filter::All, current_filter)) + .push(filter_button("Active", Filter::Active, current_filter)) + .push(filter_button( + "Completed", + Filter::Completed, + current_filter, + )), + ) + .into() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Filter { + All, + Active, + Completed, +} + +impl Default for Filter { + fn default() -> Self { + Filter::All + } +} + +impl Filter { + fn matches(&self, task: &Task) -> bool { + match self { + Filter::All => true, + Filter::Active => !task.completed, + Filter::Completed => task.completed, + } + } +} + +fn loading_message<'a>() -> Element<'a, Message> { + container( + text("Loading...") + .horizontal_alignment(alignment::Horizontal::Center) + .size(50), + ) + .width(Length::Fill) + .height(Length::Fill) + .center_y() + .into() +} + +fn empty_message(message: &str) -> Element<'_, Message> { + container( + text(message) + .width(Length::Fill) + .size(25) + .horizontal_alignment(alignment::Horizontal::Center) + .color([0.7, 0.7, 0.7]), + ) + .width(Length::Fill) + .height(Length::Units(200)) + .center_y() + .into() +} + +// Fonts +const ICONS: Font = Font::External { + name: "Icons", + bytes: include_bytes!("../../../todos/fonts/icons.ttf"), +}; + +fn icon(unicode: char) -> Text { + Text::new(unicode.to_string()) + .font(ICONS) + .width(Length::Units(20)) + .horizontal_alignment(alignment::Horizontal::Center) + .size(20) +} + +fn edit_icon() -> Text { + icon('\u{F303}') +} + +fn delete_icon() -> Text { + icon('\u{F1F8}') +} + +// Persistence +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SavedState { + input_value: String, + filter: Filter, + tasks: Vec<Task>, +} + +#[derive(Debug, Clone)] +enum LoadError { + FileError, + FormatError, +} + +#[derive(Debug, Clone)] +enum SaveError { + FileError, + WriteError, + FormatError, +} + +#[cfg(not(target_arch = "wasm32"))] +impl SavedState { + fn path() -> std::path::PathBuf { + let mut path = if let Some(project_dirs) = + directories_next::ProjectDirs::from("rs", "Iced", "Todos") + { + project_dirs.data_dir().into() + } else { + std::env::current_dir().unwrap_or(std::path::PathBuf::new()) + }; + + path.push("todos.json"); + + path + } + + async fn load() -> Result<SavedState, LoadError> { + use async_std::prelude::*; + + let mut contents = String::new(); + + let mut file = async_std::fs::File::open(Self::path()) + .await + .map_err(|_| LoadError::FileError)?; + + file.read_to_string(&mut contents) + .await + .map_err(|_| LoadError::FileError)?; + + serde_json::from_str(&contents).map_err(|_| LoadError::FormatError) + } + + async fn save(self) -> Result<(), SaveError> { + use async_std::prelude::*; + + let json = serde_json::to_string_pretty(&self) + .map_err(|_| SaveError::FormatError)?; + + let path = Self::path(); + + if let Some(dir) = path.parent() { + async_std::fs::create_dir_all(dir) + .await + .map_err(|_| SaveError::FileError)?; + } + + { + let mut file = async_std::fs::File::create(path) + .await + .map_err(|_| SaveError::FileError)?; + + file.write_all(json.as_bytes()) + .await + .map_err(|_| SaveError::WriteError)?; + } + + // This is a simple way to save at most once every couple seconds + async_std::task::sleep(std::time::Duration::from_secs(2)).await; + + Ok(()) + } +} + +#[cfg(target_arch = "wasm32")] +impl SavedState { + fn storage() -> Option<web_sys::Storage> { + let window = web_sys::window()?; + + window.local_storage().ok()? + } + + async fn load() -> Result<SavedState, LoadError> { + let storage = Self::storage().ok_or(LoadError::FileError)?; + + let contents = storage + .get_item("state") + .map_err(|_| LoadError::FileError)? + .ok_or(LoadError::FileError)?; + + serde_json::from_str(&contents).map_err(|_| LoadError::FormatError) + } + + async fn save(self) -> Result<(), SaveError> { + let storage = Self::storage().ok_or(SaveError::FileError)?; + + let json = serde_json::to_string_pretty(&self) + .map_err(|_| SaveError::FormatError)?; + + storage + .set_item("state", &json) + .map_err(|_| SaveError::WriteError)?; + + let _ = wasm_timer::Delay::new(std::time::Duration::from_secs(2)).await; + + Ok(()) + } +} + +mod style { + use iced::{button, Background, Color, Vector}; + + pub enum Button { + FilterActive, + FilterSelected, + Icon, + Destructive, + } + + impl button::StyleSheet for Button { + fn active(&self) -> button::Style { + match self { + Button::FilterActive => button::Style::default(), + Button::FilterSelected => button::Style { + background: Some(Background::Color(Color::from_rgb( + 0.2, 0.2, 0.7, + ))), + border_radius: 10.0, + text_color: Color::WHITE, + ..button::Style::default() + }, + Button::Icon => button::Style { + text_color: Color::from_rgb(0.5, 0.5, 0.5), + ..button::Style::default() + }, + Button::Destructive => button::Style { + background: Some(Background::Color(Color::from_rgb( + 0.8, 0.2, 0.2, + ))), + border_radius: 5.0, + text_color: Color::WHITE, + shadow_offset: Vector::new(1.0, 1.0), + ..button::Style::default() + }, + } + } + + fn hovered(&self) -> button::Style { + let active = self.active(); + + button::Style { + text_color: match self { + Button::Icon => Color::from_rgb(0.2, 0.2, 0.7), + Button::FilterActive => Color::from_rgb(0.2, 0.2, 0.7), + _ => active.text_color, + }, + shadow_offset: active.shadow_offset + Vector::new(0.0, 1.0), + ..active + } + } + } +} diff --git a/examples/pure/tour/Cargo.toml b/examples/pure/tour/Cargo.toml new file mode 100644 index 00000000..8ce5f198 --- /dev/null +++ b/examples/pure/tour/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "pure_tour" +version = "0.1.0" +authors = ["Héctor Ramón Jiménez <hector0193@gmail.com>"] +edition = "2021" +publish = false + +[dependencies] +iced = { path = "../../..", features = ["image", "debug", "pure"] } +env_logger = "0.8" diff --git a/examples/pure/tour/README.md b/examples/pure/tour/README.md new file mode 100644 index 00000000..e7cd2d5c --- /dev/null +++ b/examples/pure/tour/README.md @@ -0,0 +1,28 @@ +## Tour + +A simple UI tour that can run both on native platforms and the web! It showcases different widgets that can be built using Iced. + +The __[`main`]__ file contains all the code of the example! All the cross-platform GUI is defined in terms of __state__, __messages__, __update logic__ and __view logic__. + +<div align="center"> + <a href="https://gfycat.com/politeadorableiberianmole"> + <img src="https://thumbs.gfycat.com/PoliteAdorableIberianmole-small.gif"> + </a> +</div> + +[`main`]: src/main.rs +[`iced_winit`]: ../../winit +[`iced_native`]: ../../native +[`iced_wgpu`]: ../../wgpu +[`iced_web`]: https://github.com/iced-rs/iced_web +[`winit`]: https://github.com/rust-windowing/winit +[`wgpu`]: https://github.com/gfx-rs/wgpu-rs + +You can run the native version with `cargo run`: +``` +cargo run --package tour +``` + +The web version can be run by following [the usage instructions of `iced_web`] or by accessing [iced.rs](https://iced.rs/)! + +[the usage instructions of `iced_web`]: https://github.com/iced-rs/iced_web#usage diff --git a/examples/pure/tour/index.html b/examples/pure/tour/index.html new file mode 100644 index 00000000..c64af912 --- /dev/null +++ b/examples/pure/tour/index.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8" content="text/html; charset=utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Tour - Iced</title> + <base data-trunk-public-url /> +</head> +<body> +<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="z" data-bin="tour" /> +</body> +</html> diff --git a/examples/pure/tour/src/main.rs b/examples/pure/tour/src/main.rs new file mode 100644 index 00000000..7a50bcdc --- /dev/null +++ b/examples/pure/tour/src/main.rs @@ -0,0 +1,702 @@ +use iced::alignment; +use iced::pure::widget::{ + checkbox, column, container, horizontal_space, image, radio, row, + scrollable, slider, text, text_input, toggler, vertical_space, +}; +use iced::pure::{Button, Column, Container, Element, Sandbox, Slider}; +use iced::{Color, Length, Settings}; + +pub fn main() -> iced::Result { + env_logger::init(); + + Tour::run(Settings::default()) +} + +pub struct Tour { + steps: Steps, + debug: bool, +} + +impl Sandbox for Tour { + type Message = Message; + + fn new() -> Tour { + Tour { + steps: Steps::new(), + debug: false, + } + } + + fn title(&self) -> String { + format!("{} - Iced", self.steps.title()) + } + + fn update(&mut self, event: Message) { + match event { + Message::BackPressed => { + self.steps.go_back(); + } + Message::NextPressed => { + self.steps.advance(); + } + Message::StepMessage(step_msg) => { + self.steps.update(step_msg, &mut self.debug); + } + } + } + + fn view(&self) -> Element<Message> { + let Tour { steps, .. } = self; + + let mut controls = row(); + + if steps.has_previous() { + controls = controls.push( + button("Back") + .on_press(Message::BackPressed) + .style(style::Button::Secondary), + ); + } + + controls = controls.push(horizontal_space(Length::Fill)); + + if steps.can_continue() { + controls = controls.push( + button("Next") + .on_press(Message::NextPressed) + .style(style::Button::Primary), + ); + } + + let content: Element<_> = column() + .max_width(540) + .spacing(20) + .padding(20) + .push(steps.view(self.debug).map(Message::StepMessage)) + .push(controls) + .into(); + + let content = if self.debug { + // TODO + //content.explain(Color::BLACK) + content + } else { + content + }; + + let scrollable = + scrollable(container(content).width(Length::Fill).center_x()); + + container(scrollable).height(Length::Fill).center_y().into() + } +} + +#[derive(Debug, Clone)] +pub enum Message { + BackPressed, + NextPressed, + StepMessage(StepMessage), +} + +struct Steps { + steps: Vec<Step>, + current: usize, +} + +impl Steps { + fn new() -> Steps { + Steps { + steps: vec![ + Step::Welcome, + Step::Slider { value: 50 }, + Step::RowsAndColumns { + layout: Layout::Row, + spacing: 20, + }, + Step::Text { + size: 30, + color: Color::BLACK, + }, + Step::Radio { selection: None }, + Step::Toggler { + can_continue: false, + }, + Step::Image { width: 300 }, + Step::Scrollable, + Step::TextInput { + value: String::new(), + is_secure: false, + }, + Step::Debugger, + Step::End, + ], + current: 0, + } + } + + fn update(&mut self, msg: StepMessage, debug: &mut bool) { + self.steps[self.current].update(msg, debug); + } + + fn view(&self, debug: bool) -> Element<StepMessage> { + self.steps[self.current].view(debug) + } + + fn advance(&mut self) { + if self.can_continue() { + self.current += 1; + } + } + + fn go_back(&mut self) { + if self.has_previous() { + self.current -= 1; + } + } + + fn has_previous(&self) -> bool { + self.current > 0 + } + + fn can_continue(&self) -> bool { + self.current + 1 < self.steps.len() + && self.steps[self.current].can_continue() + } + + fn title(&self) -> &str { + self.steps[self.current].title() + } +} + +enum Step { + Welcome, + Slider { value: u8 }, + RowsAndColumns { layout: Layout, spacing: u16 }, + Text { size: u16, color: Color }, + Radio { selection: Option<Language> }, + Toggler { can_continue: bool }, + Image { width: u16 }, + Scrollable, + TextInput { value: String, is_secure: bool }, + Debugger, + End, +} + +#[derive(Debug, Clone)] +pub enum StepMessage { + SliderChanged(u8), + LayoutChanged(Layout), + SpacingChanged(u16), + TextSizeChanged(u16), + TextColorChanged(Color), + LanguageSelected(Language), + ImageWidthChanged(u16), + InputChanged(String), + ToggleSecureInput(bool), + DebugToggled(bool), + TogglerChanged(bool), +} + +impl<'a> Step { + fn update(&mut self, msg: StepMessage, debug: &mut bool) { + match msg { + StepMessage::DebugToggled(value) => { + if let Step::Debugger = self { + *debug = value; + } + } + StepMessage::LanguageSelected(language) => { + if let Step::Radio { selection } = self { + *selection = Some(language); + } + } + StepMessage::SliderChanged(new_value) => { + if let Step::Slider { value, .. } = self { + *value = new_value; + } + } + StepMessage::TextSizeChanged(new_size) => { + if let Step::Text { size, .. } = self { + *size = new_size; + } + } + StepMessage::TextColorChanged(new_color) => { + if let Step::Text { color, .. } = self { + *color = new_color; + } + } + StepMessage::LayoutChanged(new_layout) => { + if let Step::RowsAndColumns { layout, .. } = self { + *layout = new_layout; + } + } + StepMessage::SpacingChanged(new_spacing) => { + if let Step::RowsAndColumns { spacing, .. } = self { + *spacing = new_spacing; + } + } + StepMessage::ImageWidthChanged(new_width) => { + if let Step::Image { width, .. } = self { + *width = new_width; + } + } + StepMessage::InputChanged(new_value) => { + if let Step::TextInput { value, .. } = self { + *value = new_value; + } + } + StepMessage::ToggleSecureInput(toggle) => { + if let Step::TextInput { is_secure, .. } = self { + *is_secure = toggle; + } + } + StepMessage::TogglerChanged(value) => { + if let Step::Toggler { can_continue, .. } = self { + *can_continue = value; + } + } + }; + } + + fn title(&self) -> &str { + match self { + Step::Welcome => "Welcome", + Step::Radio { .. } => "Radio button", + Step::Toggler { .. } => "Toggler", + Step::Slider { .. } => "Slider", + Step::Text { .. } => "Text", + Step::Image { .. } => "Image", + Step::RowsAndColumns { .. } => "Rows and columns", + Step::Scrollable => "Scrollable", + Step::TextInput { .. } => "Text input", + Step::Debugger => "Debugger", + Step::End => "End", + } + } + + fn can_continue(&self) -> bool { + match self { + Step::Welcome => true, + Step::Radio { selection } => *selection == Some(Language::Rust), + Step::Toggler { can_continue } => *can_continue, + Step::Slider { .. } => true, + Step::Text { .. } => true, + Step::Image { .. } => true, + Step::RowsAndColumns { .. } => true, + Step::Scrollable => true, + Step::TextInput { value, .. } => !value.is_empty(), + Step::Debugger => true, + Step::End => false, + } + } + + fn view(&self, debug: bool) -> Element<StepMessage> { + match self { + Step::Welcome => Self::welcome(), + Step::Radio { selection } => Self::radio(*selection), + Step::Toggler { can_continue } => Self::toggler(*can_continue), + Step::Slider { value } => Self::slider(*value), + Step::Text { size, color } => Self::text(*size, *color), + Step::Image { width } => Self::image(*width), + Step::RowsAndColumns { layout, spacing } => { + Self::rows_and_columns(*layout, *spacing) + } + Step::Scrollable => Self::scrollable(), + Step::TextInput { value, is_secure } => { + Self::text_input(value, *is_secure) + } + Step::Debugger => Self::debugger(debug), + Step::End => Self::end(), + } + .into() + } + + fn container(title: &str) -> Column<'a, StepMessage> { + column().spacing(20).push(text(title).size(50)) + } + + fn welcome() -> Column<'a, StepMessage> { + Self::container("Welcome!") + .push( + "This is a simple tour meant to showcase a bunch of widgets \ + that can be easily implemented on top of Iced.", + ) + .push( + "Iced is a cross-platform GUI library for Rust focused on \ + simplicity and type-safety. It is heavily inspired by Elm.", + ) + .push( + "It was originally born as part of Coffee, an opinionated \ + 2D game engine for Rust.", + ) + .push( + "On native platforms, Iced provides by default a renderer \ + built on top of wgpu, a graphics library supporting Vulkan, \ + Metal, DX11, and DX12.", + ) + .push( + "Additionally, this tour can also run on WebAssembly thanks \ + to dodrio, an experimental VDOM library for Rust.", + ) + .push( + "You will need to interact with the UI in order to reach the \ + end!", + ) + } + + fn slider(value: u8) -> Column<'a, StepMessage> { + Self::container("Slider") + .push( + "A slider allows you to smoothly select a value from a range \ + of values.", + ) + .push( + "The following slider lets you choose an integer from \ + 0 to 100:", + ) + .push(slider(0..=100, value, StepMessage::SliderChanged)) + .push( + text(value.to_string()) + .width(Length::Fill) + .horizontal_alignment(alignment::Horizontal::Center), + ) + } + + fn rows_and_columns( + layout: Layout, + spacing: u16, + ) -> Column<'a, StepMessage> { + let row_radio = + radio("Row", Layout::Row, Some(layout), StepMessage::LayoutChanged); + + let column_radio = radio( + "Column", + Layout::Column, + Some(layout), + StepMessage::LayoutChanged, + ); + + let layout_section: Element<_> = match layout { + Layout::Row => row() + .spacing(spacing) + .push(row_radio) + .push(column_radio) + .into(), + Layout::Column => column() + .spacing(spacing) + .push(row_radio) + .push(column_radio) + .into(), + }; + + let spacing_section = column() + .spacing(10) + .push(slider(0..=80, spacing, StepMessage::SpacingChanged)) + .push( + text(format!("{} px", spacing)) + .width(Length::Fill) + .horizontal_alignment(alignment::Horizontal::Center), + ); + + Self::container("Rows and columns") + .spacing(spacing) + .push( + "Iced uses a layout model based on flexbox to position UI \ + elements.", + ) + .push( + "Rows and columns can be used to distribute content \ + horizontally or vertically, respectively.", + ) + .push(layout_section) + .push("You can also easily change the spacing between elements:") + .push(spacing_section) + } + + fn text(size: u16, color: Color) -> Column<'a, StepMessage> { + let size_section = column() + .padding(20) + .spacing(20) + .push("You can change its size:") + .push(text(format!("This text is {} pixels", size)).size(size)) + .push(Slider::new(10..=70, size, StepMessage::TextSizeChanged)); + + let color_sliders = row() + .spacing(10) + .push(color_slider(color.r, move |r| Color { r, ..color })) + .push(color_slider(color.g, move |g| Color { g, ..color })) + .push(color_slider(color.b, move |b| Color { b, ..color })); + + let color_section = column() + .padding(20) + .spacing(20) + .push("And its color:") + .push(text(format!("{:?}", color)).color(color)) + .push(color_sliders); + + Self::container("Text") + .push( + "Text is probably the most essential widget for your UI. \ + It will try to adapt to the dimensions of its container.", + ) + .push(size_section) + .push(color_section) + } + + fn radio(selection: Option<Language>) -> Column<'a, StepMessage> { + let question = column() + .padding(20) + .spacing(10) + .push(text("Iced is written in...").size(24)) + .push(Language::all().iter().cloned().fold( + column().padding(10).spacing(20), + |choices, language| { + choices.push(radio( + language, + language, + selection, + StepMessage::LanguageSelected, + )) + }, + )); + + Self::container("Radio button") + .push( + "A radio button is normally used to represent a choice... \ + Surprise test!", + ) + .push(question) + .push( + "Iced works very well with iterators! The list above is \ + basically created by folding a column over the different \ + choices, creating a radio button for each one of them!", + ) + } + + fn toggler(can_continue: bool) -> Column<'a, StepMessage> { + Self::container("Toggler") + .push("A toggler is mostly used to enable or disable something.") + .push( + Container::new(toggler( + "Toggle me to continue...".to_owned(), + can_continue, + StepMessage::TogglerChanged, + )) + .padding([0, 40]), + ) + } + + fn image(width: u16) -> Column<'a, StepMessage> { + Self::container("Image") + .push("An image that tries to keep its aspect ratio.") + .push(ferris(width)) + .push(slider(100..=500, width, StepMessage::ImageWidthChanged)) + .push( + text(format!("Width: {} px", width.to_string())) + .width(Length::Fill) + .horizontal_alignment(alignment::Horizontal::Center), + ) + } + + fn scrollable() -> Column<'a, StepMessage> { + Self::container("Scrollable") + .push( + "Iced supports scrollable content. Try it out! Find the \ + button further below.", + ) + .push( + text("Tip: You can use the scrollbar to scroll down faster!") + .size(16), + ) + .push(vertical_space(Length::Units(4096))) + .push( + text("You are halfway there!") + .width(Length::Fill) + .size(30) + .horizontal_alignment(alignment::Horizontal::Center), + ) + .push(vertical_space(Length::Units(4096))) + .push(ferris(300)) + .push( + text("You made it!") + .width(Length::Fill) + .size(50) + .horizontal_alignment(alignment::Horizontal::Center), + ) + } + + fn text_input(value: &str, is_secure: bool) -> Column<'a, StepMessage> { + let text_input = text_input( + "Type something to continue...", + value, + StepMessage::InputChanged, + ) + .padding(10) + .size(30); + + Self::container("Text input") + .push("Use a text input to ask for different kinds of information.") + .push(if is_secure { + text_input.password() + } else { + text_input + }) + .push(checkbox( + "Enable password mode", + is_secure, + StepMessage::ToggleSecureInput, + )) + .push( + "A text input produces a message every time it changes. It is \ + very easy to keep track of its contents:", + ) + .push( + text(if value.is_empty() { + "You have not typed anything yet..." + } else { + value + }) + .width(Length::Fill) + .horizontal_alignment(alignment::Horizontal::Center), + ) + } + + fn debugger(debug: bool) -> Column<'a, StepMessage> { + Self::container("Debugger") + .push( + "You can ask Iced to visually explain the layouting of the \ + different elements comprising your UI!", + ) + .push( + "Give it a shot! Check the following checkbox to be able to \ + see element boundaries.", + ) + .push(if cfg!(target_arch = "wasm32") { + Element::new( + text("Not available on web yet!") + .color([0.7, 0.7, 0.7]) + .horizontal_alignment(alignment::Horizontal::Center), + ) + } else { + checkbox("Explain layout", debug, StepMessage::DebugToggled) + .into() + }) + .push("Feel free to go back and take a look.") + } + + fn end() -> Column<'a, StepMessage> { + Self::container("You reached the end!") + .push("This tour will be updated as more features are added.") + .push("Make sure to keep an eye on it!") + } +} + +fn ferris<'a>(width: u16) -> Container<'a, StepMessage> { + container( + // This should go away once we unify resource loading on native + // platforms + if cfg!(target_arch = "wasm32") { + image("tour/images/ferris.png") + } else { + image(format!( + "{}/../../tour/images/ferris.png", + env!("CARGO_MANIFEST_DIR") + )) + } + .width(Length::Units(width)), + ) + .width(Length::Fill) + .center_x() +} + +fn button<'a, Message: Clone>(label: &str) -> Button<'a, Message> { + iced::pure::button( + text(label).horizontal_alignment(alignment::Horizontal::Center), + ) + .padding(12) + .width(Length::Units(100)) +} + +fn color_slider<'a>( + component: f32, + update: impl Fn(f32) -> Color + 'a, +) -> Slider<'a, f64, StepMessage> { + slider(0.0..=1.0, f64::from(component), move |c| { + StepMessage::TextColorChanged(update(c as f32)) + }) + .step(0.01) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Language { + Rust, + Elm, + Ruby, + Haskell, + C, + Other, +} + +impl Language { + fn all() -> [Language; 6] { + [ + Language::C, + Language::Elm, + Language::Ruby, + Language::Haskell, + Language::Rust, + Language::Other, + ] + } +} + +impl From<Language> for String { + fn from(language: Language) -> String { + String::from(match language { + Language::Rust => "Rust", + Language::Elm => "Elm", + Language::Ruby => "Ruby", + Language::Haskell => "Haskell", + Language::C => "C", + Language::Other => "Other", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layout { + Row, + Column, +} + +mod style { + use iced::{button, Background, Color, Vector}; + + pub enum Button { + Primary, + Secondary, + } + + impl button::StyleSheet for Button { + fn active(&self) -> button::Style { + button::Style { + background: Some(Background::Color(match self { + Button::Primary => Color::from_rgb(0.11, 0.42, 0.87), + Button::Secondary => Color::from_rgb(0.5, 0.5, 0.5), + })), + border_radius: 12.0, + shadow_offset: Vector::new(1.0, 1.0), + text_color: Color::from_rgb8(0xEE, 0xEE, 0xEE), + ..button::Style::default() + } + } + + fn hovered(&self) -> button::Style { + button::Style { + text_color: Color::WHITE, + shadow_offset: Vector::new(1.0, 2.0), + ..self.active() + } + } + } +} diff --git a/examples/stopwatch/src/main.rs b/examples/stopwatch/src/main.rs index dc8a4de7..377d7a2d 100644 --- a/examples/stopwatch/src/main.rs +++ b/examples/stopwatch/src/main.rs @@ -105,8 +105,8 @@ impl Application for Stopwatch { Text::new(label) .horizontal_alignment(alignment::Horizontal::Center), ) - .min_width(80) .padding(10) + .width(Length::Units(80)) .style(style) }; diff --git a/examples/tour/src/main.rs b/examples/tour/src/main.rs index e199c88c..2024d25a 100644 --- a/examples/tour/src/main.rs +++ b/examples/tour/src/main.rs @@ -763,7 +763,7 @@ fn button<'a, Message: Clone>( Text::new(label).horizontal_alignment(alignment::Horizontal::Center), ) .padding(12) - .min_width(100) + .width(Length::Units(100)) } fn color_slider( diff --git a/lazy/Cargo.toml b/lazy/Cargo.toml index b840de50..2d7451f3 100644 --- a/lazy/Cargo.toml +++ b/lazy/Cargo.toml @@ -3,9 +3,17 @@ name = "iced_lazy" version = "0.1.0" edition = "2021" +[features] +pure = ["iced_pure"] + [dependencies] ouroboros = "0.13" [dependencies.iced_native] version = "0.4" path = "../native" + +[dependencies.iced_pure] +version = "0.1" +path = "../pure" +optional = true diff --git a/lazy/src/lib.rs b/lazy/src/lib.rs index 05fce765..5d7d10e4 100644 --- a/lazy/src/lib.rs +++ b/lazy/src/lib.rs @@ -1,6 +1,9 @@ pub mod component; pub mod responsive; +#[cfg(feature = "pure")] +pub mod pure; + pub use component::Component; pub use responsive::Responsive; diff --git a/lazy/src/pure.rs b/lazy/src/pure.rs new file mode 100644 index 00000000..9cea807e --- /dev/null +++ b/lazy/src/pure.rs @@ -0,0 +1 @@ +pub mod component; diff --git a/lazy/src/pure/component.rs b/lazy/src/pure/component.rs new file mode 100644 index 00000000..c79e34f8 --- /dev/null +++ b/lazy/src/pure/component.rs @@ -0,0 +1,481 @@ +//! Build and reuse custom widgets using The Elm Architecture. +use iced_native::event; +use iced_native::layout::{self, Layout}; +use iced_native::mouse; +use iced_native::overlay; +use iced_native::renderer; +use iced_native::{Clipboard, Length, Point, Rectangle, Shell, Size}; +use iced_pure::widget::tree::{self, Tree}; +use iced_pure::{Element, Widget}; + +use ouroboros::self_referencing; +use std::cell::{Ref, RefCell}; +use std::marker::PhantomData; + +/// A reusable, custom widget that uses The Elm Architecture. +/// +/// A [`Component`] allows you to implement custom widgets as if they were +/// `iced` applications with encapsulated state. +/// +/// In other words, a [`Component`] allows you to turn `iced` applications into +/// custom widgets and embed them without cumbersome wiring. +/// +/// A [`Component`] produces widgets that may fire an [`Event`](Component::Event) +/// and update the internal state of the [`Component`]. +/// +/// Additionally, a [`Component`] is capable of producing a `Message` to notify +/// the parent application of any relevant interactions. +pub trait Component<Message, Renderer> { + /// The internal state of this [`Component`]. + type State: Default; + + /// The type of event this [`Component`] handles internally. + type Event; + + /// Processes an [`Event`](Component::Event) and updates the [`Component`] state accordingly. + /// + /// It can produce a `Message` for the parent application. + fn update( + &mut self, + state: &mut Self::State, + event: Self::Event, + ) -> Option<Message>; + + /// Produces the widgets of the [`Component`], which may trigger an [`Event`](Component::Event) + /// on user interaction. + fn view(&self, state: &Self::State) -> Element<Self::Event, Renderer>; +} + +/// Turns an implementor of [`Component`] into an [`Element`] that can be +/// embedded in any application. +pub fn view<'a, C, Message, Renderer>( + component: C, +) -> Element<'a, Message, Renderer> +where + C: Component<Message, Renderer> + 'a, + C::State: 'static, + Message: 'a, + Renderer: iced_native::Renderer + 'a, +{ + Element::new(Instance { + state: RefCell::new(Some( + StateBuilder { + component: Box::new(component), + message: PhantomData, + state: PhantomData, + element_builder: |_| None, + } + .build(), + )), + }) +} + +struct Instance<'a, Message, Renderer, Event, S> { + state: RefCell<Option<State<'a, Message, Renderer, Event, S>>>, +} + +#[self_referencing] +struct State<'a, Message: 'a, Renderer: 'a, Event: 'a, S: 'a> { + component: + Box<dyn Component<Message, Renderer, Event = Event, State = S> + 'a>, + message: PhantomData<Message>, + state: PhantomData<S>, + + #[borrows(component)] + #[covariant] + element: Option<Element<'this, Event, Renderer>>, +} + +impl<'a, Message, Renderer, Event, S> Instance<'a, Message, Renderer, Event, S> +where + S: Default, +{ + fn with_element<T>( + &self, + f: impl FnOnce(&Element<'_, Event, Renderer>) -> T, + ) -> T { + self.with_element_mut(|element| f(element)) + } + + fn with_element_mut<T>( + &self, + f: impl FnOnce(&mut Element<'_, Event, Renderer>) -> T, + ) -> T { + if self + .state + .borrow() + .as_ref() + .unwrap() + .borrow_element() + .is_none() + { + let heads = self.state.borrow_mut().take().unwrap().into_heads(); + + *self.state.borrow_mut() = Some( + StateBuilder { + component: heads.component, + message: PhantomData, + state: PhantomData, + element_builder: |state| Some(state.view(&S::default())), + } + .build(), + ); + } + + self.state + .borrow_mut() + .as_mut() + .unwrap() + .with_element_mut(|element| f(element.as_mut().unwrap())) + } +} + +impl<'a, Message, Renderer, Event, S> Widget<Message, Renderer> + for Instance<'a, Message, Renderer, Event, S> +where + S: 'static + Default, + Renderer: iced_native::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::<S>() + } + + fn state(&self) -> tree::State { + tree::State::new(S::default()) + } + + fn children(&self) -> Vec<Tree> { + self.with_element(|element| vec![Tree::new(element)]) + } + + fn diff(&self, tree: &mut Tree) { + self.with_element(|element| { + tree.diff_children(std::slice::from_ref(&element)) + }) + } + + fn width(&self) -> Length { + self.with_element(|element| element.as_widget().width()) + } + + fn height(&self) -> Length { + self.with_element(|element| element.as_widget().height()) + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.with_element(|element| { + element.as_widget().layout(renderer, limits) + }) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: iced_native::Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + let mut local_messages = Vec::new(); + let mut local_shell = Shell::new(&mut local_messages); + + let event_status = self.with_element_mut(|element| { + element.as_widget_mut().on_event( + &mut tree.children[0], + event, + layout, + cursor_position, + renderer, + clipboard, + &mut local_shell, + ) + }); + + local_shell.revalidate_layout(|| shell.invalidate_layout()); + + if !local_messages.is_empty() { + let mut heads = self.state.take().unwrap().into_heads(); + + for message in local_messages.into_iter().filter_map(|message| { + heads + .component + .update(tree.state.downcast_mut::<S>(), message) + }) { + shell.publish(message); + } + + self.state = RefCell::new(Some( + StateBuilder { + component: heads.component, + message: PhantomData, + state: PhantomData, + element_builder: |state| { + Some(state.view(tree.state.downcast_ref::<S>())) + }, + } + .build(), + )); + + shell.invalidate_layout(); + } + + event_status + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + ) { + self.with_element(|element| { + element.as_widget().draw( + &tree.children[0], + renderer, + style, + layout, + cursor_position, + viewport, + ); + }); + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.with_element(|element| { + element.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor_position, + viewport, + renderer, + ) + }) + } + + fn overlay<'b>( + &'b self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + ) -> Option<overlay::Element<'b, Message, Renderer>> { + let overlay = OverlayBuilder { + instance: self, + instance_ref_builder: |instance| instance.state.borrow(), + tree, + types: PhantomData, + overlay_builder: |instance, tree| { + instance + .as_ref() + .unwrap() + .borrow_element() + .as_ref() + .unwrap() + .as_widget() + .overlay(&mut tree.children[0], layout, renderer) + }, + } + .build(); + + let has_overlay = overlay.with_overlay(|overlay| { + overlay.as_ref().map(overlay::Element::position) + }); + + has_overlay.map(|position| { + overlay::Element::new( + position, + Box::new(OverlayInstance { + overlay: Some(overlay), + }), + ) + }) + } +} + +#[self_referencing] +struct Overlay<'a, 'b, Message, Renderer, Event, S> { + instance: &'a Instance<'b, Message, Renderer, Event, S>, + tree: &'a mut Tree, + types: PhantomData<(Message, Event, S)>, + + #[borrows(instance)] + #[covariant] + instance_ref: Ref<'this, Option<State<'a, Message, Renderer, Event, S>>>, + + #[borrows(instance_ref, mut tree)] + #[covariant] + overlay: Option<overlay::Element<'this, Event, Renderer>>, +} + +struct OverlayInstance<'a, 'b, Message, Renderer, Event, S> { + overlay: Option<Overlay<'a, 'b, Message, Renderer, Event, S>>, +} + +impl<'a, 'b, Message, Renderer, Event, S> + OverlayInstance<'a, 'b, Message, Renderer, Event, S> +{ + fn with_overlay_maybe<T>( + &self, + f: impl FnOnce(&overlay::Element<'_, Event, Renderer>) -> T, + ) -> Option<T> { + self.overlay + .as_ref() + .unwrap() + .borrow_overlay() + .as_ref() + .map(f) + } + + fn with_overlay_mut_maybe<T>( + &mut self, + f: impl FnOnce(&mut overlay::Element<'_, Event, Renderer>) -> T, + ) -> Option<T> { + self.overlay + .as_mut() + .unwrap() + .with_overlay_mut(|overlay| overlay.as_mut().map(f)) + } +} + +impl<'a, 'b, Message, Renderer, Event, S> overlay::Overlay<Message, Renderer> + for OverlayInstance<'a, 'b, Message, Renderer, Event, S> +where + Renderer: iced_native::Renderer, + S: 'static + Default, +{ + fn layout( + &self, + renderer: &Renderer, + bounds: Size, + position: Point, + ) -> layout::Node { + self.with_overlay_maybe(|overlay| { + let vector = position - overlay.position(); + + overlay.layout(renderer, bounds).translate(vector) + }) + .unwrap_or_default() + } + + fn draw( + &self, + renderer: &mut Renderer, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + ) { + self.with_overlay_maybe(|overlay| { + overlay.draw(renderer, style, layout, cursor_position); + }); + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.with_overlay_maybe(|overlay| { + overlay.mouse_interaction( + layout, + cursor_position, + viewport, + renderer, + ) + }) + .unwrap_or_default() + } + + fn on_event( + &mut self, + event: iced_native::Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> iced_native::event::Status { + let mut local_messages = Vec::new(); + let mut local_shell = Shell::new(&mut local_messages); + + let event_status = self + .with_overlay_mut_maybe(|overlay| { + overlay.on_event( + event, + layout, + cursor_position, + renderer, + clipboard, + &mut local_shell, + ) + }) + .unwrap_or_else(|| iced_native::event::Status::Ignored); + + local_shell.revalidate_layout(|| shell.invalidate_layout()); + + if !local_messages.is_empty() { + let overlay = self.overlay.take().unwrap().into_heads(); + let mut heads = overlay.instance.state.take().unwrap().into_heads(); + + for message in local_messages.into_iter().filter_map(|message| { + heads + .component + .update(overlay.tree.state.downcast_mut::<S>(), message) + }) { + shell.publish(message); + } + + *overlay.instance.state.borrow_mut() = Some( + StateBuilder { + component: heads.component, + message: PhantomData, + state: PhantomData, + element_builder: |state| { + Some(state.view(overlay.tree.state.downcast_ref::<S>())) + }, + } + .build(), + ); + + self.overlay = Some( + OverlayBuilder { + instance: overlay.instance, + instance_ref_builder: |instance| instance.state.borrow(), + tree: overlay.tree, + types: PhantomData, + overlay_builder: |instance, tree| { + instance + .as_ref() + .unwrap() + .borrow_element() + .as_ref() + .unwrap() + .as_widget() + .overlay(tree, layout, renderer) + }, + } + .build(), + ); + + shell.invalidate_layout(); + } + + event_status + } +} diff --git a/native/src/widget/button.rs b/native/src/widget/button.rs index 57fdd7d4..b03d9f27 100644 --- a/native/src/widget/button.rs +++ b/native/src/widget/button.rs @@ -61,8 +61,6 @@ pub struct Button<'a, Message, Renderer> { on_press: Option<Message>, width: Length, height: Length, - min_width: u32, - min_height: u32, padding: Padding, style_sheet: Box<dyn StyleSheet + 'a>, } @@ -84,8 +82,6 @@ where on_press: None, width: Length::Shrink, height: Length::Shrink, - min_width: 0, - min_height: 0, padding: Padding::new(5), style_sheet: Default::default(), } @@ -103,18 +99,6 @@ where self } - /// Sets the minimum width of the [`Button`]. - pub fn min_width(mut self, min_width: u32) -> Self { - self.min_width = min_width; - self - } - - /// Sets the minimum height of the [`Button`]. - pub fn min_height(mut self, min_height: u32) -> Self { - self.min_height = min_height; - self - } - /// Sets the [`Padding`] of the [`Button`]. pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self { self.padding = padding.into(); @@ -151,6 +135,153 @@ impl State { } } +/// Processes the given [`Event`] and updates the [`State`] of a [`Button`] +/// accordingly. +pub fn update<'a, Message: Clone>( + event: Event, + layout: Layout<'_>, + cursor_position: Point, + shell: &mut Shell<'_, Message>, + on_press: &Option<Message>, + state: impl FnOnce() -> &'a mut State, +) -> event::Status { + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + if on_press.is_some() { + let bounds = layout.bounds(); + + if bounds.contains(cursor_position) { + let state = state(); + + state.is_pressed = true; + + return event::Status::Captured; + } + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerLifted { .. }) => { + if let Some(on_press) = on_press.clone() { + let state = state(); + + if state.is_pressed { + state.is_pressed = false; + + let bounds = layout.bounds(); + + if bounds.contains(cursor_position) { + shell.publish(on_press); + } + + return event::Status::Captured; + } + } + } + Event::Touch(touch::Event::FingerLost { .. }) => { + let state = state(); + + state.is_pressed = false; + } + _ => {} + } + + event::Status::Ignored +} + +/// Draws a [`Button`]. +pub fn draw<'a, Renderer: crate::Renderer>( + renderer: &mut Renderer, + bounds: Rectangle, + cursor_position: Point, + is_enabled: bool, + style_sheet: &dyn StyleSheet, + state: impl FnOnce() -> &'a State, +) -> Style { + let is_mouse_over = bounds.contains(cursor_position); + + let styling = if !is_enabled { + style_sheet.disabled() + } else if is_mouse_over { + let state = state(); + + if state.is_pressed { + style_sheet.pressed() + } else { + style_sheet.hovered() + } + } else { + style_sheet.active() + }; + + if styling.background.is_some() || styling.border_width > 0.0 { + if styling.shadow_offset != Vector::default() { + // TODO: Implement proper shadow support + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: bounds.x + styling.shadow_offset.x, + y: bounds.y + styling.shadow_offset.y, + ..bounds + }, + border_radius: styling.border_radius, + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + Background::Color([0.0, 0.0, 0.0, 0.5].into()), + ); + } + + renderer.fill_quad( + renderer::Quad { + bounds, + border_radius: styling.border_radius, + border_width: styling.border_width, + border_color: styling.border_color, + }, + styling + .background + .unwrap_or(Background::Color(Color::TRANSPARENT)), + ); + } + + styling +} + +/// Computes the layout of a [`Button`]. +pub fn layout<Renderer>( + renderer: &Renderer, + limits: &layout::Limits, + width: Length, + height: Length, + padding: Padding, + layout_content: impl FnOnce(&Renderer, &layout::Limits) -> layout::Node, +) -> layout::Node { + let limits = limits.width(width).height(height).pad(padding); + + let mut content = layout_content(renderer, &limits); + content.move_to(Point::new(padding.left.into(), padding.top.into())); + + let size = limits.resolve(content.size()).pad(padding); + + layout::Node::with_children(size, vec![content]) +} + +/// Returns the [`mouse::Interaction`] of a [`Button`]. +pub fn mouse_interaction( + layout: Layout<'_>, + cursor_position: Point, + is_enabled: bool, +) -> mouse::Interaction { + let is_mouse_over = layout.bounds().contains(cursor_position); + + if is_mouse_over && is_enabled { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } +} + impl<'a, Message, Renderer> Widget<Message, Renderer> for Button<'a, Message, Renderer> where @@ -170,22 +301,14 @@ where renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { - let limits = limits - .min_width(self.min_width) - .min_height(self.min_height) - .width(self.width) - .height(self.height) - .pad(self.padding); - - let mut content = self.content.layout(renderer, &limits); - content.move_to(Point::new( - self.padding.left.into(), - self.padding.top.into(), - )); - - let size = limits.resolve(content.size()).pad(self.padding); - - layout::Node::with_children(size, vec![content]) + layout( + renderer, + limits, + self.width, + self.height, + self.padding, + |renderer, limits| self.content.layout(renderer, limits), + ) } fn on_event( @@ -208,42 +331,14 @@ where return event::Status::Captured; } - match event { - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerPressed { .. }) => { - if self.on_press.is_some() { - let bounds = layout.bounds(); - - if bounds.contains(cursor_position) { - self.state.is_pressed = true; - - return event::Status::Captured; - } - } - } - Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerLifted { .. }) => { - if let Some(on_press) = self.on_press.clone() { - let bounds = layout.bounds(); - - if self.state.is_pressed { - self.state.is_pressed = false; - - if bounds.contains(cursor_position) { - shell.publish(on_press); - } - - return event::Status::Captured; - } - } - } - Event::Touch(touch::Event::FingerLost { .. }) => { - self.state.is_pressed = false; - } - _ => {} - } - - event::Status::Ignored + update( + event, + layout, + cursor_position, + shell, + &self.on_press, + || &mut self.state, + ) } fn mouse_interaction( @@ -253,14 +348,7 @@ where _viewport: &Rectangle, _renderer: &Renderer, ) -> mouse::Interaction { - let is_mouse_over = layout.bounds().contains(cursor_position); - let is_disabled = self.on_press.is_none(); - - if is_mouse_over && !is_disabled { - mouse::Interaction::Pointer - } else { - mouse::Interaction::default() - } + mouse_interaction(layout, cursor_position, self.on_press.is_some()) } fn draw( @@ -274,51 +362,14 @@ where let bounds = layout.bounds(); let content_layout = layout.children().next().unwrap(); - let is_mouse_over = bounds.contains(cursor_position); - let is_disabled = self.on_press.is_none(); - - let styling = if is_disabled { - self.style_sheet.disabled() - } else if is_mouse_over { - if self.state.is_pressed { - self.style_sheet.pressed() - } else { - self.style_sheet.hovered() - } - } else { - self.style_sheet.active() - }; - - if styling.background.is_some() || styling.border_width > 0.0 { - if styling.shadow_offset != Vector::default() { - // TODO: Implement proper shadow support - renderer.fill_quad( - renderer::Quad { - bounds: Rectangle { - x: bounds.x + styling.shadow_offset.x, - y: bounds.y + styling.shadow_offset.y, - ..bounds - }, - border_radius: styling.border_radius, - border_width: 0.0, - border_color: Color::TRANSPARENT, - }, - Background::Color([0.0, 0.0, 0.0, 0.5].into()), - ); - } - - renderer.fill_quad( - renderer::Quad { - bounds, - border_radius: styling.border_radius, - border_width: styling.border_width, - border_color: styling.border_color, - }, - styling - .background - .unwrap_or(Background::Color(Color::TRANSPARENT)), - ); - } + let styling = draw( + renderer, + bounds, + cursor_position, + self.on_press.is_some(), + self.style_sheet.as_ref(), + || &self.state, + ); self.content.draw( renderer, diff --git a/native/src/widget/checkbox.rs b/native/src/widget/checkbox.rs index 15cbf93a..122c5e52 100644 --- a/native/src/widget/checkbox.rs +++ b/native/src/widget/checkbox.rs @@ -34,7 +34,7 @@ pub use iced_style::checkbox::{Style, StyleSheet}; #[allow(missing_debug_implementations)] pub struct Checkbox<'a, Message, Renderer: text::Renderer> { is_checked: bool, - on_toggle: Box<dyn Fn(bool) -> Message>, + on_toggle: Box<dyn Fn(bool) -> Message + 'a>, label: String, width: Length, size: u16, @@ -61,7 +61,7 @@ impl<'a, Message, Renderer: text::Renderer> Checkbox<'a, Message, Renderer> { /// `Message`. pub fn new<F>(is_checked: bool, label: impl Into<String>, f: F) -> Self where - F: 'static + Fn(bool) -> Message, + F: 'a + Fn(bool) -> Message, { Checkbox { is_checked, diff --git a/native/src/widget/container.rs b/native/src/widget/container.rs index ca85a425..0e7c301e 100644 --- a/native/src/widget/container.rs +++ b/native/src/widget/container.rs @@ -116,6 +116,32 @@ where } } +/// Computes the layout of a [`Container`]. +pub fn layout<Renderer>( + renderer: &Renderer, + limits: &layout::Limits, + width: Length, + height: Length, + padding: Padding, + horizontal_alignment: alignment::Horizontal, + vertical_alignment: alignment::Vertical, + layout_content: impl FnOnce(&Renderer, &layout::Limits) -> layout::Node, +) -> layout::Node { + let limits = limits.loose().width(width).height(height).pad(padding); + + let mut content = layout_content(renderer, &limits.loose()); + let size = limits.resolve(content.size()); + + content.move_to(Point::new(padding.left.into(), padding.top.into())); + content.align( + Alignment::from(horizontal_alignment), + Alignment::from(vertical_alignment), + size, + ); + + layout::Node::with_children(size.pad(padding), vec![content]) +} + impl<'a, Message, Renderer> Widget<Message, Renderer> for Container<'a, Message, Renderer> where @@ -134,28 +160,16 @@ where renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { - let limits = limits - .loose() - .max_width(self.max_width) - .max_height(self.max_height) - .width(self.width) - .height(self.height) - .pad(self.padding); - - let mut content = self.content.layout(renderer, &limits.loose()); - let size = limits.resolve(content.size()); - - content.move_to(Point::new( - self.padding.left.into(), - self.padding.top.into(), - )); - content.align( - Alignment::from(self.horizontal_alignment), - Alignment::from(self.vertical_alignment), - size, - ); - - layout::Node::with_children(size.pad(self.padding), vec![content]) + layout( + renderer, + limits, + self.width, + self.height, + self.padding, + self.horizontal_alignment, + self.vertical_alignment, + |renderer, limits| self.content.layout(renderer, limits), + ) } fn on_event( diff --git a/native/src/widget/image.rs b/native/src/widget/image.rs index de0ffbc0..8e7a28e5 100644 --- a/native/src/widget/image.rs +++ b/native/src/widget/image.rs @@ -65,6 +65,46 @@ impl<Handle> Image<Handle> { } } +/// Computes the layout of an [`Image`]. +pub fn layout<Renderer, Handle>( + renderer: &Renderer, + limits: &layout::Limits, + handle: &Handle, + width: Length, + height: Length, + content_fit: ContentFit, +) -> layout::Node +where + Renderer: image::Renderer<Handle = Handle>, +{ + // The raw w/h of the underlying image + let image_size = { + let (width, height) = renderer.dimensions(handle); + + Size::new(width as f32, height as f32) + }; + + // The size to be available to the widget prior to `Shrink`ing + let raw_size = limits.width(width).height(height).resolve(image_size); + + // The uncropped size of the image when fit to the bounds above + let full_size = content_fit.fit(image_size, raw_size); + + // Shrink the widget to fit the resized image, if requested + let final_size = Size { + width: match width { + Length::Shrink => f32::min(raw_size.width, full_size.width), + _ => raw_size.width, + }, + height: match height { + Length::Shrink => f32::min(raw_size.height, full_size.height), + _ => raw_size.height, + }, + }; + + layout::Node::new(final_size) +} + impl<Message, Renderer, Handle> Widget<Message, Renderer> for Image<Handle> where Renderer: image::Renderer<Handle = Handle>, @@ -83,32 +123,14 @@ where renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { - // The raw w/h of the underlying image - let (width, height) = renderer.dimensions(&self.handle); - let image_size = Size::new(width as f32, height as f32); - - // The size to be available to the widget prior to `Shrink`ing - let raw_size = limits - .width(self.width) - .height(self.height) - .resolve(image_size); - - // The uncropped size of the image when fit to the bounds above - let full_size = self.content_fit.fit(image_size, raw_size); - - // Shrink the widget to fit the resized image, if requested - let final_size = Size { - width: match self.width { - Length::Shrink => f32::min(raw_size.width, full_size.width), - _ => raw_size.width, - }, - height: match self.height { - Length::Shrink => f32::min(raw_size.height, full_size.height), - _ => raw_size.height, - }, - }; - - layout::Node::new(final_size) + layout( + renderer, + limits, + &self.handle, + self.width, + self.height, + self.content_fit, + ) } fn draw( diff --git a/native/src/widget/pick_list.rs b/native/src/widget/pick_list.rs index 3be6c20c..e050ada5 100644 --- a/native/src/widget/pick_list.rs +++ b/native/src/widget/pick_list.rs @@ -23,11 +23,7 @@ pub struct PickList<'a, T, Message, Renderer: text::Renderer> where [T]: ToOwned<Owned = Vec<T>>, { - menu: &'a mut menu::State, - keyboard_modifiers: &'a mut keyboard::Modifiers, - is_open: &'a mut bool, - hovered_option: &'a mut Option<usize>, - last_selection: &'a mut Option<T>, + state: &'a mut State<T>, on_selected: Box<dyn Fn(T) -> Message>, options: Cow<'a, [T]>, placeholder: Option<String>, @@ -49,8 +45,9 @@ pub struct State<T> { last_selection: Option<T>, } -impl<T> Default for State<T> { - fn default() -> Self { +impl<T> State<T> { + /// Creates a new [`State`] for a [`PickList`]. + pub fn new() -> Self { Self { menu: menu::State::default(), keyboard_modifiers: keyboard::Modifiers::default(), @@ -61,6 +58,12 @@ impl<T> Default for State<T> { } } +impl<T> Default for State<T> { + fn default() -> Self { + Self::new() + } +} + impl<'a, T: 'a, Message, Renderer: text::Renderer> PickList<'a, T, Message, Renderer> where @@ -79,20 +82,8 @@ where selected: Option<T>, on_selected: impl Fn(T) -> Message + 'static, ) -> Self { - let State { - menu, - keyboard_modifiers, - is_open, - hovered_option, - last_selection, - } = state; - Self { - menu, - keyboard_modifiers, - is_open, - hovered_option, - last_selection, + state, on_selected: Box::new(on_selected), options: options.into(), placeholder: None, @@ -145,128 +136,118 @@ where } } -impl<'a, T: 'a, Message, Renderer> Widget<Message, Renderer> - for PickList<'a, T, Message, Renderer> +/// Computes the layout of a [`PickList`]. +pub fn layout<Renderer, T>( + renderer: &Renderer, + limits: &layout::Limits, + width: Length, + padding: Padding, + text_size: Option<u16>, + font: &Renderer::Font, + placeholder: Option<&str>, + options: &[T], +) -> layout::Node where - T: Clone + ToString + Eq, - [T]: ToOwned<Owned = Vec<T>>, - Message: 'static, - Renderer: text::Renderer + 'a, + Renderer: text::Renderer, + T: ToString, { - fn width(&self) -> Length { - self.width - } + use std::f32; - fn height(&self) -> Length { - Length::Shrink - } + let limits = limits.width(width).height(Length::Shrink).pad(padding); - fn layout( - &self, - renderer: &Renderer, - limits: &layout::Limits, - ) -> layout::Node { - use std::f32; - - let limits = limits - .width(self.width) - .height(Length::Shrink) - .pad(self.padding); - - let text_size = self.text_size.unwrap_or(renderer.default_size()); - let font = self.font.clone(); - - let max_width = match self.width { - Length::Shrink => { - let measure = |label: &str| -> u32 { - let (width, _) = renderer.measure( - label, - text_size, - font.clone(), - Size::new(f32::INFINITY, f32::INFINITY), - ); - - width.round() as u32 - }; + let text_size = text_size.unwrap_or(renderer.default_size()); - let labels = self.options.iter().map(ToString::to_string); + let max_width = match width { + Length::Shrink => { + let measure = |label: &str| -> u32 { + let (width, _) = renderer.measure( + label, + text_size, + font.clone(), + Size::new(f32::INFINITY, f32::INFINITY), + ); - let labels_width = - labels.map(|label| measure(&label)).max().unwrap_or(100); + width.round() as u32 + }; - let placeholder_width = self - .placeholder - .as_ref() - .map(String::as_str) - .map(measure) - .unwrap_or(100); + let labels = options.iter().map(ToString::to_string); - labels_width.max(placeholder_width) - } - _ => 0, - }; + let labels_width = + labels.map(|label| measure(&label)).max().unwrap_or(100); - let size = { - let intrinsic = Size::new( - max_width as f32 - + f32::from(text_size) - + f32::from(self.padding.left), - f32::from(text_size), - ); + let placeholder_width = placeholder.map(measure).unwrap_or(100); - limits.resolve(intrinsic).pad(self.padding) - }; + labels_width.max(placeholder_width) + } + _ => 0, + }; - layout::Node::new(size) - } + let size = { + let intrinsic = Size::new( + max_width as f32 + f32::from(text_size) + f32::from(padding.left), + f32::from(text_size), + ); - fn on_event( - &mut self, - event: Event, - layout: Layout<'_>, - cursor_position: Point, - _renderer: &Renderer, - _clipboard: &mut dyn Clipboard, - shell: &mut Shell<'_, Message>, - ) -> event::Status { - match event { - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerPressed { .. }) => { - let event_status = if *self.is_open { - // TODO: Encode cursor availability in the type system - *self.is_open = - cursor_position.x < 0.0 || cursor_position.y < 0.0; - - event::Status::Captured - } else if layout.bounds().contains(cursor_position) { - let selected = self.selected.as_ref(); - - *self.is_open = true; - *self.hovered_option = self - .options - .iter() - .position(|option| Some(option) == selected); - - event::Status::Captured - } else { - event::Status::Ignored - }; + limits.resolve(intrinsic).pad(padding) + }; + + layout::Node::new(size) +} - if let Some(last_selection) = self.last_selection.take() { - shell.publish((self.on_selected)(last_selection)); +/// Processes an [`Event`] and updates the [`State`] of a [`PickList`] +/// accordingly. +pub fn update<'a, T, Message>( + event: Event, + layout: Layout<'_>, + cursor_position: Point, + shell: &mut Shell<'_, Message>, + on_selected: &dyn Fn(T) -> Message, + selected: Option<&T>, + options: &[T], + state: impl FnOnce() -> &'a mut State<T>, +) -> event::Status +where + T: PartialEq + Clone + 'a, +{ + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + let state = state(); - *self.is_open = false; + let event_status = if state.is_open { + // TODO: Encode cursor availability in the type system + state.is_open = + cursor_position.x < 0.0 || cursor_position.y < 0.0; - event::Status::Captured - } else { - event_status - } + event::Status::Captured + } else if layout.bounds().contains(cursor_position) { + state.is_open = true; + state.hovered_option = + options.iter().position(|option| Some(option) == selected); + + event::Status::Captured + } else { + event::Status::Ignored + }; + + if let Some(last_selection) = state.last_selection.take() { + shell.publish((on_selected)(last_selection)); + + state.is_open = false; + + event::Status::Captured + } else { + event_status } - Event::Mouse(mouse::Event::WheelScrolled { - delta: mouse::ScrollDelta::Lines { y, .. }, - }) if self.keyboard_modifiers.command() + } + Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { y, .. }, + }) => { + let state = state(); + + if state.keyboard_modifiers.command() && layout.bounds().contains(cursor_position) - && !*self.is_open => + && !state.is_open { fn find_next<'a, T: PartialEq>( selected: &'a T, @@ -278,34 +259,219 @@ where } let next_option = if y < 0.0 { - if let Some(selected) = self.selected.as_ref() { - find_next(selected, self.options.iter()) + if let Some(selected) = selected { + find_next(selected, options.iter()) } else { - self.options.first() + options.first() } } else if y > 0.0 { - if let Some(selected) = self.selected.as_ref() { - find_next(selected, self.options.iter().rev()) + if let Some(selected) = selected { + find_next(selected, options.iter().rev()) } else { - self.options.last() + options.last() } } else { None }; if let Some(next_option) = next_option { - shell.publish((self.on_selected)(next_option.clone())); + shell.publish((on_selected)(next_option.clone())); } event::Status::Captured - } - Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { - *self.keyboard_modifiers = modifiers; - + } else { event::Status::Ignored } - _ => event::Status::Ignored, } + Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { + let state = state(); + + state.keyboard_modifiers = modifiers; + + event::Status::Ignored + } + _ => event::Status::Ignored, + } +} + +/// Returns the current [`mouse::Interaction`] of a [`PickList`]. +pub fn mouse_interaction( + layout: Layout<'_>, + cursor_position: Point, +) -> mouse::Interaction { + let bounds = layout.bounds(); + let is_mouse_over = bounds.contains(cursor_position); + + if is_mouse_over { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + } +} + +/// Returns the current overlay of a [`PickList`]. +pub fn overlay<'a, T, Message, Renderer>( + layout: Layout<'_>, + state: &'a mut State<T>, + padding: Padding, + text_size: Option<u16>, + font: Renderer::Font, + options: &'a [T], + style_sheet: &dyn StyleSheet, +) -> Option<overlay::Element<'a, Message, Renderer>> +where + Message: 'a, + Renderer: text::Renderer + 'a, + T: Clone + ToString, +{ + if state.is_open { + let bounds = layout.bounds(); + + let mut menu = Menu::new( + &mut state.menu, + options, + &mut state.hovered_option, + &mut state.last_selection, + ) + .width(bounds.width.round() as u16) + .padding(padding) + .font(font) + .style(style_sheet.menu()); + + if let Some(text_size) = text_size { + menu = menu.text_size(text_size); + } + + Some(menu.overlay(layout.position(), bounds.height)) + } else { + None + } +} + +/// Draws a [`PickList`]. +pub fn draw<T, Renderer>( + renderer: &mut Renderer, + layout: Layout<'_>, + cursor_position: Point, + padding: Padding, + text_size: Option<u16>, + font: &Renderer::Font, + placeholder: Option<&str>, + selected: Option<&T>, + style_sheet: &dyn StyleSheet, +) where + Renderer: text::Renderer, + T: ToString, +{ + let bounds = layout.bounds(); + let is_mouse_over = bounds.contains(cursor_position); + let is_selected = selected.is_some(); + + let style = if is_mouse_over { + style_sheet.hovered() + } else { + style_sheet.active() + }; + + renderer.fill_quad( + renderer::Quad { + bounds, + border_color: style.border_color, + border_width: style.border_width, + border_radius: style.border_radius, + }, + style.background, + ); + + renderer.fill_text(Text { + content: &Renderer::ARROW_DOWN_ICON.to_string(), + font: Renderer::ICON_FONT, + size: bounds.height * style.icon_size, + bounds: Rectangle { + x: bounds.x + bounds.width - f32::from(padding.horizontal()), + y: bounds.center_y(), + ..bounds + }, + color: style.text_color, + horizontal_alignment: alignment::Horizontal::Right, + vertical_alignment: alignment::Vertical::Center, + }); + + let label = selected.map(ToString::to_string); + + if let Some(label) = + label.as_ref().map(String::as_str).or_else(|| placeholder) + { + renderer.fill_text(Text { + content: label, + size: f32::from(text_size.unwrap_or(renderer.default_size())), + font: font.clone(), + color: is_selected + .then(|| style.text_color) + .unwrap_or(style.placeholder_color), + bounds: Rectangle { + x: bounds.x + f32::from(padding.left), + y: bounds.center_y(), + ..bounds + }, + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Center, + }) + } +} + +impl<'a, T: 'a, Message, Renderer> Widget<Message, Renderer> + for PickList<'a, T, Message, Renderer> +where + T: Clone + ToString + Eq, + [T]: ToOwned<Owned = Vec<T>>, + Message: 'static, + Renderer: text::Renderer + 'a, +{ + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + Length::Shrink + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout( + renderer, + limits, + self.width, + self.padding, + self.text_size, + &self.font, + self.placeholder.as_ref().map(String::as_str), + &self.options, + ) + } + + fn on_event( + &mut self, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + update( + event, + layout, + cursor_position, + shell, + self.on_selected.as_ref(), + self.selected.as_ref(), + &self.options, + || &mut self.state, + ) } fn mouse_interaction( @@ -315,14 +481,7 @@ where _viewport: &Rectangle, _renderer: &Renderer, ) -> mouse::Interaction { - let bounds = layout.bounds(); - let is_mouse_over = bounds.contains(cursor_position); - - if is_mouse_over { - mouse::Interaction::Pointer - } else { - mouse::Interaction::default() - } + mouse_interaction(layout, cursor_position) } fn draw( @@ -333,66 +492,17 @@ where cursor_position: Point, _viewport: &Rectangle, ) { - let bounds = layout.bounds(); - let is_mouse_over = bounds.contains(cursor_position); - let is_selected = self.selected.is_some(); - - let style = if is_mouse_over { - self.style_sheet.hovered() - } else { - self.style_sheet.active() - }; - - renderer.fill_quad( - renderer::Quad { - bounds, - border_color: style.border_color, - border_width: style.border_width, - border_radius: style.border_radius, - }, - style.background, - ); - - renderer.fill_text(Text { - content: &Renderer::ARROW_DOWN_ICON.to_string(), - font: Renderer::ICON_FONT, - size: bounds.height * style.icon_size, - bounds: Rectangle { - x: bounds.x + bounds.width - - f32::from(self.padding.horizontal()), - y: bounds.center_y(), - ..bounds - }, - color: style.text_color, - horizontal_alignment: alignment::Horizontal::Right, - vertical_alignment: alignment::Vertical::Center, - }); - - if let Some(label) = self - .selected - .as_ref() - .map(ToString::to_string) - .as_ref() - .or_else(|| self.placeholder.as_ref()) - { - renderer.fill_text(Text { - content: label, - size: f32::from( - self.text_size.unwrap_or(renderer.default_size()), - ), - font: self.font.clone(), - color: is_selected - .then(|| style.text_color) - .unwrap_or(style.placeholder_color), - bounds: Rectangle { - x: bounds.x + f32::from(self.padding.left), - y: bounds.center_y(), - ..bounds - }, - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, - }) - } + draw( + renderer, + layout, + cursor_position, + self.padding, + self.text_size, + &self.font, + self.placeholder.as_ref().map(String::as_str), + self.selected.as_ref(), + self.style_sheet.as_ref(), + ) } fn overlay( @@ -400,28 +510,15 @@ where layout: Layout<'_>, _renderer: &Renderer, ) -> Option<overlay::Element<'_, Message, Renderer>> { - if *self.is_open { - let bounds = layout.bounds(); - - let mut menu = Menu::new( - &mut self.menu, - &self.options, - &mut self.hovered_option, - &mut self.last_selection, - ) - .width(bounds.width.round() as u16) - .padding(self.padding) - .font(self.font.clone()) - .style(self.style_sheet.menu()); - - if let Some(text_size) = self.text_size { - menu = menu.text_size(text_size); - } - - Some(menu.overlay(layout.position(), bounds.height)) - } else { - None - } + overlay( + layout, + &mut self.state, + self.padding, + self.text_size, + self.font.clone(), + &self.options, + self.style_sheet.as_ref(), + ) } } diff --git a/native/src/widget/radio.rs b/native/src/widget/radio.rs index fed2925b..657ae786 100644 --- a/native/src/widget/radio.rs +++ b/native/src/widget/radio.rs @@ -79,7 +79,7 @@ where ) -> Self where V: Eq + Copy, - F: 'static + Fn(V) -> Message, + F: FnOnce(V) -> Message, { Radio { is_selected: Some(value) == selected, diff --git a/native/src/widget/scrollable.rs b/native/src/widget/scrollable.rs index ce734ad8..3752fd71 100644 --- a/native/src/widget/scrollable.rs +++ b/native/src/widget/scrollable.rs @@ -139,235 +139,201 @@ impl<'a, Message, Renderer: crate::Renderer> Scrollable<'a, Message, Renderer> { self.content = self.content.push(child); self } - - fn notify_on_scroll( - &self, - bounds: Rectangle, - content_bounds: Rectangle, - shell: &mut Shell<'_, Message>, - ) { - if content_bounds.height <= bounds.height { - return; - } - - if let Some(on_scroll) = &self.on_scroll { - shell.publish(on_scroll( - self.state.offset.absolute(bounds, content_bounds) - / (content_bounds.height - bounds.height), - )); - } - } - - fn scrollbar( - &self, - bounds: Rectangle, - content_bounds: Rectangle, - ) -> Option<Scrollbar> { - let offset = self.state.offset(bounds, content_bounds); - - if content_bounds.height > bounds.height { - let outer_width = self.scrollbar_width.max(self.scroller_width) - + 2 * self.scrollbar_margin; - - let outer_bounds = Rectangle { - x: bounds.x + bounds.width - outer_width as f32, - y: bounds.y, - width: outer_width as f32, - height: bounds.height, - }; - - let scrollbar_bounds = Rectangle { - x: bounds.x + bounds.width - - f32::from(outer_width / 2 + self.scrollbar_width / 2), - y: bounds.y, - width: self.scrollbar_width as f32, - height: bounds.height, - }; - - let ratio = bounds.height / content_bounds.height; - let scroller_height = bounds.height * ratio; - let y_offset = offset as f32 * ratio; - - let scroller_bounds = Rectangle { - x: bounds.x + bounds.width - - f32::from(outer_width / 2 + self.scroller_width / 2), - y: scrollbar_bounds.y + y_offset, - width: self.scroller_width as f32, - height: scroller_height, - }; - - Some(Scrollbar { - outer_bounds, - bounds: scrollbar_bounds, - scroller: Scroller { - bounds: scroller_bounds, - }, - }) - } else { - None - } - } } -impl<'a, Message, Renderer> Widget<Message, Renderer> - for Scrollable<'a, Message, Renderer> -where - Renderer: crate::Renderer, -{ - fn width(&self) -> Length { - Widget::<Message, Renderer>::width(&self.content) - } - - fn height(&self) -> Length { - self.height - } +/// Computes the layout of a [`Scrollable`]. +pub fn layout<Renderer>( + renderer: &Renderer, + limits: &layout::Limits, + width: Length, + height: Length, + layout_content: impl FnOnce(&Renderer, &layout::Limits) -> layout::Node, +) -> layout::Node { + let limits = limits.width(width).height(height); - fn layout( - &self, - renderer: &Renderer, - limits: &layout::Limits, - ) -> layout::Node { - let limits = limits - .max_height(self.max_height) - .width(Widget::<Message, Renderer>::width(&self.content)) - .height(self.height); - - let child_limits = layout::Limits::new( - Size::new(limits.min().width, 0.0), - Size::new(limits.max().width, f32::INFINITY), - ); + let child_limits = layout::Limits::new( + Size::new(limits.min().width, 0.0), + Size::new(limits.max().width, f32::INFINITY), + ); - let content = self.content.layout(renderer, &child_limits); - let size = limits.resolve(content.size()); + let content = layout_content(renderer, &child_limits); + let size = limits.resolve(content.size()); - layout::Node::with_children(size, vec![content]) - } + layout::Node::with_children(size, vec![content]) +} - fn on_event( - &mut self, - event: Event, - layout: Layout<'_>, - cursor_position: Point, - renderer: &Renderer, - clipboard: &mut dyn Clipboard, - shell: &mut Shell<'_, Message>, - ) -> event::Status { - let bounds = layout.bounds(); - let is_mouse_over = bounds.contains(cursor_position); - - let content = layout.children().next().unwrap(); - let content_bounds = content.bounds(); - - let scrollbar = self.scrollbar(bounds, content_bounds); - let is_mouse_over_scrollbar = scrollbar - .as_ref() - .map(|scrollbar| scrollbar.is_mouse_over(cursor_position)) - .unwrap_or(false); - - let event_status = { - let cursor_position = if is_mouse_over && !is_mouse_over_scrollbar { - Point::new( - cursor_position.x, - cursor_position.y - + self.state.offset(bounds, content_bounds) as f32, - ) - } else { - // TODO: Make `cursor_position` an `Option<Point>` so we can encode - // cursor availability. - // This will probably happen naturally once we add multi-window - // support. - Point::new(cursor_position.x, -1.0) - }; - - self.content.on_event( - event.clone(), - content, - cursor_position, - renderer, - clipboard, - shell, +/// Processes an [`Event`] and updates the [`State`] of a [`Scrollable`] +/// accordingly. +pub fn update<Message>( + state: &mut State, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + scrollbar_width: u16, + scrollbar_margin: u16, + scroller_width: u16, + on_scroll: &Option<Box<dyn Fn(f32) -> Message>>, + update_content: impl FnOnce( + Event, + Layout<'_>, + Point, + &mut dyn Clipboard, + &mut Shell<'_, Message>, + ) -> event::Status, +) -> event::Status { + let bounds = layout.bounds(); + let is_mouse_over = bounds.contains(cursor_position); + + let content = layout.children().next().unwrap(); + let content_bounds = content.bounds(); + + let scrollbar = scrollbar( + state, + scrollbar_width, + scrollbar_margin, + scroller_width, + bounds, + content_bounds, + ); + let is_mouse_over_scrollbar = scrollbar + .as_ref() + .map(|scrollbar| scrollbar.is_mouse_over(cursor_position)) + .unwrap_or(false); + + let event_status = { + let cursor_position = if is_mouse_over && !is_mouse_over_scrollbar { + Point::new( + cursor_position.x, + cursor_position.y + state.offset(bounds, content_bounds) as f32, ) + } else { + // TODO: Make `cursor_position` an `Option<Point>` so we can encode + // cursor availability. + // This will probably happen naturally once we add multi-window + // support. + Point::new(cursor_position.x, -1.0) }; - if let event::Status::Captured = event_status { - return event::Status::Captured; - } + update_content( + event.clone(), + content, + cursor_position, + clipboard, + shell, + ) + }; + + if let event::Status::Captured = event_status { + return event::Status::Captured; + } + + if is_mouse_over { + match event { + Event::Mouse(mouse::Event::WheelScrolled { delta }) => { + match delta { + mouse::ScrollDelta::Lines { y, .. } => { + // TODO: Configurable speed (?) + state.scroll(y * 60.0, bounds, content_bounds); + } + mouse::ScrollDelta::Pixels { y, .. } => { + state.scroll(y, bounds, content_bounds); + } + } - if is_mouse_over { - match event { - Event::Mouse(mouse::Event::WheelScrolled { delta }) => { - match delta { - mouse::ScrollDelta::Lines { y, .. } => { - // TODO: Configurable speed (?) - self.state.scroll(y * 60.0, bounds, content_bounds); - } - mouse::ScrollDelta::Pixels { y, .. } => { - self.state.scroll(y, bounds, content_bounds); - } + notify_on_scroll( + state, + on_scroll, + bounds, + content_bounds, + shell, + ); + + return event::Status::Captured; + } + Event::Touch(event) => { + match event { + touch::Event::FingerPressed { .. } => { + state.scroll_box_touched_at = Some(cursor_position); } + touch::Event::FingerMoved { .. } => { + if let Some(scroll_box_touched_at) = + state.scroll_box_touched_at + { + let delta = + cursor_position.y - scroll_box_touched_at.y; - self.notify_on_scroll(bounds, content_bounds, shell); + state.scroll(delta, bounds, content_bounds); - return event::Status::Captured; - } - Event::Touch(event) => { - match event { - touch::Event::FingerPressed { .. } => { - self.state.scroll_box_touched_at = - Some(cursor_position); - } - touch::Event::FingerMoved { .. } => { - if let Some(scroll_box_touched_at) = - self.state.scroll_box_touched_at - { - let delta = - cursor_position.y - scroll_box_touched_at.y; - - self.state.scroll( - delta, - bounds, - content_bounds, - ); - - self.state.scroll_box_touched_at = - Some(cursor_position); - - self.notify_on_scroll( - bounds, - content_bounds, - shell, - ); - } - } - touch::Event::FingerLifted { .. } - | touch::Event::FingerLost { .. } => { - self.state.scroll_box_touched_at = None; + state.scroll_box_touched_at = Some(cursor_position); + + notify_on_scroll( + state, + on_scroll, + bounds, + content_bounds, + shell, + ); } } - - return event::Status::Captured; + touch::Event::FingerLifted { .. } + | touch::Event::FingerLost { .. } => { + state.scroll_box_touched_at = None; + } } - _ => {} + + return event::Status::Captured; } + _ => {} } + } + + if state.is_scroller_grabbed() { + match event { + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerLifted { .. }) + | Event::Touch(touch::Event::FingerLost { .. }) => { + state.scroller_grabbed_at = None; - if self.state.is_scroller_grabbed() { - match event { - Event::Mouse(mouse::Event::ButtonReleased( - mouse::Button::Left, - )) - | Event::Touch(touch::Event::FingerLifted { .. }) - | Event::Touch(touch::Event::FingerLost { .. }) => { - self.state.scroller_grabbed_at = None; + return event::Status::Captured; + } + Event::Mouse(mouse::Event::CursorMoved { .. }) + | Event::Touch(touch::Event::FingerMoved { .. }) => { + if let (Some(scrollbar), Some(scroller_grabbed_at)) = + (scrollbar, state.scroller_grabbed_at) + { + state.scroll_to( + scrollbar.scroll_percentage( + scroller_grabbed_at, + cursor_position, + ), + bounds, + content_bounds, + ); + + notify_on_scroll( + state, + on_scroll, + bounds, + content_bounds, + shell, + ); return event::Status::Captured; } - Event::Mouse(mouse::Event::CursorMoved { .. }) - | Event::Touch(touch::Event::FingerMoved { .. }) => { - if let (Some(scrollbar), Some(scroller_grabbed_at)) = - (scrollbar, self.state.scroller_grabbed_at) + } + _ => {} + } + } else if is_mouse_over_scrollbar { + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + if let Some(scrollbar) = scrollbar { + if let Some(scroller_grabbed_at) = + scrollbar.grab_scroller(cursor_position) { - self.state.scroll_to( + state.scroll_to( scrollbar.scroll_percentage( scroller_grabbed_at, cursor_position, @@ -376,50 +342,329 @@ where content_bounds, ); - self.notify_on_scroll(bounds, content_bounds, shell); + state.scroller_grabbed_at = Some(scroller_grabbed_at); + + notify_on_scroll( + state, + on_scroll, + bounds, + content_bounds, + shell, + ); return event::Status::Captured; } } - _ => {} } - } else if is_mouse_over_scrollbar { - match event { - Event::Mouse(mouse::Event::ButtonPressed( - mouse::Button::Left, - )) - | Event::Touch(touch::Event::FingerPressed { .. }) => { - if let Some(scrollbar) = scrollbar { - if let Some(scroller_grabbed_at) = - scrollbar.grab_scroller(cursor_position) - { - self.state.scroll_to( - scrollbar.scroll_percentage( - scroller_grabbed_at, - cursor_position, - ), - bounds, - content_bounds, - ); + _ => {} + } + } - self.state.scroller_grabbed_at = - Some(scroller_grabbed_at); + event::Status::Ignored +} - self.notify_on_scroll( - bounds, - content_bounds, - shell, - ); +/// Computes the current [`mouse::Interaction`] of a [`Scrollable`]. +pub fn mouse_interaction( + state: &State, + layout: Layout<'_>, + cursor_position: Point, + scrollbar_width: u16, + scrollbar_margin: u16, + scroller_width: u16, + content_interaction: impl FnOnce( + Layout<'_>, + Point, + &Rectangle, + ) -> mouse::Interaction, +) -> mouse::Interaction { + let bounds = layout.bounds(); + let content_layout = layout.children().next().unwrap(); + let content_bounds = content_layout.bounds(); + let scrollbar = scrollbar( + state, + scrollbar_width, + scrollbar_margin, + scroller_width, + bounds, + content_bounds, + ); + + let is_mouse_over = bounds.contains(cursor_position); + let is_mouse_over_scrollbar = scrollbar + .as_ref() + .map(|scrollbar| scrollbar.is_mouse_over(cursor_position)) + .unwrap_or(false); + + if is_mouse_over_scrollbar || state.is_scroller_grabbed() { + mouse::Interaction::Idle + } else { + let offset = state.offset(bounds, content_bounds); - return event::Status::Captured; - } - } + let cursor_position = if is_mouse_over && !is_mouse_over_scrollbar { + Point::new(cursor_position.x, cursor_position.y + offset as f32) + } else { + Point::new(cursor_position.x, -1.0) + }; + + content_interaction( + content_layout, + cursor_position, + &Rectangle { + y: bounds.y + offset as f32, + ..bounds + }, + ) + } +} + +/// Draws a [`Scrollable`]. +pub fn draw<Renderer>( + state: &State, + renderer: &mut Renderer, + layout: Layout<'_>, + cursor_position: Point, + scrollbar_width: u16, + scrollbar_margin: u16, + scroller_width: u16, + style_sheet: &dyn StyleSheet, + draw_content: impl FnOnce(&mut Renderer, Layout<'_>, Point, &Rectangle), +) where + Renderer: crate::Renderer, +{ + let bounds = layout.bounds(); + let content_layout = layout.children().next().unwrap(); + let content_bounds = content_layout.bounds(); + let offset = state.offset(bounds, content_bounds); + let scrollbar = scrollbar( + state, + scrollbar_width, + scrollbar_margin, + scroller_width, + bounds, + content_bounds, + ); + + let is_mouse_over = bounds.contains(cursor_position); + let is_mouse_over_scrollbar = scrollbar + .as_ref() + .map(|scrollbar| scrollbar.is_mouse_over(cursor_position)) + .unwrap_or(false); + + let cursor_position = if is_mouse_over && !is_mouse_over_scrollbar { + Point::new(cursor_position.x, cursor_position.y + offset as f32) + } else { + Point::new(cursor_position.x, -1.0) + }; + + if let Some(scrollbar) = scrollbar { + renderer.with_layer(bounds, |renderer| { + renderer.with_translation( + Vector::new(0.0, -(offset as f32)), + |renderer| { + draw_content( + renderer, + content_layout, + cursor_position, + &Rectangle { + y: bounds.y + offset as f32, + ..bounds + }, + ); + }, + ); + }); + + let style = if state.is_scroller_grabbed() { + style_sheet.dragging() + } else if is_mouse_over_scrollbar { + style_sheet.hovered() + } else { + style_sheet.active() + }; + + let is_scrollbar_visible = + style.background.is_some() || style.border_width > 0.0; + + renderer.with_layer( + Rectangle { + width: bounds.width + 2.0, + height: bounds.height + 2.0, + ..bounds + }, + |renderer| { + if is_scrollbar_visible { + renderer.fill_quad( + renderer::Quad { + bounds: scrollbar.bounds, + border_radius: style.border_radius, + border_width: style.border_width, + border_color: style.border_color, + }, + style + .background + .unwrap_or(Background::Color(Color::TRANSPARENT)), + ); } - _ => {} - } - } - event::Status::Ignored + if is_mouse_over + || state.is_scroller_grabbed() + || is_scrollbar_visible + { + renderer.fill_quad( + renderer::Quad { + bounds: scrollbar.scroller.bounds, + border_radius: style.scroller.border_radius, + border_width: style.scroller.border_width, + border_color: style.scroller.border_color, + }, + style.scroller.color, + ); + } + }, + ); + } else { + draw_content( + renderer, + content_layout, + cursor_position, + &Rectangle { + y: bounds.y + offset as f32, + ..bounds + }, + ); + } +} + +fn scrollbar( + state: &State, + scrollbar_width: u16, + scrollbar_margin: u16, + scroller_width: u16, + bounds: Rectangle, + content_bounds: Rectangle, +) -> Option<Scrollbar> { + let offset = state.offset(bounds, content_bounds); + + if content_bounds.height > bounds.height { + let outer_width = + scrollbar_width.max(scroller_width) + 2 * scrollbar_margin; + + let outer_bounds = Rectangle { + x: bounds.x + bounds.width - outer_width as f32, + y: bounds.y, + width: outer_width as f32, + height: bounds.height, + }; + + let scrollbar_bounds = Rectangle { + x: bounds.x + bounds.width + - f32::from(outer_width / 2 + scrollbar_width / 2), + y: bounds.y, + width: scrollbar_width as f32, + height: bounds.height, + }; + + let ratio = bounds.height / content_bounds.height; + let scroller_height = bounds.height * ratio; + let y_offset = offset as f32 * ratio; + + let scroller_bounds = Rectangle { + x: bounds.x + bounds.width + - f32::from(outer_width / 2 + scroller_width / 2), + y: scrollbar_bounds.y + y_offset, + width: scroller_width as f32, + height: scroller_height, + }; + + Some(Scrollbar { + outer_bounds, + bounds: scrollbar_bounds, + scroller: Scroller { + bounds: scroller_bounds, + }, + }) + } else { + None + } +} + +fn notify_on_scroll<Message>( + state: &State, + on_scroll: &Option<Box<dyn Fn(f32) -> Message>>, + bounds: Rectangle, + content_bounds: Rectangle, + shell: &mut Shell<'_, Message>, +) { + if content_bounds.height <= bounds.height { + return; + } + + if let Some(on_scroll) = on_scroll { + shell.publish(on_scroll( + state.offset.absolute(bounds, content_bounds) + / (content_bounds.height - bounds.height), + )); + } +} + +impl<'a, Message, Renderer> Widget<Message, Renderer> + for Scrollable<'a, Message, Renderer> +where + Renderer: crate::Renderer, +{ + fn width(&self) -> Length { + Widget::<Message, Renderer>::width(&self.content) + } + + fn height(&self) -> Length { + self.height + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout( + renderer, + limits, + Widget::<Message, Renderer>::width(self), + self.height, + |renderer, limits| self.content.layout(renderer, limits), + ) + } + + fn on_event( + &mut self, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + update( + &mut self.state, + event, + layout, + cursor_position, + clipboard, + shell, + self.scrollbar_width, + self.scrollbar_margin, + self.scroller_width, + &self.on_scroll, + |event, layout, cursor_position, clipboard, shell| { + self.content.on_event( + event, + layout, + cursor_position, + renderer, + clipboard, + shell, + ) + }, + ) } fn mouse_interaction( @@ -429,38 +674,22 @@ where _viewport: &Rectangle, renderer: &Renderer, ) -> mouse::Interaction { - let bounds = layout.bounds(); - let content_layout = layout.children().next().unwrap(); - let content_bounds = content_layout.bounds(); - let scrollbar = self.scrollbar(bounds, content_bounds); - - let is_mouse_over = bounds.contains(cursor_position); - let is_mouse_over_scrollbar = scrollbar - .as_ref() - .map(|scrollbar| scrollbar.is_mouse_over(cursor_position)) - .unwrap_or(false); - - if is_mouse_over_scrollbar || self.state.is_scroller_grabbed() { - mouse::Interaction::Idle - } else { - let offset = self.state.offset(bounds, content_bounds); - - let cursor_position = if is_mouse_over && !is_mouse_over_scrollbar { - Point::new(cursor_position.x, cursor_position.y + offset as f32) - } else { - Point::new(cursor_position.x, -1.0) - }; - - self.content.mouse_interaction( - content_layout, - cursor_position, - &Rectangle { - y: bounds.y + offset as f32, - ..bounds - }, - renderer, - ) - } + mouse_interaction( + &self.state, + layout, + cursor_position, + self.scrollbar_width, + self.scrollbar_margin, + self.scroller_width, + |layout, cursor_position, viewport| { + self.content.mouse_interaction( + layout, + cursor_position, + viewport, + renderer, + ) + }, + ) } fn draw( @@ -471,103 +700,25 @@ where cursor_position: Point, _viewport: &Rectangle, ) { - let bounds = layout.bounds(); - let content_layout = layout.children().next().unwrap(); - let content_bounds = content_layout.bounds(); - let offset = self.state.offset(bounds, content_bounds); - let scrollbar = self.scrollbar(bounds, content_bounds); - - let is_mouse_over = bounds.contains(cursor_position); - let is_mouse_over_scrollbar = scrollbar - .as_ref() - .map(|scrollbar| scrollbar.is_mouse_over(cursor_position)) - .unwrap_or(false); - - let cursor_position = if is_mouse_over && !is_mouse_over_scrollbar { - Point::new(cursor_position.x, cursor_position.y + offset as f32) - } else { - Point::new(cursor_position.x, -1.0) - }; - - if let Some(scrollbar) = scrollbar { - renderer.with_layer(bounds, |renderer| { - renderer.with_translation( - Vector::new(0.0, -(offset as f32)), - |renderer| { - self.content.draw( - renderer, - style, - content_layout, - cursor_position, - &Rectangle { - y: bounds.y + offset as f32, - ..bounds - }, - ); - }, - ); - }); - - let style = if self.state.is_scroller_grabbed() { - self.style_sheet.dragging() - } else if is_mouse_over_scrollbar { - self.style_sheet.hovered() - } else { - self.style_sheet.active() - }; - - let is_scrollbar_visible = - style.background.is_some() || style.border_width > 0.0; - - renderer.with_layer( - Rectangle { - width: bounds.width + 2.0, - height: bounds.height + 2.0, - ..bounds - }, - |renderer| { - if is_scrollbar_visible { - renderer.fill_quad( - renderer::Quad { - bounds: scrollbar.bounds, - border_radius: style.border_radius, - border_width: style.border_width, - border_color: style.border_color, - }, - style.background.unwrap_or(Background::Color( - Color::TRANSPARENT, - )), - ); - } - - if is_mouse_over - || self.state.is_scroller_grabbed() - || is_scrollbar_visible - { - renderer.fill_quad( - renderer::Quad { - bounds: scrollbar.scroller.bounds, - border_radius: style.scroller.border_radius, - border_width: style.scroller.border_width, - border_color: style.scroller.border_color, - }, - style.scroller.color, - ); - } - }, - ); - } else { - self.content.draw( - renderer, - style, - content_layout, - cursor_position, - &Rectangle { - y: bounds.y + offset as f32, - ..bounds - }, - ); - } + draw( + &self.state, + renderer, + layout, + cursor_position, + self.scrollbar_width, + self.scrollbar_margin, + self.scroller_width, + self.style_sheet.as_ref(), + |renderer, layout, cursor_position, viewport| { + self.content.draw( + renderer, + style, + layout, + cursor_position, + viewport, + ) + }, + ) } fn overlay( diff --git a/native/src/widget/slider.rs b/native/src/widget/slider.rs index 289f75f5..4c56083e 100644 --- a/native/src/widget/slider.rs +++ b/native/src/widget/slider.rs @@ -142,6 +142,207 @@ where } } +/// Processes an [`Event`] and updates the [`State`] of a [`Slider`] +/// accordingly. +pub fn update<Message, T>( + event: Event, + layout: Layout<'_>, + cursor_position: Point, + shell: &mut Shell<'_, Message>, + state: &mut State, + value: &mut T, + range: &RangeInclusive<T>, + step: T, + on_change: &dyn Fn(T) -> Message, + on_release: &Option<Message>, +) -> event::Status +where + T: Copy + Into<f64> + num_traits::FromPrimitive, + Message: Clone, +{ + let is_dragging = state.is_dragging; + + let mut change = || { + let bounds = layout.bounds(); + let new_value = if cursor_position.x <= bounds.x { + *range.start() + } else if cursor_position.x >= bounds.x + bounds.width { + *range.end() + } else { + let step = step.into(); + let start = (*range.start()).into(); + let end = (*range.end()).into(); + + let percent = f64::from(cursor_position.x - bounds.x) + / f64::from(bounds.width); + + let steps = (percent * (end - start) / step).round(); + let value = steps * step + start; + + if let Some(value) = T::from_f64(value) { + value + } else { + return; + } + }; + + if ((*value).into() - new_value.into()).abs() > f64::EPSILON { + shell.publish((on_change)(new_value)); + + *value = new_value; + } + }; + + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + if layout.bounds().contains(cursor_position) { + change(); + state.is_dragging = true; + + return event::Status::Captured; + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerLifted { .. }) + | Event::Touch(touch::Event::FingerLost { .. }) => { + if is_dragging { + if let Some(on_release) = on_release.clone() { + shell.publish(on_release); + } + state.is_dragging = false; + + return event::Status::Captured; + } + } + Event::Mouse(mouse::Event::CursorMoved { .. }) + | Event::Touch(touch::Event::FingerMoved { .. }) => { + if is_dragging { + change(); + + return event::Status::Captured; + } + } + _ => {} + } + + event::Status::Ignored +} + +/// Draws a [`Slider`]. +pub fn draw<T>( + renderer: &mut impl crate::Renderer, + layout: Layout<'_>, + cursor_position: Point, + state: &State, + value: T, + range: &RangeInclusive<T>, + style_sheet: &dyn StyleSheet, +) where + T: Into<f64> + Copy, +{ + let bounds = layout.bounds(); + let is_mouse_over = bounds.contains(cursor_position); + + let style = if state.is_dragging { + style_sheet.dragging() + } else if is_mouse_over { + style_sheet.hovered() + } else { + style_sheet.active() + }; + + let rail_y = bounds.y + (bounds.height / 2.0).round(); + + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: bounds.x, + y: rail_y, + width: bounds.width, + height: 2.0, + }, + border_radius: 0.0, + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + style.rail_colors.0, + ); + + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: bounds.x, + y: rail_y + 2.0, + width: bounds.width, + height: 2.0, + }, + border_radius: 0.0, + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + Background::Color(style.rail_colors.1), + ); + + let (handle_width, handle_height, handle_border_radius) = match style + .handle + .shape + { + HandleShape::Circle { radius } => (radius * 2.0, radius * 2.0, radius), + HandleShape::Rectangle { + width, + border_radius, + } => (f32::from(width), f32::from(bounds.height), border_radius), + }; + + let value = value.into() as f32; + let (range_start, range_end) = { + let (start, end) = range.clone().into_inner(); + + (start.into() as f32, end.into() as f32) + }; + + let handle_offset = if range_start >= range_end { + 0.0 + } else { + (bounds.width - handle_width) * (value - range_start) + / (range_end - range_start) + }; + + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: bounds.x + handle_offset.round(), + y: rail_y - handle_height / 2.0, + width: handle_width, + height: handle_height, + }, + border_radius: handle_border_radius, + border_width: style.handle.border_width, + border_color: style.handle.border_color, + }, + style.handle.color, + ); +} + +/// Computes the current [`mouse::Interaction`] of a [`Slider`]. +pub fn mouse_interaction( + layout: Layout<'_>, + cursor_position: Point, + state: &State, +) -> mouse::Interaction { + let bounds = layout.bounds(); + let is_mouse_over = bounds.contains(cursor_position); + + if state.is_dragging { + mouse::Interaction::Grabbing + } else if is_mouse_over { + mouse::Interaction::Grab + } else { + mouse::Interaction::default() + } +} + /// The local state of a [`Slider`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct State { @@ -192,73 +393,18 @@ where _clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, ) -> event::Status { - let is_dragging = self.state.is_dragging; - - let mut change = || { - let bounds = layout.bounds(); - let new_value = if cursor_position.x <= bounds.x { - *self.range.start() - } else if cursor_position.x >= bounds.x + bounds.width { - *self.range.end() - } else { - let step = self.step.into(); - let start = (*self.range.start()).into(); - let end = (*self.range.end()).into(); - - let percent = f64::from(cursor_position.x - bounds.x) - / f64::from(bounds.width); - - let steps = (percent * (end - start) / step).round(); - let value = steps * step + start; - - if let Some(value) = T::from_f64(value) { - value - } else { - return; - } - }; - - if (self.value.into() - new_value.into()).abs() > f64::EPSILON { - shell.publish((self.on_change)(new_value)); - - self.value = new_value; - } - }; - - match event { - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerPressed { .. }) => { - if layout.bounds().contains(cursor_position) { - change(); - self.state.is_dragging = true; - - return event::Status::Captured; - } - } - Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerLifted { .. }) - | Event::Touch(touch::Event::FingerLost { .. }) => { - if is_dragging { - if let Some(on_release) = self.on_release.clone() { - shell.publish(on_release); - } - self.state.is_dragging = false; - - return event::Status::Captured; - } - } - Event::Mouse(mouse::Event::CursorMoved { .. }) - | Event::Touch(touch::Event::FingerMoved { .. }) => { - if is_dragging { - change(); - - return event::Status::Captured; - } - } - _ => {} - } - - event::Status::Ignored + update( + event, + layout, + cursor_position, + shell, + &mut self.state, + &mut self.value, + &self.range, + self.step, + self.on_change.as_ref(), + &self.on_release, + ) } fn draw( @@ -269,90 +415,15 @@ where cursor_position: Point, _viewport: &Rectangle, ) { - let bounds = layout.bounds(); - let is_mouse_over = bounds.contains(cursor_position); - - let style = if self.state.is_dragging { - self.style_sheet.dragging() - } else if is_mouse_over { - self.style_sheet.hovered() - } else { - self.style_sheet.active() - }; - - let rail_y = bounds.y + (bounds.height / 2.0).round(); - - renderer.fill_quad( - renderer::Quad { - bounds: Rectangle { - x: bounds.x, - y: rail_y, - width: bounds.width, - height: 2.0, - }, - border_radius: 0.0, - border_width: 0.0, - border_color: Color::TRANSPARENT, - }, - style.rail_colors.0, - ); - - renderer.fill_quad( - renderer::Quad { - bounds: Rectangle { - x: bounds.x, - y: rail_y + 2.0, - width: bounds.width, - height: 2.0, - }, - border_radius: 0.0, - border_width: 0.0, - border_color: Color::TRANSPARENT, - }, - Background::Color(style.rail_colors.1), - ); - - let (handle_width, handle_height, handle_border_radius) = match style - .handle - .shape - { - HandleShape::Circle { radius } => { - (radius * 2.0, radius * 2.0, radius) - } - HandleShape::Rectangle { - width, - border_radius, - } => (f32::from(width), f32::from(bounds.height), border_radius), - }; - - let value = self.value.into() as f32; - let (range_start, range_end) = { - let (start, end) = self.range.clone().into_inner(); - - (start.into() as f32, end.into() as f32) - }; - - let handle_offset = if range_start >= range_end { - 0.0 - } else { - (bounds.width - handle_width) * (value - range_start) - / (range_end - range_start) - }; - - renderer.fill_quad( - renderer::Quad { - bounds: Rectangle { - x: bounds.x + handle_offset.round(), - y: rail_y - handle_height / 2.0, - width: handle_width, - height: handle_height, - }, - border_radius: handle_border_radius, - border_width: style.handle.border_width, - border_color: style.handle.border_color, - }, - style.handle.color, - ); + draw( + renderer, + layout, + cursor_position, + &self.state, + self.value, + &self.range, + self.style_sheet.as_ref(), + ) } fn mouse_interaction( @@ -362,16 +433,7 @@ where _viewport: &Rectangle, _renderer: &Renderer, ) -> mouse::Interaction { - let bounds = layout.bounds(); - let is_mouse_over = bounds.contains(cursor_position); - - if self.state.is_dragging { - mouse::Interaction::Grabbing - } else if is_mouse_over { - mouse::Interaction::Grab - } else { - mouse::Interaction::default() - } + mouse_interaction(layout, cursor_position, &self.state) } } diff --git a/native/src/widget/text_input.rs b/native/src/widget/text_input.rs index e30e2343..d13d6ef1 100644 --- a/native/src/widget/text_input.rs +++ b/native/src/widget/text_input.rs @@ -24,8 +24,6 @@ use crate::{ Shell, Size, Vector, Widget, }; -use std::u32; - pub use iced_style::text_input::{Style, StyleSheet}; /// A field that can be filled with text. @@ -61,10 +59,9 @@ pub struct TextInput<'a, Message, Renderer: text::Renderer> { is_secure: bool, font: Renderer::Font, width: Length, - max_width: u32, padding: Padding, size: Option<u16>, - on_change: Box<dyn Fn(String) -> Message>, + on_change: Box<dyn Fn(String) -> Message + 'a>, on_submit: Option<Message>, style_sheet: Box<dyn StyleSheet + 'a>, } @@ -88,7 +85,7 @@ where on_change: F, ) -> Self where - F: 'static + Fn(String) -> Message, + F: 'a + Fn(String) -> Message, { TextInput { state, @@ -97,7 +94,6 @@ where is_secure: false, font: Default::default(), width: Length::Fill, - max_width: u32::MAX, padding: Padding::ZERO, size: None, on_change: Box::new(on_change), @@ -126,12 +122,6 @@ where self } - /// Sets the maximum width of the [`TextInput`]. - pub fn max_width(mut self, max_width: u32) -> Self { - self.max_width = max_width; - self - } - /// Sets the [`Padding`] of the [`TextInput`]. pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self { self.padding = padding.into(); @@ -166,520 +156,313 @@ where } } -impl<'a, Message, Renderer> TextInput<'a, Message, Renderer> +/// Computes the layout of a [`TextInput`]. +pub fn layout<Renderer>( + renderer: &Renderer, + limits: &layout::Limits, + width: Length, + padding: Padding, + size: Option<u16>, +) -> layout::Node where Renderer: text::Renderer, { - /// Draws the [`TextInput`] with the given [`Renderer`], overriding its - /// [`Value`] if provided. - pub fn draw( - &self, - renderer: &mut Renderer, - layout: Layout<'_>, - cursor_position: Point, - value: Option<&Value>, - ) { - let value = value.unwrap_or(&self.value); - let secure_value = self.is_secure.then(|| value.secure()); - let value = secure_value.as_ref().unwrap_or(&value); + let text_size = size.unwrap_or(renderer.default_size()); - let bounds = layout.bounds(); - let text_bounds = layout.children().next().unwrap().bounds(); + let limits = limits + .pad(padding) + .width(width) + .height(Length::Units(text_size)); - let is_mouse_over = bounds.contains(cursor_position); + let mut text = layout::Node::new(limits.resolve(Size::ZERO)); + text.move_to(Point::new(padding.left.into(), padding.top.into())); - let style = if self.state.is_focused() { - self.style_sheet.focused() - } else if is_mouse_over { - self.style_sheet.hovered() - } else { - self.style_sheet.active() - }; - - renderer.fill_quad( - renderer::Quad { - bounds, - border_radius: style.border_radius, - border_width: style.border_width, - border_color: style.border_color, - }, - style.background, - ); - - let text = value.to_string(); - let size = self.size.unwrap_or(renderer.default_size()); - - let (cursor, offset) = if self.state.is_focused() { - match self.state.cursor.state(&value) { - cursor::State::Index(position) => { - let (text_value_width, offset) = - measure_cursor_and_scroll_offset( - renderer, - text_bounds, - &value, - size, - position, - self.font.clone(), - ); - - ( - Some(( - renderer::Quad { - bounds: Rectangle { - x: text_bounds.x + text_value_width, - y: text_bounds.y, - width: 1.0, - height: text_bounds.height, - }, - border_radius: 0.0, - border_width: 0.0, - border_color: Color::TRANSPARENT, - }, - self.style_sheet.value_color(), - )), - offset, - ) - } - cursor::State::Selection { start, end } => { - let left = start.min(end); - let right = end.max(start); - - let (left_position, left_offset) = - measure_cursor_and_scroll_offset( - renderer, - text_bounds, - &value, - size, - left, - self.font.clone(), - ); - - let (right_position, right_offset) = - measure_cursor_and_scroll_offset( - renderer, - text_bounds, - &value, - size, - right, - self.font.clone(), - ); - - let width = right_position - left_position; - - ( - Some(( - renderer::Quad { - bounds: Rectangle { - x: text_bounds.x + left_position, - y: text_bounds.y, - width, - height: text_bounds.height, - }, - border_radius: 0.0, - border_width: 0.0, - border_color: Color::TRANSPARENT, - }, - self.style_sheet.selection_color(), - )), - if end == right { - right_offset - } else { - left_offset - }, - ) - } - } - } else { - (None, 0.0) - }; - - let text_width = renderer.measure_width( - if text.is_empty() { - &self.placeholder - } else { - &text - }, - size, - self.font.clone(), - ); - - let render = |renderer: &mut Renderer| { - if let Some((cursor, color)) = cursor { - renderer.fill_quad(cursor, color); - } - - renderer.fill_text(Text { - content: if text.is_empty() { - &self.placeholder - } else { - &text - }, - color: if text.is_empty() { - self.style_sheet.placeholder_color() - } else { - self.style_sheet.value_color() - }, - font: self.font.clone(), - bounds: Rectangle { - y: text_bounds.center_y(), - width: f32::INFINITY, - ..text_bounds - }, - size: f32::from(size), - horizontal_alignment: alignment::Horizontal::Left, - vertical_alignment: alignment::Vertical::Center, - }); - }; - - if text_width > text_bounds.width { - renderer.with_layer(text_bounds, |renderer| { - renderer.with_translation(Vector::new(-offset, 0.0), render) - }); - } else { - render(renderer); - } - } + layout::Node::with_children(text.size().pad(padding), vec![text]) } -impl<'a, Message, Renderer> Widget<Message, Renderer> - for TextInput<'a, Message, Renderer> +/// Processes an [`Event`] and updates the [`State`] of a [`TextInput`] +/// accordingly. +pub fn update<'a, Message, Renderer>( + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + value: &mut Value, + size: Option<u16>, + font: &Renderer::Font, + is_secure: bool, + on_change: &dyn Fn(String) -> Message, + on_submit: &Option<Message>, + state: impl FnOnce() -> &'a mut State, +) -> event::Status where Message: Clone, Renderer: text::Renderer, { - fn width(&self) -> Length { - self.width - } - - fn height(&self) -> Length { - Length::Shrink - } - - fn layout( - &self, - renderer: &Renderer, - limits: &layout::Limits, - ) -> layout::Node { - let text_size = self.size.unwrap_or(renderer.default_size()); - - let limits = limits - .pad(self.padding) - .width(self.width) - .max_width(self.max_width) - .height(Length::Units(text_size)); - - let mut text = layout::Node::new(limits.resolve(Size::ZERO)); - text.move_to(Point::new( - self.padding.left.into(), - self.padding.top.into(), - )); - - layout::Node::with_children(text.size().pad(self.padding), vec![text]) - } - - fn on_event( - &mut self, - event: Event, - layout: Layout<'_>, - cursor_position: Point, - renderer: &Renderer, - clipboard: &mut dyn Clipboard, - shell: &mut Shell<'_, Message>, - ) -> event::Status { - match event { - Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerPressed { .. }) => { - let is_clicked = layout.bounds().contains(cursor_position); - - self.state.is_focused = is_clicked; - - if is_clicked { - let text_layout = layout.children().next().unwrap(); - let target = cursor_position.x - text_layout.bounds().x; - - let click = mouse::Click::new( - cursor_position, - self.state.last_click, - ); - - match click.kind() { - click::Kind::Single => { - let position = if target > 0.0 { - let value = if self.is_secure { - self.value.secure() - } else { - self.value.clone() - }; - - find_cursor_position( - renderer, - text_layout.bounds(), - self.font.clone(), - self.size, - &value, - &self.state, - target, - ) + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + let state = state(); + let is_clicked = layout.bounds().contains(cursor_position); + + state.is_focused = is_clicked; + + if is_clicked { + let text_layout = layout.children().next().unwrap(); + let target = cursor_position.x - text_layout.bounds().x; + + let click = + mouse::Click::new(cursor_position, state.last_click); + + match click.kind() { + click::Kind::Single => { + let position = if target > 0.0 { + let value = if is_secure { + value.secure() } else { - None + value.clone() }; - self.state.cursor.move_to(position.unwrap_or(0)); - self.state.is_dragging = true; - } - click::Kind::Double => { - if self.is_secure { - self.state.cursor.select_all(&self.value); - } else { - let position = find_cursor_position( - renderer, - text_layout.bounds(), - self.font.clone(), - self.size, - &self.value, - &self.state, - target, - ) - .unwrap_or(0); - - self.state.cursor.select_range( - self.value.previous_start_of_word(position), - self.value.next_end_of_word(position), - ); - } + find_cursor_position( + renderer, + text_layout.bounds(), + font.clone(), + size, + &value, + state, + target, + ) + } else { + None + }; - self.state.is_dragging = false; - } - click::Kind::Triple => { - self.state.cursor.select_all(&self.value); - self.state.is_dragging = false; + state.cursor.move_to(position.unwrap_or(0)); + state.is_dragging = true; + } + click::Kind::Double => { + if is_secure { + state.cursor.select_all(value); + } else { + let position = find_cursor_position( + renderer, + text_layout.bounds(), + font.clone(), + size, + value, + state, + target, + ) + .unwrap_or(0); + + state.cursor.select_range( + value.previous_start_of_word(position), + value.next_end_of_word(position), + ); } + + state.is_dragging = false; + } + click::Kind::Triple => { + state.cursor.select_all(value); + state.is_dragging = false; } + } - self.state.last_click = Some(click); + state.last_click = Some(click); - return event::Status::Captured; - } - } - Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) - | Event::Touch(touch::Event::FingerLifted { .. }) - | Event::Touch(touch::Event::FingerLost { .. }) => { - self.state.is_dragging = false; + return event::Status::Captured; } - Event::Mouse(mouse::Event::CursorMoved { position }) - | Event::Touch(touch::Event::FingerMoved { position, .. }) => { - if self.state.is_dragging { - let text_layout = layout.children().next().unwrap(); - let target = position.x - text_layout.bounds().x; - - let value = if self.is_secure { - self.value.secure() - } else { - self.value.clone() - }; + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerLifted { .. }) + | Event::Touch(touch::Event::FingerLost { .. }) => { + state().is_dragging = false; + } + Event::Mouse(mouse::Event::CursorMoved { position }) + | Event::Touch(touch::Event::FingerMoved { position, .. }) => { + let state = state(); - let position = find_cursor_position( - renderer, - text_layout.bounds(), - self.font.clone(), - self.size, - &value, - &self.state, - target, - ) - .unwrap_or(0); + if state.is_dragging { + let text_layout = layout.children().next().unwrap(); + let target = position.x - text_layout.bounds().x; - self.state.cursor.select_range( - self.state.cursor.start(&value), - position, - ); + let value = if is_secure { + value.secure() + } else { + value.clone() + }; + + let position = find_cursor_position( + renderer, + text_layout.bounds(), + font.clone(), + size, + &value, + state, + target, + ) + .unwrap_or(0); + + state + .cursor + .select_range(state.cursor.start(&value), position); - return event::Status::Captured; - } + return event::Status::Captured; } - Event::Keyboard(keyboard::Event::CharacterReceived(c)) - if self.state.is_focused - && self.state.is_pasting.is_none() - && !self.state.keyboard_modifiers.command() - && !c.is_control() => + } + Event::Keyboard(keyboard::Event::CharacterReceived(c)) => { + let state = state(); + + if state.is_focused + && state.is_pasting.is_none() + && !state.keyboard_modifiers.command() + && !c.is_control() { - let mut editor = - Editor::new(&mut self.value, &mut self.state.cursor); + let mut editor = Editor::new(value, &mut state.cursor); editor.insert(c); - let message = (self.on_change)(editor.contents()); + let message = (on_change)(editor.contents()); shell.publish(message); return event::Status::Captured; } - Event::Keyboard(keyboard::Event::KeyPressed { - key_code, .. - }) if self.state.is_focused => { - let modifiers = self.state.keyboard_modifiers; + } + Event::Keyboard(keyboard::Event::KeyPressed { key_code, .. }) => { + let state = state(); + + if state.is_focused { + let modifiers = state.keyboard_modifiers; match key_code { keyboard::KeyCode::Enter | keyboard::KeyCode::NumpadEnter => { - if let Some(on_submit) = self.on_submit.clone() { + if let Some(on_submit) = on_submit.clone() { shell.publish(on_submit); } } keyboard::KeyCode::Backspace => { if platform::is_jump_modifier_pressed(modifiers) - && self - .state - .cursor - .selection(&self.value) - .is_none() + && state.cursor.selection(value).is_none() { - if self.is_secure { - let cursor_pos = - self.state.cursor.end(&self.value); - self.state.cursor.select_range(0, cursor_pos); + if is_secure { + let cursor_pos = state.cursor.end(value); + state.cursor.select_range(0, cursor_pos); } else { - self.state - .cursor - .select_left_by_words(&self.value); + state.cursor.select_left_by_words(value); } } - let mut editor = Editor::new( - &mut self.value, - &mut self.state.cursor, - ); - + let mut editor = Editor::new(value, &mut state.cursor); editor.backspace(); - let message = (self.on_change)(editor.contents()); + let message = (on_change)(editor.contents()); shell.publish(message); } keyboard::KeyCode::Delete => { if platform::is_jump_modifier_pressed(modifiers) - && self - .state - .cursor - .selection(&self.value) - .is_none() + && state.cursor.selection(value).is_none() { - if self.is_secure { - let cursor_pos = - self.state.cursor.end(&self.value); - self.state + if is_secure { + let cursor_pos = state.cursor.end(value); + state .cursor - .select_range(cursor_pos, self.value.len()); + .select_range(cursor_pos, value.len()); } else { - self.state - .cursor - .select_right_by_words(&self.value); + state.cursor.select_right_by_words(value); } } - let mut editor = Editor::new( - &mut self.value, - &mut self.state.cursor, - ); - + let mut editor = Editor::new(value, &mut state.cursor); editor.delete(); - let message = (self.on_change)(editor.contents()); + let message = (on_change)(editor.contents()); shell.publish(message); } keyboard::KeyCode::Left => { if platform::is_jump_modifier_pressed(modifiers) - && !self.is_secure + && !is_secure { if modifiers.shift() { - self.state - .cursor - .select_left_by_words(&self.value); + state.cursor.select_left_by_words(value); } else { - self.state - .cursor - .move_left_by_words(&self.value); + state.cursor.move_left_by_words(value); } } else if modifiers.shift() { - self.state.cursor.select_left(&self.value) + state.cursor.select_left(value) } else { - self.state.cursor.move_left(&self.value); + state.cursor.move_left(value); } } keyboard::KeyCode::Right => { if platform::is_jump_modifier_pressed(modifiers) - && !self.is_secure + && !is_secure { if modifiers.shift() { - self.state - .cursor - .select_right_by_words(&self.value); + state.cursor.select_right_by_words(value); } else { - self.state - .cursor - .move_right_by_words(&self.value); + state.cursor.move_right_by_words(value); } } else if modifiers.shift() { - self.state.cursor.select_right(&self.value) + state.cursor.select_right(value) } else { - self.state.cursor.move_right(&self.value); + state.cursor.move_right(value); } } keyboard::KeyCode::Home => { if modifiers.shift() { - self.state.cursor.select_range( - self.state.cursor.start(&self.value), - 0, - ); + state + .cursor + .select_range(state.cursor.start(value), 0); } else { - self.state.cursor.move_to(0); + state.cursor.move_to(0); } } keyboard::KeyCode::End => { if modifiers.shift() { - self.state.cursor.select_range( - self.state.cursor.start(&self.value), - self.value.len(), + state.cursor.select_range( + state.cursor.start(value), + value.len(), ); } else { - self.state.cursor.move_to(self.value.len()); + state.cursor.move_to(value.len()); } } keyboard::KeyCode::C - if self.state.keyboard_modifiers.command() => + if state.keyboard_modifiers.command() => { - match self.state.cursor.selection(&self.value) { + match state.cursor.selection(value) { Some((start, end)) => { clipboard.write( - self.value.select(start, end).to_string(), + value.select(start, end).to_string(), ); } None => {} } } keyboard::KeyCode::X - if self.state.keyboard_modifiers.command() => + if state.keyboard_modifiers.command() => { - match self.state.cursor.selection(&self.value) { + match state.cursor.selection(value) { Some((start, end)) => { clipboard.write( - self.value.select(start, end).to_string(), + value.select(start, end).to_string(), ); } None => {} } - let mut editor = Editor::new( - &mut self.value, - &mut self.state.cursor, - ); - + let mut editor = Editor::new(value, &mut state.cursor); editor.delete(); - let message = (self.on_change)(editor.contents()); + let message = (on_change)(editor.contents()); shell.publish(message); } keyboard::KeyCode::V => { - if self.state.keyboard_modifiers.command() { - let content = match self.state.is_pasting.take() { + if state.keyboard_modifiers.command() { + let content = match state.is_pasting.take() { Some(content) => content, None => { let content: String = clipboard @@ -693,32 +476,30 @@ where } }; - let mut editor = Editor::new( - &mut self.value, - &mut self.state.cursor, - ); + let mut editor = + Editor::new(value, &mut state.cursor); editor.paste(content.clone()); - let message = (self.on_change)(editor.contents()); + let message = (on_change)(editor.contents()); shell.publish(message); - self.state.is_pasting = Some(content); + state.is_pasting = Some(content); } else { - self.state.is_pasting = None; + state.is_pasting = None; } } keyboard::KeyCode::A - if self.state.keyboard_modifiers.command() => + if state.keyboard_modifiers.command() => { - self.state.cursor.select_all(&self.value); + state.cursor.select_all(value); } keyboard::KeyCode::Escape => { - self.state.is_focused = false; - self.state.is_dragging = false; - self.state.is_pasting = None; + state.is_focused = false; + state.is_dragging = false; + state.is_pasting = None; - self.state.keyboard_modifiers = + state.keyboard_modifiers = keyboard::Modifiers::default(); } keyboard::KeyCode::Tab @@ -728,15 +509,17 @@ where } _ => {} } - - return event::Status::Captured; } - Event::Keyboard(keyboard::Event::KeyReleased { - key_code, .. - }) if self.state.is_focused => { + + return event::Status::Captured; + } + Event::Keyboard(keyboard::Event::KeyReleased { key_code, .. }) => { + let state = state(); + + if state.is_focused { match key_code { keyboard::KeyCode::V => { - self.state.is_pasting = None; + state.is_pasting = None; } keyboard::KeyCode::Tab | keyboard::KeyCode::Up @@ -748,15 +531,246 @@ where return event::Status::Captured; } - Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) - if self.state.is_focused => - { - self.state.keyboard_modifiers = modifiers; + } + Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { + let state = state(); + + if state.is_focused { + state.keyboard_modifiers = modifiers; + } + } + _ => {} + } + + event::Status::Ignored +} + +/// Draws the [`TextInput`] with the given [`Renderer`], overriding its +/// [`Value`] if provided. +pub fn draw<Renderer>( + renderer: &mut Renderer, + layout: Layout<'_>, + cursor_position: Point, + state: &State, + value: &Value, + placeholder: &str, + size: Option<u16>, + font: &Renderer::Font, + is_secure: bool, + style_sheet: &dyn StyleSheet, +) where + Renderer: text::Renderer, +{ + let secure_value = is_secure.then(|| value.secure()); + let value = secure_value.as_ref().unwrap_or(&value); + + let bounds = layout.bounds(); + let text_bounds = layout.children().next().unwrap().bounds(); + + let is_mouse_over = bounds.contains(cursor_position); + + let style = if state.is_focused() { + style_sheet.focused() + } else if is_mouse_over { + style_sheet.hovered() + } else { + style_sheet.active() + }; + + renderer.fill_quad( + renderer::Quad { + bounds, + border_radius: style.border_radius, + border_width: style.border_width, + border_color: style.border_color, + }, + style.background, + ); + + let text = value.to_string(); + let size = size.unwrap_or(renderer.default_size()); + + let (cursor, offset) = if state.is_focused() { + match state.cursor.state(&value) { + cursor::State::Index(position) => { + let (text_value_width, offset) = + measure_cursor_and_scroll_offset( + renderer, + text_bounds, + &value, + size, + position, + font.clone(), + ); + + ( + Some(( + renderer::Quad { + bounds: Rectangle { + x: text_bounds.x + text_value_width, + y: text_bounds.y, + width: 1.0, + height: text_bounds.height, + }, + border_radius: 0.0, + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + style_sheet.value_color(), + )), + offset, + ) + } + cursor::State::Selection { start, end } => { + let left = start.min(end); + let right = end.max(start); + + let (left_position, left_offset) = + measure_cursor_and_scroll_offset( + renderer, + text_bounds, + &value, + size, + left, + font.clone(), + ); + + let (right_position, right_offset) = + measure_cursor_and_scroll_offset( + renderer, + text_bounds, + &value, + size, + right, + font.clone(), + ); + + let width = right_position - left_position; + + ( + Some(( + renderer::Quad { + bounds: Rectangle { + x: text_bounds.x + left_position, + y: text_bounds.y, + width, + height: text_bounds.height, + }, + border_radius: 0.0, + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + style_sheet.selection_color(), + )), + if end == right { + right_offset + } else { + left_offset + }, + ) } - _ => {} } + } else { + (None, 0.0) + }; + + let text_width = renderer.measure_width( + if text.is_empty() { placeholder } else { &text }, + size, + font.clone(), + ); + + let render = |renderer: &mut Renderer| { + if let Some((cursor, color)) = cursor { + renderer.fill_quad(cursor, color); + } + + renderer.fill_text(Text { + content: if text.is_empty() { placeholder } else { &text }, + color: if text.is_empty() { + style_sheet.placeholder_color() + } else { + style_sheet.value_color() + }, + font: font.clone(), + bounds: Rectangle { + y: text_bounds.center_y(), + width: f32::INFINITY, + ..text_bounds + }, + size: f32::from(size), + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Center, + }); + }; + + if text_width > text_bounds.width { + renderer.with_layer(text_bounds, |renderer| { + renderer.with_translation(Vector::new(-offset, 0.0), render) + }); + } else { + render(renderer); + } +} - event::Status::Ignored +/// Computes the current [`mouse::Interaction`] of the [`TextInput`]. +pub fn mouse_interaction( + layout: Layout<'_>, + cursor_position: Point, +) -> mouse::Interaction { + if layout.bounds().contains(cursor_position) { + mouse::Interaction::Text + } else { + mouse::Interaction::default() + } +} + +impl<'a, Message, Renderer> Widget<Message, Renderer> + for TextInput<'a, Message, Renderer> +where + Message: Clone, + Renderer: text::Renderer, +{ + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + Length::Shrink + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout(renderer, limits, self.width, self.padding, self.size) + } + + fn on_event( + &mut self, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + update( + event, + layout, + cursor_position, + renderer, + clipboard, + shell, + &mut self.value, + self.size, + &self.font, + self.is_secure, + self.on_change.as_ref(), + &self.on_submit, + || &mut self.state, + ) } fn mouse_interaction( @@ -766,11 +780,7 @@ where _viewport: &Rectangle, _renderer: &Renderer, ) -> mouse::Interaction { - if layout.bounds().contains(cursor_position) { - mouse::Interaction::Text - } else { - mouse::Interaction::default() - } + mouse_interaction(layout, cursor_position) } fn draw( @@ -781,7 +791,18 @@ where cursor_position: Point, _viewport: &Rectangle, ) { - self.draw(renderer, layout, cursor_position, None) + draw( + renderer, + layout, + cursor_position, + &self.state, + &self.value, + &self.placeholder, + self.size, + &self.font, + self.is_secure, + self.style_sheet.as_ref(), + ) } } diff --git a/native/src/widget/toggler.rs b/native/src/widget/toggler.rs index 48237edb..a7847871 100644 --- a/native/src/widget/toggler.rs +++ b/native/src/widget/toggler.rs @@ -32,7 +32,7 @@ pub use iced_style::toggler::{Style, StyleSheet}; #[allow(missing_debug_implementations)] pub struct Toggler<'a, Message, Renderer: text::Renderer> { is_active: bool, - on_toggle: Box<dyn Fn(bool) -> Message>, + on_toggle: Box<dyn Fn(bool) -> Message + 'a>, label: Option<String>, width: Length, size: u16, @@ -61,7 +61,7 @@ impl<'a, Message, Renderer: text::Renderer> Toggler<'a, Message, Renderer> { f: F, ) -> Self where - F: 'static + Fn(bool) -> Message, + F: 'a + Fn(bool) -> Message, { Toggler { is_active, diff --git a/pure/Cargo.toml b/pure/Cargo.toml new file mode 100644 index 00000000..317dccdf --- /dev/null +++ b/pure/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "iced_pure" +version = "0.1.0" +edition = "2021" + +[dependencies] +iced_native = { version = "0.4", path = "../native" } +iced_style = { version = "0.3", path = "../style" } +num-traits = "0.2" diff --git a/pure/src/flex.rs b/pure/src/flex.rs new file mode 100644 index 00000000..8d473f08 --- /dev/null +++ b/pure/src/flex.rs @@ -0,0 +1,232 @@ +//! Distribute elements using a flex-based layout. +// This code is heavily inspired by the [`druid`] codebase. +// +// [`druid`]: https://github.com/xi-editor/druid +// +// Copyright 2018 The xi-editor Authors, Héctor Ramón +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +use crate::Element; + +use iced_native::layout::{Limits, Node}; +use iced_native::{Alignment, Padding, Point, Size}; + +/// The main axis of a flex layout. +#[derive(Debug)] +pub enum Axis { + /// The horizontal axis + Horizontal, + + /// The vertical axis + Vertical, +} + +impl Axis { + fn main(&self, size: Size) -> f32 { + match self { + Axis::Horizontal => size.width, + Axis::Vertical => size.height, + } + } + + fn cross(&self, size: Size) -> f32 { + match self { + Axis::Horizontal => size.height, + Axis::Vertical => size.width, + } + } + + fn pack(&self, main: f32, cross: f32) -> (f32, f32) { + match self { + Axis::Horizontal => (main, cross), + Axis::Vertical => (cross, main), + } + } +} + +/// Computes the flex layout with the given axis and limits, applying spacing, +/// padding and alignment to the items as needed. +/// +/// It returns a new layout [`Node`]. +pub fn resolve<Message, Renderer>( + axis: Axis, + renderer: &Renderer, + limits: &Limits, + padding: Padding, + spacing: f32, + align_items: Alignment, + items: &[Element<Message, Renderer>], +) -> Node +where + Renderer: iced_native::Renderer, +{ + let limits = limits.pad(padding); + let total_spacing = spacing * items.len().saturating_sub(1) as f32; + let max_cross = axis.cross(limits.max()); + + let mut fill_sum = 0; + let mut cross = axis.cross(limits.min()).max(axis.cross(limits.fill())); + let mut available = axis.main(limits.max()) - total_spacing; + + let mut nodes: Vec<Node> = Vec::with_capacity(items.len()); + nodes.resize(items.len(), Node::default()); + + if align_items == Alignment::Fill { + let mut fill_cross = axis.cross(limits.min()); + + items.iter().for_each(|child| { + let cross_fill_factor = match axis { + Axis::Horizontal => child.as_widget().height(), + Axis::Vertical => child.as_widget().width(), + } + .fill_factor(); + + if cross_fill_factor == 0 { + let (max_width, max_height) = axis.pack(available, max_cross); + + let child_limits = + Limits::new(Size::ZERO, Size::new(max_width, max_height)); + + let layout = child.as_widget().layout(renderer, &child_limits); + let size = layout.size(); + + fill_cross = fill_cross.max(axis.cross(size)); + } + }); + + cross = fill_cross; + } + + for (i, child) in items.iter().enumerate() { + let fill_factor = match axis { + Axis::Horizontal => child.as_widget().width(), + Axis::Vertical => child.as_widget().height(), + } + .fill_factor(); + + if fill_factor == 0 { + let (min_width, min_height) = if align_items == Alignment::Fill { + axis.pack(0.0, cross) + } else { + axis.pack(0.0, 0.0) + }; + + let (max_width, max_height) = if align_items == Alignment::Fill { + axis.pack(available, cross) + } else { + axis.pack(available, max_cross) + }; + + let child_limits = Limits::new( + Size::new(min_width, min_height), + Size::new(max_width, max_height), + ); + + let layout = child.as_widget().layout(renderer, &child_limits); + let size = layout.size(); + + available -= axis.main(size); + + if align_items != Alignment::Fill { + cross = cross.max(axis.cross(size)); + } + + nodes[i] = layout; + } else { + fill_sum += fill_factor; + } + } + + let remaining = available.max(0.0); + + for (i, child) in items.iter().enumerate() { + let fill_factor = match axis { + Axis::Horizontal => child.as_widget().width(), + Axis::Vertical => child.as_widget().height(), + } + .fill_factor(); + + if fill_factor != 0 { + let max_main = remaining * fill_factor as f32 / fill_sum as f32; + let min_main = if max_main.is_infinite() { + 0.0 + } else { + max_main + }; + + let (min_width, min_height) = if align_items == Alignment::Fill { + axis.pack(min_main, cross) + } else { + axis.pack(min_main, axis.cross(limits.min())) + }; + + let (max_width, max_height) = if align_items == Alignment::Fill { + axis.pack(max_main, cross) + } else { + axis.pack(max_main, max_cross) + }; + + let child_limits = Limits::new( + Size::new(min_width, min_height), + Size::new(max_width, max_height), + ); + + let layout = child.as_widget().layout(renderer, &child_limits); + + if align_items != Alignment::Fill { + cross = cross.max(axis.cross(layout.size())); + } + + nodes[i] = layout; + } + } + + let pad = axis.pack(padding.left as f32, padding.top as f32); + let mut main = pad.0; + + for (i, node) in nodes.iter_mut().enumerate() { + if i > 0 { + main += spacing; + } + + let (x, y) = axis.pack(main, pad.1); + + node.move_to(Point::new(x, y)); + + match axis { + Axis::Horizontal => { + node.align( + Alignment::Start, + align_items, + Size::new(0.0, cross), + ); + } + Axis::Vertical => { + node.align( + align_items, + Alignment::Start, + Size::new(cross, 0.0), + ); + } + } + + let size = node.size(); + + main += axis.main(size); + } + + let (width, height) = axis.pack(main - pad.0, cross); + let size = limits.resolve(Size::new(width, height)); + + Node::with_children(size.pad(padding), nodes) +} diff --git a/pure/src/lib.rs b/pure/src/lib.rs new file mode 100644 index 00000000..1b51d55b --- /dev/null +++ b/pure/src/lib.rs @@ -0,0 +1,152 @@ +pub mod overlay; +pub mod widget; + +pub(crate) mod flex; + +pub use widget::*; + +use iced_native::event::{self, Event}; +use iced_native::layout::{self, Layout}; +use iced_native::mouse; +use iced_native::renderer; +use iced_native::{Clipboard, Length, Point, Rectangle, Shell}; + +pub struct Pure<'a, Message, Renderer> { + state: &'a mut State, + element: Element<'a, Message, Renderer>, +} + +impl<'a, Message, Renderer> Pure<'a, Message, Renderer> +where + Message: 'static, + Renderer: iced_native::Renderer + 'static, +{ + pub fn new( + state: &'a mut State, + content: impl Into<Element<'a, Message, Renderer>>, + ) -> Self { + let element = content.into(); + let _ = state.diff(&element); + + Self { state, element } + } +} + +pub struct State { + state_tree: widget::Tree, +} + +impl State { + pub fn new() -> Self { + Self { + state_tree: widget::Tree::empty(), + } + } + + fn diff<Message, Renderer>( + &mut self, + new_element: &Element<Message, Renderer>, + ) { + self.state_tree.diff(new_element); + } +} + +impl<'a, Message, Renderer> iced_native::Widget<Message, Renderer> + for Pure<'a, Message, Renderer> +where + Message: 'a, + Renderer: iced_native::Renderer + 'a, +{ + fn width(&self) -> Length { + self.element.as_widget().width() + } + + fn height(&self) -> Length { + self.element.as_widget().height() + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.element.as_widget().layout(renderer, limits) + } + + fn on_event( + &mut self, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + self.element.as_widget_mut().on_event( + &mut self.state.state_tree, + event, + layout, + cursor_position, + renderer, + clipboard, + shell, + ) + } + + fn draw( + &self, + renderer: &mut Renderer, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + ) { + self.element.as_widget().draw( + &self.state.state_tree, + renderer, + style, + layout, + cursor_position, + viewport, + ) + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.element.as_widget().mouse_interaction( + &self.state.state_tree, + layout, + cursor_position, + viewport, + renderer, + ) + } + + fn overlay( + &mut self, + layout: Layout<'_>, + renderer: &Renderer, + ) -> Option<overlay::Element<'_, Message, Renderer>> { + self.element.as_widget_mut().overlay( + &mut self.state.state_tree, + layout, + renderer, + ) + } +} + +impl<'a, Message, Renderer> Into<iced_native::Element<'a, Message, Renderer>> + for Pure<'a, Message, Renderer> +where + Message: 'a, + Renderer: iced_native::Renderer + 'a, +{ + fn into(self) -> iced_native::Element<'a, Message, Renderer> { + iced_native::Element::new(self) + } +} diff --git a/pure/src/overlay.rs b/pure/src/overlay.rs new file mode 100644 index 00000000..72415634 --- /dev/null +++ b/pure/src/overlay.rs @@ -0,0 +1,21 @@ +use crate::Tree; + +use iced_native::Layout; + +pub use iced_native::overlay::*; + +pub fn from_children<'a, Message, Renderer>( + children: &'a [crate::Element<'_, Message, Renderer>], + tree: &'a mut Tree, + layout: Layout<'_>, + renderer: &Renderer, +) -> Option<Element<'a, Message, Renderer>> { + children + .iter() + .zip(&mut tree.children) + .zip(layout.children()) + .filter_map(|((child, state), layout)| { + child.as_widget().overlay(state, layout, renderer) + }) + .next() +} diff --git a/pure/src/widget.rs b/pure/src/widget.rs new file mode 100644 index 00000000..c516c1f2 --- /dev/null +++ b/pure/src/widget.rs @@ -0,0 +1,236 @@ +pub mod image; +pub mod tree; + +mod button; +mod checkbox; +mod column; +mod container; +mod element; +mod pick_list; +mod radio; +mod row; +mod scrollable; +mod slider; +mod space; +mod text; +mod text_input; +mod toggler; + +pub use button::Button; +pub use checkbox::Checkbox; +pub use column::Column; +pub use container::Container; +pub use element::Element; +pub use image::Image; +pub use pick_list::PickList; +pub use radio::Radio; +pub use row::Row; +pub use scrollable::Scrollable; +pub use slider::Slider; +pub use space::Space; +pub use text::Text; +pub use text_input::TextInput; +pub use toggler::Toggler; +pub use tree::Tree; + +use iced_native::event::{self, Event}; +use iced_native::layout::{self, Layout}; +use iced_native::mouse; +use iced_native::overlay; +use iced_native::renderer; +use iced_native::{Clipboard, Length, Point, Rectangle, Shell}; + +use std::borrow::Cow; + +pub trait Widget<Message, Renderer> { + fn width(&self) -> Length; + + fn height(&self) -> Length; + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node; + + fn draw( + &self, + state: &Tree, + renderer: &mut Renderer, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + ); + + fn tag(&self) -> tree::Tag { + tree::Tag::stateless() + } + + fn state(&self) -> tree::State { + tree::State::None + } + + fn children(&self) -> Vec<Tree> { + Vec::new() + } + + fn diff(&self, _tree: &mut Tree) {} + + fn mouse_interaction( + &self, + _state: &Tree, + _layout: Layout<'_>, + _cursor_position: Point, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + mouse::Interaction::Idle + } + + fn on_event( + &mut self, + _state: &mut Tree, + _event: Event, + _layout: Layout<'_>, + _cursor_position: Point, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + _shell: &mut Shell<'_, Message>, + ) -> event::Status { + event::Status::Ignored + } + + fn overlay<'a>( + &'a self, + _state: &'a mut Tree, + _layout: Layout<'_>, + _renderer: &Renderer, + ) -> Option<overlay::Element<'a, Message, Renderer>> { + None + } +} + +pub fn container<'a, Message, Renderer>( + content: impl Into<Element<'a, Message, Renderer>>, +) -> Container<'a, Message, Renderer> +where + Renderer: iced_native::Renderer, +{ + Container::new(content) +} + +pub fn column<'a, Message, Renderer>() -> Column<'a, Message, Renderer> { + Column::new() +} + +pub fn row<'a, Message, Renderer>() -> Row<'a, Message, Renderer> { + Row::new() +} + +pub fn scrollable<'a, Message, Renderer>( + content: impl Into<Element<'a, Message, Renderer>>, +) -> Scrollable<'a, Message, Renderer> +where + Renderer: iced_native::Renderer, +{ + Scrollable::new(content) +} + +pub fn button<'a, Message, Renderer>( + content: impl Into<Element<'a, Message, Renderer>>, +) -> Button<'a, Message, Renderer> { + Button::new(content) +} + +pub fn text<Renderer>(text: impl Into<String>) -> Text<Renderer> +where + Renderer: iced_native::text::Renderer, +{ + Text::new(text) +} + +pub fn checkbox<'a, Message, Renderer>( + label: impl Into<String>, + is_checked: bool, + f: impl Fn(bool) -> Message + 'a, +) -> Checkbox<'a, Message, Renderer> +where + Renderer: iced_native::text::Renderer, +{ + Checkbox::new(is_checked, label, f) +} + +pub fn radio<'a, Message, Renderer, V>( + label: impl Into<String>, + value: V, + selected: Option<V>, + on_click: impl FnOnce(V) -> Message, +) -> Radio<'a, Message, Renderer> +where + Message: Clone, + Renderer: iced_native::text::Renderer, + V: Copy + Eq, +{ + Radio::new(value, label, selected, on_click) +} + +pub fn toggler<'a, Message, Renderer>( + label: impl Into<Option<String>>, + is_checked: bool, + f: impl Fn(bool) -> Message + 'a, +) -> Toggler<'a, Message, Renderer> +where + Renderer: iced_native::text::Renderer, +{ + Toggler::new(is_checked, label, f) +} + +pub fn text_input<'a, Message, Renderer>( + placeholder: &str, + value: &str, + on_change: impl Fn(String) -> Message + 'a, +) -> TextInput<'a, Message, Renderer> +where + Message: Clone, + Renderer: iced_native::text::Renderer, +{ + TextInput::new(placeholder, value, on_change) +} + +pub fn slider<'a, Message, T>( + range: std::ops::RangeInclusive<T>, + value: T, + on_change: impl Fn(T) -> Message + 'a, +) -> Slider<'a, T, Message> +where + Message: Clone, + T: Copy + From<u8> + std::cmp::PartialOrd, +{ + Slider::new(range, value, on_change) +} + +pub fn pick_list<'a, Message, Renderer, T>( + options: impl Into<Cow<'a, [T]>>, + selected: Option<T>, + on_selected: impl Fn(T) -> Message + 'a, +) -> PickList<'a, T, Message, Renderer> +where + T: ToString + Eq + 'static, + [T]: ToOwned<Owned = Vec<T>>, + Renderer: iced_native::text::Renderer, +{ + PickList::new(options, selected, on_selected) +} + +pub fn image<Handle>(handle: impl Into<Handle>) -> Image<Handle> { + Image::new(handle.into()) +} + +pub fn horizontal_space(width: Length) -> Space { + Space::with_width(width) +} + +pub fn vertical_space(height: Length) -> Space { + Space::with_height(height) +} diff --git a/pure/src/widget/button.rs b/pure/src/widget/button.rs new file mode 100644 index 00000000..4380b608 --- /dev/null +++ b/pure/src/widget/button.rs @@ -0,0 +1,224 @@ +use crate::overlay; +use crate::widget::tree::{self, Tree}; +use crate::widget::{Element, Widget}; + +use iced_native::event::{self, Event}; +use iced_native::layout; +use iced_native::mouse; +use iced_native::renderer; +use iced_native::widget::button; +use iced_native::{ + Clipboard, Layout, Length, Padding, Point, Rectangle, Shell, +}; +use iced_style::button::StyleSheet; + +pub use button::State; + +pub struct Button<'a, Message, Renderer> { + content: Element<'a, Message, Renderer>, + on_press: Option<Message>, + style_sheet: Box<dyn StyleSheet + 'a>, + width: Length, + height: Length, + padding: Padding, +} + +impl<'a, Message, Renderer> Button<'a, Message, Renderer> { + pub fn new(content: impl Into<Element<'a, Message, Renderer>>) -> Self { + Button { + content: content.into(), + on_press: None, + style_sheet: Default::default(), + width: Length::Shrink, + height: Length::Shrink, + padding: Padding::new(5), + } + } + + /// Sets the width of the [`Button`]. + pub fn width(mut self, width: Length) -> Self { + self.width = width; + self + } + + /// Sets the height of the [`Button`]. + pub fn height(mut self, height: Length) -> Self { + self.height = height; + self + } + + /// Sets the [`Padding`] of the [`Button`]. + pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the message that will be produced when the [`Button`] is pressed. + /// + /// Unless `on_press` is called, the [`Button`] will be disabled. + pub fn on_press(mut self, msg: Message) -> Self { + self.on_press = Some(msg); + self + } + + /// Sets the style of the [`Button`]. + pub fn style( + mut self, + style_sheet: impl Into<Box<dyn StyleSheet + 'a>>, + ) -> Self { + self.style_sheet = style_sheet.into(); + self + } +} + +impl<'a, Message, Renderer> Widget<Message, Renderer> + for Button<'a, Message, Renderer> +where + Message: 'static + Clone, + Renderer: 'static + iced_native::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::<State>() + } + + fn state(&self) -> tree::State { + tree::State::new(State::new()) + } + + fn children(&self) -> Vec<Tree> { + vec![Tree::new(&self.content)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)) + } + + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + self.height + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + button::layout( + renderer, + limits, + self.width, + self.height, + self.padding, + |renderer, limits| { + self.content.as_widget().layout(renderer, &limits) + }, + ) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + if let event::Status::Captured = self.content.as_widget_mut().on_event( + &mut tree.children[0], + event.clone(), + layout.children().next().unwrap(), + cursor_position, + renderer, + clipboard, + shell, + ) { + return event::Status::Captured; + } + + button::update( + event, + layout, + cursor_position, + shell, + &self.on_press, + || tree.state.downcast_mut::<State>(), + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + _style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + ) { + let bounds = layout.bounds(); + let content_layout = layout.children().next().unwrap(); + + let styling = button::draw( + renderer, + bounds, + cursor_position, + self.on_press.is_some(), + self.style_sheet.as_ref(), + || tree.state.downcast_ref::<State>(), + ); + + self.content.as_widget().draw( + &tree.children[0], + renderer, + &renderer::Style { + text_color: styling.text_color, + }, + content_layout, + cursor_position, + &bounds, + ); + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + button::mouse_interaction( + layout, + cursor_position, + self.on_press.is_some(), + ) + } + + fn overlay<'b>( + &'b self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + ) -> Option<overlay::Element<'b, Message, Renderer>> { + self.content.as_widget().overlay( + &mut tree.children[0], + layout.children().next().unwrap(), + renderer, + ) + } +} + +impl<'a, Message, Renderer> Into<Element<'a, Message, Renderer>> + for Button<'a, Message, Renderer> +where + Message: Clone + 'static, + Renderer: iced_native::Renderer + 'static, +{ + fn into(self) -> Element<'a, Message, Renderer> { + Element::new(self) + } +} diff --git a/pure/src/widget/checkbox.rs b/pure/src/widget/checkbox.rs new file mode 100644 index 00000000..3448e616 --- /dev/null +++ b/pure/src/widget/checkbox.rs @@ -0,0 +1,102 @@ +use crate::{Element, Tree, Widget}; + +use iced_native::event::{self, Event}; +use iced_native::layout::{self, Layout}; +use iced_native::mouse; +use iced_native::renderer; +use iced_native::text; +use iced_native::{Clipboard, Length, Point, Rectangle, Shell}; + +pub use iced_native::widget::Checkbox; + +impl<'a, Message, Renderer> Widget<Message, Renderer> + for Checkbox<'a, Message, Renderer> +where + Renderer: text::Renderer, +{ + fn width(&self) -> Length { + <Self as iced_native::Widget<Message, Renderer>>::width(self) + } + + fn height(&self) -> Length { + <Self as iced_native::Widget<Message, Renderer>>::height(self) + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + <Self as iced_native::Widget<Message, Renderer>>::layout( + self, renderer, limits, + ) + } + + fn on_event( + &mut self, + _state: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + <Self as iced_native::Widget<Message, Renderer>>::on_event( + self, + event, + layout, + cursor_position, + renderer, + clipboard, + shell, + ) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + ) { + <Self as iced_native::Widget<Message, Renderer>>::draw( + self, + renderer, + style, + layout, + cursor_position, + viewport, + ) + } + + fn mouse_interaction( + &self, + _state: &Tree, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + <Self as iced_native::Widget<Message, Renderer>>::mouse_interaction( + self, + layout, + cursor_position, + viewport, + renderer, + ) + } +} + +impl<'a, Message, Renderer> Into<Element<'a, Message, Renderer>> + for Checkbox<'a, Message, Renderer> +where + Message: 'a, + Renderer: text::Renderer + 'a, +{ + fn into(self) -> Element<'a, Message, Renderer> { + Element::new(self) + } +} diff --git a/pure/src/widget/column.rs b/pure/src/widget/column.rs new file mode 100644 index 00000000..37ff96c5 --- /dev/null +++ b/pure/src/widget/column.rs @@ -0,0 +1,224 @@ +use crate::flex; +use crate::overlay; +use crate::widget::{Element, Tree, Widget}; + +use iced_native::event::{self, Event}; +use iced_native::layout::{self, Layout}; +use iced_native::mouse; +use iced_native::renderer; +use iced_native::{ + Alignment, Clipboard, Length, Padding, Point, Rectangle, Shell, +}; + +use std::u32; + +pub struct Column<'a, Message, Renderer> { + spacing: u16, + padding: Padding, + width: Length, + height: Length, + max_width: u32, + align_items: Alignment, + children: Vec<Element<'a, Message, Renderer>>, +} + +impl<'a, Message, Renderer> Column<'a, Message, Renderer> { + pub fn new() -> Self { + Self::with_children(Vec::new()) + } + + pub fn with_children( + children: Vec<Element<'a, Message, Renderer>>, + ) -> Self { + Column { + spacing: 0, + padding: Padding::ZERO, + width: Length::Shrink, + height: Length::Shrink, + max_width: u32::MAX, + align_items: Alignment::Start, + children, + } + } + + pub fn spacing(mut self, units: u16) -> Self { + self.spacing = units; + self + } + + pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + pub fn width(mut self, width: Length) -> Self { + self.width = width; + self + } + + pub fn height(mut self, height: Length) -> Self { + self.height = height; + self + } + + /// Sets the maximum width of the [`Column`]. + pub fn max_width(mut self, max_width: u32) -> Self { + self.max_width = max_width; + self + } + + pub fn align_items(mut self, align: Alignment) -> Self { + self.align_items = align; + self + } + + pub fn push( + mut self, + child: impl Into<Element<'a, Message, Renderer>>, + ) -> Self { + self.children.push(child.into()); + self + } +} + +impl<'a, Message, Renderer> Widget<Message, Renderer> + for Column<'a, Message, Renderer> +where + Renderer: iced_native::Renderer, +{ + fn children(&self) -> Vec<Tree> { + self.children.iter().map(Tree::new).collect() + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(&self.children); + } + + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + self.height + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let limits = limits + .max_width(self.max_width) + .width(self.width) + .height(self.height); + + flex::resolve( + flex::Axis::Vertical, + renderer, + &limits, + self.padding, + self.spacing as f32, + self.align_items, + &self.children, + ) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + self.children + .iter_mut() + .zip(&mut tree.children) + .zip(layout.children()) + .map(|((child, state), layout)| { + child.as_widget_mut().on_event( + state, + event.clone(), + layout, + cursor_position, + renderer, + clipboard, + shell, + ) + }) + .fold(event::Status::Ignored, event::Status::merge) + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.children + .iter() + .zip(&tree.children) + .zip(layout.children()) + .map(|((child, state), layout)| { + child.as_widget().mouse_interaction( + state, + layout, + cursor_position, + viewport, + renderer, + ) + }) + .max() + .unwrap_or_default() + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + ) { + for ((child, state), layout) in self + .children + .iter() + .zip(&tree.children) + .zip(layout.children()) + { + child.as_widget().draw( + state, + renderer, + style, + layout, + cursor_position, + viewport, + ); + } + } + + fn overlay<'b>( + &'b self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + ) -> Option<overlay::Element<'b, Message, Renderer>> { + overlay::from_children(&self.children, tree, layout, renderer) + } +} + +impl<'a, Message, Renderer> Into<Element<'a, Message, Renderer>> + for Column<'a, Message, Renderer> +where + Message: 'static, + Renderer: iced_native::Renderer + 'static, +{ + fn into(self) -> Element<'a, Message, Renderer> { + Element::new(self) + } +} diff --git a/pure/src/widget/container.rs b/pure/src/widget/container.rs new file mode 100644 index 00000000..ebf69cab --- /dev/null +++ b/pure/src/widget/container.rs @@ -0,0 +1,251 @@ +//! Decorate content and apply alignment. +use crate::{Element, Tree, Widget}; + +use iced_native::alignment; +use iced_native::event::{self, Event}; +use iced_native::layout; +use iced_native::mouse; +use iced_native::overlay; +use iced_native::renderer; +use iced_native::widget::container; +use iced_native::{ + Clipboard, Layout, Length, Padding, Point, Rectangle, Shell, +}; + +use std::u32; + +pub use iced_style::container::{Style, StyleSheet}; + +/// An element decorating some content. +/// +/// It is normally used for alignment purposes. +#[allow(missing_debug_implementations)] +pub struct Container<'a, Message, Renderer> { + padding: Padding, + width: Length, + height: Length, + max_width: u32, + max_height: u32, + horizontal_alignment: alignment::Horizontal, + vertical_alignment: alignment::Vertical, + style_sheet: Box<dyn StyleSheet + 'a>, + content: Element<'a, Message, Renderer>, +} + +impl<'a, Message, Renderer> Container<'a, Message, Renderer> +where + Renderer: iced_native::Renderer, +{ + /// Creates an empty [`Container`]. + pub fn new<T>(content: T) -> Self + where + T: Into<Element<'a, Message, Renderer>>, + { + Container { + padding: Padding::ZERO, + width: Length::Shrink, + height: Length::Shrink, + max_width: u32::MAX, + max_height: u32::MAX, + horizontal_alignment: alignment::Horizontal::Left, + vertical_alignment: alignment::Vertical::Top, + style_sheet: Default::default(), + content: content.into(), + } + } + + /// Sets the [`Padding`] of the [`Container`]. + pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the width of the [`Container`]. + pub fn width(mut self, width: Length) -> Self { + self.width = width; + self + } + + /// Sets the height of the [`Container`]. + pub fn height(mut self, height: Length) -> Self { + self.height = height; + self + } + + /// Sets the maximum width of the [`Container`]. + pub fn max_width(mut self, max_width: u32) -> Self { + self.max_width = max_width; + self + } + + /// Sets the maximum height of the [`Container`] in pixels. + pub fn max_height(mut self, max_height: u32) -> Self { + self.max_height = max_height; + self + } + + /// Sets the content alignment for the horizontal axis of the [`Container`]. + pub fn align_x(mut self, alignment: alignment::Horizontal) -> Self { + self.horizontal_alignment = alignment; + self + } + + /// Sets the content alignment for the vertical axis of the [`Container`]. + pub fn align_y(mut self, alignment: alignment::Vertical) -> Self { + self.vertical_alignment = alignment; + self + } + + /// Centers the contents in the horizontal axis of the [`Container`]. + pub fn center_x(mut self) -> Self { + self.horizontal_alignment = alignment::Horizontal::Center; + self + } + + /// Centers the contents in the vertical axis of the [`Container`]. + pub fn center_y(mut self) -> Self { + self.vertical_alignment = alignment::Vertical::Center; + self + } + + /// Sets the style of the [`Container`]. + pub fn style( + mut self, + style_sheet: impl Into<Box<dyn StyleSheet + 'a>>, + ) -> Self { + self.style_sheet = style_sheet.into(); + self + } +} + +impl<'a, Message, Renderer> Widget<Message, Renderer> + for Container<'a, Message, Renderer> +where + Renderer: iced_native::Renderer, +{ + fn children(&self) -> Vec<Tree> { + vec![Tree::new(&self.content)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)) + } + + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + self.height + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + container::layout( + renderer, + limits, + self.width, + self.height, + self.padding, + self.horizontal_alignment, + self.vertical_alignment, + |renderer, limits| { + self.content.as_widget().layout(renderer, limits) + }, + ) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + self.content.as_widget_mut().on_event( + &mut tree.children[0], + event, + layout.children().next().unwrap(), + cursor_position, + renderer, + clipboard, + shell, + ) + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout.children().next().unwrap(), + cursor_position, + viewport, + renderer, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + renderer_style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + ) { + let style = self.style_sheet.style(); + + container::draw_background(renderer, &style, layout.bounds()); + + self.content.as_widget().draw( + &tree.children[0], + renderer, + &renderer::Style { + text_color: style + .text_color + .unwrap_or(renderer_style.text_color), + }, + layout.children().next().unwrap(), + cursor_position, + viewport, + ); + } + + fn overlay<'b>( + &'b self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + ) -> Option<overlay::Element<'b, Message, Renderer>> { + self.content.as_widget().overlay( + &mut tree.children[0], + layout.children().next().unwrap(), + renderer, + ) + } +} + +impl<'a, Message, Renderer> From<Container<'a, Message, Renderer>> + for Element<'a, Message, Renderer> +where + Renderer: 'a + iced_native::Renderer, + Message: 'a, +{ + fn from( + column: Container<'a, Message, Renderer>, + ) -> Element<'a, Message, Renderer> { + Element::new(column) + } +} diff --git a/pure/src/widget/element.rs b/pure/src/widget/element.rs new file mode 100644 index 00000000..3d5697fe --- /dev/null +++ b/pure/src/widget/element.rs @@ -0,0 +1,163 @@ +use crate::widget::tree::{self, Tree}; +use crate::widget::Widget; + +use iced_native::event::{self, Event}; +use iced_native::layout::{self, Layout}; +use iced_native::mouse; +use iced_native::renderer; +use iced_native::{Clipboard, Length, Point, Rectangle, Shell}; + +pub struct Element<'a, Message, Renderer> { + widget: Box<dyn Widget<Message, Renderer> + 'a>, +} + +impl<'a, Message, Renderer> Element<'a, Message, Renderer> { + pub fn new(widget: impl Widget<Message, Renderer> + 'a) -> Self { + Self { + widget: Box::new(widget), + } + } + + pub fn as_widget(&self) -> &dyn Widget<Message, Renderer> { + self.widget.as_ref() + } + + pub fn as_widget_mut(&mut self) -> &mut dyn Widget<Message, Renderer> { + self.widget.as_mut() + } + + pub fn map<B>( + self, + f: impl Fn(Message) -> B + 'a, + ) -> Element<'a, B, Renderer> + where + Message: 'a, + Renderer: iced_native::Renderer + 'a, + B: 'a, + { + Element::new(Map::new(self.widget, f)) + } +} + +struct Map<'a, A, B, Renderer> { + widget: Box<dyn Widget<A, Renderer> + 'a>, + mapper: Box<dyn Fn(A) -> B + 'a>, +} + +impl<'a, A, B, Renderer> Map<'a, A, B, Renderer> { + pub fn new<F>( + widget: Box<dyn Widget<A, Renderer> + 'a>, + mapper: F, + ) -> Map<'a, A, B, Renderer> + where + F: 'a + Fn(A) -> B, + { + Map { + widget, + mapper: Box::new(mapper), + } + } +} + +impl<'a, A, B, Renderer> Widget<B, Renderer> for Map<'a, A, B, Renderer> +where + Renderer: iced_native::Renderer + 'a, + A: 'a, + B: 'a, +{ + fn tag(&self) -> tree::Tag { + self.widget.tag() + } + + fn state(&self) -> tree::State { + self.widget.state() + } + + fn children(&self) -> Vec<Tree> { + self.widget.children() + } + + fn diff(&self, tree: &mut Tree) { + self.widget.diff(tree) + } + + fn width(&self) -> Length { + self.widget.width() + } + + fn height(&self) -> Length { + self.widget.height() + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.widget.layout(renderer, limits) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, B>, + ) -> event::Status { + let mut local_messages = Vec::new(); + let mut local_shell = Shell::new(&mut local_messages); + + let status = self.widget.on_event( + tree, + event, + layout, + cursor_position, + renderer, + clipboard, + &mut local_shell, + ); + + shell.merge(local_shell, &self.mapper); + + status + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + ) { + self.widget.draw( + tree, + renderer, + style, + layout, + cursor_position, + viewport, + ) + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.widget.mouse_interaction( + tree, + layout, + cursor_position, + viewport, + renderer, + ) + } +} diff --git a/pure/src/widget/image.rs b/pure/src/widget/image.rs new file mode 100644 index 00000000..a5bca5a0 --- /dev/null +++ b/pure/src/widget/image.rs @@ -0,0 +1,66 @@ +use crate::widget::{Tree, Widget}; +use crate::Element; + +use iced_native::layout::{self, Layout}; +use iced_native::renderer; +use iced_native::widget::image; +use iced_native::{Length, Point, Rectangle}; + +use std::hash::Hash; + +pub use image::Image; + +impl<Message, Renderer, Handle> Widget<Message, Renderer> for Image<Handle> +where + Handle: Clone + Hash, + Renderer: iced_native::image::Renderer<Handle = Handle>, +{ + fn width(&self) -> Length { + <Self as iced_native::Widget<Message, Renderer>>::width(self) + } + + fn height(&self) -> Length { + <Self as iced_native::Widget<Message, Renderer>>::height(self) + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + <Self as iced_native::Widget<Message, Renderer>>::layout( + self, renderer, limits, + ) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + ) { + <Self as iced_native::Widget<Message, Renderer>>::draw( + self, + renderer, + style, + layout, + cursor_position, + viewport, + ) + } +} + +impl<'a, Message, Renderer, Handle> Into<Element<'a, Message, Renderer>> + for Image<Handle> +where + Message: Clone + 'a, + Renderer: iced_native::image::Renderer<Handle = Handle> + 'a, + Handle: Clone + Hash + 'a, +{ + fn into(self) -> Element<'a, Message, Renderer> { + Element::new(self) + } +} diff --git a/pure/src/widget/pick_list.rs b/pure/src/widget/pick_list.rs new file mode 100644 index 00000000..9573f27a --- /dev/null +++ b/pure/src/widget/pick_list.rs @@ -0,0 +1,234 @@ +//! Display a dropdown list of selectable values. +use crate::widget::tree::{self, Tree}; +use crate::{Element, Widget}; + +use iced_native::event::{self, Event}; +use iced_native::layout; +use iced_native::mouse; +use iced_native::overlay; +use iced_native::renderer; +use iced_native::text; +use iced_native::widget::pick_list; +use iced_native::{ + Clipboard, Layout, Length, Padding, Point, Rectangle, Shell, +}; + +use std::borrow::Cow; + +pub use iced_style::pick_list::{Style, StyleSheet}; + +/// A widget for selecting a single value from a list of options. +#[allow(missing_debug_implementations)] +pub struct PickList<'a, T, Message, Renderer: text::Renderer> +where + [T]: ToOwned<Owned = Vec<T>>, +{ + on_selected: Box<dyn Fn(T) -> Message + 'a>, + options: Cow<'a, [T]>, + placeholder: Option<String>, + selected: Option<T>, + width: Length, + padding: Padding, + text_size: Option<u16>, + font: Renderer::Font, + style_sheet: Box<dyn StyleSheet + 'a>, +} + +impl<'a, T: 'a, Message, Renderer: text::Renderer> + PickList<'a, T, Message, Renderer> +where + T: ToString + Eq, + [T]: ToOwned<Owned = Vec<T>>, +{ + /// The default padding of a [`PickList`]. + pub const DEFAULT_PADDING: Padding = Padding::new(5); + + /// Creates a new [`PickList`] with the given [`State`], a list of options, + /// the current selected value, and the message to produce when an option is + /// selected. + pub fn new( + options: impl Into<Cow<'a, [T]>>, + selected: Option<T>, + on_selected: impl Fn(T) -> Message + 'a, + ) -> Self { + Self { + on_selected: Box::new(on_selected), + options: options.into(), + placeholder: None, + selected, + width: Length::Shrink, + text_size: None, + padding: Self::DEFAULT_PADDING, + font: Default::default(), + style_sheet: Default::default(), + } + } + + /// Sets the placeholder of the [`PickList`]. + pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self { + self.placeholder = Some(placeholder.into()); + self + } + + /// Sets the width of the [`PickList`]. + pub fn width(mut self, width: Length) -> Self { + self.width = width; + self + } + + /// Sets the [`Padding`] of the [`PickList`]. + pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the text size of the [`PickList`]. + pub fn text_size(mut self, size: u16) -> Self { + self.text_size = Some(size); + self + } + + /// Sets the font of the [`PickList`]. + pub fn font(mut self, font: Renderer::Font) -> Self { + self.font = font; + self + } + + /// Sets the style of the [`PickList`]. + pub fn style( + mut self, + style_sheet: impl Into<Box<dyn StyleSheet + 'a>>, + ) -> Self { + self.style_sheet = style_sheet.into(); + self + } +} + +impl<'a, T: 'a, Message, Renderer> Widget<Message, Renderer> + for PickList<'a, T, Message, Renderer> +where + T: Clone + ToString + Eq + 'static, + [T]: ToOwned<Owned = Vec<T>>, + Message: 'static, + Renderer: text::Renderer + 'a, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::<pick_list::State<T>>() + } + + fn state(&self) -> tree::State { + tree::State::new(pick_list::State::<T>::new()) + } + + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + Length::Shrink + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + pick_list::layout( + renderer, + limits, + self.width, + self.padding, + self.text_size, + &self.font, + self.placeholder.as_ref().map(String::as_str), + &self.options, + ) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + pick_list::update( + event, + layout, + cursor_position, + shell, + self.on_selected.as_ref(), + self.selected.as_ref(), + &self.options, + || tree.state.downcast_mut::<pick_list::State<T>>(), + ) + } + + fn mouse_interaction( + &self, + _tree: &Tree, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + pick_list::mouse_interaction(layout, cursor_position) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + _style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + ) { + pick_list::draw( + renderer, + layout, + cursor_position, + self.padding, + self.text_size, + &self.font, + self.placeholder.as_ref().map(String::as_str), + self.selected.as_ref(), + self.style_sheet.as_ref(), + ) + } + + fn overlay<'b>( + &'b self, + tree: &'b mut Tree, + layout: Layout<'_>, + _renderer: &Renderer, + ) -> Option<overlay::Element<'b, Message, Renderer>> { + let state = tree.state.downcast_mut::<pick_list::State<T>>(); + + pick_list::overlay( + layout, + state, + self.padding, + self.text_size, + self.font.clone(), + &self.options, + self.style_sheet.as_ref(), + ) + } +} + +impl<'a, T: 'a, Message, Renderer> Into<Element<'a, Message, Renderer>> + for PickList<'a, T, Message, Renderer> +where + T: Clone + ToString + Eq + 'static, + [T]: ToOwned<Owned = Vec<T>>, + Renderer: text::Renderer + 'a, + Message: 'static, +{ + fn into(self) -> Element<'a, Message, Renderer> { + Element::new(self) + } +} diff --git a/pure/src/widget/radio.rs b/pure/src/widget/radio.rs new file mode 100644 index 00000000..ce3ede84 --- /dev/null +++ b/pure/src/widget/radio.rs @@ -0,0 +1,103 @@ +use crate::{Element, Tree, Widget}; + +use iced_native::event::{self, Event}; +use iced_native::layout::{self, Layout}; +use iced_native::mouse; +use iced_native::renderer; +use iced_native::text; +use iced_native::{Clipboard, Length, Point, Rectangle, Shell}; + +pub use iced_native::widget::Radio; + +impl<'a, Message, Renderer> Widget<Message, Renderer> + for Radio<'a, Message, Renderer> +where + Message: Clone, + Renderer: text::Renderer, +{ + fn width(&self) -> Length { + <Self as iced_native::Widget<Message, Renderer>>::width(self) + } + + fn height(&self) -> Length { + <Self as iced_native::Widget<Message, Renderer>>::height(self) + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + <Self as iced_native::Widget<Message, Renderer>>::layout( + self, renderer, limits, + ) + } + + fn on_event( + &mut self, + _state: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + <Self as iced_native::Widget<Message, Renderer>>::on_event( + self, + event, + layout, + cursor_position, + renderer, + clipboard, + shell, + ) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + ) { + <Self as iced_native::Widget<Message, Renderer>>::draw( + self, + renderer, + style, + layout, + cursor_position, + viewport, + ) + } + + fn mouse_interaction( + &self, + _state: &Tree, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + <Self as iced_native::Widget<Message, Renderer>>::mouse_interaction( + self, + layout, + cursor_position, + viewport, + renderer, + ) + } +} + +impl<'a, Message, Renderer> Into<Element<'a, Message, Renderer>> + for Radio<'a, Message, Renderer> +where + Message: 'a + Clone, + Renderer: text::Renderer + 'a, +{ + fn into(self) -> Element<'a, Message, Renderer> { + Element::new(self) + } +} diff --git a/pure/src/widget/row.rs b/pure/src/widget/row.rs new file mode 100644 index 00000000..fa0efa68 --- /dev/null +++ b/pure/src/widget/row.rs @@ -0,0 +1,211 @@ +use crate::flex; +use crate::overlay; +use crate::widget::{Element, Tree, Widget}; + +use iced_native::event::{self, Event}; +use iced_native::layout::{self, Layout}; +use iced_native::mouse; +use iced_native::renderer; +use iced_native::{ + Alignment, Clipboard, Length, Padding, Point, Rectangle, Shell, +}; + +pub struct Row<'a, Message, Renderer> { + spacing: u16, + padding: Padding, + width: Length, + height: Length, + align_items: Alignment, + children: Vec<Element<'a, Message, Renderer>>, +} + +impl<'a, Message, Renderer> Row<'a, Message, Renderer> { + pub fn new() -> Self { + Self::with_children(Vec::new()) + } + + pub fn with_children( + children: Vec<Element<'a, Message, Renderer>>, + ) -> Self { + Row { + spacing: 0, + padding: Padding::ZERO, + width: Length::Shrink, + height: Length::Shrink, + align_items: Alignment::Start, + children, + } + } + + pub fn spacing(mut self, units: u16) -> Self { + self.spacing = units; + self + } + + pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + pub fn width(mut self, width: Length) -> Self { + self.width = width; + self + } + + pub fn height(mut self, height: Length) -> Self { + self.height = height; + self + } + + pub fn align_items(mut self, align: Alignment) -> Self { + self.align_items = align; + self + } + + pub fn push( + mut self, + child: impl Into<Element<'a, Message, Renderer>>, + ) -> Self { + self.children.push(child.into()); + self + } +} + +impl<'a, Message, Renderer> Widget<Message, Renderer> + for Row<'a, Message, Renderer> +where + Renderer: iced_native::Renderer, +{ + fn children(&self) -> Vec<Tree> { + self.children.iter().map(Tree::new).collect() + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(&self.children) + } + + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + self.height + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let limits = limits.width(self.width).height(self.height); + + flex::resolve( + flex::Axis::Horizontal, + renderer, + &limits, + self.padding, + self.spacing as f32, + self.align_items, + &self.children, + ) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + self.children + .iter_mut() + .zip(&mut tree.children) + .zip(layout.children()) + .map(|((child, state), layout)| { + child.as_widget_mut().on_event( + state, + event.clone(), + layout, + cursor_position, + renderer, + clipboard, + shell, + ) + }) + .fold(event::Status::Ignored, event::Status::merge) + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.children + .iter() + .zip(&tree.children) + .zip(layout.children()) + .map(|((child, state), layout)| { + child.as_widget().mouse_interaction( + state, + layout, + cursor_position, + viewport, + renderer, + ) + }) + .max() + .unwrap_or_default() + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + ) { + for ((child, state), layout) in self + .children + .iter() + .zip(&tree.children) + .zip(layout.children()) + { + child.as_widget().draw( + state, + renderer, + style, + layout, + cursor_position, + viewport, + ); + } + } + + fn overlay<'b>( + &'b self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + ) -> Option<overlay::Element<'b, Message, Renderer>> { + overlay::from_children(&self.children, tree, layout, renderer) + } +} + +impl<'a, Message, Renderer> Into<Element<'a, Message, Renderer>> + for Row<'a, Message, Renderer> +where + Message: 'static, + Renderer: iced_native::Renderer + 'static, +{ + fn into(self) -> Element<'a, Message, Renderer> { + Element::new(self) + } +} diff --git a/pure/src/widget/scrollable.rs b/pure/src/widget/scrollable.rs new file mode 100644 index 00000000..1548fa9d --- /dev/null +++ b/pure/src/widget/scrollable.rs @@ -0,0 +1,265 @@ +use crate::overlay; +use crate::widget::tree::{self, Tree}; +use crate::{Element, Widget}; + +use iced_native::event::{self, Event}; +use iced_native::layout::{self, Layout}; +use iced_native::mouse; +use iced_native::renderer; +use iced_native::widget::scrollable; +use iced_native::{Clipboard, Length, Point, Rectangle, Shell, Vector}; + +pub use iced_style::scrollable::StyleSheet; + +/// A widget that can vertically display an infinite amount of content with a +/// scrollbar. +#[allow(missing_debug_implementations)] +pub struct Scrollable<'a, Message, Renderer> { + height: Length, + scrollbar_width: u16, + scrollbar_margin: u16, + scroller_width: u16, + on_scroll: Option<Box<dyn Fn(f32) -> Message>>, + style_sheet: Box<dyn StyleSheet + 'a>, + content: Element<'a, Message, Renderer>, +} + +impl<'a, Message, Renderer: iced_native::Renderer> + Scrollable<'a, Message, Renderer> +{ + /// Creates a new [`Scrollable`]. + pub fn new(content: impl Into<Element<'a, Message, Renderer>>) -> Self { + Scrollable { + height: Length::Shrink, + scrollbar_width: 10, + scrollbar_margin: 0, + scroller_width: 10, + on_scroll: None, + style_sheet: Default::default(), + content: content.into(), + } + } + + /// Sets the height of the [`Scrollable`]. + pub fn height(mut self, height: Length) -> Self { + self.height = height; + self + } + + /// Sets the scrollbar width of the [`Scrollable`] . + /// Silently enforces a minimum value of 1. + pub fn scrollbar_width(mut self, scrollbar_width: u16) -> Self { + self.scrollbar_width = scrollbar_width.max(1); + self + } + + /// Sets the scrollbar margin of the [`Scrollable`] . + pub fn scrollbar_margin(mut self, scrollbar_margin: u16) -> Self { + self.scrollbar_margin = scrollbar_margin; + self + } + + /// Sets the scroller width of the [`Scrollable`] . + /// + /// It silently enforces a minimum value of 1. + pub fn scroller_width(mut self, scroller_width: u16) -> Self { + self.scroller_width = scroller_width.max(1); + self + } + + /// Sets a function to call when the [`Scrollable`] is scrolled. + /// + /// The function takes the new relative offset of the [`Scrollable`] + /// (e.g. `0` means top, while `1` means bottom). + pub fn on_scroll(mut self, f: impl Fn(f32) -> Message + 'static) -> Self { + self.on_scroll = Some(Box::new(f)); + self + } + + /// Sets the style of the [`Scrollable`] . + pub fn style( + mut self, + style_sheet: impl Into<Box<dyn StyleSheet + 'a>>, + ) -> Self { + self.style_sheet = style_sheet.into(); + self + } +} + +impl<'a, Message, Renderer> Widget<Message, Renderer> + for Scrollable<'a, Message, Renderer> +where + Renderer: iced_native::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::<scrollable::State>() + } + + fn state(&self) -> tree::State { + tree::State::new(scrollable::State::new()) + } + + fn children(&self) -> Vec<Tree> { + vec![Tree::new(&self.content)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)) + } + + fn width(&self) -> Length { + self.content.as_widget().width() + } + + fn height(&self) -> Length { + self.height + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + scrollable::layout( + renderer, + limits, + Widget::<Message, Renderer>::width(self), + self.height, + |renderer, limits| { + self.content.as_widget().layout(renderer, limits) + }, + ) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + scrollable::update( + tree.state.downcast_mut::<scrollable::State>(), + event, + layout, + cursor_position, + clipboard, + shell, + self.scrollbar_width, + self.scrollbar_margin, + self.scroller_width, + &self.on_scroll, + |event, layout, cursor_position, clipboard, shell| { + self.content.as_widget_mut().on_event( + &mut tree.children[0], + event, + layout, + cursor_position, + renderer, + clipboard, + shell, + ) + }, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + ) { + scrollable::draw( + tree.state.downcast_ref::<scrollable::State>(), + renderer, + layout, + cursor_position, + self.scrollbar_width, + self.scrollbar_margin, + self.scroller_width, + self.style_sheet.as_ref(), + |renderer, layout, cursor_position, viewport| { + self.content.as_widget().draw( + &tree.children[0], + renderer, + style, + layout, + cursor_position, + viewport, + ) + }, + ) + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + scrollable::mouse_interaction( + tree.state.downcast_ref::<scrollable::State>(), + layout, + cursor_position, + self.scrollbar_width, + self.scrollbar_margin, + self.scroller_width, + |layout, cursor_position, viewport| { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor_position, + viewport, + renderer, + ) + }, + ) + } + + fn overlay<'b>( + &'b self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + ) -> Option<overlay::Element<'b, Message, Renderer>> { + self.content + .as_widget() + .overlay( + &mut tree.children[0], + layout.children().next().unwrap(), + renderer, + ) + .map(|overlay| { + let bounds = layout.bounds(); + let content_layout = layout.children().next().unwrap(); + let content_bounds = content_layout.bounds(); + let offset = tree + .state + .downcast_ref::<scrollable::State>() + .offset(bounds, content_bounds); + + overlay.translate(Vector::new(0.0, -(offset as f32))) + }) + } +} + +impl<'a, Message, Renderer> From<Scrollable<'a, Message, Renderer>> + for Element<'a, Message, Renderer> +where + Message: 'a + Clone, + Renderer: 'a + iced_native::Renderer, +{ + fn from( + text_input: Scrollable<'a, Message, Renderer>, + ) -> Element<'a, Message, Renderer> { + Element::new(text_input) + } +} diff --git a/pure/src/widget/slider.rs b/pure/src/widget/slider.rs new file mode 100644 index 00000000..1107bdc1 --- /dev/null +++ b/pure/src/widget/slider.rs @@ -0,0 +1,243 @@ +//! Display an interactive selector of a single value from a range of values. +//! +//! A [`Slider`] has some local [`State`]. +use crate::widget::tree::{self, Tree}; +use crate::{Element, Widget}; + +use iced_native::event::{self, Event}; +use iced_native::layout; +use iced_native::mouse; +use iced_native::renderer; +use iced_native::widget::slider; +use iced_native::{Clipboard, Layout, Length, Point, Rectangle, Shell, Size}; + +use std::ops::RangeInclusive; + +pub use iced_style::slider::{Handle, HandleShape, Style, StyleSheet}; + +/// An horizontal bar and a handle that selects a single value from a range of +/// values. +/// +/// A [`Slider`] will try to fill the horizontal space of its container. +/// +/// The [`Slider`] range of numeric values is generic and its step size defaults +/// to 1 unit. +/// +/// # Example +/// ``` +/// # use iced_native::widget::slider::{self, Slider}; +/// # +/// #[derive(Clone)] +/// pub enum Message { +/// SliderChanged(f32), +/// } +/// +/// let state = &mut slider::State::new(); +/// let value = 50.0; +/// +/// Slider::new(state, 0.0..=100.0, value, Message::SliderChanged); +/// ``` +/// +///  +#[allow(missing_debug_implementations)] +pub struct Slider<'a, T, Message> { + range: RangeInclusive<T>, + step: T, + value: T, + on_change: Box<dyn Fn(T) -> Message + 'a>, + on_release: Option<Message>, + width: Length, + height: u16, + style_sheet: Box<dyn StyleSheet + 'a>, +} + +impl<'a, T, Message> Slider<'a, T, Message> +where + T: Copy + From<u8> + std::cmp::PartialOrd, + Message: Clone, +{ + /// The default height of a [`Slider`]. + pub const DEFAULT_HEIGHT: u16 = 22; + + /// Creates a new [`Slider`]. + /// + /// It expects: + /// * an inclusive range of possible values + /// * the current value of the [`Slider`] + /// * a function that will be called when the [`Slider`] is dragged. + /// It receives the new value of the [`Slider`] and must produce a + /// `Message`. + pub fn new<F>(range: RangeInclusive<T>, value: T, on_change: F) -> Self + where + F: 'a + Fn(T) -> Message, + { + let value = if value >= *range.start() { + value + } else { + *range.start() + }; + + let value = if value <= *range.end() { + value + } else { + *range.end() + }; + + Slider { + value, + range, + step: T::from(1), + on_change: Box::new(on_change), + on_release: None, + width: Length::Fill, + height: Self::DEFAULT_HEIGHT, + style_sheet: Default::default(), + } + } + + /// Sets the release message of the [`Slider`]. + /// This is called when the mouse is released from the slider. + /// + /// Typically, the user's interaction with the slider is finished when this message is produced. + /// This is useful if you need to spawn a long-running task from the slider's result, where + /// the default on_change message could create too many events. + pub fn on_release(mut self, on_release: Message) -> Self { + self.on_release = Some(on_release); + self + } + + /// Sets the width of the [`Slider`]. + pub fn width(mut self, width: Length) -> Self { + self.width = width; + self + } + + /// Sets the height of the [`Slider`]. + pub fn height(mut self, height: u16) -> Self { + self.height = height; + self + } + + /// Sets the style of the [`Slider`]. + pub fn style( + mut self, + style_sheet: impl Into<Box<dyn StyleSheet + 'a>>, + ) -> Self { + self.style_sheet = style_sheet.into(); + self + } + + /// Sets the step size of the [`Slider`]. + pub fn step(mut self, step: T) -> Self { + self.step = step; + self + } +} + +impl<'a, T, Message, Renderer> Widget<Message, Renderer> + for Slider<'a, T, Message> +where + T: Copy + Into<f64> + num_traits::FromPrimitive, + Message: Clone, + Renderer: iced_native::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::<slider::State>() + } + + fn state(&self) -> tree::State { + tree::State::new(slider::State::new()) + } + + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + Length::Shrink + } + + fn layout( + &self, + _renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let limits = + limits.width(self.width).height(Length::Units(self.height)); + + let size = limits.resolve(Size::ZERO); + + layout::Node::new(size) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + slider::update( + event, + layout, + cursor_position, + shell, + tree.state.downcast_mut::<slider::State>(), + &mut self.value, + &self.range, + self.step, + self.on_change.as_ref(), + &self.on_release, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + _style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + ) { + slider::draw( + renderer, + layout, + cursor_position, + tree.state.downcast_ref::<slider::State>(), + self.value, + &self.range, + self.style_sheet.as_ref(), + ) + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + slider::mouse_interaction( + layout, + cursor_position, + tree.state.downcast_ref::<slider::State>(), + ) + } +} + +impl<'a, T, Message, Renderer> From<Slider<'a, T, Message>> + for Element<'a, Message, Renderer> +where + T: 'a + Copy + Into<f64> + num_traits::FromPrimitive, + Message: 'a + Clone, + Renderer: 'a + iced_native::Renderer, +{ + fn from(slider: Slider<'a, T, Message>) -> Element<'a, Message, Renderer> { + Element::new(slider) + } +} diff --git a/pure/src/widget/space.rs b/pure/src/widget/space.rs new file mode 100644 index 00000000..c04d962a --- /dev/null +++ b/pure/src/widget/space.rs @@ -0,0 +1,98 @@ +use crate::{Element, Tree, Widget}; + +use iced_native::event::{self, Event}; +use iced_native::layout::{self, Layout}; +use iced_native::mouse; +use iced_native::renderer; +use iced_native::{Clipboard, Length, Point, Rectangle, Shell}; + +pub use iced_native::widget::Space; + +impl<'a, Message, Renderer> Widget<Message, Renderer> for Space +where + Renderer: iced_native::Renderer, +{ + fn width(&self) -> Length { + <Self as iced_native::Widget<Message, Renderer>>::width(self) + } + + fn height(&self) -> Length { + <Self as iced_native::Widget<Message, Renderer>>::height(self) + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + <Self as iced_native::Widget<Message, Renderer>>::layout( + self, renderer, limits, + ) + } + + fn on_event( + &mut self, + _state: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + <Self as iced_native::Widget<Message, Renderer>>::on_event( + self, + event, + layout, + cursor_position, + renderer, + clipboard, + shell, + ) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + ) { + <Self as iced_native::Widget<Message, Renderer>>::draw( + self, + renderer, + style, + layout, + cursor_position, + viewport, + ) + } + + fn mouse_interaction( + &self, + _state: &Tree, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + <Self as iced_native::Widget<Message, Renderer>>::mouse_interaction( + self, + layout, + cursor_position, + viewport, + renderer, + ) + } +} + +impl<'a, Message, Renderer> Into<Element<'a, Message, Renderer>> for Space +where + Renderer: iced_native::Renderer + 'a, +{ + fn into(self) -> Element<'a, Message, Renderer> { + Element::new(self) + } +} diff --git a/pure/src/widget/text.rs b/pure/src/widget/text.rs new file mode 100644 index 00000000..bfcbaa4b --- /dev/null +++ b/pure/src/widget/text.rs @@ -0,0 +1,70 @@ +use crate::{Element, Tree, Widget}; + +use iced_native::layout::{self, Layout}; +use iced_native::renderer; +use iced_native::text; +use iced_native::{Length, Point, Rectangle}; + +pub use iced_native::widget::Text; + +impl<Message, Renderer> Widget<Message, Renderer> for Text<Renderer> +where + Renderer: text::Renderer, +{ + fn width(&self) -> Length { + <Self as iced_native::Widget<Message, Renderer>>::width(self) + } + + fn height(&self) -> Length { + <Self as iced_native::Widget<Message, Renderer>>::height(self) + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + <Self as iced_native::Widget<Message, Renderer>>::layout( + self, renderer, limits, + ) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + ) { + <Self as iced_native::Widget<Message, Renderer>>::draw( + self, + renderer, + style, + layout, + cursor_position, + viewport, + ) + } +} + +impl<'a, Message, Renderer> Into<Element<'a, Message, Renderer>> + for Text<Renderer> +where + Renderer: text::Renderer + 'static, +{ + fn into(self) -> Element<'a, Message, Renderer> { + Element::new(self) + } +} + +impl<'a, Message, Renderer> Into<Element<'a, Message, Renderer>> + for &'static str +where + Renderer: text::Renderer + 'static, +{ + fn into(self) -> Element<'a, Message, Renderer> { + Text::new(self).into() + } +} diff --git a/pure/src/widget/text_input.rs b/pure/src/widget/text_input.rs new file mode 100644 index 00000000..dec11164 --- /dev/null +++ b/pure/src/widget/text_input.rs @@ -0,0 +1,241 @@ +use crate::widget::tree::{self, Tree}; +use crate::widget::{Element, Widget}; + +use iced_native::event::{self, Event}; +use iced_native::layout::{self, Layout}; +use iced_native::mouse; +use iced_native::renderer; +use iced_native::text; +use iced_native::widget::text_input; +use iced_native::{Clipboard, Length, Padding, Point, Rectangle, Shell}; + +pub use iced_style::text_input::StyleSheet; + +/// A field that can be filled with text. +/// +/// # Example +/// ``` +/// # use iced_native::renderer::Null; +/// # use iced_native::widget::text_input; +/// # +/// # pub type TextInput<'a, Message> = iced_native::widget::TextInput<'a, Message, Null>; +/// #[derive(Debug, Clone)] +/// enum Message { +/// TextInputChanged(String), +/// } +/// +/// let mut state = text_input::State::new(); +/// let value = "Some text"; +/// +/// let input = TextInput::new( +/// &mut state, +/// "This is the placeholder...", +/// value, +/// Message::TextInputChanged, +/// ) +/// .padding(10); +/// ``` +///  +#[allow(missing_debug_implementations)] +pub struct TextInput<'a, Message, Renderer: text::Renderer> { + placeholder: String, + value: text_input::Value, + is_secure: bool, + font: Renderer::Font, + width: Length, + padding: Padding, + size: Option<u16>, + on_change: Box<dyn Fn(String) -> Message + 'a>, + on_submit: Option<Message>, + style_sheet: Box<dyn StyleSheet + 'a>, +} + +impl<'a, Message, Renderer> TextInput<'a, Message, Renderer> +where + Message: Clone, + Renderer: text::Renderer, +{ + /// Creates a new [`TextInput`]. + /// + /// It expects: + /// - some [`State`] + /// - a placeholder + /// - the current value + /// - a function that produces a message when the [`TextInput`] changes + pub fn new<F>(placeholder: &str, value: &str, on_change: F) -> Self + where + F: 'a + Fn(String) -> Message, + { + TextInput { + placeholder: String::from(placeholder), + value: text_input::Value::new(value), + is_secure: false, + font: Default::default(), + width: Length::Fill, + padding: Padding::ZERO, + size: None, + on_change: Box::new(on_change), + on_submit: None, + style_sheet: Default::default(), + } + } + + /// Converts the [`TextInput`] into a secure password input. + pub fn password(mut self) -> Self { + self.is_secure = true; + self + } + + /// Sets the [`Font`] of the [`Text`]. + /// + /// [`Font`]: crate::widget::text::Renderer::Font + /// [`Text`]: crate::widget::Text + pub fn font(mut self, font: Renderer::Font) -> Self { + self.font = font; + self + } + /// Sets the width of the [`TextInput`]. + pub fn width(mut self, width: Length) -> Self { + self.width = width; + self + } + + /// Sets the [`Padding`] of the [`TextInput`]. + pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self { + self.padding = padding.into(); + self + } + + /// Sets the text size of the [`TextInput`]. + pub fn size(mut self, size: u16) -> Self { + self.size = Some(size); + self + } + + /// Sets the message that should be produced when the [`TextInput`] is + /// focused and the enter key is pressed. + pub fn on_submit(mut self, message: Message) -> Self { + self.on_submit = Some(message); + self + } + + /// Sets the style of the [`TextInput`]. + pub fn style( + mut self, + style_sheet: impl Into<Box<dyn StyleSheet + 'a>>, + ) -> Self { + self.style_sheet = style_sheet.into(); + self + } +} + +impl<'a, Message, Renderer> Widget<Message, Renderer> + for TextInput<'a, Message, Renderer> +where + Message: Clone, + Renderer: iced_native::text::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::<text_input::State>() + } + + fn state(&self) -> tree::State { + tree::State::new(text_input::State::new()) + } + + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + Length::Shrink + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + text_input::layout( + renderer, + limits, + self.width, + self.padding, + self.size, + ) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + text_input::update( + event, + layout, + cursor_position, + renderer, + clipboard, + shell, + &mut self.value, + self.size, + &self.font, + self.is_secure, + self.on_change.as_ref(), + &self.on_submit, + || tree.state.downcast_mut::<text_input::State>(), + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + _style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + ) { + text_input::draw( + renderer, + layout, + cursor_position, + tree.state.downcast_ref::<text_input::State>(), + &self.value, + &self.placeholder, + self.size, + &self.font, + self.is_secure, + self.style_sheet.as_ref(), + ) + } + + fn mouse_interaction( + &self, + _state: &Tree, + layout: Layout<'_>, + cursor_position: Point, + _viewport: &Rectangle, + _renderer: &Renderer, + ) -> mouse::Interaction { + text_input::mouse_interaction(layout, cursor_position) + } +} + +impl<'a, Message, Renderer> From<TextInput<'a, Message, Renderer>> + for Element<'a, Message, Renderer> +where + Message: 'a + Clone, + Renderer: 'a + text::Renderer, +{ + fn from( + text_input: TextInput<'a, Message, Renderer>, + ) -> Element<'a, Message, Renderer> { + Element::new(text_input) + } +} diff --git a/pure/src/widget/toggler.rs b/pure/src/widget/toggler.rs new file mode 100644 index 00000000..1b3367a4 --- /dev/null +++ b/pure/src/widget/toggler.rs @@ -0,0 +1,103 @@ +use crate::widget::{Tree, Widget}; +use crate::Element; + +use iced_native::event::{self, Event}; +use iced_native::layout::{self, Layout}; +use iced_native::mouse; +use iced_native::renderer; +use iced_native::text; +use iced_native::{Clipboard, Length, Point, Rectangle, Shell}; + +pub use iced_native::widget::toggler::{Style, StyleSheet, Toggler}; + +impl<'a, Message, Renderer> Widget<Message, Renderer> + for Toggler<'a, Message, Renderer> +where + Renderer: text::Renderer, +{ + fn width(&self) -> Length { + <Self as iced_native::Widget<Message, Renderer>>::width(self) + } + + fn height(&self) -> Length { + <Self as iced_native::Widget<Message, Renderer>>::height(self) + } + + fn layout( + &self, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + <Self as iced_native::Widget<Message, Renderer>>::layout( + self, renderer, limits, + ) + } + + fn draw( + &self, + _state: &Tree, + renderer: &mut Renderer, + style: &renderer::Style, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + ) { + <Self as iced_native::Widget<Message, Renderer>>::draw( + self, + renderer, + style, + layout, + cursor_position, + viewport, + ) + } + + fn mouse_interaction( + &self, + _state: &Tree, + layout: Layout<'_>, + cursor_position: Point, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + <Self as iced_native::Widget<Message, Renderer>>::mouse_interaction( + self, + layout, + cursor_position, + viewport, + renderer, + ) + } + + fn on_event( + &mut self, + _state: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor_position: Point, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + <Self as iced_native::Widget<Message, Renderer>>::on_event( + self, + event, + layout, + cursor_position, + renderer, + clipboard, + shell, + ) + } +} + +impl<'a, Message, Renderer> Into<Element<'a, Message, Renderer>> + for Toggler<'a, Message, Renderer> +where + Message: 'a, + Renderer: text::Renderer + 'a, +{ + fn into(self) -> Element<'a, Message, Renderer> { + Element::new(self) + } +} diff --git a/pure/src/widget/tree.rs b/pure/src/widget/tree.rs new file mode 100644 index 00000000..33f5693a --- /dev/null +++ b/pure/src/widget/tree.rs @@ -0,0 +1,115 @@ +use crate::widget::Element; + +use std::any::{self, Any}; + +pub struct Tree { + pub tag: Tag, + pub state: State, + pub children: Vec<Tree>, +} + +impl Tree { + pub fn empty() -> Self { + Self { + tag: Tag::stateless(), + state: State::None, + children: Vec::new(), + } + } + + pub fn new<Message, Renderer>( + element: &Element<'_, Message, Renderer>, + ) -> Self { + Self { + tag: element.as_widget().tag(), + state: element.as_widget().state(), + children: element.as_widget().children(), + } + } + + pub fn diff<Message, Renderer>( + &mut self, + new: &Element<'_, Message, Renderer>, + ) { + if self.tag == new.as_widget().tag() { + new.as_widget().diff(self) + } else { + *self = Self::new(new); + } + } + + pub fn diff_children<Message, Renderer>( + &mut self, + new_children: &[Element<'_, Message, Renderer>], + ) { + if self.children.len() > new_children.len() { + self.children.truncate(new_children.len()); + } + + for (child_state, new) in + self.children.iter_mut().zip(new_children.iter()) + { + child_state.diff(new); + } + + if self.children.len() < new_children.len() { + self.children.extend( + new_children[self.children.len()..].iter().map(Self::new), + ); + } + } +} + +#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Hash)] +pub struct Tag(any::TypeId); + +impl Tag { + pub fn of<T>() -> Self + where + T: 'static, + { + Self(any::TypeId::of::<T>()) + } + + pub fn stateless() -> Self { + Self::of::<()>() + } +} + +pub enum State { + None, + Some(Box<dyn Any>), +} + +impl State { + pub fn new<T>(state: T) -> Self + where + T: 'static, + { + State::Some(Box::new(state)) + } + + pub fn downcast_ref<T>(&self) -> &T + where + T: 'static, + { + match self { + State::None => panic!("Downcast on stateless state"), + State::Some(state) => { + state.downcast_ref().expect("Downcast widget state") + } + } + } + + pub fn downcast_mut<T>(&mut self) -> &mut T + where + T: 'static, + { + match self { + State::None => panic!("Downcast on stateless state"), + State::Some(state) => { + state.downcast_mut().expect("Downcast widget state") + } + } + } +} @@ -195,6 +195,9 @@ pub mod time; pub mod widget; pub mod window; +#[cfg(feature = "pure")] +pub mod pure; + #[cfg(all(not(feature = "glow"), feature = "wgpu"))] use iced_winit as runtime; @@ -214,6 +217,7 @@ pub use application::Application; pub use element::Element; pub use error::Error; pub use executor::Executor; +pub use renderer::Renderer; pub use result::Result; pub use sandbox::Sandbox; pub use settings::Settings; diff --git a/src/pure.rs b/src/pure.rs new file mode 100644 index 00000000..5cab5fd9 --- /dev/null +++ b/src/pure.rs @@ -0,0 +1,51 @@ +//! Leverage pure, virtual widgets in your application. +//! +//! The widgets found in this module are completely stateless versions of +//! [the original widgets]. +//! +//! Effectively, this means that, as a user of the library, you do not need to +//! keep track of the local state of each widget (e.g. [`button::State`]). +//! Instead, the runtime will keep track of everything for you! +//! +//! You can embed pure widgets anywhere in your [impure `Application`] using the +//! [`Pure`] widget and some [`State`]. +//! +//! In case you want to only use pure widgets in your application, this module +//! offers an alternate [`Application`] trait with a completely pure `view` +//! method. +//! +//! [the original widgets]: crate::widget +//! [`button::State`]: crate::widget::button::State +//! [impure `Application`]: crate::Application +pub use iced_pure::{ + Button as _, Column as _, Element as _, Image as _, Row as _, Text as _, *, +}; + +/// A generic, pure [`Widget`]. +pub type Element<'a, Message> = + iced_pure::Element<'a, Message, crate::Renderer>; + +/// A pure container widget. +pub type Container<'a, Message> = + iced_pure::Container<'a, Message, crate::Renderer>; + +/// A pure column widget. +pub type Column<'a, Message> = iced_pure::Column<'a, Message, crate::Renderer>; + +/// A pure row widget. +pub type Row<'a, Message> = iced_pure::Row<'a, Message, crate::Renderer>; + +/// A pure button widget. +pub type Button<'a, Message> = iced_pure::Button<'a, Message, crate::Renderer>; + +/// A pure text widget. +pub type Text = iced_pure::Text<crate::Renderer>; + +/// A pure image widget. +pub type Image = iced_pure::Image<crate::widget::image::Handle>; + +mod application; +mod sandbox; + +pub use application::Application; +pub use sandbox::Sandbox; diff --git a/src/pure/application.rs b/src/pure/application.rs new file mode 100644 index 00000000..5f400bea --- /dev/null +++ b/src/pure/application.rs @@ -0,0 +1,186 @@ +use crate::pure::{self, Pure}; +use crate::window; +use crate::{Color, Command, Executor, Settings, Subscription}; + +/// A pure version of [`Application`]. +/// +/// Unlike the impure version, the `view` method of this trait takes an +/// immutable reference to `self` and returns a pure [`Element`]. +/// +/// [`Application`]: crate::Application +/// [`Element`]: pure::Element +pub trait Application: Sized { + /// The [`Executor`] that will run commands and subscriptions. + /// + /// The [default executor] can be a good starting point! + /// + /// [`Executor`]: Self::Executor + /// [default executor]: crate::executor::Default + type Executor: Executor; + + /// The type of __messages__ your [`Application`] will produce. + type Message: std::fmt::Debug + Send; + + /// The data needed to initialize your [`Application`]. + type Flags; + + /// Initializes the [`Application`] with the flags provided to + /// [`run`] as part of the [`Settings`]. + /// + /// Here is where you should return the initial state of your app. + /// + /// Additionally, you can return a [`Command`] if you need to perform some + /// async action in the background on startup. This is useful if you want to + /// load state from a file, perform an initial HTTP request, etc. + /// + /// [`run`]: Self::run + fn new(flags: Self::Flags) -> (Self, Command<Self::Message>); + + /// Returns the current title of the [`Application`]. + /// + /// This title can be dynamic! The runtime will automatically update the + /// title of your application when necessary. + fn title(&self) -> String; + + /// Handles a __message__ and updates the state of the [`Application`]. + /// + /// This is where you define your __update logic__. All the __messages__, + /// produced by either user interactions or commands, will be handled by + /// this method. + /// + /// Any [`Command`] returned will be executed immediately in the background. + fn update(&mut self, message: Self::Message) -> Command<Self::Message>; + + /// Returns the event [`Subscription`] for the current state of the + /// application. + /// + /// A [`Subscription`] will be kept alive as long as you keep returning it, + /// and the __messages__ produced will be handled by + /// [`update`](#tymethod.update). + /// + /// By default, this method returns an empty [`Subscription`]. + fn subscription(&self) -> Subscription<Self::Message> { + Subscription::none() + } + + /// Returns the widgets to display in the [`Application`]. + /// + /// These widgets can produce __messages__ based on user interaction. + fn view(&self) -> pure::Element<'_, Self::Message>; + + /// Returns the current [`Application`] mode. + /// + /// The runtime will automatically transition your application if a new mode + /// is returned. + /// + /// Currently, the mode only has an effect in native platforms. + /// + /// By default, an application will run in windowed mode. + fn mode(&self) -> window::Mode { + window::Mode::Windowed + } + + /// Returns the background color of the [`Application`]. + /// + /// By default, it returns [`Color::WHITE`]. + fn background_color(&self) -> Color { + Color::WHITE + } + + /// Returns the scale factor of the [`Application`]. + /// + /// It can be used to dynamically control the size of the UI at runtime + /// (i.e. zooming). + /// + /// For instance, a scale factor of `2.0` will make widgets twice as big, + /// while a scale factor of `0.5` will shrink them to half their size. + /// + /// By default, it returns `1.0`. + fn scale_factor(&self) -> f64 { + 1.0 + } + + /// Returns whether the [`Application`] should be terminated. + /// + /// By default, it returns `false`. + fn should_exit(&self) -> bool { + false + } + + /// Runs the [`Application`]. + /// + /// On native platforms, this method will take control of the current thread + /// until the [`Application`] exits. + /// + /// On the web platform, this method __will NOT return__ unless there is an + /// [`Error`] during startup. + /// + /// [`Error`]: crate::Error + fn run(settings: Settings<Self::Flags>) -> crate::Result + where + Self: 'static, + { + <Instance<Self> as crate::Application>::run(settings) + } +} + +struct Instance<A: Application> { + application: A, + state: pure::State, +} + +impl<A> crate::Application for Instance<A> +where + A: Application, + A::Message: 'static, +{ + type Executor = A::Executor; + type Message = A::Message; + type Flags = A::Flags; + + fn new(flags: Self::Flags) -> (Self, Command<Self::Message>) { + let (application, command) = A::new(flags); + + ( + Instance { + application, + state: pure::State::new(), + }, + command, + ) + } + + fn title(&self) -> String { + A::title(&self.application) + } + + fn update(&mut self, message: Self::Message) -> Command<Self::Message> { + A::update(&mut self.application, message) + } + + fn subscription(&self) -> Subscription<Self::Message> { + A::subscription(&self.application) + } + + fn view(&mut self) -> crate::Element<'_, Self::Message> { + let content = A::view(&self.application); + + Pure::new(&mut self.state, content).into() + } + + fn mode(&self) -> window::Mode { + A::mode(&self.application) + } + + fn background_color(&self) -> Color { + A::background_color(&self.application) + } + + fn scale_factor(&self) -> f64 { + A::scale_factor(&self.application) + } + + fn should_exit(&self) -> bool { + A::should_exit(&self.application) + } +} diff --git a/src/pure/sandbox.rs b/src/pure/sandbox.rs new file mode 100644 index 00000000..fbd1d7a8 --- /dev/null +++ b/src/pure/sandbox.rs @@ -0,0 +1,119 @@ +use crate::pure; +use crate::{Color, Command, Error, Settings, Subscription}; + +/// A pure version of [`Sandbox`]. +/// +/// Unlike the impure version, the `view` method of this trait takes an +/// immutable reference to `self` and returns a pure [`Element`]. +/// +/// [`Sandbox`]: crate::Sandbox +/// [`Element`]: pure::Element +pub trait Sandbox { + /// The type of __messages__ your [`Sandbox`] will produce. + type Message: std::fmt::Debug + Send; + + /// Initializes the [`Sandbox`]. + /// + /// Here is where you should return the initial state of your app. + fn new() -> Self; + + /// Returns the current title of the [`Sandbox`]. + /// + /// This title can be dynamic! The runtime will automatically update the + /// title of your application when necessary. + fn title(&self) -> String; + + /// Handles a __message__ and updates the state of the [`Sandbox`]. + /// + /// This is where you define your __update logic__. All the __messages__, + /// produced by user interactions, will be handled by this method. + fn update(&mut self, message: Self::Message); + + /// Returns the widgets to display in the [`Sandbox`]. + /// + /// These widgets can produce __messages__ based on user interaction. + fn view(&self) -> pure::Element<'_, Self::Message>; + + /// Returns the background color of the [`Sandbox`]. + /// + /// By default, it returns [`Color::WHITE`]. + fn background_color(&self) -> Color { + Color::WHITE + } + + /// Returns the scale factor of the [`Sandbox`]. + /// + /// It can be used to dynamically control the size of the UI at runtime + /// (i.e. zooming). + /// + /// For instance, a scale factor of `2.0` will make widgets twice as big, + /// while a scale factor of `0.5` will shrink them to half their size. + /// + /// By default, it returns `1.0`. + fn scale_factor(&self) -> f64 { + 1.0 + } + + /// Returns whether the [`Sandbox`] should be terminated. + /// + /// By default, it returns `false`. + fn should_exit(&self) -> bool { + false + } + + /// Runs the [`Sandbox`]. + /// + /// On native platforms, this method will take control of the current thread + /// and __will NOT return__. + /// + /// It should probably be that last thing you call in your `main` function. + fn run(settings: Settings<()>) -> Result<(), Error> + where + Self: 'static + Sized, + { + <Self as pure::Application>::run(settings) + } +} + +impl<T> pure::Application for T +where + T: Sandbox, +{ + type Executor = iced_futures::backend::null::Executor; + type Flags = (); + type Message = T::Message; + + fn new(_flags: ()) -> (Self, Command<T::Message>) { + (T::new(), Command::none()) + } + + fn title(&self) -> String { + T::title(self) + } + + fn update(&mut self, message: T::Message) -> Command<T::Message> { + T::update(self, message); + + Command::none() + } + + fn subscription(&self) -> Subscription<T::Message> { + Subscription::none() + } + + fn view(&self) -> pure::Element<'_, T::Message> { + T::view(self) + } + + fn background_color(&self) -> Color { + T::background_color(self) + } + + fn scale_factor(&self) -> f64 { + T::scale_factor(self) + } + + fn should_exit(&self) -> bool { + T::should_exit(self) + } +} |