summaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorpml68 <contact@pml68.dev>2025-04-13 03:40:38 +0200
committerpml68 <contact@pml68.dev>2025-04-15 23:52:42 +0200
commit495985f449e46b24e6b734d3aa9e135a779a8b77 (patch)
treef2908b3a1776458e81de63c6d2461b9fc4cec13f /crates
parentfeat(material_theme): implement `pick_list::Catalog` (diff)
downloadiced-builder-495985f449e46b24e6b734d3aa9e135a779a8b77.tar.gz
refactor: move `material_theme` and `iced_drop` into separate crates dir
Diffstat (limited to 'crates')
-rw-r--r--crates/iced_drop/Cargo.toml7
-rw-r--r--crates/iced_drop/LICENSE21
-rw-r--r--crates/iced_drop/README.md73
-rw-r--r--crates/iced_drop/src/lib.rs55
-rw-r--r--crates/iced_drop/src/widget.rs2
-rw-r--r--crates/iced_drop/src/widget/droppable.rs574
-rw-r--r--crates/iced_drop/src/widget/operation.rs1
-rw-r--r--crates/iced_drop/src/widget/operation/drop.rs88
-rw-r--r--crates/material_theme/Cargo.toml53
-rw-r--r--crates/material_theme/README.md3
-rw-r--r--crates/material_theme/assets/themes/dark.toml49
-rw-r--r--crates/material_theme/assets/themes/light.toml49
-rw-r--r--crates/material_theme/src/button.rs193
-rw-r--r--crates/material_theme/src/container.rs173
-rw-r--r--crates/material_theme/src/dialog.rs25
-rw-r--r--crates/material_theme/src/lib.rs248
-rw-r--r--crates/material_theme/src/menu.rs33
-rw-r--r--crates/material_theme/src/pick_list.rs40
-rw-r--r--crates/material_theme/src/scrollable.rs153
-rw-r--r--crates/material_theme/src/text.rs86
-rw-r--r--crates/material_theme/src/utils.rs116
21 files changed, 2042 insertions, 0 deletions
diff --git a/crates/iced_drop/Cargo.toml b/crates/iced_drop/Cargo.toml
new file mode 100644
index 0000000..0692084
--- /dev/null
+++ b/crates/iced_drop/Cargo.toml
@@ -0,0 +1,7 @@
+[package]
+name = "iced_drop"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+iced.workspace = true
diff --git a/crates/iced_drop/LICENSE b/crates/iced_drop/LICENSE
new file mode 100644
index 0000000..89d9fee
--- /dev/null
+++ b/crates/iced_drop/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 jhannyj
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/crates/iced_drop/README.md b/crates/iced_drop/README.md
new file mode 100644
index 0000000..be854f2
--- /dev/null
+++ b/crates/iced_drop/README.md
@@ -0,0 +1,73 @@
+# iced_drop - updated to `iced` 0.14-dev
+
+A small library which provides a custom widget and operation to make drag and drop easier to implement in [iced](https://github.com/iced-rs/iced/tree/master)
+
+## Usage
+
+To add drag and drog functionality, first define two messages with the following format
+
+```rust
+enum Message {
+ Drop(iced::Point, iced::Rectangle)
+ HandleZones(Vec<(iced::advanced::widget::Id, iced::Rectangle)>)
+}
+```
+
+The `Drop` message will be sent when the droppable is being dragged, and the left mouse button is released. This message provides the mouse position and layout boundaries of the droppable at the release point.
+
+The `HandleZones` message will be sent after an operation that finds the drop zones under the mouse position. It provides the Id and bounds for each drop zone.
+
+Next, create create a droppable in the view method and assign the on_drop message. The dropopable function takes an `impl Into<Element>` object, so it's easy to make a droppable from any iced widget.
+
+```rust
+iced_drop::droppable("Drop me!").on_drop(Message::Drop);
+```
+
+Next, create a "drop zone." A drop zone is any widget that operates like a container andhas some assigned Id. It's important that the widget is assigned some Id or it won't be recognized as a drop zone.
+
+```rust
+iced::widget::container("Drop zone")
+ .id(iced::widget::container::Id::new("drop_zone"));
+```
+
+Finally, handle the updates of the drop messages
+
+```rust
+match message {
+ Message::Drop(cursor_pos, _) => {
+ return iced_drop::zones_on_point(
+ Message::HandleZonesFound,
+ point,
+ None,
+ None,
+ );
+ }
+ Message::HandleZones(zones) => {
+ println!("{:?}", zones)
+ }
+}
+```
+
+On Drop, we return a widget operation that looks for drop zones under the cursor_pos. When this operation finishes, it returns the zones found and sends the `HandleZones` message. In this example, we only defined one zone, so the zones vector will either be empty if the droppable was not dropped on the zone, or it will contain the `drop_zone`
+
+## Examples
+
+There are two examples: color, todo.
+
+The color example is a very basic drag/drop showcase where the user can drag colors into zones and change the zone's color. I would start here.
+
+[Link to video](https://drive.google.com/file/d/1K1CCi2Lc90IUyDufsvoUBZmUCbeg6_Fi/view?usp=sharing)
+
+To run this examples: `cargo run -p color`
+
+The todo example is a basic todo board application similar to Trello. This is a much much more complex example as it handles custom highlighting and nested droppables, but it just shows you can make some pretty cool things with iced.
+
+[Link to video](https://drive.google.com/file/d/1MLOCk4Imd_oUnrTj_psbpYbwua976HmR/view?usp=sharing)
+
+To run this example try: `cargo run -p todo`
+
+Note: the todo example might also be a good example on how one can use operations. Check examples/todo/src/operation.rs. I didn't find any other examples of this in the iced repo except for the built in focus operations.
+
+## Future Development
+
+Right now it's a little annoying having to work with iced's Id type. At some point, I will work on a drop_zone widget that can take some generic clonable type as an id, and I will create a seperate find_zones operation that will return a list of this custom Id. This should make it easier to determine which drop zones were found.
diff --git a/crates/iced_drop/src/lib.rs b/crates/iced_drop/src/lib.rs
new file mode 100644
index 0000000..c1e1b03
--- /dev/null
+++ b/crates/iced_drop/src/lib.rs
@@ -0,0 +1,55 @@
+pub mod widget;
+
+use iced::advanced::graphics::futures::MaybeSend;
+use iced::advanced::renderer;
+use iced::advanced::widget::{operate, Id};
+use iced::task::Task;
+use iced::{Element, Point, Rectangle};
+use widget::droppable::*;
+use widget::operation::drop;
+
+pub fn droppable<'a, Message, Theme, Renderer>(
+ content: impl Into<Element<'a, Message, Theme, Renderer>>,
+) -> Droppable<'a, Message, Theme, Renderer>
+where
+ Message: Clone,
+ Renderer: renderer::Renderer,
+{
+ Droppable::new(content)
+}
+
+pub fn zones_on_point<T, MF>(
+ msg: MF,
+ point: Point,
+ options: Option<Vec<Id>>,
+ depth: Option<usize>,
+) -> Task<T>
+where
+ T: Send + 'static,
+ MF: Fn(Vec<(Id, Rectangle)>) -> T + MaybeSend + Sync + Clone + 'static,
+{
+ operate(drop::find_zones(
+ move |bounds| bounds.contains(point),
+ options,
+ depth,
+ ))
+ .map(move |id| msg(id))
+}
+
+pub fn find_zones<Message, MF, F>(
+ msg: MF,
+ filter: F,
+ options: Option<Vec<Id>>,
+ depth: Option<usize>,
+) -> Task<Message>
+where
+ Message: Send + 'static,
+ MF: Fn(Vec<(Id, Rectangle)>) -> Message
+ + MaybeSend
+ + Sync
+ + Clone
+ + 'static,
+ F: Fn(&Rectangle) -> bool + Send + 'static,
+{
+ operate(drop::find_zones(filter, options, depth)).map(move |id| msg(id))
+}
diff --git a/crates/iced_drop/src/widget.rs b/crates/iced_drop/src/widget.rs
new file mode 100644
index 0000000..6b3fed2
--- /dev/null
+++ b/crates/iced_drop/src/widget.rs
@@ -0,0 +1,2 @@
+pub mod droppable;
+pub mod operation;
diff --git a/crates/iced_drop/src/widget/droppable.rs b/crates/iced_drop/src/widget/droppable.rs
new file mode 100644
index 0000000..947cf5b
--- /dev/null
+++ b/crates/iced_drop/src/widget/droppable.rs
@@ -0,0 +1,574 @@
+//! Encapsulates a widget that can be dragged and dropped.
+use std::fmt::Debug;
+use std::vec;
+
+use iced::advanced::widget::{Operation, Tree, Widget};
+use iced::advanced::{self, Layout, layout, mouse, overlay, renderer};
+use iced::{Element, Point, Rectangle, Size, Vector};
+
+/// An element that can be dragged and dropped on a [`DropZone`]
+pub struct Droppable<
+ 'a,
+ Message,
+ Theme = iced::Theme,
+ Renderer = iced::Renderer,
+> where
+ Message: Clone,
+ Renderer: renderer::Renderer,
+{
+ content: Element<'a, Message, Theme, Renderer>,
+ id: Option<iced::advanced::widget::Id>,
+ on_click: Option<Message>,
+ on_drop: Option<Box<dyn Fn(Point, Rectangle) -> Message + 'a>>,
+ on_drag: Option<Box<dyn Fn(Point, Rectangle) -> Message + 'a>>,
+ on_cancel: Option<Message>,
+ drag_mode: Option<(bool, bool)>,
+ drag_overlay: bool,
+ drag_hide: bool,
+ drag_center: bool,
+ drag_size: Option<Size>,
+ reset_delay: usize,
+ status: Option<Status>,
+}
+
+impl<'a, Message, Theme, Renderer> Droppable<'a, Message, Theme, Renderer>
+where
+ Message: Clone,
+ Renderer: renderer::Renderer,
+{
+ /// Creates a new [`Droppable`].
+ pub fn new(
+ content: impl Into<Element<'a, Message, Theme, Renderer>>,
+ ) -> Self {
+ Self {
+ content: content.into(),
+ id: None,
+ on_click: None,
+ on_drop: None,
+ on_drag: None,
+ on_cancel: None,
+ drag_mode: Some((true, true)),
+ drag_overlay: true,
+ drag_hide: false,
+ drag_center: false,
+ drag_size: None,
+ reset_delay: 0,
+ status: None,
+ }
+ }
+
+ /// Sets the unique identifier of the [`Droppable`].
+ pub fn id(mut self, id: iced::advanced::widget::Id) -> Self {
+ self.id = Some(id);
+ self
+ }
+
+ /// Sets the message that will be produced when the [`Droppable`] is clicked.
+ pub fn on_click(mut self, message: Message) -> Self {
+ self.on_click = Some(message);
+ self
+ }
+
+ /// Sets the message that will be produced when the [`Droppable`] is dropped on a [`DropZone`].
+ ///
+ /// Unless this is set, the [`Droppable`] will be disabled.
+ pub fn on_drop<F>(mut self, message: F) -> Self
+ where
+ F: Fn(Point, Rectangle) -> Message + 'a,
+ {
+ self.on_drop = Some(Box::new(message));
+ self
+ }
+
+ /// Sets the message that will be produced when the [`Droppable`] is dragged.
+ pub fn on_drag<F>(mut self, message: F) -> Self
+ where
+ F: Fn(Point, Rectangle) -> Message + 'a,
+ {
+ self.on_drag = Some(Box::new(message));
+ self
+ }
+
+ /// Sets the message that will be produced when the user right clicks while dragging the [`Droppable`].
+ pub fn on_cancel(mut self, message: Message) -> Self {
+ self.on_cancel = Some(message);
+ self
+ }
+
+ /// Sets whether the [`Droppable`] should be drawn under the cursor while dragging.
+ pub fn drag_overlay(mut self, drag_overlay: bool) -> Self {
+ self.drag_overlay = drag_overlay;
+ self
+ }
+
+ /// Sets whether the [`Droppable`] should be hidden while dragging.
+ pub fn drag_hide(mut self, drag_hide: bool) -> Self {
+ self.drag_hide = drag_hide;
+ self
+ }
+
+ /// Sets whether the [`Droppable`] should be centered on the cursor while dragging.
+ pub fn drag_center(mut self, drag_center: bool) -> Self {
+ self.drag_center = drag_center;
+ self
+ }
+
+ // Sets whether the [`Droppable`] can be dragged along individual axes.
+ pub fn drag_mode(mut self, drag_x: bool, drag_y: bool) -> Self {
+ self.drag_mode = Some((drag_x, drag_y));
+ self
+ }
+
+ /// Sets whether the [`Droppable`] should be be resized to a given size while dragging.
+ pub fn drag_size(mut self, hide_size: Size) -> Self {
+ self.drag_size = Some(hide_size);
+ self
+ }
+
+ /// Sets the number of frames/layout calls to wait before resetting the size of the [`Droppable`] after dropping.
+ ///
+ /// This is useful for cases where the [`Droppable`] is being moved to a new location after some widget operation.
+ /// In this case, the [`Droppable`] will mainting the 'drag_size' for the given number of frames before resetting to its original size.
+ /// This prevents the [`Droppable`] from 'jumping' back to its original size before the new location is rendered which
+ /// prevents flickering.
+ ///
+ /// Warning: this should only be set if there's is some noticeble flickering when the [`Droppable`] is dropped. That is, if the
+ /// [`Droppable`] returns to its original size before it's moved to it's new location.
+ pub fn reset_delay(mut self, reset_delay: usize) -> Self {
+ self.reset_delay = reset_delay;
+ self
+ }
+}
+
+impl<'a, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
+ for Droppable<'a, Message, Theme, Renderer>
+where
+ Message: Clone,
+ Renderer: renderer::Renderer,
+{
+ fn state(&self) -> iced::advanced::widget::tree::State {
+ advanced::widget::tree::State::new(State::default())
+ }
+
+ fn tag(&self) -> iced::advanced::widget::tree::Tag {
+ advanced::widget::tree::Tag::of::<State>()
+ }
+
+ fn children(&self) -> Vec<iced::advanced::widget::Tree> {
+ vec![advanced::widget::Tree::new(&self.content)]
+ }
+
+ fn diff(&self, tree: &mut iced::advanced::widget::Tree) {
+ tree.diff_children(std::slice::from_ref(&self.content))
+ }
+
+ fn size(&self) -> iced::Size<iced::Length> {
+ self.content.as_widget().size()
+ }
+
+ fn update(
+ &mut self,
+ tree: &mut iced::advanced::widget::Tree,
+ event: &iced::Event,
+ layout: iced::advanced::Layout<'_>,
+ cursor: iced::advanced::mouse::Cursor,
+ _renderer: &Renderer,
+ _clipboard: &mut dyn iced::advanced::Clipboard,
+ shell: &mut iced::advanced::Shell<'_, Message>,
+ _viewport: &iced::Rectangle,
+ ) {
+ // handle the on event of the content first, in case that the droppable is nested
+ self.content.as_widget_mut().update(
+ &mut tree.children[0],
+ event,
+ layout,
+ cursor,
+ _renderer,
+ _clipboard,
+ shell,
+ _viewport,
+ );
+ // this should really only be captured if the droppable is nested or it contains some other
+ // widget that captures the event
+ if shell.is_event_captured() {
+ return;
+ }
+
+ if let Some(on_drop) = self.on_drop.as_deref() {
+ let state = tree.state.downcast_mut::<State>();
+ if let iced::Event::Mouse(mouse) = event {
+ match mouse {
+ mouse::Event::ButtonPressed(btn) => {
+ if *btn == mouse::Button::Left
+ && cursor.is_over(layout.bounds())
+ {
+ // select the droppable and store the position of the widget before dragging
+ state.action =
+ Action::Select(cursor.position().unwrap());
+ let bounds = layout.bounds();
+ state.widget_pos = bounds.position();
+ state.overlay_bounds.width = bounds.width;
+ state.overlay_bounds.height = bounds.height;
+
+ if let Some(on_click) = self.on_click.clone() {
+ shell.publish(on_click);
+ }
+ shell.capture_event();
+ } else if *btn == mouse::Button::Right {
+ if let Action::Drag(_, _) = state.action {
+ shell.invalidate_layout();
+ state.action = Action::None;
+ if let Some(on_cancel) = self.on_cancel.clone()
+ {
+ shell.publish(on_cancel);
+ }
+ }
+ }
+ }
+ mouse::Event::CursorMoved { mut position } => match state
+ .action
+ {
+ Action::Select(start) | Action::Drag(start, _) => {
+ // calculate the new position of the widget after dragging
+
+ if let Some((drag_x, drag_y)) = self.drag_mode {
+ position = Point {
+ x: if drag_x {
+ position.x
+ } else {
+ start.x
+ },
+ y: if drag_y {
+ position.y
+ } else {
+ start.y
+ },
+ };
+ }
+
+ state.action = Action::Drag(start, position);
+ // update the position of the overlay since the cursor was moved
+ if self.drag_center {
+ state.overlay_bounds.x = position.x
+ - state.overlay_bounds.width / 2.0;
+ state.overlay_bounds.y = position.y
+ - state.overlay_bounds.height / 2.0;
+ } else {
+ state.overlay_bounds.x =
+ state.widget_pos.x + position.x - start.x;
+ state.overlay_bounds.y =
+ state.widget_pos.y + position.y - start.y;
+ }
+ // send on drag msg
+ if let Some(on_drag) = self.on_drag.as_deref() {
+ let message =
+ (on_drag)(position, state.overlay_bounds);
+ shell.publish(message);
+ }
+
+ shell.request_redraw();
+ }
+ _ => (),
+ },
+ mouse::Event::ButtonReleased(mouse::Button::Left) => {
+ match state.action {
+ Action::Select(_) => {
+ state.action = Action::None;
+ }
+ Action::Drag(_, current) => {
+ // send on drop msg
+ let message =
+ (on_drop)(current, state.overlay_bounds);
+ shell.publish(message);
+
+ if self.reset_delay == 0 {
+ state.action = Action::None;
+ } else {
+ state.action =
+ Action::Wait(self.reset_delay);
+ }
+ }
+ _ => (),
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+
+ let current_status = if cursor.is_over(layout.bounds()) {
+ if self.on_drop.is_none() {
+ Status::Disabled
+ } else {
+ let state = tree.state.downcast_ref::<State>();
+
+ if let Action::Drag(_, _) = state.action {
+ Status::Dragged
+ } else {
+ Status::Hovered
+ }
+ }
+ } else {
+ Status::Active
+ };
+
+ if let iced::Event::Window(iced::window::Event::RedrawRequested(_now)) =
+ event
+ {
+ self.status = Some(current_status);
+ } else if self.status.is_some_and(|status| status != current_status) {
+ shell.request_redraw();
+ }
+ }
+
+ fn layout(
+ &self,
+ tree: &mut iced::advanced::widget::Tree,
+ renderer: &Renderer,
+ limits: &iced::advanced::layout::Limits,
+ ) -> iced::advanced::layout::Node {
+ let state: &mut State = tree.state.downcast_mut::<State>();
+ let content_node = self.content.as_widget().layout(
+ &mut tree.children[0],
+ renderer,
+ limits,
+ );
+
+ // Adjust the size of the original widget if it's being dragged or we're wating to reset the size
+ if let Some(new_size) = self.drag_size {
+ match state.action {
+ Action::Drag(_, _) => {
+ return iced::advanced::layout::Node::with_children(
+ new_size,
+ content_node.children().to_vec(),
+ );
+ }
+ Action::Wait(reveal_index) => {
+ if reveal_index <= 1 {
+ state.action = Action::None;
+ } else {
+ state.action = Action::Wait(reveal_index - 1);
+ }
+
+ return iced::advanced::layout::Node::with_children(
+ new_size,
+ content_node.children().to_vec(),
+ );
+ }
+ _ => (),
+ }
+ }
+
+ content_node
+ }
+
+ fn operate(
+ &self,
+ tree: &mut Tree,
+ layout: Layout<'_>,
+ renderer: &Renderer,
+ operation: &mut dyn Operation,
+ ) {
+ let state = tree.state.downcast_mut::<State>();
+ operation.custom(self.id.as_ref(), layout.bounds(), state);
+ operation.container(
+ self.id.as_ref(),
+ layout.bounds(),
+ &mut |operation| {
+ self.content.as_widget().operate(
+ &mut tree.children[0],
+ layout,
+ renderer,
+ operation,
+ );
+ },
+ );
+ }
+
+ fn draw(
+ &self,
+ tree: &iced::advanced::widget::Tree,
+ renderer: &mut Renderer,
+ theme: &Theme,
+ style: &renderer::Style,
+ layout: iced::advanced::Layout<'_>,
+ cursor: iced::advanced::mouse::Cursor,
+ viewport: &iced::Rectangle,
+ ) {
+ let state: &State = tree.state.downcast_ref::<State>();
+ if let Action::Drag(_, _) = state.action {
+ if self.drag_hide {
+ return;
+ }
+ }
+
+ self.content.as_widget().draw(
+ &tree.children[0],
+ renderer,
+ theme,
+ style,
+ layout,
+ cursor,
+ &viewport,
+ );
+ }
+
+ fn overlay<'b>(
+ &'b mut self,
+ tree: &'b mut Tree,
+ layout: Layout<'_>,
+ renderer: &Renderer,
+ _translation: Vector,
+ ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
+ let state: &mut State = tree.state.downcast_mut::<State>();
+ if self.drag_overlay {
+ if let Action::Drag(_, _) = state.action {
+ return Some(overlay::Element::new(Box::new(Overlay {
+ content: &self.content,
+ tree: &mut tree.children[0],
+ overlay_bounds: state.overlay_bounds,
+ })));
+ }
+ }
+ self.content.as_widget_mut().overlay(
+ &mut tree.children[0],
+ layout,
+ renderer,
+ _translation,
+ )
+ }
+
+ fn mouse_interaction(
+ &self,
+ tree: &iced::advanced::widget::Tree,
+ layout: iced::advanced::Layout<'_>,
+ cursor: iced::advanced::mouse::Cursor,
+ _viewport: &iced::Rectangle,
+ _renderer: &Renderer,
+ ) -> iced::advanced::mouse::Interaction {
+ let child_interact = self.content.as_widget().mouse_interaction(
+ &tree.children[0],
+ layout,
+ cursor,
+ _viewport,
+ _renderer,
+ );
+
+ if child_interact != mouse::Interaction::default() {
+ return child_interact;
+ }
+
+ let state = tree.state.downcast_ref::<State>();
+
+ if self.on_drop.is_none() && cursor.is_over(layout.bounds()) {
+ return mouse::Interaction::NotAllowed;
+ }
+
+ if let Action::Drag(_, _) = state.action {
+ return mouse::Interaction::Grabbing;
+ }
+
+ if cursor.is_over(layout.bounds()) {
+ return mouse::Interaction::Pointer;
+ }
+
+ mouse::Interaction::default()
+ }
+}
+
+impl<'a, Message, Theme, Renderer> From<Droppable<'a, Message, Theme, Renderer>>
+ for Element<'a, Message, Theme, Renderer>
+where
+ Message: 'a + Clone,
+ Theme: 'a,
+ Renderer: 'a + renderer::Renderer,
+{
+ fn from(
+ droppable: Droppable<'a, Message, Theme, Renderer>,
+ ) -> Element<'a, Message, Theme, Renderer> {
+ Element::new(droppable)
+ }
+}
+
+#[derive(Default, Clone, Copy, PartialEq, Debug)]
+pub struct State {
+ widget_pos: Point,
+ overlay_bounds: Rectangle,
+ action: Action,
+}
+
+#[derive(Default, Clone, Copy, PartialEq, Debug)]
+pub enum Status {
+ #[default]
+ Active,
+ Hovered,
+ Dragged,
+ Disabled,
+}
+
+#[derive(Default, Clone, Copy, PartialEq, Debug)]
+pub enum Action {
+ #[default]
+ None,
+ /// (point clicked)
+ Select(Point),
+ /// (start pos, current pos)
+ Drag(Point, Point),
+ /// (frames to wait)
+ Wait(usize),
+}
+
+struct Overlay<'a, 'b, Message, Theme, Renderer>
+where
+ Renderer: renderer::Renderer,
+{
+ content: &'b Element<'a, Message, Theme, Renderer>,
+ tree: &'b mut advanced::widget::Tree,
+ overlay_bounds: Rectangle,
+}
+
+impl<'a, 'b, Message, Theme, Renderer>
+ overlay::Overlay<Message, Theme, Renderer>
+ for Overlay<'a, 'b, Message, Theme, Renderer>
+where
+ Renderer: renderer::Renderer,
+{
+ fn layout(&mut self, renderer: &Renderer, _bounds: Size) -> layout::Node {
+ Widget::<Message, Theme, Renderer>::layout(
+ self.content.as_widget(),
+ self.tree,
+ renderer,
+ &layout::Limits::new(Size::ZERO, self.overlay_bounds.size()),
+ )
+ .move_to(self.overlay_bounds.position())
+ }
+
+ fn draw(
+ &self,
+ renderer: &mut Renderer,
+ theme: &Theme,
+ inherited_style: &renderer::Style,
+ layout: Layout<'_>,
+ cursor_position: mouse::Cursor,
+ ) {
+ Widget::<Message, Theme, Renderer>::draw(
+ self.content.as_widget(),
+ self.tree,
+ renderer,
+ theme,
+ inherited_style,
+ layout,
+ cursor_position,
+ &Rectangle::with_size(Size::INFINITY),
+ );
+ }
+
+ fn is_over(
+ &self,
+ _layout: Layout<'_>,
+ _renderer: &Renderer,
+ _cursor_position: Point,
+ ) -> bool {
+ false
+ }
+}
diff --git a/crates/iced_drop/src/widget/operation.rs b/crates/iced_drop/src/widget/operation.rs
new file mode 100644
index 0000000..3d7dcff
--- /dev/null
+++ b/crates/iced_drop/src/widget/operation.rs
@@ -0,0 +1 @@
+pub mod drop;
diff --git a/crates/iced_drop/src/widget/operation/drop.rs b/crates/iced_drop/src/widget/operation/drop.rs
new file mode 100644
index 0000000..ead412c
--- /dev/null
+++ b/crates/iced_drop/src/widget/operation/drop.rs
@@ -0,0 +1,88 @@
+use iced::advanced::widget::operation::{Outcome, Scrollable};
+use iced::advanced::widget::{Id, Operation};
+use iced::{Rectangle, Vector};
+
+/// Produces an [`Operation`] that will find the drop zones that pass a filter on the zone's bounds.
+/// For any drop zone to be considered, the Element must have some Id.
+/// If `options` is `None`, all drop zones will be considered.
+/// Depth determines how how deep into nested drop zones to go.
+/// If 'depth' is `None`, nested dropzones will be fully explored
+pub fn find_zones<F>(
+ filter: F,
+ options: Option<Vec<Id>>,
+ depth: Option<usize>,
+) -> impl Operation<Vec<(Id, Rectangle)>>
+where
+ F: Fn(&Rectangle) -> bool + Send + 'static,
+{
+ struct FindDropZone<F> {
+ filter: F,
+ options: Option<Vec<Id>>,
+ zones: Vec<(Id, Rectangle)>,
+ max_depth: Option<usize>,
+ c_depth: usize,
+ offset: Vector,
+ }
+
+ impl<F> Operation<Vec<(Id, Rectangle)>> for FindDropZone<F>
+ where
+ F: Fn(&Rectangle) -> bool + Send + 'static,
+ {
+ fn container(
+ &mut self,
+ id: Option<&Id>,
+ bounds: iced::Rectangle,
+ operate_on_children: &mut dyn FnMut(
+ &mut dyn Operation<Vec<(Id, Rectangle)>>,
+ ),
+ ) {
+ match id {
+ Some(id) => {
+ let is_option = match &self.options {
+ Some(options) => options.contains(id),
+ None => true,
+ };
+ let bounds = bounds - self.offset;
+ if is_option && (self.filter)(&bounds) {
+ self.c_depth += 1;
+ self.zones.push((id.clone(), bounds));
+ }
+ }
+ None => (),
+ }
+ let goto_next = match &self.max_depth {
+ Some(m_depth) => self.c_depth < *m_depth,
+ None => true,
+ };
+ if goto_next {
+ operate_on_children(self);
+ }
+ }
+
+ fn finish(&self) -> Outcome<Vec<(Id, Rectangle)>> {
+ Outcome::Some(self.zones.clone())
+ }
+
+ fn scrollable(
+ &mut self,
+ _id: Option<&Id>,
+ bounds: Rectangle,
+ _content_bounds: Rectangle,
+ translation: Vector,
+ _state: &mut dyn Scrollable,
+ ) {
+ if (self.filter)(&bounds) {
+ self.offset = self.offset + translation;
+ }
+ }
+ }
+
+ FindDropZone {
+ filter,
+ options,
+ zones: vec![],
+ max_depth: depth,
+ c_depth: 0,
+ offset: Vector { x: 0.0, y: 0.0 },
+ }
+}
diff --git a/crates/material_theme/Cargo.toml b/crates/material_theme/Cargo.toml
new file mode 100644
index 0000000..eef9605
--- /dev/null
+++ b/crates/material_theme/Cargo.toml
@@ -0,0 +1,53 @@
+[package]
+name = "material_theme"
+description = "An M3 inspired theme for `iced`"
+authors = ["pml68 <contact@pml68.dev>"]
+version = "0.14.0-dev"
+edition = "2024"
+license = "MIT"
+# readme = "README.md"
+repository = "https://github.com/pml68/iced_builder"
+categories = ["gui"]
+keywords = ["gui", "ui", "graphics", "interface", "widgets"]
+rust-version = "1.85"
+
+[features]
+default = []
+animate = ["dep:iced_anim"]
+dialog = ["dep:iced_dialog"]
+
+[dependencies]
+iced_widget = "0.14.0-dev"
+serde.workspace = true
+toml.workspace = true
+dark-light = "2.0.0"
+iced_dialog.workspace = true
+iced_dialog.optional = true
+
+[dependencies.iced_anim]
+workspace = true
+features = ["derive"]
+optional = true
+
+[lints.rust]
+missing_debug_implementations = "deny"
+unsafe_code = "deny"
+unused_results = "deny"
+
+[lints.clippy]
+type-complexity = "allow"
+semicolon_if_nothing_returned = "deny"
+trivially-copy-pass-by-ref = "deny"
+default_trait_access = "deny"
+match-wildcard-for-single-variants = "deny"
+redundant-closure-for-method-calls = "deny"
+filter_map_next = "deny"
+manual_let_else = "deny"
+unused_async = "deny"
+from_over_into = "deny"
+needless_borrow = "deny"
+new_without_default = "deny"
+useless_conversion = "deny"
+
+[lints.rustdoc]
+broken_intra_doc_links = "forbid"
diff --git a/crates/material_theme/README.md b/crates/material_theme/README.md
new file mode 100644
index 0000000..da5a1ec
--- /dev/null
+++ b/crates/material_theme/README.md
@@ -0,0 +1,3 @@
+# material_theme
+
+## A [Material3](https://m3.material.io) inspired custom theme for [`iced`](https://iced.rs)
diff --git a/crates/material_theme/assets/themes/dark.toml b/crates/material_theme/assets/themes/dark.toml
new file mode 100644
index 0000000..18a369f
--- /dev/null
+++ b/crates/material_theme/assets/themes/dark.toml
@@ -0,0 +1,49 @@
+name = "Dark"
+
+shadow = "#000000"
+scrim = "#4d000000"
+
+[primary]
+color = "#9bd4a1"
+on_primary = "#003916"
+primary_container = "#1b5129"
+on_primary_container = "#b6f1bb"
+
+[secondary]
+color = "#b8ccb6"
+on_secondary = "#233425"
+secondary_container = "#394b3a"
+on_secondary_container = "#d3e8d1"
+
+[tertiary]
+color = "#a1ced7"
+on_tertiary = "#00363e"
+tertiary_container = "#1f4d55"
+on_tertiary_container = "#bdeaf4"
+
+[error]
+color = "#ffb4ab"
+on_error = "#690005"
+error_container = "#93000a"
+on_error_container = "#ffdad6"
+
+[surface]
+color = "#101510"
+on_surface = "#e0e4dc"
+on_surface_variant = "#c1c9be"
+
+[surface.surface_container]
+lowest = "#0b0f0b"
+low = "#181d18"
+base = "#1c211c"
+high = "#262b26"
+highest = "#313631"
+
+[inverse]
+inverse_surface = "#e0e4dc"
+inverse_on_surface = "#2d322c"
+inverse_primary = "#34693f"
+
+[outline]
+color = "#8b9389"
+variant = "#414941"
diff --git a/crates/material_theme/assets/themes/light.toml b/crates/material_theme/assets/themes/light.toml
new file mode 100644
index 0000000..a7115c4
--- /dev/null
+++ b/crates/material_theme/assets/themes/light.toml
@@ -0,0 +1,49 @@
+name = "Light"
+
+shadow = "#000000"
+scrim = "#4d000000"
+
+[primary]
+color = "#34693f"
+on_primary = "#ffffff"
+primary_container = "#b6f1bb"
+on_primary_container = "#1b5129"
+
+[secondary]
+color = "#516351"
+on_secondary = "#ffffff"
+secondary_container = "#d3e8d1"
+on_secondary_container = "#394b3a"
+
+[tertiary]
+color = "#39656d"
+on_tertiary = "#ffffff"
+tertiary_container = "#bdeaf4"
+on_tertiary_container = "#1f4d55"
+
+[error]
+color = "#ba1a1a"
+on_error = "#ffffff"
+error_container = "#ffdad6"
+on_error_container = "#93000a"
+
+[surface]
+color = "#f7fbf2"
+on_surface = "#181d18"
+on_surface_variant = "#414941"
+
+[surface.surface_container]
+lowest = "#ffffff"
+low = "#f1f5ed"
+base = "#ebefe7"
+high = "#e5e9e1"
+highest = "#e0e4dc"
+
+[inverse]
+inverse_surface = "#2d322c"
+inverse_on_surface = "#eef2ea"
+inverse_primary = "#9bd4a1"
+
+[outline]
+color = "#727970"
+variant = "#c1c9be"
diff --git a/crates/material_theme/src/button.rs b/crates/material_theme/src/button.rs
new file mode 100644
index 0000000..21d77b7
--- /dev/null
+++ b/crates/material_theme/src/button.rs
@@ -0,0 +1,193 @@
+use iced_widget::button::{Catalog, Status, Style, StyleFn};
+use iced_widget::core::{Background, Border, Color, border};
+
+use crate::Theme;
+use crate::utils::{
+ DISABLED_CONTAINER_OPACITY, DISABLED_TEXT_OPACITY, HOVERED_LAYER_OPACITY,
+ PRESSED_LAYER_OPACITY, elevation, mix, shadow_from_elevation,
+};
+
+impl Catalog for Theme {
+ type Class<'a> = StyleFn<'a, Self>;
+
+ fn default<'a>() -> Self::Class<'a> {
+ Box::new(filled)
+ }
+
+ fn style(&self, class: &Self::Class<'_>, status: Status) -> Style {
+ class(self, status)
+ }
+}
+
+fn button(
+ foreground: Color,
+ background: Color,
+ tone_overlay: Color,
+ disabled: Color,
+ shadow_color: Color,
+ elevation_level: u8,
+ status: Status,
+) -> Style {
+ let active = Style {
+ background: Some(Background::Color(background)),
+ text_color: foreground,
+ border: border::rounded(400),
+ shadow: shadow_from_elevation(elevation(elevation_level), shadow_color),
+ };
+
+ match status {
+ Status::Active => active,
+ Status::Pressed => Style {
+ background: Some(Background::Color(mix(
+ background,
+ tone_overlay,
+ HOVERED_LAYER_OPACITY,
+ ))),
+ ..active
+ },
+ Status::Hovered => Style {
+ background: Some(Background::Color(mix(
+ background,
+ tone_overlay,
+ PRESSED_LAYER_OPACITY,
+ ))),
+ text_color: foreground,
+ border: border::rounded(400),
+ shadow: shadow_from_elevation(
+ elevation(elevation_level + 1),
+ shadow_color,
+ ),
+ },
+ Status::Disabled => Style {
+ background: Some(Background::Color(Color {
+ a: DISABLED_CONTAINER_OPACITY,
+ ..disabled
+ })),
+ text_color: Color {
+ a: DISABLED_TEXT_OPACITY,
+ ..disabled
+ },
+ border: border::rounded(400),
+ ..Default::default()
+ },
+ }
+}
+
+pub fn elevated(theme: &Theme, status: Status) -> Style {
+ let surface_colors = theme.colorscheme.surface;
+
+ let foreground = theme.colorscheme.primary.color;
+ let background = surface_colors.surface_container.low;
+ let disabled = surface_colors.on_surface;
+
+ let shadow_color = theme.colorscheme.shadow;
+
+ button(
+ foreground,
+ background,
+ foreground,
+ disabled,
+ shadow_color,
+ 1,
+ status,
+ )
+}
+
+pub fn filled(theme: &Theme, status: Status) -> Style {
+ let primary_colors = theme.colorscheme.primary;
+
+ let foreground = primary_colors.on_primary;
+ let background = primary_colors.color;
+ let disabled = theme.colorscheme.surface.on_surface;
+
+ let shadow_color = theme.colorscheme.shadow;
+
+ button(
+ foreground,
+ background,
+ foreground,
+ disabled,
+ shadow_color,
+ 0,
+ status,
+ )
+}
+
+pub fn filled_tonal(theme: &Theme, status: Status) -> Style {
+ let secondary_colors = theme.colorscheme.secondary;
+
+ let foreground = secondary_colors.on_secondary_container;
+ let background = secondary_colors.secondary_container;
+ let disabled = theme.colorscheme.surface.on_surface;
+ let shadow_color = theme.colorscheme.shadow;
+
+ button(
+ foreground,
+ background,
+ foreground,
+ disabled,
+ shadow_color,
+ 0,
+ status,
+ )
+}
+
+pub fn outlined(theme: &Theme, status: Status) -> Style {
+ let foreground = theme.colorscheme.primary.color;
+ let background = Color::TRANSPARENT;
+ let disabled = theme.colorscheme.surface.on_surface;
+
+ let outline = theme.colorscheme.outline.color;
+
+ let border = match status {
+ Status::Active | Status::Pressed | Status::Hovered => Border {
+ color: outline,
+ width: 1.0,
+ radius: 400.0.into(),
+ },
+ Status::Disabled => Border {
+ color: Color {
+ a: DISABLED_CONTAINER_OPACITY,
+ ..disabled
+ },
+ width: 1.0,
+ radius: 400.0.into(),
+ },
+ };
+
+ let style = button(
+ foreground,
+ background,
+ foreground,
+ disabled,
+ Color::TRANSPARENT,
+ 0,
+ status,
+ );
+
+ Style { border, ..style }
+}
+
+pub fn text(theme: &Theme, status: Status) -> Style {
+ let foreground = theme.colorscheme.primary.color;
+ let background = Color::TRANSPARENT;
+ let disabled = theme.colorscheme.surface.on_surface;
+
+ let style = button(
+ foreground,
+ background,
+ foreground,
+ disabled,
+ Color::TRANSPARENT,
+ 0,
+ status,
+ );
+
+ match status {
+ Status::Hovered | Status::Pressed => style,
+ _ => Style {
+ background: None,
+ ..style
+ },
+ }
+}
diff --git a/crates/material_theme/src/container.rs b/crates/material_theme/src/container.rs
new file mode 100644
index 0000000..a14cfd5
--- /dev/null
+++ b/crates/material_theme/src/container.rs
@@ -0,0 +1,173 @@
+use iced_widget::container::{Catalog, Style, StyleFn};
+use iced_widget::core::{Background, border};
+
+use super::Theme;
+
+impl Catalog for Theme {
+ type Class<'a> = StyleFn<'a, Self>;
+
+ fn default<'a>() -> Self::Class<'a> {
+ Box::new(transparent)
+ }
+
+ fn style(&self, class: &Self::Class<'_>) -> Style {
+ class(self)
+ }
+}
+
+pub fn transparent(_theme: &Theme) -> Style {
+ Style {
+ border: border::rounded(4),
+ ..Style::default()
+ }
+}
+
+pub fn primary(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.primary;
+ Style {
+ background: Some(Background::Color(colors.color)),
+ text_color: Some(colors.on_primary),
+ border: border::rounded(4),
+ ..Style::default()
+ }
+}
+
+pub fn primary_container(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.primary;
+ Style {
+ background: Some(Background::Color(colors.primary_container)),
+ text_color: Some(colors.on_primary_container),
+ border: border::rounded(8),
+ ..Style::default()
+ }
+}
+
+pub fn secondary(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.secondary;
+ Style {
+ background: Some(Background::Color(colors.color)),
+ text_color: Some(colors.on_secondary),
+ border: border::rounded(4),
+ ..Style::default()
+ }
+}
+
+pub fn secondary_container(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.secondary;
+ Style {
+ background: Some(Background::Color(colors.secondary_container)),
+ text_color: Some(colors.on_secondary_container),
+ border: border::rounded(8),
+ ..Style::default()
+ }
+}
+
+pub fn tertiary(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.tertiary;
+ Style {
+ background: Some(Background::Color(colors.color)),
+ text_color: Some(colors.on_tertiary),
+ border: border::rounded(4),
+ ..Style::default()
+ }
+}
+
+pub fn tertiary_container(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.tertiary;
+ Style {
+ background: Some(Background::Color(colors.tertiary_container)),
+ text_color: Some(colors.on_tertiary_container),
+ border: border::rounded(8),
+ ..Style::default()
+ }
+}
+
+pub fn error(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.error;
+ Style {
+ background: Some(Background::Color(colors.color)),
+ text_color: Some(colors.on_error),
+ border: border::rounded(4),
+ ..Style::default()
+ }
+}
+
+pub fn error_container(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.error;
+ Style {
+ background: Some(Background::Color(colors.error_container)),
+ text_color: Some(colors.on_error_container),
+ border: border::rounded(8),
+ ..Style::default()
+ }
+}
+
+pub fn surface(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.surface;
+ Style {
+ background: Some(Background::Color(colors.color)),
+ text_color: Some(colors.on_surface),
+ border: border::rounded(4),
+ ..Style::default()
+ }
+}
+
+pub fn surface_container_lowest(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.surface;
+ Style {
+ background: Some(Background::Color(colors.surface_container.lowest)),
+ text_color: Some(colors.on_surface),
+ border: border::rounded(8),
+ ..Style::default()
+ }
+}
+
+pub fn surface_container_low(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.surface;
+ Style {
+ background: Some(Background::Color(colors.surface_container.low)),
+ text_color: Some(colors.on_surface),
+ border: border::rounded(8),
+ ..Style::default()
+ }
+}
+
+pub fn surface_container(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.surface;
+ Style {
+ background: Some(Background::Color(colors.surface_container.base)),
+ text_color: Some(colors.on_surface),
+ border: border::rounded(8),
+ ..Style::default()
+ }
+}
+
+pub fn surface_container_high(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.surface;
+ Style {
+ background: Some(Background::Color(colors.surface_container.high)),
+ text_color: Some(colors.on_surface),
+ border: border::rounded(8),
+ ..Style::default()
+ }
+}
+
+pub fn surface_container_highest(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.surface;
+ Style {
+ background: Some(Background::Color(colors.surface_container.highest)),
+ text_color: Some(colors.on_surface),
+ border: border::rounded(8),
+ ..Style::default()
+ }
+}
+
+pub fn inverse_surface(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.inverse;
+ Style {
+ background: Some(Background::Color(colors.inverse_surface)),
+ text_color: Some(colors.inverse_on_surface),
+ border: border::rounded(4),
+ ..Style::default()
+ }
+}
diff --git a/crates/material_theme/src/dialog.rs b/crates/material_theme/src/dialog.rs
new file mode 100644
index 0000000..68c61b5
--- /dev/null
+++ b/crates/material_theme/src/dialog.rs
@@ -0,0 +1,25 @@
+use iced_widget::container::Style;
+use iced_widget::core::{Background, border};
+
+use super::{Theme, text};
+
+impl iced_dialog::dialog::Catalog for Theme {
+ fn default_container<'a>()
+ -> <Self as iced_widget::container::Catalog>::Class<'a> {
+ Box::new(default_container)
+ }
+
+ fn default_title<'a>() -> <Self as iced_widget::text::Catalog>::Class<'a> {
+ Box::new(text::surface)
+ }
+}
+
+pub fn default_container(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.surface;
+ Style {
+ background: Some(Background::Color(colors.surface_container.high)),
+ text_color: Some(colors.on_surface_variant),
+ border: border::rounded(28),
+ ..Style::default()
+ }
+}
diff --git a/crates/material_theme/src/lib.rs b/crates/material_theme/src/lib.rs
new file mode 100644
index 0000000..521af2c
--- /dev/null
+++ b/crates/material_theme/src/lib.rs
@@ -0,0 +1,248 @@
+use std::sync::LazyLock;
+
+use iced_widget::core::Color;
+use iced_widget::core::theme::{Base, Style};
+use serde::Deserialize;
+
+pub mod button;
+pub mod container;
+#[cfg(feature = "dialog")]
+pub mod dialog;
+pub mod menu;
+pub mod pick_list;
+pub mod scrollable;
+pub mod text;
+pub mod utils;
+
+const DARK_THEME_CONTENT: &str = include_str!("../assets/themes/dark.toml");
+const LIGHT_THEME_CONTENT: &str = include_str!("../assets/themes/light.toml");
+
+#[derive(Debug, PartialEq, Deserialize)]
+pub struct Theme {
+ pub name: String,
+ #[serde(flatten)]
+ pub colorscheme: ColorScheme,
+}
+
+impl Theme {
+ pub fn new(name: impl Into<String>, colorscheme: ColorScheme) -> Self {
+ Self {
+ name: name.into(),
+ colorscheme,
+ }
+ }
+}
+
+impl Clone for Theme {
+ fn clone(&self) -> Self {
+ Self {
+ name: self.name.clone(),
+ colorscheme: self.colorscheme,
+ }
+ }
+
+ fn clone_from(&mut self, source: &Self) {
+ self.name = source.name.clone();
+ self.colorscheme = source.colorscheme;
+ }
+}
+
+impl Default for Theme {
+ fn default() -> Self {
+ static DEFAULT: LazyLock<Theme> = LazyLock::new(|| {
+ match dark_light::detect().unwrap_or(dark_light::Mode::Unspecified)
+ {
+ dark_light::Mode::Dark | dark_light::Mode::Unspecified => {
+ DARK.clone()
+ }
+ dark_light::Mode::Light => LIGHT.clone(),
+ }
+ });
+
+ DEFAULT.clone()
+ }
+}
+
+impl std::fmt::Display for Theme {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.name)
+ }
+}
+
+impl Base for Theme {
+ fn base(&self) -> Style {
+ Style {
+ background_color: self.colorscheme.surface.color,
+ text_color: self.colorscheme.surface.on_surface,
+ }
+ }
+
+ fn palette(&self) -> Option<iced_widget::theme::Palette> {
+ // TODO: create a Palette
+ None
+ }
+}
+
+#[cfg(feature = "animate")]
+impl iced_anim::Animate for Theme {
+ fn components() -> usize {
+ ColorScheme::components()
+ }
+
+ fn update(&mut self, components: &mut impl Iterator<Item = f32>) {
+ let mut colors = self.colorscheme;
+ colors.update(components);
+
+ *self = Theme::new("Animating Theme", colors);
+ }
+
+ fn distance_to(&self, end: &Self) -> Vec<f32> {
+ self.colorscheme.distance_to(&end.colorscheme)
+ }
+
+ fn lerp(&mut self, start: &Self, end: &Self, progress: f32) {
+ let mut colors = self.colorscheme;
+ colors.lerp(&start.colorscheme, &end.colorscheme, progress);
+
+ *self = Theme::new("Animating Theme", colors);
+ }
+}
+
+pub static DARK: LazyLock<Theme> = LazyLock::new(|| {
+ toml::from_str(DARK_THEME_CONTENT).expect("parse dark theme")
+});
+
+pub static LIGHT: LazyLock<Theme> = LazyLock::new(|| {
+ toml::from_str(LIGHT_THEME_CONTENT).expect("parse light theme")
+});
+
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
+#[cfg_attr(feature = "animate", derive(iced_anim::Animate))]
+pub struct ColorScheme {
+ pub primary: Primary,
+ pub secondary: Secondary,
+ pub tertiary: Tertiary,
+ pub error: Error,
+ pub surface: Surface,
+ pub inverse: Inverse,
+ pub outline: Outline,
+ #[serde(with = "color_serde")]
+ pub shadow: Color,
+ #[serde(with = "color_serde")]
+ pub scrim: Color,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
+#[cfg_attr(feature = "animate", derive(iced_anim::Animate))]
+pub struct Primary {
+ #[serde(with = "color_serde")]
+ pub color: Color,
+ #[serde(with = "color_serde")]
+ pub on_primary: Color,
+ #[serde(with = "color_serde")]
+ pub primary_container: Color,
+ #[serde(with = "color_serde")]
+ pub on_primary_container: Color,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
+#[cfg_attr(feature = "animate", derive(iced_anim::Animate))]
+pub struct Secondary {
+ #[serde(with = "color_serde")]
+ pub color: Color,
+ #[serde(with = "color_serde")]
+ pub on_secondary: Color,
+ #[serde(with = "color_serde")]
+ pub secondary_container: Color,
+ #[serde(with = "color_serde")]
+ pub on_secondary_container: Color,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
+#[cfg_attr(feature = "animate", derive(iced_anim::Animate))]
+pub struct Tertiary {
+ #[serde(with = "color_serde")]
+ pub color: Color,
+ #[serde(with = "color_serde")]
+ pub on_tertiary: Color,
+ #[serde(with = "color_serde")]
+ pub tertiary_container: Color,
+ #[serde(with = "color_serde")]
+ pub on_tertiary_container: Color,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
+#[cfg_attr(feature = "animate", derive(iced_anim::Animate))]
+pub struct Error {
+ #[serde(with = "color_serde")]
+ pub color: Color,
+ #[serde(with = "color_serde")]
+ pub on_error: Color,
+ #[serde(with = "color_serde")]
+ pub error_container: Color,
+ #[serde(with = "color_serde")]
+ pub on_error_container: Color,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
+#[cfg_attr(feature = "animate", derive(iced_anim::Animate))]
+pub struct Surface {
+ #[serde(with = "color_serde")]
+ pub color: Color,
+ #[serde(with = "color_serde")]
+ pub on_surface: Color,
+ #[serde(with = "color_serde")]
+ pub on_surface_variant: Color,
+ pub surface_container: SurfaceContainer,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
+#[cfg_attr(feature = "animate", derive(iced_anim::Animate))]
+pub struct SurfaceContainer {
+ #[serde(with = "color_serde")]
+ pub lowest: Color,
+ #[serde(with = "color_serde")]
+ pub low: Color,
+ #[serde(with = "color_serde")]
+ pub base: Color,
+ #[serde(with = "color_serde")]
+ pub high: Color,
+ #[serde(with = "color_serde")]
+ pub highest: Color,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
+#[cfg_attr(feature = "animate", derive(iced_anim::Animate))]
+pub struct Inverse {
+ #[serde(with = "color_serde")]
+ pub inverse_surface: Color,
+ #[serde(with = "color_serde")]
+ pub inverse_on_surface: Color,
+ #[serde(with = "color_serde")]
+ pub inverse_primary: Color,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
+#[cfg_attr(feature = "animate", derive(iced_anim::Animate))]
+pub struct Outline {
+ #[serde(with = "color_serde")]
+ pub color: Color,
+ #[serde(with = "color_serde")]
+ pub variant: Color,
+}
+
+mod color_serde {
+ use iced_widget::core::Color;
+ use serde::{Deserialize, Deserializer};
+
+ use super::utils::parse_argb;
+
+ pub fn deserialize<'de, D>(deserializer: D) -> Result<Color, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ Ok(String::deserialize(deserializer)
+ .map(|hex| parse_argb(&hex))?
+ .unwrap_or(Color::TRANSPARENT))
+ }
+}
diff --git a/crates/material_theme/src/menu.rs b/crates/material_theme/src/menu.rs
new file mode 100644
index 0000000..9f43c72
--- /dev/null
+++ b/crates/material_theme/src/menu.rs
@@ -0,0 +1,33 @@
+use iced_widget::core::{Background, border};
+use iced_widget::overlay::menu::{Catalog, Style, StyleFn};
+
+use super::Theme;
+use crate::utils::{HOVERED_LAYER_OPACITY, mix};
+
+impl Catalog for Theme {
+ type Class<'a> = StyleFn<'a, Self>;
+
+ fn default<'a>() -> <Self as Catalog>::Class<'a> {
+ Box::new(default)
+ }
+
+ fn style(&self, class: &<Self as Catalog>::Class<'_>) -> Style {
+ class(self)
+ }
+}
+
+pub fn default(theme: &Theme) -> Style {
+ let colors = theme.colorscheme.surface;
+
+ Style {
+ border: border::rounded(4),
+ background: Background::Color(colors.surface_container.base),
+ text_color: colors.on_surface,
+ selected_background: Background::Color(mix(
+ colors.surface_container.base,
+ colors.on_surface,
+ HOVERED_LAYER_OPACITY,
+ )),
+ selected_text_color: colors.on_surface,
+ }
+}
diff --git a/crates/material_theme/src/pick_list.rs b/crates/material_theme/src/pick_list.rs
new file mode 100644
index 0000000..c589100
--- /dev/null
+++ b/crates/material_theme/src/pick_list.rs
@@ -0,0 +1,40 @@
+use iced_widget::core::{Background, border};
+use iced_widget::pick_list::{Catalog, Status, Style, StyleFn};
+
+use super::Theme;
+
+impl Catalog for Theme {
+ type Class<'a> = StyleFn<'a, Self>;
+
+ fn default<'a>() -> <Self as Catalog>::Class<'a> {
+ Box::new(default)
+ }
+
+ fn style(
+ &self,
+ class: &<Self as Catalog>::Class<'_>,
+ status: Status,
+ ) -> Style {
+ class(self, status)
+ }
+}
+
+pub fn default(theme: &Theme, status: Status) -> Style {
+ let surface = theme.colorscheme.surface;
+
+ let active = Style {
+ text_color: surface.on_surface,
+ placeholder_color: surface.on_surface_variant,
+ handle_color: surface.on_surface_variant,
+ background: Background::Color(surface.surface_container.highest),
+ border: border::rounded(4),
+ };
+
+ match status {
+ Status::Active => active,
+ Status::Hovered | Status::Opened { .. } => Style {
+ background: Background::Color(surface.surface_container.highest),
+ ..active
+ },
+ }
+}
diff --git a/crates/material_theme/src/scrollable.rs b/crates/material_theme/src/scrollable.rs
new file mode 100644
index 0000000..ee739ba
--- /dev/null
+++ b/crates/material_theme/src/scrollable.rs
@@ -0,0 +1,153 @@
+use iced_widget::core::{Border, Color, border};
+use iced_widget::scrollable::{
+ Catalog, Rail, Scroller, Status, Style, StyleFn,
+};
+
+use super::Theme;
+use super::container::surface_container;
+use super::utils::mix;
+use crate::utils::{
+ DISABLED_CONTAINER_OPACITY, DISABLED_TEXT_OPACITY, HOVERED_LAYER_OPACITY,
+ PRESSED_LAYER_OPACITY,
+};
+
+impl Catalog for Theme {
+ type Class<'a> = StyleFn<'a, Self>;
+
+ fn default<'a>() -> Self::Class<'a> {
+ Box::new(default)
+ }
+
+ fn style(&self, class: &Self::Class<'_>, status: Status) -> Style {
+ class(self, status)
+ }
+}
+
+pub fn default(theme: &Theme, status: Status) -> Style {
+ let colors = theme.colorscheme.surface;
+
+ let active = Rail {
+ background: None,
+ scroller: Scroller {
+ color: colors.on_surface,
+ border: border::rounded(400),
+ },
+ border: Border::default(),
+ };
+
+ let disabled = Rail {
+ background: Some(
+ Color {
+ a: DISABLED_CONTAINER_OPACITY,
+ ..colors.on_surface
+ }
+ .into(),
+ ),
+ scroller: Scroller {
+ color: Color {
+ a: DISABLED_TEXT_OPACITY,
+ ..colors.on_surface
+ },
+ border: border::rounded(400),
+ },
+ ..active
+ };
+
+ let style = Style {
+ container: surface_container(theme),
+ vertical_rail: active,
+ horizontal_rail: active,
+ gap: None,
+ };
+
+ match status {
+ Status::Active {
+ is_horizontal_scrollbar_disabled,
+ is_vertical_scrollbar_disabled,
+ } => Style {
+ horizontal_rail: if is_horizontal_scrollbar_disabled {
+ disabled
+ } else {
+ active
+ },
+ vertical_rail: if is_vertical_scrollbar_disabled {
+ disabled
+ } else {
+ active
+ },
+ ..style
+ },
+ Status::Hovered {
+ is_horizontal_scrollbar_hovered,
+ is_vertical_scrollbar_hovered,
+ is_horizontal_scrollbar_disabled,
+ is_vertical_scrollbar_disabled,
+ } => {
+ let hovered_rail = Rail {
+ scroller: Scroller {
+ color: mix(
+ colors.on_surface,
+ colors.color,
+ HOVERED_LAYER_OPACITY,
+ ),
+ border: border::rounded(400),
+ },
+ ..active
+ };
+
+ Style {
+ horizontal_rail: if is_horizontal_scrollbar_disabled {
+ disabled
+ } else if is_horizontal_scrollbar_hovered {
+ hovered_rail
+ } else {
+ active
+ },
+ vertical_rail: if is_vertical_scrollbar_disabled {
+ disabled
+ } else if is_vertical_scrollbar_hovered {
+ hovered_rail
+ } else {
+ active
+ },
+ ..style
+ }
+ }
+ Status::Dragged {
+ is_horizontal_scrollbar_dragged,
+ is_vertical_scrollbar_dragged,
+ is_horizontal_scrollbar_disabled,
+ is_vertical_scrollbar_disabled,
+ } => {
+ let dragged_rail = Rail {
+ scroller: Scroller {
+ color: mix(
+ colors.on_surface,
+ colors.color,
+ PRESSED_LAYER_OPACITY,
+ ),
+ border: border::rounded(400),
+ },
+ ..active
+ };
+
+ Style {
+ horizontal_rail: if is_horizontal_scrollbar_disabled {
+ disabled
+ } else if is_horizontal_scrollbar_dragged {
+ dragged_rail
+ } else {
+ active
+ },
+ vertical_rail: if is_vertical_scrollbar_disabled {
+ disabled
+ } else if is_vertical_scrollbar_dragged {
+ dragged_rail
+ } else {
+ active
+ },
+ ..style
+ }
+ }
+ }
+}
diff --git a/crates/material_theme/src/text.rs b/crates/material_theme/src/text.rs
new file mode 100644
index 0000000..10b2e65
--- /dev/null
+++ b/crates/material_theme/src/text.rs
@@ -0,0 +1,86 @@
+#![allow(dead_code)]
+use iced_widget::text::{Catalog, Style, StyleFn};
+
+use crate::Theme;
+
+impl Catalog for Theme {
+ type Class<'a> = StyleFn<'a, Self>;
+
+ fn default<'a>() -> Self::Class<'a> {
+ Box::new(none)
+ }
+
+ fn style(&self, class: &Self::Class<'_>) -> Style {
+ class(self)
+ }
+}
+
+pub fn none(_: &Theme) -> Style {
+ Style { color: None }
+}
+
+pub fn primary(theme: &Theme) -> Style {
+ Style {
+ color: Some(theme.colorscheme.primary.on_primary),
+ }
+}
+
+pub fn primary_container(theme: &Theme) -> Style {
+ Style {
+ color: Some(theme.colorscheme.primary.on_primary_container),
+ }
+}
+
+pub fn secondary(theme: &Theme) -> Style {
+ Style {
+ color: Some(theme.colorscheme.secondary.on_secondary),
+ }
+}
+
+pub fn secondary_container(theme: &Theme) -> Style {
+ Style {
+ color: Some(theme.colorscheme.secondary.on_secondary_container),
+ }
+}
+
+pub fn tertiary(theme: &Theme) -> Style {
+ Style {
+ color: Some(theme.colorscheme.tertiary.on_tertiary),
+ }
+}
+
+pub fn tertiary_container(theme: &Theme) -> Style {
+ Style {
+ color: Some(theme.colorscheme.tertiary.on_tertiary_container),
+ }
+}
+
+pub fn error(theme: &Theme) -> Style {
+ Style {
+ color: Some(theme.colorscheme.error.on_error),
+ }
+}
+
+pub fn error_container(theme: &Theme) -> Style {
+ Style {
+ color: Some(theme.colorscheme.error.on_error_container),
+ }
+}
+
+pub fn surface(theme: &Theme) -> Style {
+ Style {
+ color: Some(theme.colorscheme.surface.on_surface),
+ }
+}
+
+pub fn surface_variant(theme: &Theme) -> Style {
+ Style {
+ color: Some(theme.colorscheme.surface.on_surface_variant),
+ }
+}
+
+pub fn inverse_surface(theme: &Theme) -> Style {
+ Style {
+ color: Some(theme.colorscheme.inverse.inverse_on_surface),
+ }
+}
diff --git a/crates/material_theme/src/utils.rs b/crates/material_theme/src/utils.rs
new file mode 100644
index 0000000..a05bc62
--- /dev/null
+++ b/crates/material_theme/src/utils.rs
@@ -0,0 +1,116 @@
+use iced_widget::core::{Color, Shadow, Vector};
+
+pub const HOVERED_LAYER_OPACITY: f32 = 0.08;
+pub const PRESSED_LAYER_OPACITY: f32 = 0.1;
+
+pub const DISABLED_TEXT_OPACITY: f32 = 0.38;
+pub const DISABLED_CONTAINER_OPACITY: f32 = 0.12;
+
+pub fn elevation(elevation_level: u8) -> f32 {
+ (match elevation_level {
+ 0 => 0.0,
+ 1 => 1.0,
+ 2 => 3.0,
+ 3 => 6.0,
+ 4 => 8.0,
+ _ => 12.0,
+ } as f32)
+}
+
+pub fn shadow_from_elevation(elevation: f32, color: Color) -> Shadow {
+ Shadow {
+ color,
+ offset: Vector {
+ x: 0.0,
+ y: elevation,
+ },
+ blur_radius: (elevation) * (1.0 + 0.4_f32.powf(elevation)),
+ }
+}
+
+pub fn parse_argb(s: &str) -> Option<Color> {
+ let hex = s.strip_prefix('#').unwrap_or(s);
+
+ let parse_channel = |from: usize, to: usize| {
+ let num =
+ usize::from_str_radix(&hex[from..=to], 16).ok()? as f32 / 255.0;
+
+ // If we only got half a byte (one letter), expand it into a full byte (two letters)
+ Some(if from == to { num + num * 16.0 } else { num })
+ };
+
+ Some(match hex.len() {
+ 3 => Color::from_rgb(
+ parse_channel(0, 0)?,
+ parse_channel(1, 1)?,
+ parse_channel(2, 2)?,
+ ),
+ 4 => Color::from_rgba(
+ parse_channel(1, 1)?,
+ parse_channel(2, 2)?,
+ parse_channel(3, 3)?,
+ parse_channel(0, 0)?,
+ ),
+ 6 => Color::from_rgb(
+ parse_channel(0, 1)?,
+ parse_channel(2, 3)?,
+ parse_channel(4, 5)?,
+ ),
+ 8 => Color::from_rgba(
+ parse_channel(2, 3)?,
+ parse_channel(4, 5)?,
+ parse_channel(6, 7)?,
+ parse_channel(0, 1)?,
+ ),
+ _ => None?,
+ })
+}
+
+pub fn mix(color1: Color, color2: Color, p2: f32) -> Color {
+ if p2 <= 0.0 {
+ return color1;
+ } else if p2 >= 1.0 {
+ return color2;
+ }
+
+ let p1 = 1.0 - p2;
+
+ if color1.a != 1.0 || color2.a != 1.0 {
+ let a = color1.a * p1 + color2.a * p2;
+ if a > 0.0 {
+ let c1 = color1.into_linear().map(|c| c * color1.a * p1);
+ let c2 = color2.into_linear().map(|c| c * color2.a * p2);
+
+ let [r, g, b] =
+ [c1[0] + c2[0], c1[1] + c2[1], c1[2] + c2[2]].map(|u| u / a);
+
+ return Color::from_linear_rgba(r, g, b, a);
+ }
+ }
+
+ let c1 = color1.into_linear().map(|c| c * p1);
+ let c2 = color2.into_linear().map(|c| c * p2);
+
+ Color::from_linear_rgba(
+ c1[0] + c2[0],
+ c1[1] + c2[1],
+ c1[2] + c2[2],
+ c1[3] + c2[3],
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{Color, mix};
+
+ #[test]
+ fn mixing_works() {
+ let base = Color::from_rgba(1.0, 0.0, 0.0, 0.7);
+ let overlay = Color::from_rgba(0.0, 1.0, 0.0, 0.2);
+
+ assert_eq!(
+ mix(base, overlay, 0.75).into_rgba8(),
+ Color::from_linear_rgba(0.53846, 0.46154, 0.0, 0.325).into_rgba8()
+ );
+ }
+}