From 4a537cd9332c71776857003e87be77266735c6b5 Mon Sep 17 00:00:00 2001 From: JMARyA Date: Mon, 13 Jan 2025 18:38:55 +0100 Subject: [PATCH 01/45] wip --- src/page/mod.rs | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/page/mod.rs b/src/page/mod.rs index f9091ef..a93a2df 100644 --- a/src/page/mod.rs +++ b/src/page/mod.rs @@ -1,4 +1,4 @@ -use maud::{PreEscaped, html}; +use maud::{html, PreEscaped, Render}; pub mod search; @@ -155,3 +155,35 @@ pub fn script(script: &str) -> PreEscaped { }; ) } + + +// TODO : More UI primitives like flutter + +// Row -> flex + +pub struct Row(PreEscaped); + +impl Render for Row { + fn render(&self) -> maud::Markup { + html! { + div class="flex" { (self.0) } + } + } +} + +pub fn test() { + html! { + (Row(PreEscaped("".to_string()))) + }; +} + +// Grids + +// ListViews + +// ListTiles + +// Cards + +// FlowBite? + From 8208fa8899ad79ebc973ad5549c34041f3eb506c Mon Sep 17 00:00:00 2001 From: JMARyA Date: Tue, 14 Jan 2025 22:56:07 +0100 Subject: [PATCH 02/45] update --- examples/basic.rs | 2 +- examples/ui.rs | 42 ++++++++++++ src/lib.rs | 2 +- src/ui/appbar.rs | 68 +++++++++++++++++++ src/ui/aspect.rs | 54 +++++++++++++++ src/ui/background.rs | 66 +++++++++++++++++++ src/ui/container.rs | 37 +++++++++++ src/ui/div.rs | 63 ++++++++++++++++++ src/ui/flex.rs | 78 ++++++++++++++++++++++ src/ui/header.rs | 30 +++++++++ src/ui/image.rs | 40 +++++++++++ src/ui/link.rs | 31 +++++++++ src/{page => ui}/mod.rs | 83 +++++++++++++++++++---- src/ui/padding.rs | 83 +++++++++++++++++++++++ src/ui/rounded.rs | 41 ++++++++++++ src/{page => ui}/search.rs | 0 src/ui/shadow.rs | 37 +++++++++++ src/ui/sized.rs | 35 ++++++++++ src/ui/text.rs | 131 +++++++++++++++++++++++++++++++++++++ src/ui/width.rs | 36 ++++++++++ 20 files changed, 944 insertions(+), 15 deletions(-) create mode 100644 examples/ui.rs create mode 100644 src/ui/appbar.rs create mode 100644 src/ui/aspect.rs create mode 100644 src/ui/background.rs create mode 100644 src/ui/container.rs create mode 100644 src/ui/div.rs create mode 100644 src/ui/flex.rs create mode 100644 src/ui/header.rs create mode 100644 src/ui/image.rs create mode 100644 src/ui/link.rs rename src/{page => ui}/mod.rs (77%) create mode 100644 src/ui/padding.rs create mode 100644 src/ui/rounded.rs rename src/{page => ui}/search.rs (100%) create mode 100644 src/ui/shadow.rs create mode 100644 src/ui/sized.rs create mode 100644 src/ui/text.rs create mode 100644 src/ui/width.rs diff --git a/examples/basic.rs b/examples/basic.rs index cd167c3..1b217ea 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -1,7 +1,7 @@ use based::request::{RequestContext, StringResponse}; use based::{ get_pg, - page::{Shell, render_page}, + ui::{Shell, render_page}, }; use maud::html; use rocket::get; diff --git a/examples/ui.rs b/examples/ui.rs new file mode 100644 index 0000000..9683986 --- /dev/null +++ b/examples/ui.rs @@ -0,0 +1,42 @@ +use based::request::{RequestContext, StringResponse}; +use based::ui::{Shell, render_page}; +use maud::Render; +use maud::html; +use rocket::get; +use rocket::routes; + +use based::ui::appbar::AppBar; + +#[get("/")] +pub async fn index_page(ctx: RequestContext) -> StringResponse { + let content = AppBar("MyApp", None).render(); + + let content = html!( + h1 { "Hello World!" }; + + (content) + + ); + + render_page( + content, + "Hello World", + ctx, + &Shell::new( + html! { + script src="https://cdn.tailwindcss.com" {}; + }, + html! {}, + Some(String::new()), + ), + ) + .await +} + +#[rocket::launch] +async fn launch() -> _ { + // Logging + env_logger::init(); + + rocket::build().mount("/", routes![index_page]) +} diff --git a/src/lib.rs b/src/lib.rs index 44842e3..bfe93a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,9 +4,9 @@ pub mod auth; pub mod format; #[cfg(feature = "htmx")] pub mod htmx; -pub mod page; pub mod request; pub mod result; +pub mod ui; // TODO : CORS? diff --git a/src/ui/appbar.rs b/src/ui/appbar.rs new file mode 100644 index 0000000..10f20e1 --- /dev/null +++ b/src/ui/appbar.rs @@ -0,0 +1,68 @@ +use maud::{Markup, Render}; + +use crate::auth::User; + +use crate::ui::basic::*; + +use super::UIWidget; + +#[allow(non_snake_case)] +pub fn AppBar(name: &str, user: Option) -> AppBarWidget { + AppBarWidget { + name: name.to_owned(), + user, + } +} + +pub struct AppBarWidget { + name: String, + user: Option, +} + +impl Render for AppBarWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for AppBarWidget { + fn can_inherit(&self) -> bool { + false + } + + fn render_with_class(&self, _: &str) -> Markup { + Padding(Shadow::medium(Background( + Gray::_800, + Header( + Padding( + Flex( + Div() + .vanish() + .add( + Flex(Link( + "/", + Div() + .vanish() + .add(Sized( + 10, + 10, + RoundedMedium(Image("/favicon").alt("Logo")), + )) + .add(Span(&self.name).semibold().xl().white()), + )) + .items_center() + .space_x(2), + ) + .add_some(self.user.as_ref(), |user| Text(&user.username).white()), + ) + .group() + .justify(Justify::Between) + .items_center(), + ) + .x(6), + ), + ))) + .y(2) + .render() + } +} diff --git a/src/ui/aspect.rs b/src/ui/aspect.rs new file mode 100644 index 0000000..0158951 --- /dev/null +++ b/src/ui/aspect.rs @@ -0,0 +1,54 @@ +use maud::{Markup, Render, html}; + +use super::UIWidget; + +pub struct Aspect { + kind: u8, + inner: Box, +} + +impl Aspect { + pub fn auto(inner: T) -> Self { + Self { + kind: 0, + inner: Box::new(inner), + } + } + + pub fn square(inner: T) -> Self { + Self { + kind: 1, + inner: Box::new(inner), + } + } + + pub fn video(inner: T) -> Self { + Self { + kind: 2, + inner: Box::new(inner), + } + } +} + +impl Render for Aspect { + fn render(&self) -> Markup { + let class = match self.kind { + 0 => "aspect-auto", + 1 => "aspect-square", + 2 => "aspect-video", + _ => "", + }; + + if self.inner.as_ref().can_inherit() { + html! { + (self.inner.as_ref().render_with_class(class)) + } + } else { + html! { + div class=(class) { + (self.inner.as_ref()) + } + } + } + } +} diff --git a/src/ui/background.rs b/src/ui/background.rs new file mode 100644 index 0000000..5a431c4 --- /dev/null +++ b/src/ui/background.rs @@ -0,0 +1,66 @@ +use super::UIWidget; +use maud::{Markup, Render, html}; + +pub trait UIColor { + fn color_class(&self) -> &str; +} + +pub enum Blue { + _500, +} + +impl UIColor for Blue { + fn color_class(&self) -> &str { + match self { + Blue::_500 => "blue-500", + } + } +} + +pub enum Gray { + _800, +} + +impl UIColor for Gray { + fn color_class(&self) -> &str { + match self { + Gray::_800 => "gray-800", + } + } +} + +#[allow(non_snake_case)] +pub fn Background( + color: C, + inner: T, +) -> BackgroundWidget { + BackgroundWidget(Box::new(inner), Box::new(color)) +} + +pub struct BackgroundWidget(Box, Box); + +impl Render for BackgroundWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for BackgroundWidget { + fn can_inherit(&self) -> bool { + true + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("bg-{} {class}", self.1.color_class())) + } else { + html! { + div class=(format!("bg-{} {class}", self.1.color_class())) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/container.rs b/src/ui/container.rs new file mode 100644 index 0000000..5220c77 --- /dev/null +++ b/src/ui/container.rs @@ -0,0 +1,37 @@ +use maud::{Markup, Render, html}; + +use super::UIWidget; + +#[allow(non_snake_case)] +/// A component for fixing an element's width to the current breakpoint. +pub fn Container(inner: T) -> ContainerWidget { + ContainerWidget(Box::new(inner)) +} + +pub struct ContainerWidget(Box); + +impl Render for ContainerWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for ContainerWidget { + fn can_inherit(&self) -> bool { + true + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("container {class}")) + } else { + html! { + div class=(format!("container {class}")) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/div.rs b/src/ui/div.rs new file mode 100644 index 0000000..8a40ad1 --- /dev/null +++ b/src/ui/div.rs @@ -0,0 +1,63 @@ +use maud::{Markup, Render, html}; + +use super::UIWidget; + +#[allow(non_snake_case)] +pub fn Div() -> DivWidget { + DivWidget(Vec::new(), false) +} + +pub struct DivWidget(Vec>, bool); + +impl DivWidget { + pub fn add(mut self, element: T) -> Self { + self.0.push(Box::new(element)); + self + } + + pub fn add_some T>( + mut self, + option: Option<&X>, + then: U, + ) -> Self { + if let Some(val) = option { + self.0.push(Box::new(then(val))); + } + self + } + + pub fn vanish(mut self) -> Self { + self.1 = true; + self + } +} + +impl Render for DivWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for DivWidget { + fn can_inherit(&self) -> bool { + false + } + + fn render_with_class(&self, _: &str) -> Markup { + if self.1 { + html! { + @for e in &self.0 { + (e.as_ref()) + } + } + } else { + html! { + div { + @for e in &self.0 { + (e.as_ref()) + } + } + } + } + } +} diff --git a/src/ui/flex.rs b/src/ui/flex.rs new file mode 100644 index 0000000..6ed2aa1 --- /dev/null +++ b/src/ui/flex.rs @@ -0,0 +1,78 @@ +use super::UIWidget; +use maud::{Markup, Render, html}; + +#[allow(non_snake_case)] +pub fn Flex(inner: T) -> FlexWidget { + FlexWidget(Box::new(inner), vec![], false) +} + +pub enum Justify { + Center, + Between, +} + +pub struct FlexWidget(Box, Vec, bool); + +impl Render for FlexWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl FlexWidget { + pub fn full_center(mut self) -> Self { + self.1.push("items-center".to_owned()); + self.1.push("justify-center".to_owned()); + self + } + + pub fn group(mut self) -> Self { + self.2 = true; + self + } + + pub fn justify(mut self, value: Justify) -> Self { + let class = match value { + Justify::Center => "justify-center".to_owned(), + Justify::Between => "justify-between".to_owned(), + }; + + self.1.push(class); + self + } + + pub fn space_x(mut self, x: u32) -> Self { + self.1.push(format!("space-x-{x}")); + self + } + + pub fn items_center(mut self) -> Self { + self.1.push("items-center".to_owned()); + self + } + + pub fn gap(mut self, amount: u32) -> Self { + self.1.push(format!("gap-{amount}")); + self + } +} + +impl UIWidget for FlexWidget { + fn can_inherit(&self) -> bool { + true + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() && !self.2 { + self.0 + .as_ref() + .render_with_class(&format!("flex {} {class}", self.1.join(" "))) + } else { + html! { + div class=(format!("flex {} {class}", self.1.join(" "))) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/header.rs b/src/ui/header.rs new file mode 100644 index 0000000..2751bb1 --- /dev/null +++ b/src/ui/header.rs @@ -0,0 +1,30 @@ +use maud::{Markup, Render, html}; + +use super::UIWidget; + +#[allow(non_snake_case)] +pub fn Header(inner: T) -> HeaderWidget { + HeaderWidget(Box::new(inner)) +} + +pub struct HeaderWidget(Box); + +impl Render for HeaderWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for HeaderWidget { + fn can_inherit(&self) -> bool { + true + } + + fn render_with_class(&self, class: &str) -> Markup { + html! { + header class=(class) { + (self.0.as_ref()) + } + } + } +} diff --git a/src/ui/image.rs b/src/ui/image.rs new file mode 100644 index 0000000..da0eb5e --- /dev/null +++ b/src/ui/image.rs @@ -0,0 +1,40 @@ +use super::UIWidget; +use maud::{Markup, Render, html}; + +#[allow(non_snake_case)] +pub fn Image(src: &str) -> ImageWidget { + ImageWidget { + src: src.to_owned(), + alt: String::new(), + } +} + +pub struct ImageWidget { + src: String, + alt: String, +} + +impl Render for ImageWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl ImageWidget { + pub fn alt(mut self, alt: &str) -> Self { + self.alt = alt.to_owned(); + self + } +} + +impl UIWidget for ImageWidget { + fn can_inherit(&self) -> bool { + true + } + + fn render_with_class(&self, class: &str) -> Markup { + html! { + img src=(self.src) alt=(self.alt) class=(class) {}; + } + } +} diff --git a/src/ui/link.rs b/src/ui/link.rs new file mode 100644 index 0000000..f5d4883 --- /dev/null +++ b/src/ui/link.rs @@ -0,0 +1,31 @@ +use maud::{Markup, Render, html}; + +use super::UIWidget; + +#[allow(non_snake_case)] +/// A component for fixing an element's width to the current breakpoint. +pub fn Link(reference: &str, inner: T) -> LinkWidget { + LinkWidget(Box::new(inner), reference.to_owned()) +} + +pub struct LinkWidget(Box, String); + +impl Render for LinkWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for LinkWidget { + fn can_inherit(&self) -> bool { + true + } + + fn render_with_class(&self, class: &str) -> Markup { + html! { + a class=(class) href=(self.1) { + (self.0.as_ref()) + } + } + } +} diff --git a/src/page/mod.rs b/src/ui/mod.rs similarity index 77% rename from src/page/mod.rs rename to src/ui/mod.rs index a93a2df..3114828 100644 --- a/src/page/mod.rs +++ b/src/ui/mod.rs @@ -1,11 +1,61 @@ -use maud::{html, PreEscaped, Render}; +use maud::{Markup, PreEscaped, Render, html}; +pub mod appbar; +pub mod aspect; +pub mod background; +pub mod container; +pub mod div; +pub mod flex; +pub mod header; +pub mod image; +pub mod link; +pub mod padding; +pub mod rounded; pub mod search; +pub mod shadow; +pub mod sized; +pub mod text; +pub mod width; + +// UI + +// Preludes + +// Basic Primitives +pub mod basic { + pub use super::aspect::Aspect; + pub use super::background::Background; + pub use super::background::{Blue, Gray}; + pub use super::container::Container; + pub use super::div::Div; + pub use super::flex::Flex; + pub use super::flex::Justify; + pub use super::header::Header; + pub use super::image::Image; + pub use super::link::Link; + pub use super::padding::Padding; + pub use super::rounded::Rounded; + pub use super::rounded::RoundedMedium; + pub use super::shadow::Shadow; + pub use super::sized::Sized; + pub use super::text::{Paragraph, Span, Text}; + pub use super::width::FitWidth; +} + +// Stacked Components +pub mod extended { + pub use super::appbar::AppBar; +} use crate::request::{RequestContext, StringResponse}; use rocket::http::{ContentType, Status}; +#[allow(non_snake_case)] +pub fn Nothing() -> PreEscaped { + html! {} +} + /// Represents the HTML structure of a page shell, including the head, body class, and body content. /// /// This structure is used to construct the overall HTML structure of a page, making it easier to generate consistent HTML pages dynamically. @@ -156,11 +206,6 @@ pub fn script(script: &str) -> PreEscaped { ) } - -// TODO : More UI primitives like flutter - -// Row -> flex - pub struct Row(PreEscaped); impl Render for Row { @@ -171,12 +216,6 @@ impl Render for Row { } } -pub fn test() { - html! { - (Row(PreEscaped("".to_string()))) - }; -} - // Grids // ListViews @@ -185,5 +224,23 @@ pub fn test() { // Cards -// FlowBite? +pub trait UIWidget: Render { + fn can_inherit(&self) -> bool; + fn render_with_class(&self, class: &str) -> Markup; +} +impl UIWidget for PreEscaped { + fn can_inherit(&self) -> bool { + false + } + + fn render_with_class(&self, _: &str) -> Markup { + self.render() + } +} + +// TODO : +// hover focus +// responsive media +// more elements +// htmx builder trait? diff --git a/src/ui/padding.rs b/src/ui/padding.rs new file mode 100644 index 0000000..4962109 --- /dev/null +++ b/src/ui/padding.rs @@ -0,0 +1,83 @@ +use maud::{Markup, Render, html}; + +use super::UIWidget; + +pub struct PaddingInfo { + pub right: Option, +} + +#[allow(non_snake_case)] +pub fn Padding(inner: T) -> PaddingWidget { + PaddingWidget { + inner: Box::new(inner), + right: None, + y: None, + x: None, + } +} + +pub struct PaddingWidget { + pub inner: Box, + pub right: Option, + pub y: Option, + pub x: Option, +} + +impl PaddingWidget { + pub fn right(mut self, right: u32) -> Self { + self.right = Some(right); + self + } + + pub fn y(mut self, y: u32) -> Self { + self.y = Some(y); + self + } + + pub fn x(mut self, x: u32) -> Self { + self.x = Some(x); + self + } +} + +impl Render for PaddingWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for PaddingWidget { + fn can_inherit(&self) -> bool { + true + } + + fn render_with_class(&self, class: &str) -> Markup { + let mut our_class = Vec::new(); + + if let Some(r) = self.right { + our_class.push(format!("pr-{r}")); + } + + if let Some(y) = self.y { + our_class.push(format!("py-{y}")); + } + + if let Some(x) = self.x { + our_class.push(format!("px-{x}")); + } + + let our_class = our_class.join(" "); + + if self.inner.as_ref().can_inherit() { + self.inner + .as_ref() + .render_with_class(&format!("{our_class} {class}")) + } else { + html! { + div class=(format!("{our_class} {class}")) { + (self.inner.as_ref()) + } + } + } + } +} diff --git a/src/ui/rounded.rs b/src/ui/rounded.rs new file mode 100644 index 0000000..1632274 --- /dev/null +++ b/src/ui/rounded.rs @@ -0,0 +1,41 @@ +use maud::{Markup, Render, html}; + +use super::UIWidget; + +#[allow(non_snake_case)] +pub fn Rounded(inner: T) -> RoundedWidget { + RoundedWidget(Box::new(inner), "full".to_owned()) +} + +#[allow(non_snake_case)] +pub fn RoundedMedium(inner: T) -> RoundedWidget { + RoundedWidget(Box::new(inner), "md".to_owned()) +} + +pub struct RoundedWidget(Box, String); + +impl Render for RoundedWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for RoundedWidget { + fn can_inherit(&self) -> bool { + true + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("rounded-{} {class}", self.1)) + } else { + html! { + div class=(format!("rounded-{} {class}", self.1)) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/page/search.rs b/src/ui/search.rs similarity index 100% rename from src/page/search.rs rename to src/ui/search.rs diff --git a/src/ui/shadow.rs b/src/ui/shadow.rs new file mode 100644 index 0000000..5585d3b --- /dev/null +++ b/src/ui/shadow.rs @@ -0,0 +1,37 @@ +use maud::{Markup, Render, html}; + +use super::UIWidget; + +pub struct Shadow(Box, String); + +impl Shadow { + pub fn medium(inner: T) -> Shadow { + Shadow(Box::new(inner), "md".to_owned()) + } +} + +impl Render for Shadow { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for Shadow { + fn can_inherit(&self) -> bool { + true + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("shadow-{} {class}", self.1)) + } else { + html! { + div class=(format!("shadow-{} {class}", self.1)) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/sized.rs b/src/ui/sized.rs new file mode 100644 index 0000000..dc473d6 --- /dev/null +++ b/src/ui/sized.rs @@ -0,0 +1,35 @@ +use super::UIWidget; +use maud::{Markup, Render, html}; + +#[allow(non_snake_case)] +pub fn Sized(height: u32, width: u32, inner: T) -> SizedWidget { + SizedWidget(Box::new(inner), height, width) +} + +pub struct SizedWidget(Box, u32, u32); + +impl Render for SizedWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for SizedWidget { + fn can_inherit(&self) -> bool { + true + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("h-{} w-{} {class}", self.1, self.2)) + } else { + html! { + div class=(format!("h-{} w-{} {class}", self.1, self.2)) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/text.rs b/src/ui/text.rs new file mode 100644 index 0000000..0a7c7f9 --- /dev/null +++ b/src/ui/text.rs @@ -0,0 +1,131 @@ +use super::UIWidget; +use maud::{Markup, Render, html}; + +#[allow(non_snake_case)] +pub fn Text(txt: &str) -> TextWidget { + TextWidget { + inner: None, + txt: txt.to_string(), + font: String::new(), + color: String::new(), + size: String::new(), + span: false, + } +} + +#[allow(non_snake_case)] +pub fn Paragraph(inner: T) -> TextWidget { + TextWidget { + inner: Some(Box::new(inner)), + font: String::new(), + color: String::new(), + txt: String::new(), + size: String::new(), + span: false, + } +} + +#[allow(non_snake_case)] +pub fn Span(txt: &str) -> TextWidget { + TextWidget { + inner: None, + txt: txt.to_string(), + font: String::new(), + color: String::new(), + size: String::new(), + span: true, + } +} + +pub struct TextWidget { + inner: Option>, + txt: String, + font: String, + color: String, + size: String, + span: bool, +} + +impl TextWidget { + pub fn semibold(mut self) -> Self { + self.font = "font-semibold".to_owned(); + self + } + + pub fn bold(mut self) -> Self { + self.font = "font-bold".to_owned(); + self + } + + pub fn medium(mut self) -> Self { + self.font = "font-medium".to_owned(); + self + } + + pub fn _2xl(mut self) -> Self { + self.size = "text-2xl".to_owned(); + self + } + + pub fn xl(mut self) -> Self { + self.size = "text-xl".to_owned(); + self + } + + pub fn sm(mut self) -> Self { + self.size = "text-sm".to_owned(); + self + } + + pub fn gray(mut self, i: u32) -> Self { + self.color = format!("text-gray-{}", i); + self + } + + pub fn slate(mut self, i: u32) -> Self { + self.color = format!("text-slate-{}", i); + self + } + + pub fn white(mut self) -> Self { + self.color = "text-white".to_owned(); + self + } +} + +impl Render for TextWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for TextWidget { + fn can_inherit(&self) -> bool { + true + } + + fn render_with_class(&self, class: &str) -> Markup { + let our_class = format!("{} {} {}", self.color, self.font, self.size); + if let Some(inner) = &self.inner { + if self.span { + html! { + span class=(format!("{} {}", class, our_class)) { (inner) } + } + } else { + html! { + p class=(format!("{} {}", class, our_class)) { (inner) } + } + } + } else { + if self.span { + html! { + span class=(format!("{} {}", class, our_class)) { (self.txt) } + } + } else { + html! { + p class=(format!("{} {}", class, our_class)) { (self.txt) } + } + } + } + } +} diff --git a/src/ui/width.rs b/src/ui/width.rs new file mode 100644 index 0000000..9c7a390 --- /dev/null +++ b/src/ui/width.rs @@ -0,0 +1,36 @@ +use maud::{Markup, Render, html}; + +use super::UIWidget; + +#[allow(non_snake_case)] +pub fn FitWidth(inner: T) -> FitWidthWidget { + FitWidthWidget(Box::new(inner)) +} + +pub struct FitWidthWidget(Box); + +impl Render for FitWidthWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for FitWidthWidget { + fn can_inherit(&self) -> bool { + true + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("max-w-fit {class}")) + } else { + html! { + div class=(format!("max-w-fit {class}")) { + (self.0.as_ref()) + } + } + } + } +} From ed739d792fe52d1773faa4c0a82728ac266574a6 Mon Sep 17 00:00:00 2001 From: JMARyA Date: Wed, 15 Jan 2025 18:28:59 +0100 Subject: [PATCH 03/45] update --- examples/basic.rs | 7 +- examples/ui.rs | 14 +- src/ui/color.rs | 129 ++++++++++++++ src/ui/{ => components}/appbar.rs | 41 +++-- src/ui/components/mod.rs | 7 + src/ui/{ => components}/search.rs | 16 +- src/ui/components/shell.rs | 74 ++++++++ src/ui/div.rs | 63 ------- src/ui/htmx/mod.rs | 179 ++++++++++++++++++++ src/ui/htmx/selector.rs | 33 ++++ src/ui/htmx/swap.rs | 158 ++++++++++++++++++ src/ui/htmx/trigger.rs | 179 ++++++++++++++++++++ src/ui/link.rs | 31 ---- src/ui/mod.rs | 232 ++++++-------------------- src/ui/{ => primitives}/aspect.rs | 25 ++- src/ui/{ => primitives}/background.rs | 43 ++--- src/ui/{ => primitives}/container.rs | 12 +- src/ui/primitives/div.rs | 108 ++++++++++++ src/ui/{ => primitives}/flex.rs | 23 ++- src/ui/{ => primitives}/header.rs | 12 +- src/ui/{ => primitives}/image.rs | 10 +- src/ui/primitives/input.rs | 1 + src/ui/primitives/link.rs | 77 +++++++++ src/ui/primitives/mod.rs | 108 ++++++++++++ src/ui/{ => primitives}/padding.rs | 19 ++- src/ui/primitives/rounded.rs | 68 ++++++++ src/ui/primitives/shadow.rs | 78 +++++++++ src/ui/{ => primitives}/sized.rs | 16 +- src/ui/primitives/space.rs | 151 +++++++++++++++++ src/ui/{ => primitives}/text.rs | 52 ++++-- src/ui/{ => primitives}/width.rs | 17 +- src/ui/rounded.rs | 41 ----- src/ui/shadow.rs | 37 ---- src/ui/wrapper/hover.rs | 57 +++++++ src/ui/wrapper/mod.rs | 4 + 35 files changed, 1675 insertions(+), 447 deletions(-) create mode 100644 src/ui/color.rs rename src/ui/{ => components}/appbar.rs (51%) create mode 100644 src/ui/components/mod.rs rename src/ui/{ => components}/search.rs (92%) create mode 100644 src/ui/components/shell.rs delete mode 100644 src/ui/div.rs create mode 100644 src/ui/htmx/mod.rs create mode 100644 src/ui/htmx/selector.rs create mode 100644 src/ui/htmx/swap.rs create mode 100644 src/ui/htmx/trigger.rs delete mode 100644 src/ui/link.rs rename src/ui/{ => primitives}/aspect.rs (61%) rename src/ui/{ => primitives}/background.rs (58%) rename src/ui/{ => primitives}/container.rs (76%) create mode 100644 src/ui/primitives/div.rs rename src/ui/{ => primitives}/flex.rs (75%) rename src/ui/{ => primitives}/header.rs (70%) rename src/ui/{ => primitives}/image.rs (81%) create mode 100644 src/ui/primitives/input.rs create mode 100644 src/ui/primitives/link.rs create mode 100644 src/ui/primitives/mod.rs rename src/ui/{ => primitives}/padding.rs (78%) create mode 100644 src/ui/primitives/rounded.rs create mode 100644 src/ui/primitives/shadow.rs rename src/ui/{ => primitives}/sized.rs (60%) create mode 100644 src/ui/primitives/space.rs rename src/ui/{ => primitives}/text.rs (61%) rename src/ui/{ => primitives}/width.rs (61%) delete mode 100644 src/ui/rounded.rs delete mode 100644 src/ui/shadow.rs create mode 100644 src/ui/wrapper/hover.rs create mode 100644 src/ui/wrapper/mod.rs diff --git a/examples/basic.rs b/examples/basic.rs index 1b217ea..9524ee0 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -1,8 +1,7 @@ +use based::get_pg; use based::request::{RequestContext, StringResponse}; -use based::{ - get_pg, - ui::{Shell, render_page}, -}; +use based::ui::components::Shell; +use based::ui::render_page; use maud::html; use rocket::get; use rocket::routes; diff --git a/examples/ui.rs b/examples/ui.rs index 9683986..f38c67f 100644 --- a/examples/ui.rs +++ b/examples/ui.rs @@ -1,12 +1,12 @@ use based::request::{RequestContext, StringResponse}; -use based::ui::{Shell, render_page}; +use based::ui::components::{AppBar, Shell}; +use based::ui::htmx::{Event, HTMXAttributes}; +use based::ui::{prelude::*, render_page}; use maud::Render; use maud::html; use rocket::get; use rocket::routes; -use based::ui::appbar::AppBar; - #[get("/")] pub async fn index_page(ctx: RequestContext) -> StringResponse { let content = AppBar("MyApp", None).render(); @@ -14,6 +14,14 @@ pub async fn index_page(ctx: RequestContext) -> StringResponse { let content = html!( h1 { "Hello World!" }; + (Hover( + Padding(Text("").color(Gray::_400)).x(10), + Link("/test", Text("Hello")).hx_get("/test").hx_get("/test").hx_trigger( + Event::on_load().delay("2s") + .and(Event::on_revealed()) + ) + )) + (content) ); diff --git a/src/ui/color.rs b/src/ui/color.rs new file mode 100644 index 0000000..e1ddeba --- /dev/null +++ b/src/ui/color.rs @@ -0,0 +1,129 @@ +/// UI Color +pub trait UIColor { + fn color_class(&self) -> &str; +} + +pub trait ColorCircle { + fn previous(&self) -> Self; + fn next(&self) -> Self; +} + +macro_rules! color_map { + ($name:ident, $id:literal) => { + #[derive(Debug, Clone)] + pub enum $name { + _50, + _100, + _200, + _300, + _400, + _500, + _600, + _700, + _800, + _900, + _950, + } + + impl UIColor for $name { + fn color_class(&self) -> &str { + match self { + $name::_50 => concat!($id, "-50"), + $name::_100 => concat!($id, "-100"), + $name::_200 => concat!($id, "-200"), + $name::_300 => concat!($id, "-300"), + $name::_400 => concat!($id, "-400"), + $name::_500 => concat!($id, "-500"), + $name::_600 => concat!($id, "-600"), + $name::_700 => concat!($id, "-700"), + $name::_800 => concat!($id, "-800"), + $name::_900 => concat!($id, "-900"), + $name::_950 => concat!($id, "-950"), + } + } + } + + impl ColorCircle for $name { + fn next(&self) -> Self { + match self { + $name::_50 => $name::_100, + $name::_100 => $name::_200, + $name::_200 => $name::_300, + $name::_300 => $name::_400, + $name::_400 => $name::_500, + $name::_500 => $name::_600, + $name::_600 => $name::_700, + $name::_700 => $name::_800, + $name::_800 => $name::_900, + $name::_900 => $name::_950, + $name::_950 => $name::_50, + } + } + + fn previous(&self) -> Self { + match self { + $name::_50 => $name::_950, + $name::_100 => $name::_50, + $name::_200 => $name::_100, + $name::_300 => $name::_200, + $name::_400 => $name::_300, + $name::_500 => $name::_400, + $name::_600 => $name::_500, + $name::_700 => $name::_600, + $name::_800 => $name::_700, + $name::_900 => $name::_800, + $name::_950 => $name::_900, + } + } + } + }; +} + +color_map!(Slate, "slate"); +color_map!(Gray, "gray"); +color_map!(Zinc, "zinc"); +color_map!(Neutral, "neutral"); +color_map!(Stone, "stone"); +color_map!(Red, "red"); +color_map!(Orange, "orange"); +color_map!(Amber, "amber"); +color_map!(Yellow, "yellow"); +color_map!(Lime, "lime"); +color_map!(Green, "green"); +color_map!(Emerald, "emerald"); +color_map!(Teal, "teal"); +color_map!(Cyan, "cyan"); +color_map!(Sky, "sky"); +color_map!(Blue, "blue"); +color_map!(Indigo, "indigo"); +color_map!(Violet, "violet"); +color_map!(Purple, "purple"); +color_map!(Fuchsia, "fuchsia"); +color_map!(Pink, "pink"); +color_map!(Rose, "rose"); + +#[derive(Debug, Clone)] +pub enum Colors { + /// Inherit a color + Inherit, + /// Use current color + Current, + /// Transparency + Transparent, + /// Black + Black, + /// White + White, +} + +impl UIColor for Colors { + fn color_class(&self) -> &str { + match self { + Colors::Inherit => "inherit", + Colors::Current => "current", + Colors::Transparent => "transparent", + Colors::Black => "black", + Colors::White => "white", + } + } +} diff --git a/src/ui/appbar.rs b/src/ui/components/appbar.rs similarity index 51% rename from src/ui/appbar.rs rename to src/ui/components/appbar.rs index 10f20e1..a2e08c6 100644 --- a/src/ui/appbar.rs +++ b/src/ui/components/appbar.rs @@ -2,9 +2,7 @@ use maud::{Markup, Render}; use crate::auth::User; -use crate::ui::basic::*; - -use super::UIWidget; +use crate::ui::{UIWidget, prelude::*}; #[allow(non_snake_case)] pub fn AppBar(name: &str, user: Option) -> AppBarWidget { @@ -30,6 +28,14 @@ impl UIWidget for AppBarWidget { false } + fn base_class(&self) -> Vec { + Vec::new() + } + + fn extended_class(&self) -> Vec { + self.base_class() + } + fn render_with_class(&self, _: &str) -> Markup { Padding(Shadow::medium(Background( Gray::_800, @@ -39,19 +45,22 @@ impl UIWidget for AppBarWidget { Div() .vanish() .add( - Flex(Link( - "/", - Div() - .vanish() - .add(Sized( - 10, - 10, - RoundedMedium(Image("/favicon").alt("Logo")), - )) - .add(Span(&self.name).semibold().xl().white()), - )) - .items_center() - .space_x(2), + SpaceBetween( + Flex(Link( + "/", + Div() + .vanish() + .add(Sized( + 10, + 10, + Rounded(Image("/favicon").alt("Logo")) + .size(Size::Medium), + )) + .add(Span(&self.name).semibold().xl().white()), + )) + .items_center(), + ) + .x(ScreenValue::_2), ) .add_some(self.user.as_ref(), |user| Text(&user.username).white()), ) diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs new file mode 100644 index 0000000..b367ee4 --- /dev/null +++ b/src/ui/components/mod.rs @@ -0,0 +1,7 @@ +mod appbar; +mod search; +mod shell; + +pub use appbar::AppBar; +pub use search::Search; +pub use shell::Shell; diff --git a/src/ui/search.rs b/src/ui/components/search.rs similarity index 92% rename from src/ui/search.rs rename to src/ui/components/search.rs index 9179406..60306fb 100644 --- a/src/ui/search.rs +++ b/src/ui/components/search.rs @@ -1,6 +1,7 @@ -use maud::{PreEscaped, html}; - use crate::request::{RequestContext, api::Pager}; +use crate::ui::htmx::{Event, HTMXAttributes, SwapStrategy}; +use crate::ui::prelude::*; +use maud::{PreEscaped, html}; /// Represents a search form with configurable options such as heading, placeholder, and CSS class. pub struct Search { @@ -94,9 +95,12 @@ impl Search { } @if reslen as u64 == pager.items_per_page { - div hx-get=(format!("{}?query={}&page={}", self.post_url, query, page+1)) - hx-trigger="revealed" - hx-swap="outerHTML" {}; + (Div() + .hx_get( + &format!("{}?query={}&page={}", self.post_url, query, page+1) + ).hx_trigger( + Event::on_revealed() + ).hx_swap(SwapStrategy::outerHTML)) }; } } @@ -140,7 +144,7 @@ impl Search { /// The HTML string containing the entire search form and results UI. #[must_use] pub fn build(&self, query: &str, first_page: PreEscaped) -> PreEscaped { - let no_html = PreEscaped(String::new()); + let no_html = Nothing(); html! { (self.heading.as_ref().unwrap_or_else(|| &no_html)) input type="search" name="query" diff --git a/src/ui/components/shell.rs b/src/ui/components/shell.rs new file mode 100644 index 0000000..bdcca62 --- /dev/null +++ b/src/ui/components/shell.rs @@ -0,0 +1,74 @@ +use maud::{PreEscaped, html}; + +/// Represents the HTML structure of a page shell, including the head, body class, and body content. +/// +/// This structure is used to construct the overall HTML structure of a page, making it easier to generate consistent HTML pages dynamically. +pub struct Shell { + /// The HTML content for the `` section of the page. + head: PreEscaped, + /// An optional class attribute for the `` element. + body_class: Option, + /// The HTML content for the static body portion. + body_content: PreEscaped, +} + +impl Shell { + /// Constructs a new `Shell` instance with the given head content, body content, and body class. + /// + /// # Arguments + /// * `head` - The HTML content for the page's head. + /// * `body_content` - The HTML content for the body of the page. + /// * `body_class` - An optional class to apply to the `` element. + /// + /// # Returns + /// A `Shell` instance encapsulating the provided HTML content and attributes. + #[must_use] + pub const fn new( + head: PreEscaped, + body_content: PreEscaped, + body_class: Option, + ) -> Self { + Self { + head, + body_class, + body_content, + } + } + + /// Renders the full HTML page using the shell structure, with additional content and a title. + /// + /// # Arguments + /// * `content` - The additional HTML content to render inside the main content div. + /// * `title` - The title of the page, rendered inside the `` element. + /// + /// # Returns + /// A `PreEscaped<String>` containing the full HTML page content. + #[must_use] + pub fn render(&self, content: PreEscaped<String>, title: &str) -> PreEscaped<String> { + html! { + html { + head { + title { (title) }; + (self.head) + }; + @if self.body_class.is_some() { + body class=(self.body_class.as_ref().unwrap()) { + (self.body_content); + + div id="main_content" { + (content) + }; + }; + } @else { + body { + (self.body_content); + + div id="main_content" { + (content) + }; + }; + } + } + } + } +} diff --git a/src/ui/div.rs b/src/ui/div.rs deleted file mode 100644 index 8a40ad1..0000000 --- a/src/ui/div.rs +++ /dev/null @@ -1,63 +0,0 @@ -use maud::{Markup, Render, html}; - -use super::UIWidget; - -#[allow(non_snake_case)] -pub fn Div() -> DivWidget { - DivWidget(Vec::new(), false) -} - -pub struct DivWidget(Vec<Box<dyn UIWidget>>, bool); - -impl DivWidget { - pub fn add<T: UIWidget + 'static>(mut self, element: T) -> Self { - self.0.push(Box::new(element)); - self - } - - pub fn add_some<T: UIWidget + 'static, X, U: Fn(&X) -> T>( - mut self, - option: Option<&X>, - then: U, - ) -> Self { - if let Some(val) = option { - self.0.push(Box::new(then(val))); - } - self - } - - pub fn vanish(mut self) -> Self { - self.1 = true; - self - } -} - -impl Render for DivWidget { - fn render(&self) -> Markup { - self.render_with_class("") - } -} - -impl UIWidget for DivWidget { - fn can_inherit(&self) -> bool { - false - } - - fn render_with_class(&self, _: &str) -> Markup { - if self.1 { - html! { - @for e in &self.0 { - (e.as_ref()) - } - } - } else { - html! { - div { - @for e in &self.0 { - (e.as_ref()) - } - } - } - } - } -} diff --git a/src/ui/htmx/mod.rs b/src/ui/htmx/mod.rs new file mode 100644 index 0000000..4199a2e --- /dev/null +++ b/src/ui/htmx/mod.rs @@ -0,0 +1,179 @@ +use std::collections::HashMap; + +mod selector; +mod swap; +mod trigger; + +pub use selector::Selector; +use swap::ModifiedSwapStrategy; +pub use swap::SwapStrategy; +pub use trigger::{Event, QueueOption, Trigger}; + +use super::AttrExtendable; + +pub trait HTMXAttributes: AttrExtendable + std::marker::Sized { + /// Issues a `GET` request to the specified URL + fn hx_get(self, url: &str) -> Self { + self.add_attr("hx-get", url) + } + + /// Issues a `POST` request to the specified URL + fn hx_post(self, url: &str) -> Self { + self.add_attr("hx-post", url) + } + + /// Push a URL into the browser location bar to create history + fn hx_push_url(self) -> Self { + self.add_attr("hx-push-url", "true") + } + + /// Select content to swap in from a response + fn hx_select(self, element: &str) -> Self { + self.add_attr("hx-select", element) + } + + /// Select content to swap in from a response, somewhere other than the target (out of band). + /// Select `element` from response and replace `element` in the DOM. + fn hx_select_oob(self, element: &str) -> Self { + self.add_attr("hx-select-oob", element) + } + + /// The hx-boost attribute allows you to “boost” normal anchors and form tags to use AJAX instead. + fn hx_boost(self) -> Self { + self.add_attr("hx-boost", "true") + } + + /// The hx-confirm attribute allows you to confirm an action before issuing a request. + fn hx_confirm(self, msg: &str) -> Self { + self.add_attr("hx-confirm", msg) + } + + /// The hx-delete attribute will cause an element to issue a `DELETE` request to the specified URL and swap the HTML into the DOM using a swap strategy. + fn hx_delete(self, url: &str) -> Self { + self.add_attr("hx-delete", url) + } + + /// The hx-disable attribute will disable htmx processing for a given element and all its children. + fn hx_disable(self) -> Self { + self.add_attr("hx-disable", "") + } + + /// The hx-disabled-elt attribute allows you to specify elements that will have the disabled attribute added to them for the duration of the request. + fn hx_disabled_elt(self, element: Selector) -> Self { + self.add_attr("hx-disabled-elt", &element.to_string()) + } + + /// The hx-disinherit attribute allows you to control automatic attribute inheritance. + fn hx_disinherit(self, attrs: &str) -> Self { + self.add_attr("hx-disinherit", attrs) + } + + /// The hx-encoding attribute allows you to switch the request encoding from the usual `application/x-www-form-urlencoded` encoding to `multipart/form-data`, usually to support file uploads in an ajax request. + fn hx_encoding(self) -> Self { + self.add_attr("hx-encoding", "multipart/form-data") + } + + /// The hx-headers attribute allows you to add to the headers that will be submitted with an AJAX request. + fn hx_headers(self, headers: HashMap<String, String>) -> Self { + let json = serde_json::to_value(headers).unwrap(); + let json_str = serde_json::to_string(&json).unwrap(); + self.add_attr("hx-headers", &json_str) + } + + /// The hx-vals attribute allows you to add to the parameters that will be submitted with an AJAX request. + fn hx_vals(self, vals: HashMap<String, String>) -> Self { + let json = serde_json::to_value(vals).unwrap(); + let json_str = serde_json::to_string(&json).unwrap(); + self.add_attr("hx-vals", &json_str) + } + + /// Set the hx-history attribute to false on any element in the current document, or any html fragment loaded into the current document by htmx, to prevent sensitive data being saved to the localStorage cache when htmx takes a snapshot of the page state. + /// + /// History navigation will work as expected, but on restoration the URL will be requested from the server instead of the history cache. + fn hx_history(self) -> Self { + self.add_attr("hx-history", "false") + } + + /// The hx-history-elt attribute allows you to specify the element that will be used to snapshot and restore page state during navigation. By default, the body tag is used. This is typically good enough for most setups, but you may want to narrow it down to a child element. Just make sure that the element is always visible in your application, or htmx will not be able to restore history navigation properly. + fn hx_history_elt(self) -> Self { + self.add_attr("hx-history-elt", "") + } + + /// The hx-include attribute allows you to include additional element values in an AJAX request. + fn hx_include(self, element: Selector) -> Self { + self.add_attr("hx-include", &element.to_string()) + } + + /// The hx-indicator attribute allows you to specify the element that will have the htmx-request class added to it for the duration of the request. This can be used to show spinners or progress indicators while the request is in flight. + /// + /// Note: This attribute only supports CSS queries and `closest` match. + fn hx_indicator(self, indicator: Selector) -> Self { + self.add_attr("hx-indicator", &indicator.to_string()) + } + + /// The hx-params attribute allows you to filter the parameters that will be submitted with an AJAX request. + /// + /// The possible values of this attribute are: + /// `*` - Include all parameters (default) + /// `none` - Include no parameters + /// `not <param-list>` - Include all except the comma separated list of parameter names + /// `<param-list>` - Include all the comma separated list of parameter names + fn hx_params(self, params: &str) -> Self { + self.add_attr("hx-params", params) + } + + /// The hx-patch attribute will cause an element to issue a PATCH to the specified URL and swap the HTML into the DOM using a swap strategy. + fn hx_patch(self, url: &str) -> Self { + self.add_attr("hx-patch", url) + } + + /// The hx-put attribute will cause an element to issue a PUT to the specified URL and swap the HTML into the DOM using a swap strategy + fn hx_put(self, url: &str) -> Self { + self.add_attr("hx-put", url) + } + + /// The hx-replace-url attribute allows you to replace the current url of the browser location history. + /// + /// The possible values of this attribute are: + /// `true`, which replaces the fetched URL in the browser navigation bar. + /// `false`, which disables replacing the fetched URL if it would otherwise be replaced due to inheritance. + /// A URL to be replaced into the location bar. This may be relative or absolute, as per history.replaceState(). + fn hx_replace_url(self, value: &str) -> Self { + self.add_attr("hx-replace-url", value) + } + + /// The hx-validate attribute will cause an element to validate itself by way of the HTML5 Validation API before it submits a request. + fn hx_validate(self) -> Self { + self.add_attr("hx-validte", "true") + } + + /// The hx-preserve attribute allows you to keep an element unchanged during HTML replacement. Elements with hx-preserve set are preserved by id when htmx updates any ancestor element. You must set an unchanging id on elements for hx-preserve to work. The response requires an element with the same id, but its type and other attributes are ignored. + fn hx_preserve(self) -> Self { + self.add_attr("hx-preserve", "") + } + + /// The hx-prompt attribute allows you to show a prompt before issuing a request. The value of the prompt will be included in the request in the HX-Prompt header. + fn hx_prompt(self, msg: &str) -> Self { + self.add_attr("hx-prompt", msg) + } + + /// The hx-swap attribute allows you to specify how the response will be swapped in relative to the target of an AJAX request. + fn hx_swap<T: Into<ModifiedSwapStrategy>>(self, swap: T) -> Self { + self.add_attr("hx-swap", &swap.into().to_string()) + } + + /// The hx-swap-oob attribute allows you to specify that some content in a response should be swapped into the DOM somewhere other than the target, that is “Out of Band”. This allows you to piggy back updates to other element updates on a response. + fn hx_swap_oob(self) -> Self { + self.add_attr("hx-swap-oob", "true") + } + + /// The hx-target attribute allows you to target a different element for swapping than the one issuing the AJAX request. + fn hx_target(self, element: Selector) -> Self { + self.add_attr("hx-target", &element.to_string()) + } + + /// The hx-trigger attribute allows you to specify what triggers an AJAX request. + fn hx_trigger<T: Into<Trigger>>(self, trigger: T) -> Self { + self.add_attr("hx-trigger", &trigger.into().to_string()) + } +} diff --git a/src/ui/htmx/selector.rs b/src/ui/htmx/selector.rs new file mode 100644 index 0000000..255c78c --- /dev/null +++ b/src/ui/htmx/selector.rs @@ -0,0 +1,33 @@ +pub enum Selector { + /// A CSS query selector of the element + Query(String), + /// this element itself + This, + /// closest <CSS selector> which will find the closest ancestor element or itself, that matches the given CSS selector. + Closest(String), + /// find <CSS selector> which will find the first child descendant element that matches the given CSS selector + Find(String), + /// next which resolves to element.nextElementSibling + Next, + /// next <CSS selector> which will scan the DOM forward for the first element that matches the given CSS selector. + NextQuery(String), + /// previous which resolves to element.previousElementSibling + Previous, + /// previous <CSS selector> which will scan the DOM backwards for the first element that matches the given CSS selector. + PreviousQuery(String), +} + +impl Selector { + pub fn to_string(&self) -> String { + match self { + Selector::Query(query) => query.clone(), + Selector::This => "this".to_owned(), + Selector::Closest(css) => format!("closest {css}"), + Selector::Find(css) => format!("find {css}"), + Selector::Next => "next".to_owned(), + Selector::NextQuery(css) => format!("next {css}"), + Selector::Previous => "previous".to_owned(), + Selector::PreviousQuery(css) => format!("previous {css}"), + } + } +} diff --git a/src/ui/htmx/swap.rs b/src/ui/htmx/swap.rs new file mode 100644 index 0000000..01733a5 --- /dev/null +++ b/src/ui/htmx/swap.rs @@ -0,0 +1,158 @@ +#[allow(non_camel_case_types)] +pub enum SwapStrategy { + /// Replace the inner html of the target element + innerHTML, + /// Replace the entire target element with the response + outerHTML, + /// Replace the text content of the target element, without parsing the response as HTML + textContent, + /// Insert the response before the target element + beforebegin, + /// Insert the response before the first child of the target element + afterbegin, + /// Insert the response after the last child of the target element + beforeend, + /// Insert the response after the target element + afterend, + /// Deletes the target element regardless of the response + delete, + /// Does not append content from response (out of band items will still be processed). + none, +} + +impl Default for SwapStrategy { + fn default() -> Self { + Self::innerHTML + } +} + +impl SwapStrategy { + pub fn to_string(&self) -> &str { + match self { + SwapStrategy::innerHTML => "innerHTML", + SwapStrategy::outerHTML => "outerHTML", + SwapStrategy::textContent => "textContent", + SwapStrategy::beforebegin => "beforebegin", + SwapStrategy::afterbegin => "afterbegin", + SwapStrategy::beforeend => "beforeend", + SwapStrategy::afterend => "afterend", + SwapStrategy::delete => "delete", + SwapStrategy::none => "none", + } + } + + /// If you want to use the new View Transitions API when a swap occurs, you can use the transition:true option for your swap. + pub fn transition(self) -> ModifiedSwapStrategy { + let modifier = "transition".to_owned(); + ModifiedSwapStrategy::new(self, modifier) + } + + /// You can modify the amount of time that htmx will wait after receiving a response to swap the content by including a swap modifier. + pub fn swap(self, duration: &str) -> ModifiedSwapStrategy { + let modifier = format!("swap:{duration}"); + ModifiedSwapStrategy::new(self, modifier) + } + + /// You can modify the time between the swap and the settle logic by including a settle modifier. + pub fn settle(self, duration: &str) -> ModifiedSwapStrategy { + let modifier = format!("settle:{duration}"); + ModifiedSwapStrategy::new(self, modifier) + } + + /// By default, htmx will update the title of the page if it finds a `<title>` tag in the response content. You can turn off this behavior. + pub fn ignore_title(self) -> ModifiedSwapStrategy { + let modifier = "ignoreTitle:true"; + ModifiedSwapStrategy::new(self, modifier.to_owned()) + } + + /// htmx preserves focus between requests for inputs that have a defined id attribute. By default htmx prevents auto-scrolling to focused inputs between requests which can be unwanted behavior on longer requests when the user has already scrolled away. + pub fn focus_scroll(self, enable: bool) -> ModifiedSwapStrategy { + let modifier = format!("focus-scroll:{enable}"); + ModifiedSwapStrategy::new(self, modifier) + } + + /// Ensure visibility + pub fn show(self, e: &str) -> ModifiedSwapStrategy { + let modifier = format!("show:{e}"); + ModifiedSwapStrategy::new(self, modifier) + } + + /// Scroll to this location after load + pub fn scroll(self, e: &str) -> ModifiedSwapStrategy { + let modifier = format!("scroll:{e}"); + ModifiedSwapStrategy::new(self, modifier) + } +} + +pub struct ModifiedSwapStrategy { + pub strategy: SwapStrategy, + pub modifiers: Vec<String>, +} + +impl From<SwapStrategy> for ModifiedSwapStrategy { + fn from(value: SwapStrategy) -> Self { + Self::new(value, String::new()) + } +} + +impl ModifiedSwapStrategy { + fn new(strategy: SwapStrategy, modifier: String) -> Self { + ModifiedSwapStrategy { + strategy, + modifiers: vec![modifier], + } + } + + /// If you want to use the new View Transitions API when a swap occurs, you can use the transition:true option for your swap. + pub fn transition(mut self) -> Self { + let modifier = "transition:true".to_owned(); + self.modifiers.push(modifier); + self + } + + /// You can modify the amount of time that htmx will wait after receiving a response to swap the content by including a swap modifier. + pub fn swap(mut self, duration: &str) -> Self { + let modifier = format!("swap:{duration}"); + self.modifiers.push(modifier); + self + } + + /// You can modify the time between the swap and the settle logic by including a settle modifier. + pub fn settle(mut self, duration: &str) -> Self { + let modifier = format!("settle:{duration}"); + self.modifiers.push(modifier); + self + } + + /// By default, htmx will update the title of the page if it finds a `<title>` tag in the response content. You can turn off this behavior. + pub fn ignore_title(mut self) -> Self { + let modifier = "ignoreTitle:true"; + self.modifiers.push(modifier.to_owned()); + self + } + + /// htmx preserves focus between requests for inputs that have a defined id attribute. By default htmx prevents auto-scrolling to focused inputs between requests which can be unwanted behavior on longer requests when the user has already scrolled away. + pub fn focus_scroll(mut self, enable: bool) -> Self { + let modifier = format!("focus-scroll:{enable}"); + self.modifiers.push(modifier); + self + } + + /// Ensure visibility + pub fn show(mut self, e: &str) -> Self { + let modifier = format!("show:{e}"); + self.modifiers.push(modifier); + self + } + + /// Scroll to this location after load + pub fn scroll(mut self, e: &str) -> Self { + let modifier = format!("scroll:{e}"); + self.modifiers.push(modifier); + self + } + + pub fn to_string(&self) -> String { + format!("{} {}", self.strategy.to_string(), self.modifiers.join(" ")) + } +} diff --git a/src/ui/htmx/trigger.rs b/src/ui/htmx/trigger.rs new file mode 100644 index 0000000..66be30a --- /dev/null +++ b/src/ui/htmx/trigger.rs @@ -0,0 +1,179 @@ +use super::Selector; + +pub struct Event { + modifiers: Vec<String>, + kind: String, +} + +impl Event { + pub fn to_string(&self) -> String { + if self.kind.starts_with("every") { + return self.kind.clone(); + } + + format!("{} {}", self.kind, self.modifiers.join(" ")) + } + + /// Add a second event trigger + pub fn and(self, event: Event) -> Trigger { + Trigger { + triggers: vec![self, event], + } + } + + /// Periodically poll + /// + /// Value can be something like `1s [someConditional]` + pub fn poll(value: &str) -> Self { + Event { + modifiers: vec![], + kind: format!("every {value}"), + } + } + + /// Standard events refer to web API events (e.g. `click`, `keydown`, `mouseup`, `load`). + pub fn event(event: &str) -> Self { + Event { + modifiers: vec![], + kind: event.to_string(), + } + } + + /// triggered on load (useful for lazy-loading something) + pub fn on_load() -> Self { + Event { + modifiers: vec![], + kind: "load".to_string(), + } + } + + /// triggered when an element is scrolled into the viewport (also useful for lazy-loading). If you are using overflow in css like `overflow-y: scroll` you should use `intersect once` instead of `revealed`. + pub fn on_revealed() -> Self { + Event { + modifiers: vec![], + kind: "revealed".to_string(), + } + } + + /// fires once when an element first intersects the viewport. This supports two additional options: + /// `root:<selector>` - a CSS selector of the root element for intersection + /// `threshold:<float>` - a floating point number between 0.0 and 1.0, indicating what amount of intersection to fire the event on + pub fn on_intersect(value: &str) -> Self { + Event { + modifiers: vec![], + kind: format!("intersect:{value}"), + } + } + + /// the event will only trigger once (e.g. the first click) + pub fn once(mut self) -> Self { + self.modifiers.push("once".to_string()); + self + } + + /// the event will only change if the value of the element has changed. + pub fn changed(mut self) -> Self { + self.modifiers.push("changed".to_string()); + self + } + + /// a delay will occur before an event triggers a request. If the event is seen again it will reset the delay. + pub fn delay(mut self, delay: &str) -> Self { + self.modifiers.push(format!("delay:{delay}")); + self + } + + /// a throttle will occur after an event triggers a request. If the event is seen again before the delay completes, it is ignored, the element will trigger at the end of the delay. + pub fn throttle(mut self, delay: &str) -> Self { + self.modifiers.push(format!("throttle:{delay}")); + self + } + + /// allows the event that triggers a request to come from another element in the document (e.g. listening to a key event on the body, to support hot keys) + pub fn from(mut self, element: Selector) -> Self { + self.modifiers.push(format!("from:{}", element.to_string())); + self + } + + /// allows you to filter via a CSS selector on the target of the event. This can be useful when you want to listen for triggers from elements that might not be in the DOM at the point of initialization, by, for example, listening on the body, but with a target filter for a child element + pub fn target(mut self, selector: &str) -> Self { + self.modifiers.push(format!("target:{selector}")); + self + } + + /// if this option is included the event will not trigger any other htmx requests on parents (or on elements listening on parents) + pub fn consume(mut self) -> Self { + self.modifiers.push("consume".to_string()); + self + } + + /// determines how events are queued if an event occurs while a request for another event is in flight. + pub fn queue(mut self, opt: QueueOption) -> Self { + self.modifiers.push(format!("queue:{}", opt.to_string())); + self + } +} + +#[allow(non_camel_case_types)] +pub enum QueueOption { + /// queue the first event + first, + /// queue the last event (default) + last, + /// queue all events (issue a request for each event) + all, + /// do not queue new events + none, +} + +impl QueueOption { + pub fn to_string(&self) -> &str { + match self { + QueueOption::first => "first", + QueueOption::last => "last", + QueueOption::all => "all", + QueueOption::none => "none", + } + } +} + +impl Default for QueueOption { + fn default() -> Self { + QueueOption::last + } +} + +pub struct Trigger { + triggers: Vec<Event>, +} + +impl From<Event> for Trigger { + fn from(value: Event) -> Self { + Trigger { + triggers: vec![value], + } + } +} + +impl Trigger { + pub fn new() -> Self { + Self { triggers: vec![] } + } + + pub fn add(mut self, event: Event) -> Self { + self.triggers.push(event); + self + } + + pub fn and(self, event: Event) -> Self { + self.add(event) + } + + pub fn to_string(&self) -> String { + self.triggers + .iter() + .map(|x| x.to_string()) + .collect::<Vec<_>>() + .join(", ") + } +} diff --git a/src/ui/link.rs b/src/ui/link.rs deleted file mode 100644 index f5d4883..0000000 --- a/src/ui/link.rs +++ /dev/null @@ -1,31 +0,0 @@ -use maud::{Markup, Render, html}; - -use super::UIWidget; - -#[allow(non_snake_case)] -/// A component for fixing an element's width to the current breakpoint. -pub fn Link<T: UIWidget + 'static>(reference: &str, inner: T) -> LinkWidget { - LinkWidget(Box::new(inner), reference.to_owned()) -} - -pub struct LinkWidget(Box<dyn UIWidget>, String); - -impl Render for LinkWidget { - fn render(&self) -> Markup { - self.render_with_class("") - } -} - -impl UIWidget for LinkWidget { - fn can_inherit(&self) -> bool { - true - } - - fn render_with_class(&self, class: &str) -> Markup { - html! { - a class=(class) href=(self.1) { - (self.0.as_ref()) - } - } - } -} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 3114828..3957e7d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,134 +1,46 @@ -use maud::{Markup, PreEscaped, Render, html}; - -pub mod appbar; -pub mod aspect; -pub mod background; -pub mod container; -pub mod div; -pub mod flex; -pub mod header; -pub mod image; -pub mod link; -pub mod padding; -pub mod rounded; -pub mod search; -pub mod shadow; -pub mod sized; -pub mod text; -pub mod width; +use components::Shell; +use maud::{Markup, PreEscaped, Render}; // UI -// Preludes - // Basic Primitives -pub mod basic { - pub use super::aspect::Aspect; - pub use super::background::Background; - pub use super::background::{Blue, Gray}; - pub use super::container::Container; - pub use super::div::Div; - pub use super::flex::Flex; - pub use super::flex::Justify; - pub use super::header::Header; - pub use super::image::Image; - pub use super::link::Link; - pub use super::padding::Padding; - pub use super::rounded::Rounded; - pub use super::rounded::RoundedMedium; - pub use super::shadow::Shadow; - pub use super::sized::Sized; - pub use super::text::{Paragraph, Span, Text}; - pub use super::width::FitWidth; -} +pub mod color; +pub mod htmx; +pub mod primitives; +pub mod wrapper; // Stacked Components -pub mod extended { - pub use super::appbar::AppBar; +pub mod components; + +// Preludes +pub mod prelude { + pub use super::color::*; + pub use super::primitives::Nothing; + pub use super::primitives::Side; + pub use super::primitives::Size; + pub use super::primitives::aspect::Aspect; + pub use super::primitives::background::Background; + pub use super::primitives::container::Container; + pub use super::primitives::div::Div; + pub use super::primitives::flex::{Flex, Justify}; + pub use super::primitives::header::Header; + pub use super::primitives::image::Image; + pub use super::primitives::link::Link; + pub use super::primitives::padding::Padding; + pub use super::primitives::rounded::Rounded; + pub use super::primitives::script; + pub use super::primitives::shadow::Shadow; + pub use super::primitives::sized::Sized; + pub use super::primitives::space::{ScreenValue, SpaceBetween}; + pub use super::primitives::text::{Paragraph, Span, Text}; + pub use super::primitives::width::FitWidth; + pub use super::wrapper::Hover; } use crate::request::{RequestContext, StringResponse}; use rocket::http::{ContentType, Status}; -#[allow(non_snake_case)] -pub fn Nothing() -> PreEscaped<String> { - html! {} -} - -/// Represents the HTML structure of a page shell, including the head, body class, and body content. -/// -/// This structure is used to construct the overall HTML structure of a page, making it easier to generate consistent HTML pages dynamically. -pub struct Shell { - /// The HTML content for the `<head>` section of the page. - head: PreEscaped<String>, - /// An optional class attribute for the `<body>` element. - body_class: Option<String>, - /// The HTML content for the static body portion. - body_content: PreEscaped<String>, -} - -impl Shell { - /// Constructs a new `Shell` instance with the given head content, body content, and body class. - /// - /// # Arguments - /// * `head` - The HTML content for the page's head. - /// * `body_content` - The HTML content for the body of the page. - /// * `body_class` - An optional class to apply to the `<body>` element. - /// - /// # Returns - /// A `Shell` instance encapsulating the provided HTML content and attributes. - #[must_use] - pub const fn new( - head: PreEscaped<String>, - body_content: PreEscaped<String>, - body_class: Option<String>, - ) -> Self { - Self { - head, - body_class, - body_content, - } - } - - /// Renders the full HTML page using the shell structure, with additional content and a title. - /// - /// # Arguments - /// * `content` - The additional HTML content to render inside the main content div. - /// * `title` - The title of the page, rendered inside the `<title>` element. - /// - /// # Returns - /// A `PreEscaped<String>` containing the full HTML page content. - #[must_use] - pub fn render(&self, content: PreEscaped<String>, title: &str) -> PreEscaped<String> { - html! { - html { - head { - title { (title) }; - (self.head) - }; - @if self.body_class.is_some() { - body class=(self.body_class.as_ref().unwrap()) { - (self.body_content); - - div id="main_content" { - (content) - }; - }; - } @else { - body { - (self.body_content); - - div id="main_content" { - (content) - }; - }; - } - } - } - } -} - /// Renders a full page or an HTMX-compatible fragment based on the request context. /// /// If the request is not an HTMX request, this function uses the provided shell to generate @@ -161,61 +73,6 @@ pub async fn render_page( } } -/// Generates an HTML link with HTMX attributes for dynamic behavior. -/// -/// This function creates an `<a>` element with attributes that enable HTMX behavior for navigation without reload. -/// -/// # Arguments -/// * `url` - The URL to link to. -/// * `class` - The CSS class for styling the link. -/// * `onclick` - The JavaScript `onclick` handler for the link. -/// * `content` - The content inside the link element. -/// -/// # Returns -/// A `PreEscaped<String>` containing the rendered HTML link element. -#[must_use] -pub fn htmx_link( - url: &str, - class: &str, - onclick: &str, - content: PreEscaped<String>, -) -> PreEscaped<String> { - html!( - a class=(class) onclick=(onclick) href=(url) hx-get=(url) hx-target="#main_content" hx-push-url="true" hx-swap="innerHTML" { - (content); - }; - ) -} - -/// Generates a `<script>` element containing the provided JavaScript code. -/// -/// This function wraps the provided JavaScript code in a `<script>` tag, -/// allowing for easy inclusion of custom scripts in the rendered HTML. -/// -/// # Arguments -/// * `script` - The JavaScript code to include. -/// -/// # Returns -/// A `PreEscaped<String>` containing the rendered `<script>` element. -#[must_use] -pub fn script(script: &str) -> PreEscaped<String> { - html!( - script { - (PreEscaped(script)) - }; - ) -} - -pub struct Row(PreEscaped<String>); - -impl Render for Row { - fn render(&self) -> maud::Markup { - html! { - div class="flex" { (self.0) } - } - } -} - // Grids // ListViews @@ -224,23 +81,40 @@ impl Render for Row { // Cards +/// Generic UI Widget pub trait UIWidget: Render { + /// Indicating if the widget supports inheriting classes fn can_inherit(&self) -> bool; + /// Returning the base classes for this widget + fn base_class(&self) -> Vec<String>; + fn extended_class(&self) -> Vec<String>; + /// Render the widget with additional classes fn render_with_class(&self, class: &str) -> Markup; } +/// Implementation for raw HTML with html! macro impl UIWidget for PreEscaped<String> { fn can_inherit(&self) -> bool { false } + fn base_class(&self) -> Vec<String> { + vec![] + } + + fn extended_class(&self) -> Vec<String> { + vec![] + } + fn render_with_class(&self, _: &str) -> Markup { self.render() } } -// TODO : -// hover focus -// responsive media -// more elements -// htmx builder trait? +/// Trait for an element which can add new `attrs` +pub trait AttrExtendable { + fn add_attr(self, key: &str, val: &str) -> Self; + + /// Set the `id` attribute of an element. + fn id(self, id: &str) -> Self; +} diff --git a/src/ui/aspect.rs b/src/ui/primitives/aspect.rs similarity index 61% rename from src/ui/aspect.rs rename to src/ui/primitives/aspect.rs index 0158951..4723067 100644 --- a/src/ui/aspect.rs +++ b/src/ui/primitives/aspect.rs @@ -1,6 +1,6 @@ use maud::{Markup, Render, html}; -use super::UIWidget; +use crate::ui::UIWidget; pub struct Aspect { kind: u8, @@ -32,20 +32,39 @@ impl Aspect { impl Render for Aspect { fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for Aspect { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { let class = match self.kind { 0 => "aspect-auto", 1 => "aspect-square", 2 => "aspect-video", _ => "", }; + vec![class.to_string()] + } + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.inner.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { if self.inner.as_ref().can_inherit() { html! { - (self.inner.as_ref().render_with_class(class)) + (self.inner.as_ref().render_with_class(&format!("{class} {}", self.base_class().join(" ")))) } } else { html! { - div class=(class) { + div class=(format!("{class} {}", self.base_class().join(" "))) { (self.inner.as_ref()) } } diff --git a/src/ui/background.rs b/src/ui/primitives/background.rs similarity index 58% rename from src/ui/background.rs rename to src/ui/primitives/background.rs index 5a431c4..e07503e 100644 --- a/src/ui/background.rs +++ b/src/ui/primitives/background.rs @@ -1,33 +1,6 @@ -use super::UIWidget; use maud::{Markup, Render, html}; -pub trait UIColor { - fn color_class(&self) -> &str; -} - -pub enum Blue { - _500, -} - -impl UIColor for Blue { - fn color_class(&self) -> &str { - match self { - Blue::_500 => "blue-500", - } - } -} - -pub enum Gray { - _800, -} - -impl UIColor for Gray { - fn color_class(&self) -> &str { - match self { - Gray::_800 => "gray-800", - } - } -} +use crate::ui::{UIWidget, color::UIColor}; #[allow(non_snake_case)] pub fn Background<T: UIWidget + 'static, C: UIColor + 'static>( @@ -50,14 +23,24 @@ impl UIWidget for BackgroundWidget { true } + fn base_class(&self) -> Vec<String> { + vec![format!("bg-{}", self.1.color_class())] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + fn render_with_class(&self, class: &str) -> Markup { if self.0.as_ref().can_inherit() { self.0 .as_ref() - .render_with_class(&format!("bg-{} {class}", self.1.color_class())) + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) } else { html! { - div class=(format!("bg-{} {class}", self.1.color_class())) { + div class=(format!("{} {class}", self.base_class().join(" "))) { (self.0.as_ref()) } } diff --git a/src/ui/container.rs b/src/ui/primitives/container.rs similarity index 76% rename from src/ui/container.rs rename to src/ui/primitives/container.rs index 5220c77..ce9080c 100644 --- a/src/ui/container.rs +++ b/src/ui/primitives/container.rs @@ -1,6 +1,6 @@ use maud::{Markup, Render, html}; -use super::UIWidget; +use crate::ui::UIWidget; #[allow(non_snake_case)] /// A component for fixing an element's width to the current breakpoint. @@ -21,6 +21,16 @@ impl UIWidget for ContainerWidget { true } + fn base_class(&self) -> Vec<String> { + vec!["container".to_string()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + fn render_with_class(&self, class: &str) -> Markup { if self.0.as_ref().can_inherit() { self.0 diff --git a/src/ui/primitives/div.rs b/src/ui/primitives/div.rs new file mode 100644 index 0000000..a21ccbb --- /dev/null +++ b/src/ui/primitives/div.rs @@ -0,0 +1,108 @@ +use std::collections::HashMap; + +use maud::{Markup, PreEscaped, Render, html}; + +use crate::ui::{AttrExtendable, UIWidget, htmx::HTMXAttributes}; + +#[allow(non_snake_case)] +/// `<div>` element +/// +/// Useful for grouping values together +pub fn Div() -> DivWidget { + DivWidget(Vec::new(), false, HashMap::new()) +} + +pub struct DivWidget(Vec<Box<dyn UIWidget>>, bool, HashMap<String, String>); + +impl AttrExtendable for DivWidget { + fn add_attr(mut self, key: &str, val: &str) -> Self { + self.2.insert(key.to_string(), val.to_string()); + self + } + + fn id(self, id: &str) -> Self { + self.add_attr("id", id) + } +} + +impl DivWidget { + /// Add an element to the `<div>` + pub fn add<T: UIWidget + 'static>(mut self, element: T) -> Self { + self.0.push(Box::new(element)); + self + } + + /// Add an optional element to the `<div>` + /// + /// # Example + /// + /// ```ignore + /// use based::ui::basic::*; + /// + /// let div = Div().add_some(Some("hello"), |value| Text(value)); + /// ``` + pub fn add_some<T: UIWidget + 'static, X, U: Fn(&X) -> T>( + mut self, + option: Option<&X>, + then: U, + ) -> Self { + if let Some(val) = option { + self.0.push(Box::new(then(val))); + } + self + } + + /// Extract the `<div>`s innerHTML + /// + /// This will render `<content>` instead of `<div> <content> </div>` + pub fn vanish(mut self) -> Self { + self.1 = true; + self + } +} + +impl Render for DivWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for DivWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + vec![] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + for e in &self.0 { + c.extend_from_slice(&e.extended_class()); + } + c + } + + fn render_with_class(&self, _: &str) -> Markup { + let inner = html! { + @for e in &self.0 { + (e.as_ref()) + } + }; + if self.1 { + inner + } else { + let attrs = self + .2 + .iter() + .map(|(k, v)| format!("{k}='{v}'")) + .collect::<Vec<_>>() + .join(" "); + + PreEscaped(format!("<div {attrs}> {} </a>", inner.0)) + } + } +} + +impl HTMXAttributes for DivWidget {} diff --git a/src/ui/flex.rs b/src/ui/primitives/flex.rs similarity index 75% rename from src/ui/flex.rs rename to src/ui/primitives/flex.rs index 6ed2aa1..776e6bc 100644 --- a/src/ui/flex.rs +++ b/src/ui/primitives/flex.rs @@ -1,4 +1,4 @@ -use super::UIWidget; +use crate::ui::UIWidget; use maud::{Markup, Render, html}; #[allow(non_snake_case)] @@ -41,11 +41,6 @@ impl FlexWidget { self } - pub fn space_x(mut self, x: u32) -> Self { - self.1.push(format!("space-x-{x}")); - self - } - pub fn items_center(mut self) -> Self { self.1.push("items-center".to_owned()); self @@ -62,14 +57,26 @@ impl UIWidget for FlexWidget { true } + fn base_class(&self) -> Vec<String> { + let mut res = vec!["flex".to_string()]; + res.extend_from_slice(&self.1); + res + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + fn render_with_class(&self, class: &str) -> Markup { if self.0.as_ref().can_inherit() && !self.2 { self.0 .as_ref() - .render_with_class(&format!("flex {} {class}", self.1.join(" "))) + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) } else { html! { - div class=(format!("flex {} {class}", self.1.join(" "))) { + div class=(format!("{} {class}", self.base_class().join(" "))) { (self.0.as_ref()) } } diff --git a/src/ui/header.rs b/src/ui/primitives/header.rs similarity index 70% rename from src/ui/header.rs rename to src/ui/primitives/header.rs index 2751bb1..28b5fdc 100644 --- a/src/ui/header.rs +++ b/src/ui/primitives/header.rs @@ -1,6 +1,6 @@ use maud::{Markup, Render, html}; -use super::UIWidget; +use crate::ui::UIWidget; #[allow(non_snake_case)] pub fn Header<T: UIWidget + 'static>(inner: T) -> HeaderWidget { @@ -20,6 +20,16 @@ impl UIWidget for HeaderWidget { true } + fn base_class(&self) -> Vec<String> { + vec![] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + fn render_with_class(&self, class: &str) -> Markup { html! { header class=(class) { diff --git a/src/ui/image.rs b/src/ui/primitives/image.rs similarity index 81% rename from src/ui/image.rs rename to src/ui/primitives/image.rs index da0eb5e..6d2c959 100644 --- a/src/ui/image.rs +++ b/src/ui/primitives/image.rs @@ -1,4 +1,4 @@ -use super::UIWidget; +use crate::ui::UIWidget; use maud::{Markup, Render, html}; #[allow(non_snake_case)] @@ -32,6 +32,14 @@ impl UIWidget for ImageWidget { true } + fn base_class(&self) -> Vec<String> { + vec![] + } + + fn extended_class(&self) -> Vec<String> { + self.base_class() + } + fn render_with_class(&self, class: &str) -> Markup { html! { img src=(self.src) alt=(self.alt) class=(class) {}; diff --git a/src/ui/primitives/input.rs b/src/ui/primitives/input.rs new file mode 100644 index 0000000..5b791bc --- /dev/null +++ b/src/ui/primitives/input.rs @@ -0,0 +1 @@ +// TODO : Implement input types diff --git a/src/ui/primitives/link.rs b/src/ui/primitives/link.rs new file mode 100644 index 0000000..7cf9e3c --- /dev/null +++ b/src/ui/primitives/link.rs @@ -0,0 +1,77 @@ +use std::collections::HashMap; + +use maud::{Markup, PreEscaped, Render}; + +use crate::ui::{ + AttrExtendable, UIWidget, + htmx::{HTMXAttributes, Selector, SwapStrategy}, +}; + +#[allow(non_snake_case)] +/// A component for fixing an element's width to the current breakpoint. +pub fn Link<T: UIWidget + 'static>(reference: &str, inner: T) -> LinkWidget { + LinkWidget(Box::new(inner), reference.to_owned(), HashMap::new()) +} + +pub struct LinkWidget(Box<dyn UIWidget>, String, HashMap<String, String>); + +impl AttrExtendable for LinkWidget { + fn add_attr(mut self, key: &str, val: &str) -> Self { + self.2.insert(key.to_string(), val.to_string()); + self + } + + fn id(self, id: &str) -> Self { + self.add_attr("id", id) + } +} + +impl HTMXAttributes for LinkWidget {} + +impl Render for LinkWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for LinkWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec![] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + let attrs = self + .2 + .iter() + .map(|(k, v)| format!("{k}='{v}'")) + .collect::<Vec<_>>() + .join(" "); + + PreEscaped(format!( + "<a href='{}' class='{class}' {attrs}> {} </a>", + self.1, + self.0.render().0 + )) + } +} + +impl LinkWidget { + /// Enable HTMX link capabilities + pub fn use_htmx(self) -> Self { + let url = self.1.clone(); + self.hx_get(&url) + .hx_target(Selector::Query("#main_content".to_string())) + .hx_push_url() + .hx_swap(SwapStrategy::innerHTML) + } +} diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs new file mode 100644 index 0000000..c47c985 --- /dev/null +++ b/src/ui/primitives/mod.rs @@ -0,0 +1,108 @@ +use maud::{PreEscaped, html}; + +pub mod aspect; +pub mod background; +pub mod container; +pub mod div; +pub mod flex; +pub mod header; +pub mod image; +pub mod input; +pub mod link; +pub mod padding; +pub mod rounded; +pub mod shadow; +pub mod sized; +pub mod space; +pub mod text; +pub mod width; + +#[allow(non_snake_case)] +pub fn Nothing() -> PreEscaped<String> { + html! {} +} + +/// Generates a `<script>` element containing the provided JavaScript code. +/// +/// This function wraps the provided JavaScript code in a `<script>` tag, +/// allowing for easy inclusion of custom scripts in the rendered HTML. +/// +/// # Arguments +/// * `script` - The JavaScript code to include. +/// +/// # Returns +/// A `PreEscaped<String>` containing the rendered `<script>` element. +#[must_use] +pub fn script(script: &str) -> PreEscaped<String> { + html!( + script { + (PreEscaped(script)) + }; + ) +} + +pub enum Size { + None, + Small, + Regular, + Medium, + Large, + XL, + _2XL, + _3XL, + Full, +} + +impl Size { + pub fn to_string(&self) -> &str { + match self { + Self::None => "none", + Self::Small => "sm", + Self::Regular => "", + Self::Medium => "md", + Self::Large => "lg", + Self::XL => "xl", + Self::_2XL => "2xl", + Self::_3XL => "3xl", + Self::Full => "full", + } + } +} + +pub enum Side { + Start, + End, + Top, + Right, + Bottom, + Left, + StartStart, + StartEnd, + EndEnd, + EndStart, + TopLeft, + TopRight, + BottomRight, + BottomLeft, +} + +impl Side { + pub fn to_string(&self) -> &str { + match self { + Side::Start => "s", + Side::End => "e", + Side::Top => "t", + Side::Right => "r", + Side::Bottom => "b", + Side::Left => "l", + Side::StartStart => "ss", + Side::StartEnd => "se", + Side::EndEnd => "ee", + Side::EndStart => "es", + Side::TopLeft => "tl", + Side::TopRight => "tr", + Side::BottomRight => "br", + Side::BottomLeft => "bl", + } + } +} diff --git a/src/ui/padding.rs b/src/ui/primitives/padding.rs similarity index 78% rename from src/ui/padding.rs rename to src/ui/primitives/padding.rs index 4962109..2e58dd7 100644 --- a/src/ui/padding.rs +++ b/src/ui/primitives/padding.rs @@ -1,7 +1,6 @@ +use crate::ui::UIWidget; use maud::{Markup, Render, html}; -use super::UIWidget; - pub struct PaddingInfo { pub right: Option<u32>, } @@ -51,7 +50,7 @@ impl UIWidget for PaddingWidget { true } - fn render_with_class(&self, class: &str) -> Markup { + fn base_class(&self) -> Vec<String> { let mut our_class = Vec::new(); if let Some(r) = self.right { @@ -66,15 +65,23 @@ impl UIWidget for PaddingWidget { our_class.push(format!("px-{x}")); } - let our_class = our_class.join(" "); + our_class + } + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.inner.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { if self.inner.as_ref().can_inherit() { self.inner .as_ref() - .render_with_class(&format!("{our_class} {class}")) + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) } else { html! { - div class=(format!("{our_class} {class}")) { + div class=(format!("{} {class}", self.base_class().join(" "))) { (self.inner.as_ref()) } } diff --git a/src/ui/primitives/rounded.rs b/src/ui/primitives/rounded.rs new file mode 100644 index 0000000..102d898 --- /dev/null +++ b/src/ui/primitives/rounded.rs @@ -0,0 +1,68 @@ +use crate::ui::UIWidget; +use maud::{Markup, Render, html}; + +use super::{Side, Size}; + +#[allow(non_snake_case)] +pub fn Rounded<T: UIWidget + 'static>(inner: T) -> RoundedWidget { + RoundedWidget(Box::new(inner), None, None) +} + +pub struct RoundedWidget(Box<dyn UIWidget>, Option<Size>, Option<Side>); + +impl RoundedWidget { + pub fn size(mut self, size: Size) -> Self { + self.1 = Some(size); + self + } + + pub fn side(mut self, side: Side) -> Self { + self.2 = Some(side); + self + } +} + +impl Render for RoundedWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for RoundedWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + if let Some(side) = &self.2 { + if let Some(size) = &self.1 { + return vec![format!("rounded-{}-{}", side.to_string(), size.to_string())]; + } + } else { + if let Some(size) = &self.1 { + return vec![format!("rounded-{}", size.to_string())]; + } + } + vec!["rounded".to_owned()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/primitives/shadow.rs b/src/ui/primitives/shadow.rs new file mode 100644 index 0000000..7036ef6 --- /dev/null +++ b/src/ui/primitives/shadow.rs @@ -0,0 +1,78 @@ +use crate::ui::UIWidget; +use maud::{Markup, Render, html}; + +pub struct Shadow(Box<dyn UIWidget>, String); + +impl Shadow { + pub fn medium<T: UIWidget + 'static>(inner: T) -> Shadow { + Shadow(Box::new(inner), "md".to_owned()) + } + + pub fn small<T: UIWidget + 'static>(inner: T) -> Shadow { + Shadow(Box::new(inner), "sm".to_owned()) + } + + pub fn regular<T: UIWidget + 'static>(inner: T) -> Shadow { + Shadow(Box::new(inner), String::new()) + } + + pub fn large<T: UIWidget + 'static>(inner: T) -> Shadow { + Shadow(Box::new(inner), "lg".to_owned()) + } + + pub fn none<T: UIWidget + 'static>(inner: T) -> Shadow { + Shadow(Box::new(inner), "none".to_owned()) + } + + pub fn xl<T: UIWidget + 'static>(inner: T) -> Shadow { + Shadow(Box::new(inner), "xl".to_owned()) + } + + pub fn _2xl<T: UIWidget + 'static>(inner: T) -> Shadow { + Shadow(Box::new(inner), "2xl".to_owned()) + } + + pub fn inner<T: UIWidget + 'static>(inner: T) -> Shadow { + Shadow(Box::new(inner), "inner".to_owned()) + } +} + +impl Render for Shadow { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for Shadow { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + if self.1.is_empty() { + vec!["shadow".to_string()] + } else { + vec![format!("shadow-{}", self.1)] + } + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/sized.rs b/src/ui/primitives/sized.rs similarity index 60% rename from src/ui/sized.rs rename to src/ui/primitives/sized.rs index dc473d6..eb390b5 100644 --- a/src/ui/sized.rs +++ b/src/ui/primitives/sized.rs @@ -1,4 +1,4 @@ -use super::UIWidget; +use crate::ui::UIWidget; use maud::{Markup, Render, html}; #[allow(non_snake_case)] @@ -19,14 +19,24 @@ impl UIWidget for SizedWidget { true } + fn base_class(&self) -> Vec<String> { + vec![format!("h-{}", self.1), format!("w-{}", self.2)] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + fn render_with_class(&self, class: &str) -> Markup { if self.0.as_ref().can_inherit() { self.0 .as_ref() - .render_with_class(&format!("h-{} w-{} {class}", self.1, self.2)) + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) } else { html! { - div class=(format!("h-{} w-{} {class}", self.1, self.2)) { + div class=(format!("{} {class}", self.base_class().join(" "))) { (self.0.as_ref()) } } diff --git a/src/ui/primitives/space.rs b/src/ui/primitives/space.rs new file mode 100644 index 0000000..8b4c68a --- /dev/null +++ b/src/ui/primitives/space.rs @@ -0,0 +1,151 @@ +use crate::ui::UIWidget; +use maud::{Markup, Render, html}; + +#[allow(non_snake_case)] +/// Controlling the space between child elements. +pub fn SpaceBetween<T: UIWidget + 'static>(inner: T) -> SpaceBetweenWidget { + SpaceBetweenWidget(Box::new(inner), None, None) +} + +pub struct SpaceBetweenWidget(Box<dyn UIWidget>, Option<ScreenValue>, Option<ScreenValue>); + +impl Render for SpaceBetweenWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl SpaceBetweenWidget { + pub fn x(mut self, x: ScreenValue) -> Self { + self.1 = Some(x); + self + } + + pub fn y(mut self, y: ScreenValue) -> Self { + self.2 = Some(y); + self + } +} + +impl UIWidget for SpaceBetweenWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let mut ret = Vec::new(); + + if let Some(x) = &self.1 { + ret.push(format!("space-x-{}", x.to_string())); + } + + if let Some(y) = &self.2 { + ret.push(format!("space-y-{}", y.to_string())); + } + + ret + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} + +#[allow(non_camel_case_types)] +pub enum ScreenValue { + _0, + _0p5, + _1, + _1p5, + _2, + _2p5, + _3, + _3p5, + _4, + _5, + _6, + _7, + _8, + _9, + _10, + _11, + _12, + _14, + _16, + _20, + _24, + _28, + _32, + _36, + _40, + _44, + _48, + _52, + _56, + _60, + _64, + _72, + _80, + _90, + px, + reverse, +} + +impl ScreenValue { + pub fn to_string(&self) -> &str { + match self { + ScreenValue::_0 => "0", + ScreenValue::_0p5 => "0.5", + ScreenValue::_1 => "1", + ScreenValue::_1p5 => "1.5", + ScreenValue::_2 => "2", + ScreenValue::_2p5 => "2.5", + ScreenValue::_3 => "3", + ScreenValue::_3p5 => "3.5", + ScreenValue::_4 => "4", + ScreenValue::_5 => "5", + ScreenValue::_6 => "6", + ScreenValue::_7 => "7", + ScreenValue::_8 => "8", + ScreenValue::_9 => "9", + ScreenValue::_10 => "10", + ScreenValue::_11 => "11", + ScreenValue::_12 => "12", + ScreenValue::_14 => "14", + ScreenValue::_16 => "16", + ScreenValue::_20 => "20", + ScreenValue::_24 => "24", + ScreenValue::_28 => "28", + ScreenValue::_32 => "32", + ScreenValue::_36 => "36", + ScreenValue::_40 => "40", + ScreenValue::_44 => "44", + ScreenValue::_48 => "48", + ScreenValue::_52 => "52", + ScreenValue::_56 => "56", + ScreenValue::_60 => "60", + ScreenValue::_64 => "64", + ScreenValue::_72 => "72", + ScreenValue::_80 => "80", + ScreenValue::_90 => "90", + ScreenValue::px => "px", + ScreenValue::reverse => "reverse", + } + } +} diff --git a/src/ui/text.rs b/src/ui/primitives/text.rs similarity index 61% rename from src/ui/text.rs rename to src/ui/primitives/text.rs index 0a7c7f9..756736f 100644 --- a/src/ui/text.rs +++ b/src/ui/primitives/text.rs @@ -1,7 +1,8 @@ -use super::UIWidget; +use crate::ui::{UIWidget, color::UIColor}; use maud::{Markup, Render, html}; #[allow(non_snake_case)] +/// Text UI Widget pub fn Text(txt: &str) -> TextWidget { TextWidget { inner: None, @@ -14,6 +15,7 @@ pub fn Text(txt: &str) -> TextWidget { } #[allow(non_snake_case)] +/// HTML `<p>` Paragraph pub fn Paragraph<T: UIWidget + 'static>(inner: T) -> TextWidget { TextWidget { inner: Some(Box::new(inner)), @@ -26,6 +28,7 @@ pub fn Paragraph<T: UIWidget + 'static>(inner: T) -> TextWidget { } #[allow(non_snake_case)] +/// `<span>` element pub fn Span(txt: &str) -> TextWidget { TextWidget { inner: None, @@ -47,43 +50,61 @@ pub struct TextWidget { } impl TextWidget { + /// Turn `Text` semibold. + /// + /// Adds the class `font-semibold` pub fn semibold(mut self) -> Self { self.font = "font-semibold".to_owned(); self } + /// Turn `Text` bold. + /// + /// Adds the class `font-bold` pub fn bold(mut self) -> Self { self.font = "font-bold".to_owned(); self } + /// Turn `Text` medium. + /// + /// Adds the class `font-medium` pub fn medium(mut self) -> Self { self.font = "font-medium".to_owned(); self } + /// Turn `Text` size to 2XL. + /// + /// Adds the class `text-2xl` pub fn _2xl(mut self) -> Self { self.size = "text-2xl".to_owned(); self } + /// Turn `Text` size to xl. + /// + /// Adds the class `text-xl` pub fn xl(mut self) -> Self { self.size = "text-xl".to_owned(); self } + /// Turn `Text` size to small. + /// + /// Adds the class `text-sm` pub fn sm(mut self) -> Self { self.size = "text-sm".to_owned(); self } - pub fn gray(mut self, i: u32) -> Self { - self.color = format!("text-gray-{}", i); + pub fn color<T: UIColor>(mut self, color: T) -> Self { + self.color = format!("text-{}", color.color_class()); self } - pub fn slate(mut self, i: u32) -> Self { - self.color = format!("text-slate-{}", i); + pub fn black(mut self) -> Self { + self.color = "text-black".to_owned(); self } @@ -104,26 +125,37 @@ impl UIWidget for TextWidget { true } + fn base_class(&self) -> Vec<String> { + vec![self.color.clone(), self.font.clone(), self.size.clone()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + if let Some(inner) = &self.inner { + c.extend_from_slice(&inner.extended_class()); + } + c + } + fn render_with_class(&self, class: &str) -> Markup { - let our_class = format!("{} {} {}", self.color, self.font, self.size); if let Some(inner) = &self.inner { if self.span { html! { - span class=(format!("{} {}", class, our_class)) { (inner) } + span class=(format!("{} {}", class, self.base_class().join(" "))) { (inner) } } } else { html! { - p class=(format!("{} {}", class, our_class)) { (inner) } + p class=(format!("{} {}", class, self.base_class().join(" "))) { (inner) } } } } else { if self.span { html! { - span class=(format!("{} {}", class, our_class)) { (self.txt) } + span class=(format!("{} {}", class, self.base_class().join(" "))) { (self.txt) } } } else { html! { - p class=(format!("{} {}", class, our_class)) { (self.txt) } + p class=(format!("{} {}", class, self.base_class().join(" "))) { (self.txt) } } } } diff --git a/src/ui/width.rs b/src/ui/primitives/width.rs similarity index 61% rename from src/ui/width.rs rename to src/ui/primitives/width.rs index 9c7a390..b66d6b1 100644 --- a/src/ui/width.rs +++ b/src/ui/primitives/width.rs @@ -1,7 +1,6 @@ +use crate::ui::UIWidget; use maud::{Markup, Render, html}; -use super::UIWidget; - #[allow(non_snake_case)] pub fn FitWidth<T: UIWidget + 'static>(inner: T) -> FitWidthWidget { FitWidthWidget(Box::new(inner)) @@ -20,14 +19,24 @@ impl UIWidget for FitWidthWidget { true } + fn base_class(&self) -> Vec<String> { + vec!["max-w-fit".to_string()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + fn render_with_class(&self, class: &str) -> Markup { if self.0.as_ref().can_inherit() { self.0 .as_ref() - .render_with_class(&format!("max-w-fit {class}")) + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) } else { html! { - div class=(format!("max-w-fit {class}")) { + div class=(format!("{} {class}", self.base_class().join(" "))) { (self.0.as_ref()) } } diff --git a/src/ui/rounded.rs b/src/ui/rounded.rs deleted file mode 100644 index 1632274..0000000 --- a/src/ui/rounded.rs +++ /dev/null @@ -1,41 +0,0 @@ -use maud::{Markup, Render, html}; - -use super::UIWidget; - -#[allow(non_snake_case)] -pub fn Rounded<T: UIWidget + 'static>(inner: T) -> RoundedWidget { - RoundedWidget(Box::new(inner), "full".to_owned()) -} - -#[allow(non_snake_case)] -pub fn RoundedMedium<T: UIWidget + 'static>(inner: T) -> RoundedWidget { - RoundedWidget(Box::new(inner), "md".to_owned()) -} - -pub struct RoundedWidget(Box<dyn UIWidget>, String); - -impl Render for RoundedWidget { - fn render(&self) -> Markup { - self.render_with_class("") - } -} - -impl UIWidget for RoundedWidget { - fn can_inherit(&self) -> bool { - true - } - - fn render_with_class(&self, class: &str) -> Markup { - if self.0.as_ref().can_inherit() { - self.0 - .as_ref() - .render_with_class(&format!("rounded-{} {class}", self.1)) - } else { - html! { - div class=(format!("rounded-{} {class}", self.1)) { - (self.0.as_ref()) - } - } - } - } -} diff --git a/src/ui/shadow.rs b/src/ui/shadow.rs deleted file mode 100644 index 5585d3b..0000000 --- a/src/ui/shadow.rs +++ /dev/null @@ -1,37 +0,0 @@ -use maud::{Markup, Render, html}; - -use super::UIWidget; - -pub struct Shadow(Box<dyn UIWidget>, String); - -impl Shadow { - pub fn medium<T: UIWidget + 'static>(inner: T) -> Shadow { - Shadow(Box::new(inner), "md".to_owned()) - } -} - -impl Render for Shadow { - fn render(&self) -> Markup { - self.render_with_class("") - } -} - -impl UIWidget for Shadow { - fn can_inherit(&self) -> bool { - true - } - - fn render_with_class(&self, class: &str) -> Markup { - if self.0.as_ref().can_inherit() { - self.0 - .as_ref() - .render_with_class(&format!("shadow-{} {class}", self.1)) - } else { - html! { - div class=(format!("shadow-{} {class}", self.1)) { - (self.0.as_ref()) - } - } - } - } -} diff --git a/src/ui/wrapper/hover.rs b/src/ui/wrapper/hover.rs new file mode 100644 index 0000000..3457ced --- /dev/null +++ b/src/ui/wrapper/hover.rs @@ -0,0 +1,57 @@ +use maud::{Markup, Render, html}; + +use crate::ui::UIWidget; + +#[allow(non_snake_case)] +pub fn Hover<T: UIWidget + 'static, I: UIWidget + 'static>(inherit: I, inner: T) -> HoverWrapper { + HoverWrapper(Box::new(inner), Box::new(inherit)) +} + +pub struct HoverWrapper(Box<dyn UIWidget>, Box<dyn UIWidget>); + +impl HoverWrapper { + pub fn hovered_class(&self) -> String { + self.1 + .extended_class() + .into_iter() + .filter(|x| !x.is_empty()) + .map(|x| format!("hover:{x}")) + .collect::<Vec<_>>() + .join(" ") + } +} + +impl Render for HoverWrapper { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for HoverWrapper { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec![] + } + + fn extended_class(&self) -> Vec<String> { + self.base_class() + } + + fn render_with_class(&self, class: &str) -> Markup { + // TODO : Replace lol + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.hovered_class())) + } else { + html! { + div class=(format!("{} {class}", self.hovered_class())) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/wrapper/mod.rs b/src/ui/wrapper/mod.rs new file mode 100644 index 0000000..b1312e7 --- /dev/null +++ b/src/ui/wrapper/mod.rs @@ -0,0 +1,4 @@ +pub mod hover; +pub use hover::Hover; + +// TODO : responsive media From e9a9dad0373906267f96a91c8c2e4aece05e0991 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Wed, 15 Jan 2025 18:53:55 +0100 Subject: [PATCH 04/45] refactor --- src/result.rs | 11 +++-- src/ui/color.rs | 14 +++--- src/ui/components/appbar.rs | 9 ++-- src/ui/htmx/mod.rs | 43 +++++++++++++++--- src/ui/htmx/selector.rs | 19 ++++---- src/ui/htmx/swap.rs | 34 +++++++++------ src/ui/htmx/trigger.rs | 75 ++++++++++++++++++++----------- src/ui/mod.rs | 2 + src/ui/primitives/div.rs | 14 +++--- src/ui/primitives/flex.rs | 7 ++- src/ui/primitives/image.rs | 4 +- src/ui/primitives/link.rs | 3 +- src/ui/primitives/mod.rs | 35 ++++++++------- src/ui/primitives/padding.rs | 9 ++-- src/ui/primitives/rounded.rs | 14 +++--- src/ui/primitives/shadow.rs | 32 +++++++------- src/ui/primitives/space.rs | 85 +++++++++++++++++++----------------- src/ui/primitives/text.rs | 43 ++++++++++-------- src/ui/wrapper/hover.rs | 2 +- 19 files changed, 278 insertions(+), 177 deletions(-) diff --git a/src/result.rs b/src/result.rs index f1b8f3c..9efdfa0 100644 --- a/src/result.rs +++ b/src/result.rs @@ -32,24 +32,27 @@ impl<T, E: std::fmt::Debug> LogAndIgnore for Result<T, E> { } pub trait LogNoneAndPass { + #[must_use] fn log_warn_none_and_pass(self, msg: impl Fn() -> String) -> Self; + + #[must_use] fn log_err_none_and_pass(self, msg: impl Fn() -> String) -> Self; } impl<T> LogNoneAndPass for Option<T> { fn log_warn_none_and_pass(self, msg: impl Fn() -> String) -> Option<T> { - if matches!(self, None) { + if self.is_none() { log::warn!("{}", msg()); } - return self; + self } fn log_err_none_and_pass(self, msg: impl Fn() -> String) -> Option<T> { - if matches!(self, None) { + if self.is_none() { log::error!("{}", msg()); } - return self; + self } } diff --git a/src/ui/color.rs b/src/ui/color.rs index e1ddeba..13ba7cb 100644 --- a/src/ui/color.rs +++ b/src/ui/color.rs @@ -1,10 +1,14 @@ /// UI Color pub trait UIColor { + #[must_use] fn color_class(&self) -> &str; } pub trait ColorCircle { + #[must_use] fn previous(&self) -> Self; + + #[must_use] fn next(&self) -> Self; } @@ -119,11 +123,11 @@ pub enum Colors { impl UIColor for Colors { fn color_class(&self) -> &str { match self { - Colors::Inherit => "inherit", - Colors::Current => "current", - Colors::Transparent => "transparent", - Colors::Black => "black", - Colors::White => "white", + Self::Inherit => "inherit", + Self::Current => "current", + Self::Transparent => "transparent", + Self::Black => "black", + Self::White => "white", } } } diff --git a/src/ui/components/appbar.rs b/src/ui/components/appbar.rs index a2e08c6..6a9a47c 100644 --- a/src/ui/components/appbar.rs +++ b/src/ui/components/appbar.rs @@ -5,6 +5,7 @@ use crate::auth::User; use crate::ui::{UIWidget, prelude::*}; #[allow(non_snake_case)] +#[must_use] pub fn AppBar(name: &str, user: Option<User>) -> AppBarWidget { AppBarWidget { name: name.to_owned(), @@ -44,25 +45,25 @@ impl UIWidget for AppBarWidget { Flex( Div() .vanish() - .add( + .push( SpaceBetween( Flex(Link( "/", Div() .vanish() - .add(Sized( + .push(Sized( 10, 10, Rounded(Image("/favicon").alt("Logo")) .size(Size::Medium), )) - .add(Span(&self.name).semibold().xl().white()), + .push(Span(&self.name).semibold().xl().white()), )) .items_center(), ) .x(ScreenValue::_2), ) - .add_some(self.user.as_ref(), |user| Text(&user.username).white()), + .push_some(self.user.as_ref(), |user| Text(&user.username).white()), ) .group() .justify(Justify::Between) diff --git a/src/ui/htmx/mod.rs b/src/ui/htmx/mod.rs index 4199a2e..8e6bbed 100644 --- a/src/ui/htmx/mod.rs +++ b/src/ui/htmx/mod.rs @@ -13,67 +13,80 @@ use super::AttrExtendable; pub trait HTMXAttributes: AttrExtendable + std::marker::Sized { /// Issues a `GET` request to the specified URL + #[must_use] fn hx_get(self, url: &str) -> Self { self.add_attr("hx-get", url) } /// Issues a `POST` request to the specified URL + #[must_use] fn hx_post(self, url: &str) -> Self { self.add_attr("hx-post", url) } /// Push a URL into the browser location bar to create history + #[must_use] fn hx_push_url(self) -> Self { self.add_attr("hx-push-url", "true") } /// Select content to swap in from a response + #[must_use] fn hx_select(self, element: &str) -> Self { self.add_attr("hx-select", element) } /// Select content to swap in from a response, somewhere other than the target (out of band). /// Select `element` from response and replace `element` in the DOM. + #[must_use] fn hx_select_oob(self, element: &str) -> Self { self.add_attr("hx-select-oob", element) } /// The hx-boost attribute allows you to “boost” normal anchors and form tags to use AJAX instead. + #[must_use] fn hx_boost(self) -> Self { self.add_attr("hx-boost", "true") } /// The hx-confirm attribute allows you to confirm an action before issuing a request. + #[must_use] fn hx_confirm(self, msg: &str) -> Self { self.add_attr("hx-confirm", msg) } /// The hx-delete attribute will cause an element to issue a `DELETE` request to the specified URL and swap the HTML into the DOM using a swap strategy. + #[must_use] fn hx_delete(self, url: &str) -> Self { self.add_attr("hx-delete", url) } /// The hx-disable attribute will disable htmx processing for a given element and all its children. + #[must_use] fn hx_disable(self) -> Self { self.add_attr("hx-disable", "") } /// The hx-disabled-elt attribute allows you to specify elements that will have the disabled attribute added to them for the duration of the request. + #[must_use] fn hx_disabled_elt(self, element: Selector) -> Self { - self.add_attr("hx-disabled-elt", &element.to_string()) + self.add_attr("hx-disabled-elt", &element.to_value()) } /// The hx-disinherit attribute allows you to control automatic attribute inheritance. + #[must_use] fn hx_disinherit(self, attrs: &str) -> Self { self.add_attr("hx-disinherit", attrs) } /// The hx-encoding attribute allows you to switch the request encoding from the usual `application/x-www-form-urlencoded` encoding to `multipart/form-data`, usually to support file uploads in an ajax request. + #[must_use] fn hx_encoding(self) -> Self { self.add_attr("hx-encoding", "multipart/form-data") } /// The hx-headers attribute allows you to add to the headers that will be submitted with an AJAX request. + #[must_use] fn hx_headers(self, headers: HashMap<String, String>) -> Self { let json = serde_json::to_value(headers).unwrap(); let json_str = serde_json::to_string(&json).unwrap(); @@ -81,6 +94,7 @@ pub trait HTMXAttributes: AttrExtendable + std::marker::Sized { } /// The hx-vals attribute allows you to add to the parameters that will be submitted with an AJAX request. + #[must_use] fn hx_vals(self, vals: HashMap<String, String>) -> Self { let json = serde_json::to_value(vals).unwrap(); let json_str = serde_json::to_string(&json).unwrap(); @@ -90,25 +104,29 @@ pub trait HTMXAttributes: AttrExtendable + std::marker::Sized { /// Set the hx-history attribute to false on any element in the current document, or any html fragment loaded into the current document by htmx, to prevent sensitive data being saved to the localStorage cache when htmx takes a snapshot of the page state. /// /// History navigation will work as expected, but on restoration the URL will be requested from the server instead of the history cache. + #[must_use] fn hx_history(self) -> Self { self.add_attr("hx-history", "false") } /// The hx-history-elt attribute allows you to specify the element that will be used to snapshot and restore page state during navigation. By default, the body tag is used. This is typically good enough for most setups, but you may want to narrow it down to a child element. Just make sure that the element is always visible in your application, or htmx will not be able to restore history navigation properly. + #[must_use] fn hx_history_elt(self) -> Self { self.add_attr("hx-history-elt", "") } /// The hx-include attribute allows you to include additional element values in an AJAX request. + #[must_use] fn hx_include(self, element: Selector) -> Self { - self.add_attr("hx-include", &element.to_string()) + self.add_attr("hx-include", &element.to_value()) } /// The hx-indicator attribute allows you to specify the element that will have the htmx-request class added to it for the duration of the request. This can be used to show spinners or progress indicators while the request is in flight. /// /// Note: This attribute only supports CSS queries and `closest` match. + #[must_use] fn hx_indicator(self, indicator: Selector) -> Self { - self.add_attr("hx-indicator", &indicator.to_string()) + self.add_attr("hx-indicator", &indicator.to_value()) } /// The hx-params attribute allows you to filter the parameters that will be submitted with an AJAX request. @@ -118,16 +136,19 @@ pub trait HTMXAttributes: AttrExtendable + std::marker::Sized { /// `none` - Include no parameters /// `not <param-list>` - Include all except the comma separated list of parameter names /// `<param-list>` - Include all the comma separated list of parameter names + #[must_use] fn hx_params(self, params: &str) -> Self { self.add_attr("hx-params", params) } /// The hx-patch attribute will cause an element to issue a PATCH to the specified URL and swap the HTML into the DOM using a swap strategy. + #[must_use] fn hx_patch(self, url: &str) -> Self { self.add_attr("hx-patch", url) } /// The hx-put attribute will cause an element to issue a PUT to the specified URL and swap the HTML into the DOM using a swap strategy + #[must_use] fn hx_put(self, url: &str) -> Self { self.add_attr("hx-put", url) } @@ -137,43 +158,51 @@ pub trait HTMXAttributes: AttrExtendable + std::marker::Sized { /// The possible values of this attribute are: /// `true`, which replaces the fetched URL in the browser navigation bar. /// `false`, which disables replacing the fetched URL if it would otherwise be replaced due to inheritance. - /// A URL to be replaced into the location bar. This may be relative or absolute, as per history.replaceState(). + /// A URL to be replaced into the location bar. This may be relative or absolute, as per `history.replaceState()`. + #[must_use] fn hx_replace_url(self, value: &str) -> Self { self.add_attr("hx-replace-url", value) } /// The hx-validate attribute will cause an element to validate itself by way of the HTML5 Validation API before it submits a request. + #[must_use] fn hx_validate(self) -> Self { self.add_attr("hx-validte", "true") } /// The hx-preserve attribute allows you to keep an element unchanged during HTML replacement. Elements with hx-preserve set are preserved by id when htmx updates any ancestor element. You must set an unchanging id on elements for hx-preserve to work. The response requires an element with the same id, but its type and other attributes are ignored. + #[must_use] fn hx_preserve(self) -> Self { self.add_attr("hx-preserve", "") } /// The hx-prompt attribute allows you to show a prompt before issuing a request. The value of the prompt will be included in the request in the HX-Prompt header. + #[must_use] fn hx_prompt(self, msg: &str) -> Self { self.add_attr("hx-prompt", msg) } /// The hx-swap attribute allows you to specify how the response will be swapped in relative to the target of an AJAX request. + #[must_use] fn hx_swap<T: Into<ModifiedSwapStrategy>>(self, swap: T) -> Self { - self.add_attr("hx-swap", &swap.into().to_string()) + self.add_attr("hx-swap", &swap.into().to_value()) } /// The hx-swap-oob attribute allows you to specify that some content in a response should be swapped into the DOM somewhere other than the target, that is “Out of Band”. This allows you to piggy back updates to other element updates on a response. + #[must_use] fn hx_swap_oob(self) -> Self { self.add_attr("hx-swap-oob", "true") } /// The hx-target attribute allows you to target a different element for swapping than the one issuing the AJAX request. + #[must_use] fn hx_target(self, element: Selector) -> Self { - self.add_attr("hx-target", &element.to_string()) + self.add_attr("hx-target", &element.to_value()) } /// The hx-trigger attribute allows you to specify what triggers an AJAX request. + #[must_use] fn hx_trigger<T: Into<Trigger>>(self, trigger: T) -> Self { - self.add_attr("hx-trigger", &trigger.into().to_string()) + self.add_attr("hx-trigger", &trigger.into().to_value()) } } diff --git a/src/ui/htmx/selector.rs b/src/ui/htmx/selector.rs index 255c78c..cc27e40 100644 --- a/src/ui/htmx/selector.rs +++ b/src/ui/htmx/selector.rs @@ -18,16 +18,17 @@ pub enum Selector { } impl Selector { - pub fn to_string(&self) -> String { + #[must_use] + pub fn to_value(&self) -> String { match self { - Selector::Query(query) => query.clone(), - Selector::This => "this".to_owned(), - Selector::Closest(css) => format!("closest {css}"), - Selector::Find(css) => format!("find {css}"), - Selector::Next => "next".to_owned(), - Selector::NextQuery(css) => format!("next {css}"), - Selector::Previous => "previous".to_owned(), - Selector::PreviousQuery(css) => format!("previous {css}"), + Self::Query(query) => query.clone(), + Self::This => "this".to_owned(), + Self::Closest(css) => format!("closest {css}"), + Self::Find(css) => format!("find {css}"), + Self::Next => "next".to_owned(), + Self::NextQuery(css) => format!("next {css}"), + Self::Previous => "previous".to_owned(), + Self::PreviousQuery(css) => format!("previous {css}"), } } } diff --git a/src/ui/htmx/swap.rs b/src/ui/htmx/swap.rs index 01733a5..23c44ce 100644 --- a/src/ui/htmx/swap.rs +++ b/src/ui/htmx/swap.rs @@ -27,57 +27,65 @@ impl Default for SwapStrategy { } impl SwapStrategy { - pub fn to_string(&self) -> &str { + #[must_use] + pub const fn to_value(&self) -> &str { match self { - SwapStrategy::innerHTML => "innerHTML", - SwapStrategy::outerHTML => "outerHTML", - SwapStrategy::textContent => "textContent", - SwapStrategy::beforebegin => "beforebegin", - SwapStrategy::afterbegin => "afterbegin", - SwapStrategy::beforeend => "beforeend", - SwapStrategy::afterend => "afterend", - SwapStrategy::delete => "delete", - SwapStrategy::none => "none", + Self::innerHTML => "innerHTML", + Self::outerHTML => "outerHTML", + Self::textContent => "textContent", + Self::beforebegin => "beforebegin", + Self::afterbegin => "afterbegin", + Self::beforeend => "beforeend", + Self::afterend => "afterend", + Self::delete => "delete", + Self::none => "none", } } /// If you want to use the new View Transitions API when a swap occurs, you can use the transition:true option for your swap. + #[must_use] pub fn transition(self) -> ModifiedSwapStrategy { let modifier = "transition".to_owned(); ModifiedSwapStrategy::new(self, modifier) } /// You can modify the amount of time that htmx will wait after receiving a response to swap the content by including a swap modifier. + #[must_use] pub fn swap(self, duration: &str) -> ModifiedSwapStrategy { let modifier = format!("swap:{duration}"); ModifiedSwapStrategy::new(self, modifier) } /// You can modify the time between the swap and the settle logic by including a settle modifier. + #[must_use] pub fn settle(self, duration: &str) -> ModifiedSwapStrategy { let modifier = format!("settle:{duration}"); ModifiedSwapStrategy::new(self, modifier) } /// By default, htmx will update the title of the page if it finds a `<title>` tag in the response content. You can turn off this behavior. + #[must_use] pub fn ignore_title(self) -> ModifiedSwapStrategy { let modifier = "ignoreTitle:true"; ModifiedSwapStrategy::new(self, modifier.to_owned()) } /// htmx preserves focus between requests for inputs that have a defined id attribute. By default htmx prevents auto-scrolling to focused inputs between requests which can be unwanted behavior on longer requests when the user has already scrolled away. + #[must_use] pub fn focus_scroll(self, enable: bool) -> ModifiedSwapStrategy { let modifier = format!("focus-scroll:{enable}"); ModifiedSwapStrategy::new(self, modifier) } /// Ensure visibility + #[must_use] pub fn show(self, e: &str) -> ModifiedSwapStrategy { let modifier = format!("show:{e}"); ModifiedSwapStrategy::new(self, modifier) } /// Scroll to this location after load + #[must_use] pub fn scroll(self, e: &str) -> ModifiedSwapStrategy { let modifier = format!("scroll:{e}"); ModifiedSwapStrategy::new(self, modifier) @@ -97,7 +105,7 @@ impl From<SwapStrategy> for ModifiedSwapStrategy { impl ModifiedSwapStrategy { fn new(strategy: SwapStrategy, modifier: String) -> Self { - ModifiedSwapStrategy { + Self { strategy, modifiers: vec![modifier], } @@ -152,7 +160,7 @@ impl ModifiedSwapStrategy { self } - pub fn to_string(&self) -> String { - format!("{} {}", self.strategy.to_string(), self.modifiers.join(" ")) + pub fn to_value(&self) -> String { + format!("{} {}", self.strategy.to_value(), self.modifiers.join(" ")) } } diff --git a/src/ui/htmx/trigger.rs b/src/ui/htmx/trigger.rs index 66be30a..340765b 100644 --- a/src/ui/htmx/trigger.rs +++ b/src/ui/htmx/trigger.rs @@ -6,7 +6,8 @@ pub struct Event { } impl Event { - pub fn to_string(&self) -> String { + #[must_use] + pub fn to_value(&self) -> String { if self.kind.starts_with("every") { return self.kind.clone(); } @@ -15,7 +16,8 @@ impl Event { } /// Add a second event trigger - pub fn and(self, event: Event) -> Trigger { + #[must_use] + pub fn and(self, event: Self) -> Trigger { Trigger { triggers: vec![self, event], } @@ -24,32 +26,37 @@ impl Event { /// Periodically poll /// /// Value can be something like `1s [someConditional]` + #[must_use] pub fn poll(value: &str) -> Self { - Event { + Self { modifiers: vec![], kind: format!("every {value}"), } } /// Standard events refer to web API events (e.g. `click`, `keydown`, `mouseup`, `load`). + #[allow(clippy::self_named_constructors)] + #[must_use] pub fn event(event: &str) -> Self { - Event { + Self { modifiers: vec![], kind: event.to_string(), } } /// triggered on load (useful for lazy-loading something) + #[must_use] pub fn on_load() -> Self { - Event { + Self { modifiers: vec![], kind: "load".to_string(), } } /// triggered when an element is scrolled into the viewport (also useful for lazy-loading). If you are using overflow in css like `overflow-y: scroll` you should use `intersect once` instead of `revealed`. + #[must_use] pub fn on_revealed() -> Self { - Event { + Self { modifiers: vec![], kind: "revealed".to_string(), } @@ -58,67 +65,78 @@ impl Event { /// fires once when an element first intersects the viewport. This supports two additional options: /// `root:<selector>` - a CSS selector of the root element for intersection /// `threshold:<float>` - a floating point number between 0.0 and 1.0, indicating what amount of intersection to fire the event on + #[must_use] pub fn on_intersect(value: &str) -> Self { - Event { + Self { modifiers: vec![], kind: format!("intersect:{value}"), } } /// the event will only trigger once (e.g. the first click) + #[must_use] pub fn once(mut self) -> Self { self.modifiers.push("once".to_string()); self } /// the event will only change if the value of the element has changed. + #[must_use] pub fn changed(mut self) -> Self { self.modifiers.push("changed".to_string()); self } /// a delay will occur before an event triggers a request. If the event is seen again it will reset the delay. + #[must_use] pub fn delay(mut self, delay: &str) -> Self { self.modifiers.push(format!("delay:{delay}")); self } /// a throttle will occur after an event triggers a request. If the event is seen again before the delay completes, it is ignored, the element will trigger at the end of the delay. + #[must_use] pub fn throttle(mut self, delay: &str) -> Self { self.modifiers.push(format!("throttle:{delay}")); self } /// allows the event that triggers a request to come from another element in the document (e.g. listening to a key event on the body, to support hot keys) + #[must_use] pub fn from(mut self, element: Selector) -> Self { - self.modifiers.push(format!("from:{}", element.to_string())); + self.modifiers.push(format!("from:{}", element.to_value())); self } /// allows you to filter via a CSS selector on the target of the event. This can be useful when you want to listen for triggers from elements that might not be in the DOM at the point of initialization, by, for example, listening on the body, but with a target filter for a child element + #[must_use] pub fn target(mut self, selector: &str) -> Self { self.modifiers.push(format!("target:{selector}")); self } /// if this option is included the event will not trigger any other htmx requests on parents (or on elements listening on parents) + #[must_use] pub fn consume(mut self) -> Self { self.modifiers.push("consume".to_string()); self } /// determines how events are queued if an event occurs while a request for another event is in flight. + #[must_use] pub fn queue(mut self, opt: QueueOption) -> Self { - self.modifiers.push(format!("queue:{}", opt.to_string())); + self.modifiers.push(format!("queue:{}", opt.to_value())); self } } #[allow(non_camel_case_types)] +#[derive(Default)] pub enum QueueOption { /// queue the first event first, /// queue the last event (default) + #[default] last, /// queue all events (issue a request for each event) all, @@ -127,53 +145,58 @@ pub enum QueueOption { } impl QueueOption { - pub fn to_string(&self) -> &str { + #[must_use] + pub const fn to_value(&self) -> &str { match self { - QueueOption::first => "first", - QueueOption::last => "last", - QueueOption::all => "all", - QueueOption::none => "none", + Self::first => "first", + Self::last => "last", + Self::all => "all", + Self::none => "none", } } } -impl Default for QueueOption { - fn default() -> Self { - QueueOption::last - } -} - pub struct Trigger { triggers: Vec<Event>, } impl From<Event> for Trigger { fn from(value: Event) -> Self { - Trigger { + Self { triggers: vec![value], } } } impl Trigger { - pub fn new() -> Self { + #[must_use] + pub const fn new() -> Self { Self { triggers: vec![] } } - pub fn add(mut self, event: Event) -> Self { + #[must_use] + pub fn push(mut self, event: Event) -> Self { self.triggers.push(event); self } + #[must_use] pub fn and(self, event: Event) -> Self { - self.add(event) + self.push(event) } - pub fn to_string(&self) -> String { + #[must_use] + pub fn to_value(&self) -> String { self.triggers .iter() - .map(|x| x.to_string()) + .map(Event::to_value) .collect::<Vec<_>>() .join(", ") } } + +impl Default for Trigger { + fn default() -> Self { + Self::new() + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 3957e7d..c088430 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -113,8 +113,10 @@ impl UIWidget for PreEscaped<String> { /// Trait for an element which can add new `attrs` pub trait AttrExtendable { + #[must_use] fn add_attr(self, key: &str, val: &str) -> Self; /// Set the `id` attribute of an element. + #[must_use] fn id(self, id: &str) -> Self; } diff --git a/src/ui/primitives/div.rs b/src/ui/primitives/div.rs index a21ccbb..986d55f 100644 --- a/src/ui/primitives/div.rs +++ b/src/ui/primitives/div.rs @@ -8,6 +8,7 @@ use crate::ui::{AttrExtendable, UIWidget, htmx::HTMXAttributes}; /// `<div>` element /// /// Useful for grouping values together +#[must_use] pub fn Div() -> DivWidget { DivWidget(Vec::new(), false, HashMap::new()) } @@ -16,7 +17,7 @@ pub struct DivWidget(Vec<Box<dyn UIWidget>>, bool, HashMap<String, String>); impl AttrExtendable for DivWidget { fn add_attr(mut self, key: &str, val: &str) -> Self { - self.2.insert(key.to_string(), val.to_string()); + self.2.insert(key.to_string(), val.replace('\'', "\\'")); self } @@ -27,7 +28,8 @@ impl AttrExtendable for DivWidget { impl DivWidget { /// Add an element to the `<div>` - pub fn add<T: UIWidget + 'static>(mut self, element: T) -> Self { + #[must_use] + pub fn push<T: UIWidget + 'static>(mut self, element: T) -> Self { self.0.push(Box::new(element)); self } @@ -39,9 +41,10 @@ impl DivWidget { /// ```ignore /// use based::ui::basic::*; /// - /// let div = Div().add_some(Some("hello"), |value| Text(value)); + /// let div = Div().push(Some("hello"), |value| Text(value)); /// ``` - pub fn add_some<T: UIWidget + 'static, X, U: Fn(&X) -> T>( + #[must_use] + pub fn push_some<T: UIWidget + 'static, X, U: Fn(&X) -> T>( mut self, option: Option<&X>, then: U, @@ -55,7 +58,8 @@ impl DivWidget { /// Extract the `<div>`s innerHTML /// /// This will render `<content>` instead of `<div> <content> </div>` - pub fn vanish(mut self) -> Self { + #[must_use] + pub const fn vanish(mut self) -> Self { self.1 = true; self } diff --git a/src/ui/primitives/flex.rs b/src/ui/primitives/flex.rs index 776e6bc..17c9030 100644 --- a/src/ui/primitives/flex.rs +++ b/src/ui/primitives/flex.rs @@ -20,17 +20,20 @@ impl Render for FlexWidget { } impl FlexWidget { + #[must_use] pub fn full_center(mut self) -> Self { self.1.push("items-center".to_owned()); self.1.push("justify-center".to_owned()); self } - pub fn group(mut self) -> Self { + #[must_use] + pub const fn group(mut self) -> Self { self.2 = true; self } + #[must_use] pub fn justify(mut self, value: Justify) -> Self { let class = match value { Justify::Center => "justify-center".to_owned(), @@ -41,11 +44,13 @@ impl FlexWidget { self } + #[must_use] pub fn items_center(mut self) -> Self { self.1.push("items-center".to_owned()); self } + #[must_use] pub fn gap(mut self, amount: u32) -> Self { self.1.push(format!("gap-{amount}")); self diff --git a/src/ui/primitives/image.rs b/src/ui/primitives/image.rs index 6d2c959..913c60e 100644 --- a/src/ui/primitives/image.rs +++ b/src/ui/primitives/image.rs @@ -2,6 +2,7 @@ use crate::ui::UIWidget; use maud::{Markup, Render, html}; #[allow(non_snake_case)] +#[must_use] pub fn Image(src: &str) -> ImageWidget { ImageWidget { src: src.to_owned(), @@ -21,8 +22,9 @@ impl Render for ImageWidget { } impl ImageWidget { + #[must_use] pub fn alt(mut self, alt: &str) -> Self { - self.alt = alt.to_owned(); + self.alt = alt.to_string(); self } } diff --git a/src/ui/primitives/link.rs b/src/ui/primitives/link.rs index 7cf9e3c..fbdf166 100644 --- a/src/ui/primitives/link.rs +++ b/src/ui/primitives/link.rs @@ -17,7 +17,7 @@ pub struct LinkWidget(Box<dyn UIWidget>, String, HashMap<String, String>); impl AttrExtendable for LinkWidget { fn add_attr(mut self, key: &str, val: &str) -> Self { - self.2.insert(key.to_string(), val.to_string()); + self.2.insert(key.to_string(), val.replace('\'', "\\'")); self } @@ -67,6 +67,7 @@ impl UIWidget for LinkWidget { impl LinkWidget { /// Enable HTMX link capabilities + #[must_use] pub fn use_htmx(self) -> Self { let url = self.1.clone(); self.hx_get(&url) diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index c47c985..68d7dcf 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -18,6 +18,7 @@ pub mod text; pub mod width; #[allow(non_snake_case)] +#[must_use] pub fn Nothing() -> PreEscaped<String> { html! {} } @@ -54,7 +55,8 @@ pub enum Size { } impl Size { - pub fn to_string(&self) -> &str { + #[must_use] + pub const fn to_value(&self) -> &str { match self { Self::None => "none", Self::Small => "sm", @@ -87,22 +89,23 @@ pub enum Side { } impl Side { - pub fn to_string(&self) -> &str { + #[must_use] + pub const fn to_value(&self) -> &str { match self { - Side::Start => "s", - Side::End => "e", - Side::Top => "t", - Side::Right => "r", - Side::Bottom => "b", - Side::Left => "l", - Side::StartStart => "ss", - Side::StartEnd => "se", - Side::EndEnd => "ee", - Side::EndStart => "es", - Side::TopLeft => "tl", - Side::TopRight => "tr", - Side::BottomRight => "br", - Side::BottomLeft => "bl", + Self::Start => "s", + Self::End => "e", + Self::Top => "t", + Self::Right => "r", + Self::Bottom => "b", + Self::Left => "l", + Self::StartStart => "ss", + Self::StartEnd => "se", + Self::EndEnd => "ee", + Self::EndStart => "es", + Self::TopLeft => "tl", + Self::TopRight => "tr", + Self::BottomRight => "br", + Self::BottomLeft => "bl", } } } diff --git a/src/ui/primitives/padding.rs b/src/ui/primitives/padding.rs index 2e58dd7..963aa40 100644 --- a/src/ui/primitives/padding.rs +++ b/src/ui/primitives/padding.rs @@ -23,17 +23,20 @@ pub struct PaddingWidget { } impl PaddingWidget { - pub fn right(mut self, right: u32) -> Self { + #[must_use] + pub const fn right(mut self, right: u32) -> Self { self.right = Some(right); self } - pub fn y(mut self, y: u32) -> Self { + #[must_use] + pub const fn y(mut self, y: u32) -> Self { self.y = Some(y); self } - pub fn x(mut self, x: u32) -> Self { + #[must_use] + pub const fn x(mut self, x: u32) -> Self { self.x = Some(x); self } diff --git a/src/ui/primitives/rounded.rs b/src/ui/primitives/rounded.rs index 102d898..f716814 100644 --- a/src/ui/primitives/rounded.rs +++ b/src/ui/primitives/rounded.rs @@ -11,12 +11,14 @@ pub fn Rounded<T: UIWidget + 'static>(inner: T) -> RoundedWidget { pub struct RoundedWidget(Box<dyn UIWidget>, Option<Size>, Option<Side>); impl RoundedWidget { - pub fn size(mut self, size: Size) -> Self { + #[must_use] + pub const fn size(mut self, size: Size) -> Self { self.1 = Some(size); self } - pub fn side(mut self, side: Side) -> Self { + #[must_use] + pub const fn side(mut self, side: Side) -> Self { self.2 = Some(side); self } @@ -36,12 +38,10 @@ impl UIWidget for RoundedWidget { fn base_class(&self) -> Vec<String> { if let Some(side) = &self.2 { if let Some(size) = &self.1 { - return vec![format!("rounded-{}-{}", side.to_string(), size.to_string())]; - } - } else { - if let Some(size) = &self.1 { - return vec![format!("rounded-{}", size.to_string())]; + return vec![format!("rounded-{}-{}", side.to_value(), size.to_value())]; } + } else if let Some(size) = &self.1 { + return vec![format!("rounded-{}", size.to_value())]; } vec!["rounded".to_owned()] } diff --git a/src/ui/primitives/shadow.rs b/src/ui/primitives/shadow.rs index 7036ef6..da6386d 100644 --- a/src/ui/primitives/shadow.rs +++ b/src/ui/primitives/shadow.rs @@ -4,36 +4,36 @@ use maud::{Markup, Render, html}; pub struct Shadow(Box<dyn UIWidget>, String); impl Shadow { - pub fn medium<T: UIWidget + 'static>(inner: T) -> Shadow { - Shadow(Box::new(inner), "md".to_owned()) + pub fn medium<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "md".to_owned()) } - pub fn small<T: UIWidget + 'static>(inner: T) -> Shadow { - Shadow(Box::new(inner), "sm".to_owned()) + pub fn small<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "sm".to_owned()) } - pub fn regular<T: UIWidget + 'static>(inner: T) -> Shadow { - Shadow(Box::new(inner), String::new()) + pub fn regular<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), String::new()) } - pub fn large<T: UIWidget + 'static>(inner: T) -> Shadow { - Shadow(Box::new(inner), "lg".to_owned()) + pub fn large<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "lg".to_owned()) } - pub fn none<T: UIWidget + 'static>(inner: T) -> Shadow { - Shadow(Box::new(inner), "none".to_owned()) + pub fn none<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "none".to_owned()) } - pub fn xl<T: UIWidget + 'static>(inner: T) -> Shadow { - Shadow(Box::new(inner), "xl".to_owned()) + pub fn xl<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "xl".to_owned()) } - pub fn _2xl<T: UIWidget + 'static>(inner: T) -> Shadow { - Shadow(Box::new(inner), "2xl".to_owned()) + pub fn _2xl<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "2xl".to_owned()) } - pub fn inner<T: UIWidget + 'static>(inner: T) -> Shadow { - Shadow(Box::new(inner), "inner".to_owned()) + pub fn inner<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "inner".to_owned()) } } diff --git a/src/ui/primitives/space.rs b/src/ui/primitives/space.rs index 8b4c68a..be01ac9 100644 --- a/src/ui/primitives/space.rs +++ b/src/ui/primitives/space.rs @@ -16,12 +16,14 @@ impl Render for SpaceBetweenWidget { } impl SpaceBetweenWidget { - pub fn x(mut self, x: ScreenValue) -> Self { + #[must_use] + pub const fn x(mut self, x: ScreenValue) -> Self { self.1 = Some(x); self } - pub fn y(mut self, y: ScreenValue) -> Self { + #[must_use] + pub const fn y(mut self, y: ScreenValue) -> Self { self.2 = Some(y); self } @@ -36,11 +38,11 @@ impl UIWidget for SpaceBetweenWidget { let mut ret = Vec::new(); if let Some(x) = &self.1 { - ret.push(format!("space-x-{}", x.to_string())); + ret.push(format!("space-x-{}", x.to_value())); } if let Some(y) = &self.2 { - ret.push(format!("space-y-{}", y.to_string())); + ret.push(format!("space-y-{}", y.to_value())); } ret @@ -108,44 +110,45 @@ pub enum ScreenValue { } impl ScreenValue { - pub fn to_string(&self) -> &str { + #[must_use] + pub const fn to_value(&self) -> &str { match self { - ScreenValue::_0 => "0", - ScreenValue::_0p5 => "0.5", - ScreenValue::_1 => "1", - ScreenValue::_1p5 => "1.5", - ScreenValue::_2 => "2", - ScreenValue::_2p5 => "2.5", - ScreenValue::_3 => "3", - ScreenValue::_3p5 => "3.5", - ScreenValue::_4 => "4", - ScreenValue::_5 => "5", - ScreenValue::_6 => "6", - ScreenValue::_7 => "7", - ScreenValue::_8 => "8", - ScreenValue::_9 => "9", - ScreenValue::_10 => "10", - ScreenValue::_11 => "11", - ScreenValue::_12 => "12", - ScreenValue::_14 => "14", - ScreenValue::_16 => "16", - ScreenValue::_20 => "20", - ScreenValue::_24 => "24", - ScreenValue::_28 => "28", - ScreenValue::_32 => "32", - ScreenValue::_36 => "36", - ScreenValue::_40 => "40", - ScreenValue::_44 => "44", - ScreenValue::_48 => "48", - ScreenValue::_52 => "52", - ScreenValue::_56 => "56", - ScreenValue::_60 => "60", - ScreenValue::_64 => "64", - ScreenValue::_72 => "72", - ScreenValue::_80 => "80", - ScreenValue::_90 => "90", - ScreenValue::px => "px", - ScreenValue::reverse => "reverse", + Self::_0 => "0", + Self::_0p5 => "0.5", + Self::_1 => "1", + Self::_1p5 => "1.5", + Self::_2 => "2", + Self::_2p5 => "2.5", + Self::_3 => "3", + Self::_3p5 => "3.5", + Self::_4 => "4", + Self::_5 => "5", + Self::_6 => "6", + Self::_7 => "7", + Self::_8 => "8", + Self::_9 => "9", + Self::_10 => "10", + Self::_11 => "11", + Self::_12 => "12", + Self::_14 => "14", + Self::_16 => "16", + Self::_20 => "20", + Self::_24 => "24", + Self::_28 => "28", + Self::_32 => "32", + Self::_36 => "36", + Self::_40 => "40", + Self::_44 => "44", + Self::_48 => "48", + Self::_52 => "52", + Self::_56 => "56", + Self::_60 => "60", + Self::_64 => "64", + Self::_72 => "72", + Self::_80 => "80", + Self::_90 => "90", + Self::px => "px", + Self::reverse => "reverse", } } } diff --git a/src/ui/primitives/text.rs b/src/ui/primitives/text.rs index 756736f..927822d 100644 --- a/src/ui/primitives/text.rs +++ b/src/ui/primitives/text.rs @@ -3,6 +3,7 @@ use maud::{Markup, Render, html}; #[allow(non_snake_case)] /// Text UI Widget +#[must_use] pub fn Text(txt: &str) -> TextWidget { TextWidget { inner: None, @@ -29,6 +30,7 @@ pub fn Paragraph<T: UIWidget + 'static>(inner: T) -> TextWidget { #[allow(non_snake_case)] /// `<span>` element +#[must_use] pub fn Span(txt: &str) -> TextWidget { TextWidget { inner: None, @@ -53,63 +55,72 @@ impl TextWidget { /// Turn `Text` semibold. /// /// Adds the class `font-semibold` + #[must_use] pub fn semibold(mut self) -> Self { - self.font = "font-semibold".to_owned(); + self.font = "font-semibold".to_string(); self } /// Turn `Text` bold. /// /// Adds the class `font-bold` + #[must_use] pub fn bold(mut self) -> Self { - self.font = "font-bold".to_owned(); + self.font = "font-bold".to_string(); self } /// Turn `Text` medium. /// /// Adds the class `font-medium` + #[must_use] pub fn medium(mut self) -> Self { - self.font = "font-medium".to_owned(); + self.font = "font-medium".to_string(); self } /// Turn `Text` size to 2XL. /// /// Adds the class `text-2xl` + #[must_use] pub fn _2xl(mut self) -> Self { - self.size = "text-2xl".to_owned(); + self.size = "text-2xl".to_string(); self } /// Turn `Text` size to xl. /// /// Adds the class `text-xl` + #[must_use] pub fn xl(mut self) -> Self { - self.size = "text-xl".to_owned(); + self.size = "text-xl".to_string(); self } /// Turn `Text` size to small. /// /// Adds the class `text-sm` + #[must_use] pub fn sm(mut self) -> Self { - self.size = "text-sm".to_owned(); + self.size = "text-sm".to_string(); self } - pub fn color<T: UIColor>(mut self, color: T) -> Self { + #[must_use] + pub fn color<T: UIColor>(mut self, color: &T) -> Self { self.color = format!("text-{}", color.color_class()); self } + #[must_use] pub fn black(mut self) -> Self { - self.color = "text-black".to_owned(); + self.color = "text-black".to_string(); self } + #[must_use] pub fn white(mut self) -> Self { - self.color = "text-white".to_owned(); + self.color = "text-white".to_string(); self } } @@ -148,15 +159,13 @@ impl UIWidget for TextWidget { p class=(format!("{} {}", class, self.base_class().join(" "))) { (inner) } } } + } else if self.span { + html! { + span class=(format!("{} {}", class, self.base_class().join(" "))) { (self.txt) } + } } else { - if self.span { - html! { - span class=(format!("{} {}", class, self.base_class().join(" "))) { (self.txt) } - } - } else { - html! { - p class=(format!("{} {}", class, self.base_class().join(" "))) { (self.txt) } - } + html! { + p class=(format!("{} {}", class, self.base_class().join(" "))) { (self.txt) } } } } diff --git a/src/ui/wrapper/hover.rs b/src/ui/wrapper/hover.rs index 3457ced..c491be8 100644 --- a/src/ui/wrapper/hover.rs +++ b/src/ui/wrapper/hover.rs @@ -10,7 +10,7 @@ pub fn Hover<T: UIWidget + 'static, I: UIWidget + 'static>(inherit: I, inner: T) pub struct HoverWrapper(Box<dyn UIWidget>, Box<dyn UIWidget>); impl HoverWrapper { - pub fn hovered_class(&self) -> String { + fn hovered_class(&self) -> String { self.1 .extended_class() .into_iter() From b1c6ab8b7dc3157080dacbc708f690ddb3c1519e Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Wed, 15 Jan 2025 19:20:46 +0100 Subject: [PATCH 05/45] add padding + margin --- examples/ui.rs | 2 +- src/ui/components/appbar.rs | 4 +- src/ui/mod.rs | 1 + src/ui/primitives/margin.rs | 150 +++++++++++++++++++++++++++++++++++ src/ui/primitives/mod.rs | 1 + src/ui/primitives/padding.rs | 93 +++++++++++++++++----- src/ui/wrapper/hover.rs | 1 - 7 files changed, 230 insertions(+), 22 deletions(-) create mode 100644 src/ui/primitives/margin.rs diff --git a/examples/ui.rs b/examples/ui.rs index f38c67f..8353b95 100644 --- a/examples/ui.rs +++ b/examples/ui.rs @@ -15,7 +15,7 @@ pub async fn index_page(ctx: RequestContext) -> StringResponse { h1 { "Hello World!" }; (Hover( - Padding(Text("").color(Gray::_400)).x(10), + Padding(Text("").color(&Gray::_400)).x(ScreenValue::_10), Link("/test", Text("Hello")).hx_get("/test").hx_get("/test").hx_trigger( Event::on_load().delay("2s") .and(Event::on_revealed()) diff --git a/src/ui/components/appbar.rs b/src/ui/components/appbar.rs index 6a9a47c..d949fa7 100644 --- a/src/ui/components/appbar.rs +++ b/src/ui/components/appbar.rs @@ -69,10 +69,10 @@ impl UIWidget for AppBarWidget { .justify(Justify::Between) .items_center(), ) - .x(6), + .x(ScreenValue::_6), ), ))) - .y(2) + .y(ScreenValue::_2) .render() } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index c088430..f3f527b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -26,6 +26,7 @@ pub mod prelude { pub use super::primitives::header::Header; pub use super::primitives::image::Image; pub use super::primitives::link::Link; + pub use super::primitives::margin::Margin; pub use super::primitives::padding::Padding; pub use super::primitives::rounded::Rounded; pub use super::primitives::script; diff --git a/src/ui/primitives/margin.rs b/src/ui/primitives/margin.rs new file mode 100644 index 0000000..c673aaf --- /dev/null +++ b/src/ui/primitives/margin.rs @@ -0,0 +1,150 @@ +use super::space::ScreenValue; +use crate::ui::UIWidget; +use maud::{Markup, Render, html}; + +#[allow(non_snake_case)] +pub fn Margin<T: UIWidget + 'static>(inner: T) -> Margin { + Margin { + inner: Box::new(inner), + all: None, + x: None, + y: None, + start: None, + end: None, + top: None, + right: None, + bottom: None, + left: None, + } +} + +pub struct Margin { + pub inner: Box<dyn UIWidget>, + pub all: Option<ScreenValue>, + pub x: Option<ScreenValue>, + pub y: Option<ScreenValue>, + pub start: Option<ScreenValue>, + pub end: Option<ScreenValue>, + pub top: Option<ScreenValue>, + pub right: Option<ScreenValue>, + pub bottom: Option<ScreenValue>, + pub left: Option<ScreenValue>, +} + +impl Margin { + #[must_use] + pub const fn all(mut self, all: ScreenValue) -> Self { + self.all = Some(all); + self + } + + #[must_use] + pub const fn top(mut self, top: ScreenValue) -> Self { + self.top = Some(top); + self + } + + #[must_use] + pub const fn right(mut self, right: ScreenValue) -> Self { + self.right = Some(right); + self + } + + #[must_use] + pub const fn bottom(mut self, bottom: ScreenValue) -> Self { + self.bottom = Some(bottom); + self + } + + #[must_use] + pub const fn left(mut self, left: ScreenValue) -> Self { + self.left = Some(left); + self + } + + #[must_use] + pub const fn y(mut self, y: ScreenValue) -> Self { + self.y = Some(y); + self + } + + #[must_use] + pub const fn x(mut self, x: ScreenValue) -> Self { + self.x = Some(x); + self + } +} + +impl Render for Margin { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for Margin { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let mut our_class = Vec::new(); + + if let Some(all) = &self.all { + our_class.push(format!("m-{}", all.to_value())); + } + + if let Some(x) = &self.x { + our_class.push(format!("mx-{}", x.to_value())); + } + + if let Some(y) = &self.y { + our_class.push(format!("my-{}", y.to_value())); + } + + if let Some(start) = &self.start { + our_class.push(format!("ms-{}", start.to_value())); + } + + if let Some(end) = &self.end { + our_class.push(format!("me-{}", end.to_value())); + } + + if let Some(top) = &self.top { + our_class.push(format!("mt-{}", top.to_value())); + } + + if let Some(right) = &self.right { + our_class.push(format!("mr-{}", right.to_value())); + } + + if let Some(bottom) = &self.bottom { + our_class.push(format!("mb-{}", bottom.to_value())); + } + + if let Some(left) = &self.left { + our_class.push(format!("ml-{}", left.to_value())); + } + + our_class + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.inner.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.inner.as_ref().can_inherit() { + self.inner + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.inner.as_ref()) + } + } + } + } +} diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index 68d7dcf..4dee1f8 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -9,6 +9,7 @@ pub mod header; pub mod image; pub mod input; pub mod link; +pub mod margin; pub mod padding; pub mod rounded; pub mod shadow; diff --git a/src/ui/primitives/padding.rs b/src/ui/primitives/padding.rs index 963aa40..e80233b 100644 --- a/src/ui/primitives/padding.rs +++ b/src/ui/primitives/padding.rs @@ -1,42 +1,75 @@ +use super::space::ScreenValue; use crate::ui::UIWidget; use maud::{Markup, Render, html}; -pub struct PaddingInfo { - pub right: Option<u32>, -} - #[allow(non_snake_case)] pub fn Padding<T: UIWidget + 'static>(inner: T) -> PaddingWidget { PaddingWidget { inner: Box::new(inner), - right: None, - y: None, + all: None, x: None, + y: None, + start: None, + end: None, + top: None, + right: None, + bottom: None, + left: None, } } pub struct PaddingWidget { pub inner: Box<dyn UIWidget>, - pub right: Option<u32>, - pub y: Option<u32>, - pub x: Option<u32>, + pub all: Option<ScreenValue>, + pub x: Option<ScreenValue>, + pub y: Option<ScreenValue>, + pub start: Option<ScreenValue>, + pub end: Option<ScreenValue>, + pub top: Option<ScreenValue>, + pub right: Option<ScreenValue>, + pub bottom: Option<ScreenValue>, + pub left: Option<ScreenValue>, } impl PaddingWidget { #[must_use] - pub const fn right(mut self, right: u32) -> Self { + pub const fn all(mut self, all: ScreenValue) -> Self { + self.all = Some(all); + self + } + + #[must_use] + pub const fn top(mut self, top: ScreenValue) -> Self { + self.top = Some(top); + self + } + + #[must_use] + pub const fn right(mut self, right: ScreenValue) -> Self { self.right = Some(right); self } #[must_use] - pub const fn y(mut self, y: u32) -> Self { + pub const fn bottom(mut self, bottom: ScreenValue) -> Self { + self.bottom = Some(bottom); + self + } + + #[must_use] + pub const fn left(mut self, left: ScreenValue) -> Self { + self.left = Some(left); + self + } + + #[must_use] + pub const fn y(mut self, y: ScreenValue) -> Self { self.y = Some(y); self } #[must_use] - pub const fn x(mut self, x: u32) -> Self { + pub const fn x(mut self, x: ScreenValue) -> Self { self.x = Some(x); self } @@ -56,16 +89,40 @@ impl UIWidget for PaddingWidget { fn base_class(&self) -> Vec<String> { let mut our_class = Vec::new(); - if let Some(r) = self.right { - our_class.push(format!("pr-{r}")); + if let Some(all) = &self.all { + our_class.push(format!("p-{}", all.to_value())); } - if let Some(y) = self.y { - our_class.push(format!("py-{y}")); + if let Some(x) = &self.x { + our_class.push(format!("px-{}", x.to_value())); } - if let Some(x) = self.x { - our_class.push(format!("px-{x}")); + if let Some(y) = &self.y { + our_class.push(format!("py-{}", y.to_value())); + } + + if let Some(start) = &self.start { + our_class.push(format!("ps-{}", start.to_value())); + } + + if let Some(end) = &self.end { + our_class.push(format!("pe-{}", end.to_value())); + } + + if let Some(top) = &self.top { + our_class.push(format!("pt-{}", top.to_value())); + } + + if let Some(right) = &self.right { + our_class.push(format!("pr-{}", right.to_value())); + } + + if let Some(bottom) = &self.bottom { + our_class.push(format!("pb-{}", bottom.to_value())); + } + + if let Some(left) = &self.left { + our_class.push(format!("pl-{}", left.to_value())); } our_class diff --git a/src/ui/wrapper/hover.rs b/src/ui/wrapper/hover.rs index c491be8..2b599a2 100644 --- a/src/ui/wrapper/hover.rs +++ b/src/ui/wrapper/hover.rs @@ -41,7 +41,6 @@ impl UIWidget for HoverWrapper { } fn render_with_class(&self, class: &str) -> Markup { - // TODO : Replace lol if self.0.as_ref().can_inherit() { self.0 .as_ref() From f7668c5c543a5ff31b22a4525862a4a29a507bd5 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Wed, 15 Jan 2025 21:31:12 +0100 Subject: [PATCH 06/45] update --- src/ui/mod.rs | 6 ++++++ src/ui/primitives/div.rs | 24 +++++++++++++++++++----- src/ui/primitives/link.rs | 2 +- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f3f527b..ce6b59a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -86,9 +86,15 @@ pub async fn render_page( pub trait UIWidget: Render { /// Indicating if the widget supports inheriting classes fn can_inherit(&self) -> bool; + /// Returning the base classes for this widget + /// + /// Base here means all classes defining the current widget fn base_class(&self) -> Vec<String>; + + /// Return own base classes and all classes below the tree fn extended_class(&self) -> Vec<String>; + /// Render the widget with additional classes fn render_with_class(&self, class: &str) -> Markup; } diff --git a/src/ui/primitives/div.rs b/src/ui/primitives/div.rs index 986d55f..f2341f7 100644 --- a/src/ui/primitives/div.rs +++ b/src/ui/primitives/div.rs @@ -71,6 +71,16 @@ impl Render for DivWidget { } } +impl DivWidget { + pub fn extended_class_(&self) -> Vec<String> { + let mut c = self.base_class(); + for e in &self.0 { + c.extend_from_slice(&e.extended_class()); + } + c + } +} + impl UIWidget for DivWidget { fn can_inherit(&self) -> bool { false @@ -81,11 +91,11 @@ impl UIWidget for DivWidget { } fn extended_class(&self) -> Vec<String> { - let mut c = self.base_class(); - for e in &self.0 { - c.extend_from_slice(&e.extended_class()); + if self.1 { + self.extended_class_() + } else { + vec![] } - c } fn render_with_class(&self, _: &str) -> Markup { @@ -104,7 +114,11 @@ impl UIWidget for DivWidget { .collect::<Vec<_>>() .join(" "); - PreEscaped(format!("<div {attrs}> {} </a>", inner.0)) + PreEscaped(format!( + "<div class='{}' {attrs}> {} </a>", + self.extended_class_().join(" "), + inner.0 + )) } } } diff --git a/src/ui/primitives/link.rs b/src/ui/primitives/link.rs index fbdf166..e175d2f 100644 --- a/src/ui/primitives/link.rs +++ b/src/ui/primitives/link.rs @@ -30,7 +30,7 @@ impl HTMXAttributes for LinkWidget {} impl Render for LinkWidget { fn render(&self) -> Markup { - self.render_with_class("") + self.render_with_class(&self.extended_class().join(" ")) } } From b0c6daf56e70dc41d7b52b9514470a3769dfb8a2 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Wed, 15 Jan 2025 21:42:36 +0100 Subject: [PATCH 07/45] update --- src/ui/wrapper/hover.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/ui/wrapper/hover.rs b/src/ui/wrapper/hover.rs index 2b599a2..870ddc4 100644 --- a/src/ui/wrapper/hover.rs +++ b/src/ui/wrapper/hover.rs @@ -10,14 +10,13 @@ pub fn Hover<T: UIWidget + 'static, I: UIWidget + 'static>(inherit: I, inner: T) pub struct HoverWrapper(Box<dyn UIWidget>, Box<dyn UIWidget>); impl HoverWrapper { - fn hovered_class(&self) -> String { + fn hovered_class(&self) -> Vec<String> { self.1 .extended_class() .into_iter() .filter(|x| !x.is_empty()) .map(|x| format!("hover:{x}")) .collect::<Vec<_>>() - .join(" ") } } @@ -33,21 +32,23 @@ impl UIWidget for HoverWrapper { } fn base_class(&self) -> Vec<String> { - vec![] + self.hovered_class() } fn extended_class(&self) -> Vec<String> { - self.base_class() + let mut ret = self.base_class(); + ret.extend_from_slice(&self.0.extended_class()); + ret } fn render_with_class(&self, class: &str) -> Markup { if self.0.as_ref().can_inherit() { self.0 .as_ref() - .render_with_class(&format!("{} {class}", self.hovered_class())) + .render_with_class(&format!("{} {class}", self.hovered_class().join(" "))) } else { html! { - div class=(format!("{} {class}", self.hovered_class())) { + div class=(format!("{} {class}", self.hovered_class().join(" "))) { (self.0.as_ref()) } } From a8a23db252dc3e8127b436d2022ed02a74a04667 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Wed, 15 Jan 2025 22:05:30 +0100 Subject: [PATCH 08/45] fix --- src/ui/primitives/div.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/primitives/div.rs b/src/ui/primitives/div.rs index f2341f7..d5e31d8 100644 --- a/src/ui/primitives/div.rs +++ b/src/ui/primitives/div.rs @@ -83,7 +83,7 @@ impl DivWidget { impl UIWidget for DivWidget { fn can_inherit(&self) -> bool { - false + self.1 } fn base_class(&self) -> Vec<String> { From 7b12788a92fe9a5481756913f753164ce13fece8 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Wed, 15 Jan 2025 22:27:52 +0100 Subject: [PATCH 09/45] update --- src/ui/primitives/text.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ui/primitives/text.rs b/src/ui/primitives/text.rs index 927822d..47003b1 100644 --- a/src/ui/primitives/text.rs +++ b/src/ui/primitives/text.rs @@ -79,6 +79,15 @@ impl TextWidget { self } + /// Turn `Text` size to 3XL. + /// + /// Adds the class `text-3xl` + #[must_use] + pub fn _3xl(mut self) -> Self { + self.size = "text-3xl".to_string(); + self + } + /// Turn `Text` size to 2XL. /// /// Adds the class `text-2xl` From 78e3d6b798eb32d85ab58dd8891457f7ab6f75aa Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Wed, 15 Jan 2025 22:42:53 +0100 Subject: [PATCH 10/45] implement h + w --- src/ui/components/appbar.rs | 4 +- src/ui/mod.rs | 3 +- src/ui/primitives/height.rs | 47 +++++++++++++++++++++++ src/ui/primitives/mod.rs | 1 + src/ui/primitives/sized.rs | 15 ++++++-- src/ui/primitives/space.rs | 74 +++++++++++++++++++++++++++++++++++++ src/ui/primitives/width.rs | 14 ++++--- 7 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 src/ui/primitives/height.rs diff --git a/src/ui/components/appbar.rs b/src/ui/components/appbar.rs index d949fa7..50fbbb8 100644 --- a/src/ui/components/appbar.rs +++ b/src/ui/components/appbar.rs @@ -52,8 +52,8 @@ impl UIWidget for AppBarWidget { Div() .vanish() .push(Sized( - 10, - 10, + ScreenValue::_10, + ScreenValue::_10, Rounded(Image("/favicon").alt("Logo")) .size(Size::Medium), )) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ce6b59a..92b4da5 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -24,6 +24,7 @@ pub mod prelude { pub use super::primitives::div::Div; pub use super::primitives::flex::{Flex, Justify}; pub use super::primitives::header::Header; + pub use super::primitives::height::Height; pub use super::primitives::image::Image; pub use super::primitives::link::Link; pub use super::primitives::margin::Margin; @@ -34,7 +35,7 @@ pub mod prelude { pub use super::primitives::sized::Sized; pub use super::primitives::space::{ScreenValue, SpaceBetween}; pub use super::primitives::text::{Paragraph, Span, Text}; - pub use super::primitives::width::FitWidth; + pub use super::primitives::width::Width; pub use super::wrapper::Hover; } diff --git a/src/ui/primitives/height.rs b/src/ui/primitives/height.rs new file mode 100644 index 0000000..788575e --- /dev/null +++ b/src/ui/primitives/height.rs @@ -0,0 +1,47 @@ +use crate::ui::UIWidget; +use maud::{Markup, Render, html}; + +use super::space::ScreenValue; + +#[allow(non_snake_case)] +pub fn Width<T: UIWidget + 'static>(size: ScreenValue, inner: T) -> Height { + Height(Box::new(inner), size) +} + +pub struct Height(Box<dyn UIWidget>, ScreenValue); + +impl Render for Height { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for Height { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec![format!("h-{}", self.1.to_value())] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index 4dee1f8..dcd1a31 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -6,6 +6,7 @@ pub mod container; pub mod div; pub mod flex; pub mod header; +pub mod height; pub mod image; pub mod input; pub mod link; diff --git a/src/ui/primitives/sized.rs b/src/ui/primitives/sized.rs index eb390b5..6091cae 100644 --- a/src/ui/primitives/sized.rs +++ b/src/ui/primitives/sized.rs @@ -1,12 +1,18 @@ use crate::ui::UIWidget; use maud::{Markup, Render, html}; +use super::space::ScreenValue; + #[allow(non_snake_case)] -pub fn Sized<T: UIWidget + 'static>(height: u32, width: u32, inner: T) -> SizedWidget { +pub fn Sized<T: UIWidget + 'static>( + height: ScreenValue, + width: ScreenValue, + inner: T, +) -> SizedWidget { SizedWidget(Box::new(inner), height, width) } -pub struct SizedWidget(Box<dyn UIWidget>, u32, u32); +pub struct SizedWidget(Box<dyn UIWidget>, ScreenValue, ScreenValue); impl Render for SizedWidget { fn render(&self) -> Markup { @@ -20,7 +26,10 @@ impl UIWidget for SizedWidget { } fn base_class(&self) -> Vec<String> { - vec![format!("h-{}", self.1), format!("w-{}", self.2)] + vec![ + format!("h-{}", self.1.to_value()), + format!("w-{}", self.2.to_value()), + ] } fn extended_class(&self) -> Vec<String> { diff --git a/src/ui/primitives/space.rs b/src/ui/primitives/space.rs index be01ac9..c1ad83c 100644 --- a/src/ui/primitives/space.rs +++ b/src/ui/primitives/space.rs @@ -107,6 +107,12 @@ pub enum ScreenValue { _90, px, reverse, + min, + max, + fit, + screen, + full, + auto, } impl ScreenValue { @@ -149,6 +155,74 @@ impl ScreenValue { Self::_90 => "90", Self::px => "px", Self::reverse => "reverse", + Self::min => "min", + Self::max => "max", + Self::fit => "fit", + Self::screen => "screen", + Self::full => "full", + Self::auto => "auto", + } + } +} + +pub enum Fraction { + _1on2, + _1on3, + _2on3, + _1on4, + _2on4, + _3on4, + _1on5, + _2on5, + _3on5, + _4on5, + _1on6, + _2on6, + _3on6, + _4on6, + _5on6, + _1on12, + _2on12, + _3on12, + _4on12, + _5on12, + _6on12, + _7on12, + _8on12, + _9on12, + _10on12, + _11on12, +} + +impl Fraction { + pub const fn to_value(&self) -> &str { + match self { + Fraction::_1on2 => "1/2", + Fraction::_1on3 => "1/3", + Fraction::_2on3 => "2/3", + Fraction::_1on4 => "1/4", + Fraction::_2on4 => "2/4", + Fraction::_3on4 => "3/4", + Fraction::_1on5 => "1/5", + Fraction::_2on5 => "2/5", + Fraction::_3on5 => "3/5", + Fraction::_4on5 => "4/5", + Fraction::_1on6 => "1/6", + Fraction::_2on6 => "2/6", + Fraction::_3on6 => "3/6", + Fraction::_4on6 => "4/6", + Fraction::_5on6 => "5/6", + Fraction::_1on12 => "1/12", + Fraction::_2on12 => "2/12", + Fraction::_3on12 => "3/12", + Fraction::_4on12 => "4/12", + Fraction::_5on12 => "5/12", + Fraction::_6on12 => "6/12", + Fraction::_7on12 => "7/12", + Fraction::_8on12 => "8/12", + Fraction::_9on12 => "9/12", + Fraction::_10on12 => "10/12", + Fraction::_11on12 => "11/12", } } } diff --git a/src/ui/primitives/width.rs b/src/ui/primitives/width.rs index b66d6b1..90416d5 100644 --- a/src/ui/primitives/width.rs +++ b/src/ui/primitives/width.rs @@ -1,26 +1,28 @@ use crate::ui::UIWidget; use maud::{Markup, Render, html}; +use super::space::ScreenValue; + #[allow(non_snake_case)] -pub fn FitWidth<T: UIWidget + 'static>(inner: T) -> FitWidthWidget { - FitWidthWidget(Box::new(inner)) +pub fn Width<T: UIWidget + 'static>(size: ScreenValue, inner: T) -> WidthWidget { + WidthWidget(Box::new(inner), size) } -pub struct FitWidthWidget(Box<dyn UIWidget>); +pub struct WidthWidget(Box<dyn UIWidget>, ScreenValue); -impl Render for FitWidthWidget { +impl Render for WidthWidget { fn render(&self) -> Markup { self.render_with_class("") } } -impl UIWidget for FitWidthWidget { +impl UIWidget for WidthWidget { fn can_inherit(&self) -> bool { true } fn base_class(&self) -> Vec<String> { - vec!["max-w-fit".to_string()] + vec![format!("w-{}", self.1.to_value())] } fn extended_class(&self) -> Vec<String> { From f3a85de02e21136d75787525f832948e897e3c33 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Wed, 15 Jan 2025 22:52:38 +0100 Subject: [PATCH 11/45] add context --- src/ui/mod.rs | 1 + src/ui/primitives/mod.rs | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 92b4da5..b769b24 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -16,6 +16,7 @@ pub mod components; pub mod prelude { pub use super::color::*; pub use super::primitives::Nothing; + pub use super::primitives::Context; pub use super::primitives::Side; pub use super::primitives::Size; pub use super::primitives::aspect::Aspect; diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index dcd1a31..2595030 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -1,5 +1,7 @@ use maud::{PreEscaped, html}; +use super::UIWidget; + pub mod aspect; pub mod background; pub mod container; @@ -25,6 +27,16 @@ pub fn Nothing() -> PreEscaped<String> { html! {} } +#[allow(non_snake_case)] +#[must_use] +/// Create a new inheritance context +/// +/// This acts as a hard barrier for inheritance. +/// This allows you to embed Widgets without them interacting with the rest of the tree. +pub fn Context<T: UIWidget>(inner: T) -> PreEscaped<String> { + html! { (inner) } +} + /// Generates a `<script>` element containing the provided JavaScript code. /// /// This function wraps the provided JavaScript code in a `<script>` tag, From 86f61ff3f64856ddd2d6e558b4e95e73f4bf67ff Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Thu, 16 Jan 2025 18:22:52 +0100 Subject: [PATCH 12/45] update --- examples/ui.rs | 14 +- src/htmx.rs | 11 +- src/ui/mod.rs | 7 +- src/ui/primitives/cursor.rs | 124 +++++++++++++ src/ui/primitives/flex.rs | 319 +++++++++++++++++++++++++++++++- src/ui/primitives/height.rs | 30 ++- src/ui/primitives/link.rs | 1 + src/ui/primitives/mod.rs | 5 +- src/ui/primitives/shadow.rs | 76 +++++++- src/ui/primitives/space.rs | 2 + src/ui/primitives/text.rs | 190 ++++++++++++++++++- src/ui/primitives/visibility.rs | 54 ++++++ src/ui/primitives/width.rs | 24 ++- src/ui/primitives/zindex.rs | 82 ++++++++ src/ui/wrapper/hover.rs | 20 +- 15 files changed, 918 insertions(+), 41 deletions(-) create mode 100644 src/ui/primitives/cursor.rs create mode 100644 src/ui/primitives/visibility.rs create mode 100644 src/ui/primitives/zindex.rs diff --git a/examples/ui.rs b/examples/ui.rs index 8353b95..e52b8db 100644 --- a/examples/ui.rs +++ b/examples/ui.rs @@ -15,12 +15,16 @@ pub async fn index_page(ctx: RequestContext) -> StringResponse { h1 { "Hello World!" }; (Hover( - Padding(Text("").color(&Gray::_400)).x(ScreenValue::_10), - Link("/test", Text("Hello")).hx_get("/test").hx_get("/test").hx_trigger( - Event::on_load().delay("2s") - .and(Event::on_revealed()) + Cursor::NorthEastResize.on( + Padding(Text("").color(&Gray::_400)).x(ScreenValue::_10) ) - )) + ).on( + Link("/test", Text("Hello")).hx_get("/test").hx_get("/test").hx_trigger( + Event::on_load().delay("2s") + .and(Event::on_revealed()) + ) + ) + ) (content) diff --git a/src/htmx.rs b/src/htmx.rs index 6271666..950e3ca 100644 --- a/src/htmx.rs +++ b/src/htmx.rs @@ -1,8 +1,11 @@ +use crate::request::assets::DataResponse; use rocket::get; -use crate::request::{StringResponse, respond_script}; - #[get("/assets/htmx.min.js")] -pub fn htmx_script_route() -> StringResponse { - respond_script(include_str!("htmx.min.js").to_string()) +pub fn htmx_script_route() -> DataResponse { + DataResponse::new( + include_str!("htmx.min.js").as_bytes().to_vec(), + "application/javascript".to_string(), + Some(60 * 60 * 24 * 3), + ) } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b769b24..0afe03a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -15,17 +15,18 @@ pub mod components; // Preludes pub mod prelude { pub use super::color::*; - pub use super::primitives::Nothing; pub use super::primitives::Context; + pub use super::primitives::Nothing; pub use super::primitives::Side; pub use super::primitives::Size; pub use super::primitives::aspect::Aspect; pub use super::primitives::background::Background; pub use super::primitives::container::Container; + pub use super::primitives::cursor::Cursor; pub use super::primitives::div::Div; pub use super::primitives::flex::{Flex, Justify}; pub use super::primitives::header::Header; - pub use super::primitives::height::Height; + pub use super::primitives::height::HeightWidget; pub use super::primitives::image::Image; pub use super::primitives::link::Link; pub use super::primitives::margin::Margin; @@ -36,7 +37,9 @@ pub mod prelude { pub use super::primitives::sized::Sized; pub use super::primitives::space::{ScreenValue, SpaceBetween}; pub use super::primitives::text::{Paragraph, Span, Text}; + pub use super::primitives::visibility::Visibility; pub use super::primitives::width::Width; + pub use super::primitives::zindex::ZIndex; pub use super::wrapper::Hover; } diff --git a/src/ui/primitives/cursor.rs b/src/ui/primitives/cursor.rs new file mode 100644 index 0000000..1ab521e --- /dev/null +++ b/src/ui/primitives/cursor.rs @@ -0,0 +1,124 @@ +use crate::ui::UIWidget; +use maud::{Markup, Render, html}; + +pub enum Cursor { + Auto, + Default, + Pointer, + Wait, + Text, + Move, + Help, + NotAllowed, + None, + ContextMenu, + Progress, + Cell, + Crosshair, + VerticalText, + Alias, + Copy, + NoDrop, + Grab, + Grabbing, + AllScroll, + ColResize, + RowResize, + NorthResize, + EastResize, + SouthResize, + WestResize, + NorthEastResize, + NorthWestResize, + SouthEastResize, + SouthWestResize, + EastWestResize, + NorthSouthResize, + NorthEastSouthWestResize, + NorthWestSouthEastResize, + ZoomIn, + ZoomOut, +} + +impl Cursor { + pub fn on<T: UIWidget + 'static>(self, inner: T) -> CursorWidget { + CursorWidget(self, Box::new(inner)) + } +} + +pub struct CursorWidget(Cursor, Box<dyn UIWidget>); + +impl Render for CursorWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for CursorWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let class = match self.0 { + Cursor::Auto => "cursor-auto", + Cursor::Default => "cursor-default", + Cursor::Pointer => "cursor-pointer", + Cursor::Wait => "cursor-wait", + Cursor::Text => "cursor-text", + Cursor::Move => "cursor-move", + Cursor::Help => "cursor-help", + Cursor::NotAllowed => "cursor-not-allowed", + Cursor::None => "cursor-none", + Cursor::ContextMenu => "cursor-context-menu", + Cursor::Progress => "cursor-progress", + Cursor::Cell => "cursor-cell", + Cursor::Crosshair => "cursor-crosshair", + Cursor::VerticalText => "cursor-vertical-text", + Cursor::Alias => "cursor-alias", + Cursor::Copy => "cursor-copy", + Cursor::NoDrop => "cursor-no-drop", + Cursor::Grab => "cursor-grab", + Cursor::Grabbing => "cursor-grabbing", + Cursor::AllScroll => "cursor-all-scroll", + Cursor::ColResize => "cursor-col-resize", + Cursor::RowResize => "cursor-row-resize", + Cursor::NorthResize => "cursor-n-resize", + Cursor::EastResize => "cursor-e-resize", + Cursor::SouthResize => "cursor-s-resize", + Cursor::WestResize => "cursor-w-resize", + Cursor::NorthEastResize => "cursor-ne-resize", + Cursor::NorthWestResize => "cursor-nw-resize", + Cursor::SouthEastResize => "cursor-se-resize", + Cursor::SouthWestResize => "cursor-sw-resize", + Cursor::EastWestResize => "cursor-ew-resize", + Cursor::NorthSouthResize => "cursor-ns-resize", + Cursor::NorthEastSouthWestResize => "cursor-nesw-resize", + Cursor::NorthWestSouthEastResize => "cursor-nwse-resize", + Cursor::ZoomIn => "cursor-zoom-in", + Cursor::ZoomOut => "cursor-zoom-out", + }; + + vec![class.to_string()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.1.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + let inner = &self.1; + + if inner.can_inherit() { + inner.render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (inner) + } + } + } + } +} diff --git a/src/ui/primitives/flex.rs b/src/ui/primitives/flex.rs index 17c9030..b662362 100644 --- a/src/ui/primitives/flex.rs +++ b/src/ui/primitives/flex.rs @@ -1,14 +1,22 @@ use crate::ui::UIWidget; use maud::{Markup, Render, html}; +use super::space::{Fraction, ScreenValue}; + #[allow(non_snake_case)] pub fn Flex<T: UIWidget + 'static>(inner: T) -> FlexWidget { FlexWidget(Box::new(inner), vec![], false) } pub enum Justify { + Normal, + Start, + End, Center, Between, + Around, + Evenly, + Stretch, } pub struct FlexWidget(Box<dyn UIWidget>, Vec<String>, bool); @@ -33,11 +41,29 @@ impl FlexWidget { self } + #[must_use] + pub fn direction(mut self, direction: Direction) -> Self { + self.1.push(format!("flex-{}", direction.to_value())); + self + } + + #[must_use] + pub fn wrap(mut self, wrap: Wrap) -> Self { + self.1.push(format!("flex-{}", wrap.to_value())); + self + } + #[must_use] pub fn justify(mut self, value: Justify) -> Self { let class = match value { - Justify::Center => "justify-center".to_owned(), - Justify::Between => "justify-between".to_owned(), + Justify::Center => "justify-center".to_string(), + Justify::Between => "justify-between".to_string(), + Justify::Normal => "justify-normal".to_string(), + Justify::Start => "justify-start".to_string(), + Justify::End => "justify-end".to_string(), + Justify::Around => "justify-around".to_string(), + Justify::Evenly => "justify-evenly".to_string(), + Justify::Stretch => "justify-stretch".to_string(), }; self.1.push(class); @@ -51,10 +77,56 @@ impl FlexWidget { } #[must_use] - pub fn gap(mut self, amount: u32) -> Self { - self.1.push(format!("gap-{amount}")); + pub fn gap(mut self, amount: ScreenValue) -> Self { + self.1.push(format!("gap-{}", amount.to_value())); self } + + #[must_use] + pub fn gap_x(mut self, amount: ScreenValue) -> Self { + self.1.push(format!("gap-x-{}", amount.to_value())); + self + } + + #[must_use] + pub fn gap_y(mut self, amount: ScreenValue) -> Self { + self.1.push(format!("gap-y-{}", amount.to_value())); + self + } +} + +pub enum Direction { + Row, + RowReverse, + Column, + ColumnReverse, +} + +impl Direction { + pub const fn to_value(&self) -> &str { + match self { + Direction::Row => "row", + Direction::RowReverse => "row-reverse", + Direction::Column => "col", + Direction::ColumnReverse => "col-reverse", + } + } +} + +pub enum Wrap { + Wrap, + Reverse, + NoWrap, +} + +impl Wrap { + pub const fn to_value(&self) -> &str { + match self { + Wrap::Wrap => "wrap", + Wrap::Reverse => "wrap-reverse", + Wrap::NoWrap => "nowrap", + } + } } impl UIWidget for FlexWidget { @@ -88,3 +160,242 @@ impl UIWidget for FlexWidget { } } } + +#[derive(Debug, Clone, Copy)] +pub enum Either<R, L> { + Right(R), + Left(L), +} + +impl<R, L> Either<R, L> { + pub fn map<X, Y, U>(self, lf: X, rf: Y) -> U + where + X: FnOnce(L) -> U, + Y: FnOnce(R) -> U, + { + match self { + Either::Right(r) => rf(r), + Either::Left(l) => lf(l), + } + } +} + +impl From<ScreenValue> for Either<ScreenValue, Fraction> { + fn from(value: ScreenValue) -> Self { + Self::Right(value) + } +} + +impl From<Fraction> for Either<ScreenValue, Fraction> { + fn from(value: Fraction) -> Self { + Self::Left(value) + } +} + +#[allow(non_snake_case)] +pub fn FlexBasis<T: UIWidget + 'static>( + inner: T, + value: Either<ScreenValue, Fraction>, +) -> FlexBasisWidget { + FlexBasisWidget(Box::new(inner), value) +} + +pub struct FlexBasisWidget(Box<dyn UIWidget>, Either<ScreenValue, Fraction>); + +impl Render for FlexBasisWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for FlexBasisWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + vec![format!( + "basis-{}", + self.1 + .clone() + .map(|x| x.to_value().to_string(), |x| x.to_value().to_string()) + )] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, _: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} + +#[allow(non_snake_case)] +pub fn FlexGrow<T: UIWidget + 'static>(strategy: Strategy, inner: T) -> FlexGrowWidget { + FlexGrowWidget(strategy, Box::new(inner)) +} + +pub struct FlexGrowWidget(Strategy, Box<dyn UIWidget>); + +impl Render for FlexGrowWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for FlexGrowWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + vec![self.0.to_value().to_string()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.1.extended_class()); + c + } + + fn render_with_class(&self, _: &str) -> Markup { + if self.1.as_ref().can_inherit() { + self.1 + .as_ref() + .render_with_class(&format!("{}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{}", self.base_class().join(" "))) { + (self.1.as_ref()) + } + } + } + } +} + +pub enum Strategy { + /// Allow a flex item to shrink but not grow, taking into account its initial size. + Initial, + /// Allow a flex item to grow and shrink as needed, ignoring its initial size. + Expand, + /// Allow a flex item to grow and shrink, taking into account its initial size. + Auto, + /// Prevent a flex item from growing or shrinking. + None, + /// Allow a flex item to grow to fill any available space. + Grow, + /// Prevent a flex item from growing. + NoGrow, + /// Allow a flex item to shrink if needed. + Shrink, + /// Prevent a flex item from shrinking. + NoShrink, +} + +impl Strategy { + pub fn to_value(&self) -> &str { + match self { + Self::Initial => "flex-initial", + Self::Expand => "flex-1", + Self::Auto => "flex-auto", + Self::None => "flex-none", + Self::Grow => "grow", + Self::NoGrow => "grow-0", + Self::Shrink => "shrink", + Self::NoShrink => "shrink-0", + } + } +} + +pub enum Order { + _1, + _2, + _3, + _4, + _5, + _6, + _7, + _8, + _9, + _10, + _11, + _12, + First, + Last, + None, +} + +impl Order { + pub fn on<T: UIWidget + 'static>(self, inner: T) -> OrderWidget { + OrderWidget(self, Box::new(inner)) + } +} + +pub struct OrderWidget(Order, Box<dyn UIWidget>); + +impl Render for OrderWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for OrderWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let class = match self.0 { + Order::_1 => "order-1", + Order::_2 => "order-2", + Order::_3 => "order-3", + Order::_4 => "order-4", + Order::_5 => "order-5", + Order::_6 => "order-6", + Order::_7 => "order-7", + Order::_8 => "order-8", + Order::_9 => "order-9", + Order::_10 => "order-10", + Order::_11 => "order-11", + Order::_12 => "order-12", + Order::First => "order-first", + Order::Last => "order-last", + Order::None => "order-none", + }; + + vec![class.to_string()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.1.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + let inner = &self.1; + + if inner.can_inherit() { + inner.render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (inner) + } + } + } + } +} diff --git a/src/ui/primitives/height.rs b/src/ui/primitives/height.rs index 788575e..b465d22 100644 --- a/src/ui/primitives/height.rs +++ b/src/ui/primitives/height.rs @@ -4,24 +4,44 @@ use maud::{Markup, Render, html}; use super::space::ScreenValue; #[allow(non_snake_case)] -pub fn Width<T: UIWidget + 'static>(size: ScreenValue, inner: T) -> Height { - Height(Box::new(inner), size) +pub fn Height<T: UIWidget + 'static>(size: ScreenValue, inner: T) -> HeightWidget { + HeightWidget(Box::new(inner), size, 0) } -pub struct Height(Box<dyn UIWidget>, ScreenValue); +#[allow(non_snake_case)] +pub fn MinHeight<T: UIWidget + 'static>(size: ScreenValue, inner: T) -> HeightWidget { + HeightWidget(Box::new(inner), size, 1) +} -impl Render for Height { +#[allow(non_snake_case)] +pub fn MaxHeight<T: UIWidget + 'static>(size: ScreenValue, inner: T) -> HeightWidget { + HeightWidget(Box::new(inner), size, 2) +} + +pub struct HeightWidget(Box<dyn UIWidget>, ScreenValue, u8); + +impl Render for HeightWidget { fn render(&self) -> Markup { self.render_with_class("") } } -impl UIWidget for Height { +impl UIWidget for HeightWidget { fn can_inherit(&self) -> bool { true } fn base_class(&self) -> Vec<String> { + match self.2 { + 1 => { + return vec![format!("min-h-{}", self.1.to_value())]; + } + 2 => { + return vec![format!("max-h-{}", self.1.to_value())]; + } + _ => {} + } + vec![format!("h-{}", self.1.to_value())] } diff --git a/src/ui/primitives/link.rs b/src/ui/primitives/link.rs index e175d2f..3b89130 100644 --- a/src/ui/primitives/link.rs +++ b/src/ui/primitives/link.rs @@ -69,6 +69,7 @@ impl LinkWidget { /// Enable HTMX link capabilities #[must_use] pub fn use_htmx(self) -> Self { + // todo : investigate htmx attrs let url = self.1.clone(); self.hx_get(&url) .hx_target(Selector::Query("#main_content".to_string())) diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index 2595030..9ffee18 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -5,6 +5,7 @@ use super::UIWidget; pub mod aspect; pub mod background; pub mod container; +pub mod cursor; pub mod div; pub mod flex; pub mod header; @@ -19,7 +20,9 @@ pub mod shadow; pub mod sized; pub mod space; pub mod text; +pub mod visibility; pub mod width; +pub mod zindex; #[allow(non_snake_case)] #[must_use] @@ -30,7 +33,7 @@ pub fn Nothing() -> PreEscaped<String> { #[allow(non_snake_case)] #[must_use] /// Create a new inheritance context -/// +/// /// This acts as a hard barrier for inheritance. /// This allows you to embed Widgets without them interacting with the rest of the tree. pub fn Context<T: UIWidget>(inner: T) -> PreEscaped<String> { diff --git a/src/ui/primitives/shadow.rs b/src/ui/primitives/shadow.rs index da6386d..92f2323 100644 --- a/src/ui/primitives/shadow.rs +++ b/src/ui/primitives/shadow.rs @@ -31,10 +31,6 @@ impl Shadow { pub fn _2xl<T: UIWidget + 'static>(inner: T) -> Self { Self(Box::new(inner), "2xl".to_owned()) } - - pub fn inner<T: UIWidget + 'static>(inner: T) -> Self { - Self(Box::new(inner), "inner".to_owned()) - } } impl Render for Shadow { @@ -76,3 +72,75 @@ impl UIWidget for Shadow { } } } + +pub struct DropShadow(Box<dyn UIWidget>, String); + +impl DropShadow { + pub fn small<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "sm".to_owned()) + } + + pub fn regular<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), String::new()) + } + + pub fn medium<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "md".to_owned()) + } + + pub fn large<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "lg".to_owned()) + } + + pub fn none<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "none".to_owned()) + } + + pub fn xl<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "xl".to_owned()) + } + + pub fn _2xl<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "2xl".to_owned()) + } +} + +impl Render for DropShadow { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for DropShadow { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + if self.1.is_empty() { + vec!["drop-shadow".to_string()] + } else { + vec![format!("drop-shadow-{}", self.1)] + } + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/primitives/space.rs b/src/ui/primitives/space.rs index c1ad83c..2888980 100644 --- a/src/ui/primitives/space.rs +++ b/src/ui/primitives/space.rs @@ -70,6 +70,7 @@ impl UIWidget for SpaceBetweenWidget { } #[allow(non_camel_case_types)] +#[derive(Debug, Clone, Copy)] pub enum ScreenValue { _0, _0p5, @@ -165,6 +166,7 @@ impl ScreenValue { } } +#[derive(Debug, Clone, Copy)] pub enum Fraction { _1on2, _1on3, diff --git a/src/ui/primitives/text.rs b/src/ui/primitives/text.rs index 47003b1..39ca05d 100644 --- a/src/ui/primitives/text.rs +++ b/src/ui/primitives/text.rs @@ -8,8 +8,10 @@ pub fn Text(txt: &str) -> TextWidget { TextWidget { inner: None, txt: txt.to_string(), + family: String::new(), font: String::new(), color: String::new(), + style: Vec::new(), size: String::new(), span: false, } @@ -21,8 +23,10 @@ pub fn Paragraph<T: UIWidget + 'static>(inner: T) -> TextWidget { TextWidget { inner: Some(Box::new(inner)), font: String::new(), + family: String::new(), color: String::new(), txt: String::new(), + style: Vec::new(), size: String::new(), span: false, } @@ -35,7 +39,9 @@ pub fn Span(txt: &str) -> TextWidget { TextWidget { inner: None, txt: txt.to_string(), + family: String::new(), font: String::new(), + style: Vec::new(), color: String::new(), size: String::new(), span: true, @@ -45,6 +51,8 @@ pub fn Span(txt: &str) -> TextWidget { pub struct TextWidget { inner: Option<Box<dyn UIWidget>>, txt: String, + family: String, + style: Vec<String>, font: String, color: String, size: String, @@ -52,6 +60,41 @@ pub struct TextWidget { } impl TextWidget { + // Weight + + #[must_use] + pub fn thin(mut self) -> Self { + self.font = "font-thin".to_string(); + self + } + + #[must_use] + pub fn extralight(mut self) -> Self { + self.font = "font-extralight".to_string(); + self + } + + #[must_use] + pub fn light(mut self) -> Self { + self.font = "font-light".to_string(); + self + } + + #[must_use] + pub fn normal(mut self) -> Self { + self.font = "font-normal".to_string(); + self + } + + /// Turn `Text` medium. + /// + /// Adds the class `font-medium` + #[must_use] + pub fn medium(mut self) -> Self { + self.font = "font-medium".to_string(); + self + } + /// Turn `Text` semibold. /// /// Adds the class `font-semibold` @@ -70,12 +113,83 @@ impl TextWidget { self } - /// Turn `Text` medium. - /// - /// Adds the class `font-medium` #[must_use] - pub fn medium(mut self) -> Self { - self.font = "font-medium".to_string(); + pub fn extrabold(mut self) -> Self { + self.font = "font-extrabold".to_string(); + self + } + + #[must_use] + pub fn weight_black(mut self) -> Self { + self.font = "font-black".to_string(); + self + } + + // Styles + + #[must_use] + pub fn italic(mut self, apply: bool) -> Self { + if apply { + self.style.push("italic".to_string()); + } else { + self.style.push("not-italic".to_string()) + } + self + } + + #[must_use] + pub fn number_style(mut self, s: NumberStyle) -> Self { + self.style.push(s.to_value().to_string()); + self + } + + // Sizes + + #[must_use] + pub fn _9xl(mut self) -> Self { + self.size = "text-9xl".to_string(); + self + } + + #[must_use] + pub fn _8xl(mut self) -> Self { + self.size = "text-8xl".to_string(); + self + } + + #[must_use] + pub fn _7xl(mut self) -> Self { + self.size = "text-7xl".to_string(); + self + } + + #[must_use] + pub fn _6xl(mut self) -> Self { + self.size = "text-6xl".to_string(); + self + } + + #[must_use] + pub fn _5xl(mut self) -> Self { + self.size = "text-5xl".to_string(); + self + } + + #[must_use] + pub fn _4xl(mut self) -> Self { + self.size = "text-4xl".to_string(); + self + } + + #[must_use] + pub fn large(mut self) -> Self { + self.size = "text-lg".to_string(); + self + } + + #[must_use] + pub fn base_size(mut self) -> Self { + self.size = "text-base".to_string(); self } @@ -115,6 +229,17 @@ impl TextWidget { self } + /// Turn `Text` size to x small. + /// + /// Adds the class `text-xs` + #[must_use] + pub fn xs(mut self) -> Self { + self.size = "text-xs".to_string(); + self + } + + // Text Color + #[must_use] pub fn color<T: UIColor>(mut self, color: &T) -> Self { self.color = format!("text-{}", color.color_class()); @@ -132,6 +257,26 @@ impl TextWidget { self.color = "text-white".to_string(); self } + + // Font Family + + #[must_use] + pub fn sans(mut self) -> Self { + self.family = "font-sans".to_string(); + self + } + + #[must_use] + pub fn serif(mut self) -> Self { + self.family = "font-serif".to_string(); + self + } + + #[must_use] + pub fn mono(mut self) -> Self { + self.family = "font-mono".to_string(); + self + } } impl Render for TextWidget { @@ -146,7 +291,12 @@ impl UIWidget for TextWidget { } fn base_class(&self) -> Vec<String> { - vec![self.color.clone(), self.font.clone(), self.size.clone()] + vec![ + self.color.clone(), + self.font.clone(), + self.size.clone(), + self.family.clone(), + ] } fn extended_class(&self) -> Vec<String> { @@ -179,3 +329,31 @@ impl UIWidget for TextWidget { } } } + +pub enum NumberStyle { + Normal, + Ordinal, + SlashedZero, + OldStyle, + Lining, + Proportional, + Tabular, + DiagonalFractions, + StackedFractions, +} + +impl NumberStyle { + pub const fn to_value(&self) -> &str { + match self { + NumberStyle::Normal => "normal-nums", + NumberStyle::Ordinal => "ordinal", + NumberStyle::SlashedZero => "slashed-zero", + NumberStyle::OldStyle => "oldstyle-nums", + NumberStyle::Lining => "lining-nums", + NumberStyle::Proportional => "proportional-nums", + NumberStyle::Tabular => "tabular-nums", + NumberStyle::DiagonalFractions => "diagonal-fractions", + NumberStyle::StackedFractions => "stacked-fractions", + } + } +} diff --git a/src/ui/primitives/visibility.rs b/src/ui/primitives/visibility.rs new file mode 100644 index 0000000..3c4d5cd --- /dev/null +++ b/src/ui/primitives/visibility.rs @@ -0,0 +1,54 @@ +use crate::ui::UIWidget; +use maud::{Markup, Render, html}; + +pub struct Visibility(Box<dyn UIWidget>, String); + +impl Visibility { + pub fn visible<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "visible".to_string()) + } + + pub fn hidden<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "invisible".to_string()) + } + + pub fn collapsed<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "collapse".to_string()) + } +} + +impl Render for Visibility { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for Visibility { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec![self.1.clone()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/primitives/width.rs b/src/ui/primitives/width.rs index 90416d5..807e0a0 100644 --- a/src/ui/primitives/width.rs +++ b/src/ui/primitives/width.rs @@ -5,10 +5,20 @@ use super::space::ScreenValue; #[allow(non_snake_case)] pub fn Width<T: UIWidget + 'static>(size: ScreenValue, inner: T) -> WidthWidget { - WidthWidget(Box::new(inner), size) + WidthWidget(Box::new(inner), size, 0) } -pub struct WidthWidget(Box<dyn UIWidget>, ScreenValue); +#[allow(non_snake_case)] +pub fn MinWidth<T: UIWidget + 'static>(size: ScreenValue, inner: T) -> WidthWidget { + WidthWidget(Box::new(inner), size, 1) +} + +#[allow(non_snake_case)] +pub fn MaxWidth<T: UIWidget + 'static>(size: ScreenValue, inner: T) -> WidthWidget { + WidthWidget(Box::new(inner), size, 2) +} + +pub struct WidthWidget(Box<dyn UIWidget>, ScreenValue, u8); impl Render for WidthWidget { fn render(&self) -> Markup { @@ -22,6 +32,16 @@ impl UIWidget for WidthWidget { } fn base_class(&self) -> Vec<String> { + match self.2 { + 1 => { + return vec![format!("min-w-{}", self.1.to_value())]; + } + 2 => { + return vec![format!("max-w-{}", self.1.to_value())]; + } + _ => {} + } + vec![format!("w-{}", self.1.to_value())] } diff --git a/src/ui/primitives/zindex.rs b/src/ui/primitives/zindex.rs new file mode 100644 index 0000000..2ddf0b4 --- /dev/null +++ b/src/ui/primitives/zindex.rs @@ -0,0 +1,82 @@ +use maud::{Markup, Render, html}; + +use crate::ui::UIWidget; + +pub struct ZIndex(Box<dyn UIWidget>, u8); + +impl Render for ZIndex { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl ZIndex { + pub fn auto<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), 0) + } + + pub fn zero<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), 1) + } + + pub fn one<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), 2) + } + + pub fn two<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), 3) + } + + pub fn three<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), 4) + } + + pub fn four<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), 5) + } + + pub fn five<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), 6) + } +} + +impl UIWidget for ZIndex { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let class = match self.1 { + 0 => "z-auto", + 1 => "z-0", + 2 => "z-10", + 3 => "z-20", + 4 => "z-30", + 5 => "z-40", + 6 => "z-50", + _ => "z-auto", + }; + + vec![class.to_string()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/wrapper/hover.rs b/src/ui/wrapper/hover.rs index 870ddc4..7996497 100644 --- a/src/ui/wrapper/hover.rs +++ b/src/ui/wrapper/hover.rs @@ -3,11 +3,11 @@ use maud::{Markup, Render, html}; use crate::ui::UIWidget; #[allow(non_snake_case)] -pub fn Hover<T: UIWidget + 'static, I: UIWidget + 'static>(inherit: I, inner: T) -> HoverWrapper { - HoverWrapper(Box::new(inner), Box::new(inherit)) +pub fn Hover<I: UIWidget + 'static>(inherit: I) -> HoverWrapper { + HoverWrapper(None, Box::new(inherit)) } -pub struct HoverWrapper(Box<dyn UIWidget>, Box<dyn UIWidget>); +pub struct HoverWrapper(Option<Box<dyn UIWidget>>, Box<dyn UIWidget>); impl HoverWrapper { fn hovered_class(&self) -> Vec<String> { @@ -18,6 +18,11 @@ impl HoverWrapper { .map(|x| format!("hover:{x}")) .collect::<Vec<_>>() } + + pub fn on<T: UIWidget + 'static>(mut self, inner: T) -> Self { + self.0 = Some(Box::new(inner)); + self + } } impl Render for HoverWrapper { @@ -36,20 +41,19 @@ impl UIWidget for HoverWrapper { } fn extended_class(&self) -> Vec<String> { - let mut ret = self.base_class(); - ret.extend_from_slice(&self.0.extended_class()); - ret + self.base_class() } fn render_with_class(&self, class: &str) -> Markup { - if self.0.as_ref().can_inherit() { + if self.0.as_ref().unwrap().can_inherit() { self.0 .as_ref() + .unwrap() .render_with_class(&format!("{} {class}", self.hovered_class().join(" "))) } else { html! { div class=(format!("{} {class}", self.hovered_class().join(" "))) { - (self.0.as_ref()) + (self.0.as_ref().unwrap()) } } } From bcb69805ef5c71c566bf3297d1c7fa769125847f Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Thu, 16 Jan 2025 20:24:01 +0100 Subject: [PATCH 13/45] add animation --- src/ui/mod.rs | 7 +- src/ui/primitives/animation.rs | 204 +++++++++++++++++++++++++++++++++ src/ui/primitives/mod.rs | 1 + src/ui/wrapper/hover.rs | 7 +- 4 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 src/ui/primitives/animation.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0afe03a..524c0e8 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -19,14 +19,15 @@ pub mod prelude { pub use super::primitives::Nothing; pub use super::primitives::Side; pub use super::primitives::Size; + pub use super::primitives::animation::{Animated, Animation, Delay, Duration, Scope, Timing}; pub use super::primitives::aspect::Aspect; pub use super::primitives::background::Background; pub use super::primitives::container::Container; pub use super::primitives::cursor::Cursor; pub use super::primitives::div::Div; - pub use super::primitives::flex::{Flex, Justify}; + pub use super::primitives::flex::{Flex, FlexBasis, FlexGrow, Justify}; pub use super::primitives::header::Header; - pub use super::primitives::height::HeightWidget; + pub use super::primitives::height::{Height, MaxHeight, MinHeight}; pub use super::primitives::image::Image; pub use super::primitives::link::Link; pub use super::primitives::margin::Margin; @@ -38,7 +39,7 @@ pub mod prelude { pub use super::primitives::space::{ScreenValue, SpaceBetween}; pub use super::primitives::text::{Paragraph, Span, Text}; pub use super::primitives::visibility::Visibility; - pub use super::primitives::width::Width; + pub use super::primitives::width::{MaxWidth, MinWidth, Width}; pub use super::primitives::zindex::ZIndex; pub use super::wrapper::Hover; } diff --git a/src/ui/primitives/animation.rs b/src/ui/primitives/animation.rs new file mode 100644 index 0000000..d95dd24 --- /dev/null +++ b/src/ui/primitives/animation.rs @@ -0,0 +1,204 @@ +use maud::{Markup, Render, html}; + +use crate::ui::UIWidget; + +#[allow(non_snake_case)] +pub fn Animated<T: UIWidget + 'static>(inner: T) -> AnimatedWidget { + AnimatedWidget { + inner: Box::new(inner), + scope: Scope::Normal, + timing: None, + delay: None, + duration: None, + animation: None, + } +} + +pub struct AnimatedWidget { + inner: Box<dyn UIWidget>, + scope: Scope, + timing: Option<Timing>, + delay: Option<Delay>, + duration: Option<Duration>, + animation: Option<Animation>, +} + +impl AnimatedWidget { + pub fn scope(mut self, scope: Scope) -> Self { + self.scope = scope; + self + } + + pub fn timing(mut self, timing: Timing) -> Self { + self.timing = Some(timing); + self + } + + pub fn delay(mut self, delay: Delay) -> Self { + self.delay = Some(delay); + self + } + + pub fn duration(mut self, duration: Duration) -> Self { + self.duration = Some(duration); + self + } + + pub fn animate(mut self, animation: Animation) -> Self { + self.animation = Some(animation); + self + } +} + +impl Render for AnimatedWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for AnimatedWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let mut ret = vec![self.scope.to_value().to_string()]; + + if let Some(timing) = &self.timing { + ret.push(timing.to_value().to_owned()); + } + + if let Some(delay) = &self.delay { + ret.push(delay.to_value()); + } + + if let Some(duration) = &self.duration { + ret.push(duration.to_value()); + } + + if let Some(anim) = &self.animation { + ret.push(anim.to_value().to_owned()); + } + + ret + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.inner.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.inner.as_ref().can_inherit() { + self.inner + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.inner.as_ref()) + } + } + } + } +} + +pub enum Scope { + None, + All, + Normal, + Colors, + Opacity, + Shadow, + Transform, +} + +impl Scope { + pub fn to_value(&self) -> &str { + match self { + Scope::None => "transition-none", + Scope::All => "transition-all", + Scope::Normal => "transition", + Scope::Colors => "transition-colors", + Scope::Opacity => "transition-opacity", + Scope::Shadow => "transition-shadow", + Scope::Transform => "transition-transform", + } + } +} + +macro_rules! num_opt { + ($name:ident, $id:literal) => { + pub enum $name { + Custom(String), + _0, + _75, + _100, + _150, + _200, + _300, + _500, + _700, + _1000, + } + + impl $name { + pub fn to_value(&self) -> String { + match self { + Self::Custom(s) => format!("{}-[{s}]", $id), + Self::_0 => concat!($id, "-0").to_string(), + Self::_75 => concat!($id, "-75").to_string(), + Self::_100 => concat!($id, "-100").to_string(), + Self::_150 => concat!($id, "-150").to_string(), + Self::_200 => concat!($id, "-200").to_string(), + Self::_300 => concat!($id, "-300").to_string(), + Self::_500 => concat!($id, "-500").to_string(), + Self::_700 => concat!($id, "-700").to_string(), + Self::_1000 => concat!($id, "-1000").to_string(), + } + } + } + }; +} + +num_opt!(Duration, "duration"); +num_opt!(Delay, "delay"); + +pub enum Timing { + EaseLinear, + EaseIn, + EaseOut, + EaseInOut, +} + +impl Timing { + pub const fn to_value(&self) -> &str { + match self { + Timing::EaseLinear => "ease-linear", + Timing::EaseIn => "ease-in", + Timing::EaseOut => "ease-out", + Timing::EaseInOut => "ease-in-out", + } + } +} + +pub enum Animation { + None, + Spin, + Ping, + Pulse, + Bounce, +} + +impl Animation { + pub fn to_value(&self) -> &str { + match self { + Animation::None => "animate-none", + Animation::Spin => "animate-spin", + Animation::Ping => "animate-ping", + Animation::Pulse => "animate-pulse", + Animation::Bounce => "animate-bounce", + } + } +} diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index 9ffee18..d99d291 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -2,6 +2,7 @@ use maud::{PreEscaped, html}; use super::UIWidget; +pub mod animation; pub mod aspect; pub mod background; pub mod container; diff --git a/src/ui/wrapper/hover.rs b/src/ui/wrapper/hover.rs index 7996497..b6d069f 100644 --- a/src/ui/wrapper/hover.rs +++ b/src/ui/wrapper/hover.rs @@ -41,7 +41,12 @@ impl UIWidget for HoverWrapper { } fn extended_class(&self) -> Vec<String> { - self.base_class() + let mut ret = self.base_class(); + if let Some(inner) = &self.0 { + ret.extend_from_slice(&inner.extended_class()); + } + + ret } fn render_with_class(&self, class: &str) -> Markup { From 18c51e88d1266e553fab98789b92773c61b14793 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Thu, 16 Jan 2025 20:46:22 +0100 Subject: [PATCH 14/45] fix --- src/ui/primitives/div.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ui/primitives/div.rs b/src/ui/primitives/div.rs index d5e31d8..b5efb17 100644 --- a/src/ui/primitives/div.rs +++ b/src/ui/primitives/div.rs @@ -83,7 +83,7 @@ impl DivWidget { impl UIWidget for DivWidget { fn can_inherit(&self) -> bool { - self.1 + !self.1 } fn base_class(&self) -> Vec<String> { @@ -98,7 +98,7 @@ impl UIWidget for DivWidget { } } - fn render_with_class(&self, _: &str) -> Markup { + fn render_with_class(&self, class: &str) -> Markup { let inner = html! { @for e in &self.0 { (e.as_ref()) @@ -115,7 +115,7 @@ impl UIWidget for DivWidget { .join(" "); PreEscaped(format!( - "<div class='{}' {attrs}> {} </a>", + "<div class='{} {class}' {attrs}> {} </div>", self.extended_class_().join(" "), inner.0 )) From cd3d4a5a6dffa80516909cc5d29be7e3c19e4bb9 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Thu, 16 Jan 2025 23:04:46 +0100 Subject: [PATCH 15/45] update --- src/ui/mod.rs | 4 +++- src/ui/primitives/link.rs | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 524c0e8..8ef0eae 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -25,7 +25,9 @@ pub mod prelude { pub use super::primitives::container::Container; pub use super::primitives::cursor::Cursor; pub use super::primitives::div::Div; - pub use super::primitives::flex::{Flex, FlexBasis, FlexGrow, Justify}; + pub use super::primitives::flex::{ + Direction, Flex, FlexBasis, FlexGrow, Justify, Order, Strategy, Wrap, + }; pub use super::primitives::header::Header; pub use super::primitives::height::{Height, MaxHeight, MinHeight}; pub use super::primitives::image::Image; diff --git a/src/ui/primitives/link.rs b/src/ui/primitives/link.rs index 3b89130..0cf42e8 100644 --- a/src/ui/primitives/link.rs +++ b/src/ui/primitives/link.rs @@ -75,5 +75,7 @@ impl LinkWidget { .hx_target(Selector::Query("#main_content".to_string())) .hx_push_url() .hx_swap(SwapStrategy::innerHTML) + .hx_boost() + .hx_disabled_elt(Selector::This) } } From 8887eb07c1a787f4ed962d3f9a1f5bee5815f8ec Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Thu, 16 Jan 2025 23:21:52 +0100 Subject: [PATCH 16/45] update --- Cargo.lock | 284 ++++++++++++++++----------------------- src/ui/primitives/div.rs | 30 +++++ 2 files changed, 149 insertions(+), 165 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6cda3c7..fa156dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,18 +17,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" -[[package]] -name = "ahash" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -83,9 +71,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.83" +version = "0.1.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +checksum = "3f934833b4b7233644e5848f235df3f57ed8c80f1528a26c3dfa13d2147fa056" dependencies = [ "proc-macro2", "quote", @@ -210,9 +198,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" dependencies = [ "serde", ] @@ -244,9 +232,9 @@ checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "bytemuck" -version = "1.20.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b37c88a63ffd85d15b406896cc343916d7cf57838a847b3a6f2ca5d39a5695a" +checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3" [[package]] name = "byteorder" @@ -262,9 +250,9 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cc" -version = "1.2.4" +version = "1.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf" +checksum = "c8293772165d9345bdaaa39b45b2109591e63fe5e6fbc23c6ff930a048aa310b" dependencies = [ "shlex", ] @@ -426,9 +414,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" +checksum = "0e60eed09d8c01d3cee5b7d30acb059b76614c918fa0f992e0dd6eeb10daad6f" [[package]] name = "der" @@ -476,7 +464,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.8.0", "proc-macro2", "proc-macro2-diagnostics", "quote", @@ -572,9 +560,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.3.1" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" dependencies = [ "concurrent-queue", "parking", @@ -618,6 +606,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" + [[package]] name = "foreign-types" version = "0.3.2" @@ -784,9 +778,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" [[package]] name = "h2" @@ -812,24 +806,25 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] [[package]] name = "hashbrown" version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hashlink" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.15.2", ] [[package]] @@ -1184,9 +1179,9 @@ checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "js-sys" -version = "0.3.76" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ "once_cell", "wasm-bindgen", @@ -1203,9 +1198,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.168" +version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" [[package]] name = "libm" @@ -1219,16 +1214,15 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ - "cc", "pkg-config", "vcpkg", ] [[package]] name = "linux-raw-sys" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "litemap" @@ -1248,9 +1242,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" [[package]] name = "loom" @@ -1320,17 +1314,11 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" +checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924" dependencies = [ "adler2", ] @@ -1382,16 +1370,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1467,9 +1445,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.5" +version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ "memchr", ] @@ -1486,7 +1464,7 @@ version = "0.10.68" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.8.0", "cfg-if", "foreign-types", "libc", @@ -1559,12 +1537,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pear" version = "0.2.9" @@ -1605,9 +1577,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project-lite" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -1682,9 +1654,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.92" +version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" dependencies = [ "unicode-ident", ] @@ -1704,9 +1676,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.37" +version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" dependencies = [ "proc-macro2", ] @@ -1767,7 +1739,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.8.0", ] [[package]] @@ -2016,11 +1988,11 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" -version = "0.38.42" +version = "0.38.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" +checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.8.0", "errno", "libc", "linux-raw-sys", @@ -2038,9 +2010,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" +checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" [[package]] name = "ryu" @@ -2084,7 +2056,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.8.0", "core-foundation", "core-foundation-sys", "libc", @@ -2093,9 +2065,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.12.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa39c7303dc58b5543c94d22c1766b0d31f2ee58306363ea622b10bbc075eaa2" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" dependencies = [ "core-foundation-sys", "libc", @@ -2103,18 +2075,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.216" +version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" +checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.216" +version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" +checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" dependencies = [ "proc-macro2", "quote", @@ -2123,9 +2095,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.133" +version = "1.0.135" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +checksum = "2b0d7ba2887406110130a978386c4e1befb98c674b4fba677954e4db976630d9" dependencies = [ "itoa", "memchr", @@ -2263,21 +2235,11 @@ dependencies = [ "der", ] -[[package]] -name = "sqlformat" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" -dependencies = [ - "nom", - "unicode_categories", -] - [[package]] name = "sqlx" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93334716a037193fac19df402f8571269c84a00852f6a7066b5d2616dcd64d3e" +checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f" dependencies = [ "sqlx-core", "sqlx-macros", @@ -2288,38 +2250,32 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d8060b456358185f7d50c55d9b5066ad956956fddec42ee2e8567134a8936e" +checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0" dependencies = [ - "atoi", - "byteorder", "bytes", "chrono", "crc", "crossbeam-queue", "either", "event-listener", - "futures-channel", "futures-core", "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.14.5", + "hashbrown 0.15.2", "hashlink", - "hex", "indexmap", "log", "memchr", "native-tls", "once_cell", - "paste", "percent-encoding", "serde", "serde_json", "sha2", "smallvec", - "sqlformat", "thiserror", "tokio", "tokio-stream", @@ -2330,9 +2286,9 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cac0692bcc9de3b073e8d747391827297e075c7710ff6276d9f7a1f3d58c6657" +checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310" dependencies = [ "proc-macro2", "quote", @@ -2343,9 +2299,9 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1804e8a7c7865599c9c79be146dc8a9fd8cc86935fa641d3ea58e5f0688abaa5" +checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad" dependencies = [ "dotenvy", "either", @@ -2369,13 +2325,13 @@ dependencies = [ [[package]] name = "sqlx-mysql" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64bb4714269afa44aef2755150a0fc19d756fb580a67db8885608cf02f47d06a" +checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.6.0", + "bitflags 2.8.0", "byteorder", "bytes", "chrono", @@ -2413,13 +2369,13 @@ dependencies = [ [[package]] name = "sqlx-postgres" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fa91a732d854c5d7726349bb4bb879bb9478993ceb764247660aee25f67c2f8" +checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.6.0", + "bitflags 2.8.0", "byteorder", "chrono", "crc", @@ -2427,7 +2383,6 @@ dependencies = [ "etcetera", "futures-channel", "futures-core", - "futures-io", "futures-util", "hex", "hkdf", @@ -2453,9 +2408,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5b2cf34a45953bfd3daaf3db0f7a7878ab9b7a6b91b422d24a7a9e4c857b680" +checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540" dependencies = [ "atoi", "chrono", @@ -2519,9 +2474,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.90" +version = "2.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" +checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" dependencies = [ "proc-macro2", "quote", @@ -2568,12 +2523,13 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.14.0" +version = "3.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" +checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" dependencies = [ "cfg-if", "fastrand", + "getrandom", "once_cell", "rustix", "windows-sys 0.59.0", @@ -2590,18 +2546,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.69" +version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.69" +version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" dependencies = [ "proc-macro2", "quote", @@ -2661,9 +2617,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8" dependencies = [ "tinyvec_macros", ] @@ -2676,9 +2632,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.42.0" +version = "1.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" +checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e" dependencies = [ "backtrace", "bytes", @@ -2694,9 +2650,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", @@ -2872,9 +2828,9 @@ dependencies = [ [[package]] name = "unicase" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicase_serde" @@ -2919,12 +2875,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unicode_categories" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" - [[package]] name = "untrusted" version = "0.7.1" @@ -2956,9 +2906,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +checksum = "744018581f9a3454a9e15beb8a33b017183f1e7c0cd170232a2d1453b23a51c4" dependencies = [ "getrandom", "serde", @@ -3015,20 +2965,21 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", @@ -3040,9 +2991,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.49" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ "cfg-if", "js-sys", @@ -3053,9 +3004,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3063,9 +3014,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", @@ -3076,15 +3027,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] [[package]] name = "web-sys" -version = "0.3.76" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" dependencies = [ "js-sys", "wasm-bindgen", @@ -3299,9 +3253,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.20" +version = "0.6.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" dependencies = [ "memchr", ] diff --git a/src/ui/primitives/div.rs b/src/ui/primitives/div.rs index b5efb17..03ecf52 100644 --- a/src/ui/primitives/div.rs +++ b/src/ui/primitives/div.rs @@ -55,6 +55,36 @@ impl DivWidget { self } + #[must_use] + pub fn push_if<T: UIWidget + 'static, U: Fn() -> T>( + mut self, + condition: bool, + then: U, + ) -> Self { + if condition { + self.0.push(Box::new(then())); + } + self + } + + #[must_use] + pub fn push_for< + 'a, + T: UIWidget + 'static, + X: 'a, + U: Fn(&X) -> T, + I: IntoIterator<Item = &'a X>, + >( + mut self, + iterator: I, + then: U, + ) -> Self { + for val in iterator { + self.0.push(Box::new(then(val))); + } + self + } + /// Extract the `<div>`s innerHTML /// /// This will render `<content>` instead of `<div> <content> </div>` From 340f014365c4346de10569c6e719518ffecf3d9f Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Thu, 16 Jan 2025 23:31:51 +0100 Subject: [PATCH 17/45] fix --- src/ui/primitives/div.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ui/primitives/div.rs b/src/ui/primitives/div.rs index 03ecf52..02e0cbc 100644 --- a/src/ui/primitives/div.rs +++ b/src/ui/primitives/div.rs @@ -68,19 +68,19 @@ impl DivWidget { } #[must_use] - pub fn push_for< - 'a, + pub fn push_for_each< T: UIWidget + 'static, - X: 'a, + X, U: Fn(&X) -> T, - I: IntoIterator<Item = &'a X>, + I: Iterator<Item = X>, + O: Into<I>, >( mut self, - iterator: I, + iterator: O, then: U, ) -> Self { - for val in iterator { - self.0.push(Box::new(then(val))); + for val in iterator.into() { + self.0.push(Box::new(then(&val))); } self } From bf72429ac55040df596e33d63db30149875129fa Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Fri, 17 Jan 2025 14:39:54 +0100 Subject: [PATCH 18/45] update + text --- src/lib.rs | 4 + src/ui/mod.rs | 7 +- src/ui/primitives/div.rs | 1 + src/ui/primitives/height.rs | 40 ++- src/ui/primitives/text.rs | 633 +++++++++++++++++++++++++++++++++++- src/ui/primitives/width.rs | 37 ++- 6 files changed, 703 insertions(+), 19 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index bfe93a5..1985369 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,10 @@ pub mod ui; // Postgres +// TODO : IDEA +// more efficient table join using WHERE ANY instead of multiple SELECTs +// map_tables(Vec<T>, Fn(&T) -> U) -> Vec<U> + pub static PG: OnceCell<sqlx::PgPool> = OnceCell::const_new(); /// A macro to retrieve or initialize the `PostgreSQL` connection pool. diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 8ef0eae..280dfa5 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -39,7 +39,12 @@ pub mod prelude { pub use super::primitives::shadow::Shadow; pub use super::primitives::sized::Sized; pub use super::primitives::space::{ScreenValue, SpaceBetween}; - pub use super::primitives::text::{Paragraph, Span, Text}; + pub use super::primitives::text::{ + DecorationKind, DecorationStyle, DecorationThickness, LetterSpacing, LineClamp, LineHeight, + ListStyle, NumberStyle, Paragraph, Span, Text, TextAlignment, TextContent, TextDecoration, + TextHyphens, TextOverflow, TextTransform, TextWhitespace, TextWordBreak, TextWrap, + UnderlineOffset, VerticalTextAlignment, + }; pub use super::primitives::visibility::Visibility; pub use super::primitives::width::{MaxWidth, MinWidth, Width}; pub use super::primitives::zindex::ZIndex; diff --git a/src/ui/primitives/div.rs b/src/ui/primitives/div.rs index 02e0cbc..c4a597d 100644 --- a/src/ui/primitives/div.rs +++ b/src/ui/primitives/div.rs @@ -67,6 +67,7 @@ impl DivWidget { self } + // todo : Fix weird types #[must_use] pub fn push_for_each< T: UIWidget + 'static, diff --git a/src/ui/primitives/height.rs b/src/ui/primitives/height.rs index b465d22..72bff47 100644 --- a/src/ui/primitives/height.rs +++ b/src/ui/primitives/height.rs @@ -1,24 +1,36 @@ use crate::ui::UIWidget; use maud::{Markup, Render, html}; -use super::space::ScreenValue; +use super::{ + flex::Either, + space::{Fraction, ScreenValue}, +}; #[allow(non_snake_case)] -pub fn Height<T: UIWidget + 'static>(size: ScreenValue, inner: T) -> HeightWidget { +pub fn Height<T: UIWidget + 'static>( + size: Either<ScreenValue, Fraction>, + inner: T, +) -> HeightWidget { HeightWidget(Box::new(inner), size, 0) } #[allow(non_snake_case)] -pub fn MinHeight<T: UIWidget + 'static>(size: ScreenValue, inner: T) -> HeightWidget { +pub fn MinHeight<T: UIWidget + 'static>( + size: Either<ScreenValue, Fraction>, + inner: T, +) -> HeightWidget { HeightWidget(Box::new(inner), size, 1) } #[allow(non_snake_case)] -pub fn MaxHeight<T: UIWidget + 'static>(size: ScreenValue, inner: T) -> HeightWidget { +pub fn MaxHeight<T: UIWidget + 'static>( + size: Either<ScreenValue, Fraction>, + inner: T, +) -> HeightWidget { HeightWidget(Box::new(inner), size, 2) } -pub struct HeightWidget(Box<dyn UIWidget>, ScreenValue, u8); +pub struct HeightWidget(Box<dyn UIWidget>, Either<ScreenValue, Fraction>, u8); impl Render for HeightWidget { fn render(&self) -> Markup { @@ -34,15 +46,27 @@ impl UIWidget for HeightWidget { fn base_class(&self) -> Vec<String> { match self.2 { 1 => { - return vec![format!("min-h-{}", self.1.to_value())]; + return vec![format!( + "min-h-{}", + self.1 + .map(|x| x.to_value().to_string(), |x| x.to_value().to_string()) + )]; } 2 => { - return vec![format!("max-h-{}", self.1.to_value())]; + return vec![format!( + "max-h-{}", + self.1 + .map(|x| x.to_value().to_string(), |x| x.to_value().to_string()) + )]; } _ => {} } - vec![format!("h-{}", self.1.to_value())] + vec![format!( + "h-{}", + self.1 + .map(|x| x.to_value().to_string(), |x| x.to_value().to_string()) + )] } fn extended_class(&self) -> Vec<String> { diff --git a/src/ui/primitives/text.rs b/src/ui/primitives/text.rs index 39ca05d..d57f132 100644 --- a/src/ui/primitives/text.rs +++ b/src/ui/primitives/text.rs @@ -1,6 +1,8 @@ use crate::ui::{UIWidget, color::UIColor}; use maud::{Markup, Render, html}; +use super::{Nothing, space::ScreenValue}; + #[allow(non_snake_case)] /// Text UI Widget #[must_use] @@ -13,6 +15,21 @@ pub fn Text(txt: &str) -> TextWidget { color: String::new(), style: Vec::new(), size: String::new(), + line_height: None, + overflow: None, + wrap: None, + indent: None, + transform: None, + decoration: None, + whitespace: None, + wordbreak: None, + hyphens: None, + spacing: None, + clamp: None, + pseudo: None, + align: None, + vert_align: None, + list_style: None, span: false, } } @@ -28,6 +45,21 @@ pub fn Paragraph<T: UIWidget + 'static>(inner: T) -> TextWidget { txt: String::new(), style: Vec::new(), size: String::new(), + indent: None, + overflow: None, + decoration: None, + whitespace: None, + wordbreak: None, + hyphens: None, + wrap: None, + spacing: None, + transform: None, + pseudo: None, + vert_align: None, + line_height: None, + list_style: None, + clamp: None, + align: None, span: false, } } @@ -43,7 +75,22 @@ pub fn Span(txt: &str) -> TextWidget { font: String::new(), style: Vec::new(), color: String::new(), + list_style: None, size: String::new(), + indent: None, + overflow: None, + whitespace: None, + wordbreak: None, + hyphens: None, + decoration: None, + transform: None, + wrap: None, + vert_align: None, + spacing: None, + line_height: None, + clamp: None, + align: None, + pseudo: None, span: true, } } @@ -55,11 +102,44 @@ pub struct TextWidget { style: Vec<String>, font: String, color: String, + list_style: Option<ListStyle>, + line_height: Option<LineHeight>, + decoration: Option<DecorationWidget>, + transform: Option<TextTransform>, + vert_align: Option<VerticalTextAlignment>, + overflow: Option<TextOverflow>, + indent: Option<ScreenValue>, + wrap: Option<TextWrap>, + whitespace: Option<TextWhitespace>, + wordbreak: Option<TextWordBreak>, + hyphens: Option<TextHyphens>, size: String, span: bool, + spacing: Option<LetterSpacing>, + pseudo: Option<TextContent>, + align: Option<TextAlignment>, + clamp: Option<LineClamp>, } impl TextWidget { + #[must_use] + pub fn whitespace(mut self, whitespace: TextWhitespace) -> Self { + self.whitespace = Some(whitespace); + self + } + + #[must_use] + pub fn wordbreak(mut self, wordbreak: TextWordBreak) -> Self { + self.wordbreak = Some(wordbreak); + self + } + + #[must_use] + pub fn hyphen(mut self, hyphen: TextHyphens) -> Self { + self.hyphens = Some(hyphen); + self + } + // Weight #[must_use] @@ -137,12 +217,78 @@ impl TextWidget { self } + #[must_use] + pub fn wrap(mut self, wrap: TextWrap) -> Self { + self.wrap = Some(wrap); + self + } + + #[must_use] + pub fn indentation(mut self, indent: ScreenValue) -> Self { + self.indent = Some(indent); + self + } + + #[must_use] + pub fn align(mut self, alignment: TextAlignment) -> Self { + self.align = Some(alignment); + self + } + + #[must_use] + pub fn align_vertical(mut self, alignment: VerticalTextAlignment) -> Self { + self.vert_align = Some(alignment); + self + } + + #[must_use] + pub fn transform(mut self, transform: TextTransform) -> Self { + self.transform = Some(transform); + self + } + #[must_use] pub fn number_style(mut self, s: NumberStyle) -> Self { self.style.push(s.to_value().to_string()); self } + #[must_use] + pub fn overflow(mut self, overflow: TextOverflow) -> Self { + self.overflow = Some(overflow); + self + } + + #[must_use] + pub fn max_lines(mut self, l: LineClamp) -> Self { + self.clamp = Some(l); + self + } + + #[must_use] + pub fn decoration(mut self, decoration: DecorationWidget) -> Self { + self.decoration = Some(decoration); + self + } + + #[must_use] + pub fn list_style(mut self, style: ListStyle) -> Self { + self.list_style = Some(style); + self + } + + #[must_use] + pub fn content(mut self, content: TextContent) -> Self { + self.pseudo = Some(content); + self + } + + #[must_use] + pub fn line_height(mut self, height: LineHeight) -> Self { + self.line_height = Some(height); + self + } + // Sizes #[must_use] @@ -291,12 +437,48 @@ impl UIWidget for TextWidget { } fn base_class(&self) -> Vec<String> { - vec![ + let mut ret = vec![ self.color.clone(), self.font.clone(), self.size.clone(), self.family.clone(), - ] + ]; + + macro_rules! add_option { + ($opt:ident, $ret:ident) => { + if let Some($opt) = &self.$opt { + $ret.push($opt.to_value().to_string()); + } + }; + } + + add_option!(spacing, ret); + + if let Some(indent) = &self.indent { + ret.push(format!("indent-{}", indent.to_value())); + } + + add_option!(clamp, ret); + add_option!(align, ret); + add_option!(vert_align, ret); + add_option!(list_style, ret); + add_option!(pseudo, ret); + add_option!(line_height, ret); + + if let Some(decoration) = &self.decoration { + ret.extend_from_slice(&decoration.base_class()); + } + + ret.extend_from_slice(&self.style); + + add_option!(transform, ret); + add_option!(overflow, ret); + add_option!(wrap, ret); + add_option!(whitespace, ret); + add_option!(wordbreak, ret); + add_option!(hyphens, ret); + + ret } fn extended_class(&self) -> Vec<String> { @@ -357,3 +539,450 @@ impl NumberStyle { } } } + +pub enum LetterSpacing { + Tighter, + Tight, + Normal, + Wide, + Wider, + Widest, +} + +impl LetterSpacing { + pub const fn to_value(&self) -> &str { + match self { + LetterSpacing::Tighter => "tracking-tighter", + LetterSpacing::Tight => "tracking-tight", + LetterSpacing::Normal => "tracking-normal", + LetterSpacing::Wide => "tracking-wide", + LetterSpacing::Wider => "tracking-wider", + LetterSpacing::Widest => "tracking-widest", + } + } +} + +pub enum LineClamp { + None, + _1, + _2, + _3, + _4, + _5, + _6, +} + +impl LineClamp { + pub fn to_value(&self) -> &str { + match self { + LineClamp::None => "line-clamp-none", + LineClamp::_1 => "line-clamp-1", + LineClamp::_2 => "line-clamp-2", + LineClamp::_3 => "line-clamp-3", + LineClamp::_4 => "line-clamp-4", + LineClamp::_5 => "line-clamp-5", + LineClamp::_6 => "line-clamp-6", + } + } +} + +pub enum TextAlignment { + Left, + Center, + Right, + Justify, + Start, + End, +} + +impl TextAlignment { + pub const fn to_value(&self) -> &str { + match self { + TextAlignment::Left => "text-left", + TextAlignment::Center => "text-center", + TextAlignment::Right => "text-right", + TextAlignment::Justify => "text-justify", + TextAlignment::Start => "text-start", + TextAlignment::End => "text-end", + } + } +} + +pub enum ListStyle { + Url(String), + None, + Disc, + Decimal, +} + +impl ListStyle { + pub fn to_value(&self) -> String { + match self { + ListStyle::Url(url) => format!("list-image-[url({url})]"), + ListStyle::None => "list-none".to_string(), + ListStyle::Disc => "list-disc".to_string(), + ListStyle::Decimal => "list-decimal".to_string(), + } + } +} + +pub enum TextContent { + None, + Before(String), + After(String), +} + +impl TextContent { + pub fn to_value(&self) -> String { + match self { + TextContent::None => "content-none".to_string(), + TextContent::Before(c) => format!("before:content-['{c}']"), + TextContent::After(c) => format!("after:content-['{c}']"), + } + } +} + +pub enum LineHeight { + _3, + _4, + _5, + _6, + _7, + _8, + _9, + _10, + None, + Tight, + Snug, + Normal, + Relaxed, + Loose, +} + +impl LineHeight { + pub const fn to_value(&self) -> &str { + match self { + LineHeight::_3 => "leading-3", + LineHeight::_4 => "leading-4", + LineHeight::_5 => "leading-5", + LineHeight::_6 => "leading-6", + LineHeight::_7 => "leading-7", + LineHeight::_8 => "leading-8", + LineHeight::_9 => "leading-9", + LineHeight::_10 => "leading-10", + LineHeight::None => "leading-none", + LineHeight::Tight => "leading-tight", + LineHeight::Snug => "leading-snug", + LineHeight::Normal => "leading-normal", + LineHeight::Relaxed => "leading-relaxed", + LineHeight::Loose => "leading-loose", + } + } +} + +// Decoration + +#[allow(non_snake_case)] +pub fn TextDecoration<T: UIWidget + 'static>(kind: DecorationKind) -> DecorationWidget { + DecorationWidget { + kind: kind.to_value().to_string(), + color: None, + style: None, + underline_offset: None, + thickness: None, + } +} + +pub struct DecorationWidget { + kind: String, + color: Option<String>, + style: Option<DecorationStyle>, + thickness: Option<DecorationThickness>, + underline_offset: Option<UnderlineOffset>, +} + +impl DecorationWidget { + #[must_use] + pub fn thickness(mut self, thickness: DecorationThickness) -> Self { + self.thickness = Some(thickness); + self + } + + #[must_use] + pub fn style(mut self, style: DecorationStyle) -> Self { + self.style = Some(style); + self + } + + #[must_use] + pub fn color<C: UIColor>(mut self, color: C) -> Self { + self.color = Some(format!("decoration-{}", color.color_class())); + self + } + + #[must_use] + pub fn underline_offset(mut self, offset: UnderlineOffset) -> Self { + self.underline_offset = Some(offset); + self + } +} + +pub enum DecorationKind { + Underline, + Overline, + LineThrough, + NoUnderline, +} + +impl DecorationKind { + pub const fn to_value(&self) -> &str { + match self { + DecorationKind::Underline => "underline", + DecorationKind::Overline => "overline", + DecorationKind::LineThrough => "line-through", + DecorationKind::NoUnderline => "no-underline", + } + } +} + +pub enum DecorationThickness { + Auto, + FromFont, + _0, + _1, + _2, + _4, + _8, +} + +impl DecorationThickness { + pub const fn to_value(&self) -> &str { + match self { + DecorationThickness::Auto => "decoration-auto", + DecorationThickness::FromFont => "decoration-from-font", + DecorationThickness::_0 => "decoration-0", + DecorationThickness::_1 => "decoration-1", + DecorationThickness::_2 => "decoration-2", + DecorationThickness::_4 => "decoration-4", + DecorationThickness::_8 => "decoration-8", + } + } +} + +pub enum DecorationStyle { + Solid, + Double, + Dotted, + Dashed, + Wavy, +} + +impl DecorationStyle { + pub const fn to_value(&self) -> &str { + match self { + DecorationStyle::Solid => "decoration-solid", + DecorationStyle::Double => "decoration-double", + DecorationStyle::Dotted => "decoration-dotted", + DecorationStyle::Dashed => "decoration-dashed", + DecorationStyle::Wavy => "decoration-wavy", + } + } +} + +impl Render for DecorationWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for DecorationWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + let mut ret = vec![self.kind.clone()]; + + if let Some(color) = &self.color { + ret.push(color.clone()); + } + + if let Some(style) = &self.style { + ret.push(style.to_value().to_string()); + } + + if let Some(thickness) = &self.thickness { + ret.push(thickness.to_value().to_string()); + } + + if let Some(offset) = &self.underline_offset { + ret.push(offset.to_value().to_string()); + } + + ret + } + + fn extended_class(&self) -> Vec<String> { + self.base_class() + } + + fn render_with_class(&self, _: &str) -> Markup { + Nothing() + } +} + +pub enum UnderlineOffset { + Auto, + _0, + _1, + _2, + _4, + _8, +} + +impl UnderlineOffset { + pub const fn to_value(&self) -> &str { + match self { + Self::Auto => "underline-offset-auto", + Self::_0 => "underline-offset-0", + Self::_1 => "underline-offset-1", + Self::_2 => "underline-offset-2", + Self::_4 => "underline-offset-4", + Self::_8 => "underline-offset-8", + } + } +} + +pub enum VerticalTextAlignment { + Baseline, + Top, + Middle, + Bottom, + TextTop, + TextBottom, + Sub, + Super, +} + +impl VerticalTextAlignment { + pub const fn to_value(&self) -> &str { + match self { + VerticalTextAlignment::Baseline => "align-baseline", + VerticalTextAlignment::Top => "align-top", + VerticalTextAlignment::Middle => "align-middle", + VerticalTextAlignment::Bottom => "align-bottom", + VerticalTextAlignment::TextTop => "align-text-top", + VerticalTextAlignment::TextBottom => "align-text-bottom", + VerticalTextAlignment::Sub => "align-sub", + VerticalTextAlignment::Super => "align-super", + } + } +} + +pub enum TextTransform { + Uppecase, + Lowercase, + Capitalize, + None, +} + +impl TextTransform { + pub const fn to_value(&self) -> &str { + match self { + TextTransform::Uppecase => "uppercase", + TextTransform::Lowercase => "lowercase", + TextTransform::Capitalize => "capitalize", + TextTransform::None => "normal-case", + } + } +} + +pub enum TextOverflow { + Truncate, + Ellipsis, + Clip, +} + +impl TextOverflow { + pub const fn to_value(&self) -> &str { + match self { + TextOverflow::Truncate => "truncate", + TextOverflow::Ellipsis => "text-ellipsis", + TextOverflow::Clip => "text-clip", + } + } +} + +pub enum TextWrap { + Wrap, + NoWrap, + Balance, + Pretty, +} + +impl TextWrap { + pub const fn to_value(&self) -> &str { + match self { + TextWrap::Wrap => "text-wrap", + TextWrap::NoWrap => "text-nowrap", + TextWrap::Balance => "text-balance", + TextWrap::Pretty => "text-pretty", + } + } +} + +pub enum TextWhitespace { + Normal, + NoWrap, + Pre, + PreLine, + PreWrap, + BreakSpaces, +} + +impl TextWhitespace { + pub const fn to_value(&self) -> &str { + match self { + TextWhitespace::Normal => "whitespace-normal", + TextWhitespace::NoWrap => "whitespace-nowrap", + TextWhitespace::Pre => "whitespace-pre", + TextWhitespace::PreLine => "whitespace-pre-line", + TextWhitespace::PreWrap => "whitespace-pre-wrap", + TextWhitespace::BreakSpaces => "whitespace-break-spaces", + } + } +} + +pub enum TextWordBreak { + Normal, + Words, + All, + Keep, +} + +impl TextWordBreak { + pub const fn to_value(&self) -> &str { + match self { + TextWordBreak::Normal => "break-normal", + TextWordBreak::Words => "break-words", + TextWordBreak::All => "break-all", + TextWordBreak::Keep => "break-keep", + } + } +} + +pub enum TextHyphens { + None, + Manual, + Auto, +} + +impl TextHyphens { + pub const fn to_value(&self) -> &str { + match self { + TextHyphens::None => "hyphens-none", + TextHyphens::Manual => "hyphens-manual", + TextHyphens::Auto => "hyphens-auto", + } + } +} diff --git a/src/ui/primitives/width.rs b/src/ui/primitives/width.rs index 807e0a0..b5e662e 100644 --- a/src/ui/primitives/width.rs +++ b/src/ui/primitives/width.rs @@ -1,24 +1,33 @@ use crate::ui::UIWidget; use maud::{Markup, Render, html}; -use super::space::ScreenValue; +use super::{ + flex::Either, + space::{Fraction, ScreenValue}, +}; #[allow(non_snake_case)] -pub fn Width<T: UIWidget + 'static>(size: ScreenValue, inner: T) -> WidthWidget { +pub fn Width<T: UIWidget + 'static>(size: Either<ScreenValue, Fraction>, inner: T) -> WidthWidget { WidthWidget(Box::new(inner), size, 0) } #[allow(non_snake_case)] -pub fn MinWidth<T: UIWidget + 'static>(size: ScreenValue, inner: T) -> WidthWidget { +pub fn MinWidth<T: UIWidget + 'static>( + size: Either<ScreenValue, Fraction>, + inner: T, +) -> WidthWidget { WidthWidget(Box::new(inner), size, 1) } #[allow(non_snake_case)] -pub fn MaxWidth<T: UIWidget + 'static>(size: ScreenValue, inner: T) -> WidthWidget { +pub fn MaxWidth<T: UIWidget + 'static>( + size: Either<ScreenValue, Fraction>, + inner: T, +) -> WidthWidget { WidthWidget(Box::new(inner), size, 2) } -pub struct WidthWidget(Box<dyn UIWidget>, ScreenValue, u8); +pub struct WidthWidget(Box<dyn UIWidget>, Either<ScreenValue, Fraction>, u8); impl Render for WidthWidget { fn render(&self) -> Markup { @@ -34,15 +43,27 @@ impl UIWidget for WidthWidget { fn base_class(&self) -> Vec<String> { match self.2 { 1 => { - return vec![format!("min-w-{}", self.1.to_value())]; + return vec![format!( + "min-w-{}", + self.1 + .map(|x| x.to_value().to_string(), |x| x.to_value().to_string()) + )]; } 2 => { - return vec![format!("max-w-{}", self.1.to_value())]; + return vec![format!( + "max-w-{}", + self.1 + .map(|x| x.to_value().to_string(), |x| x.to_value().to_string()) + )]; } _ => {} } - vec![format!("w-{}", self.1.to_value())] + vec![format!( + "w-{}", + self.1 + .map(|x| x.to_value().to_string(), |x| x.to_value().to_string()) + )] } fn extended_class(&self) -> Vec<String> { From 0444726a2d9d33436ff692f2b3c5ee065205e60f Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Fri, 17 Jan 2025 14:55:55 +0100 Subject: [PATCH 19/45] update --- src/ui/primitives/div.rs | 20 +++++++------------- src/ui/primitives/height.rs | 18 +++++++++--------- src/ui/primitives/width.rs | 19 +++++++++++-------- 3 files changed, 27 insertions(+), 30 deletions(-) diff --git a/src/ui/primitives/div.rs b/src/ui/primitives/div.rs index c4a597d..05e8eb1 100644 --- a/src/ui/primitives/div.rs +++ b/src/ui/primitives/div.rs @@ -67,22 +67,16 @@ impl DivWidget { self } - // todo : Fix weird types #[must_use] - pub fn push_for_each< + pub fn push_for_each<T, X, F>(mut self, items: &[X], mut action: F) -> Self + where T: UIWidget + 'static, - X, - U: Fn(&X) -> T, - I: Iterator<Item = X>, - O: Into<I>, - >( - mut self, - iterator: O, - then: U, - ) -> Self { - for val in iterator.into() { - self.0.push(Box::new(then(&val))); + F: FnMut(&X) -> T, + { + for item in items { + self.0.push(Box::new(action(item))); } + self } diff --git a/src/ui/primitives/height.rs b/src/ui/primitives/height.rs index 72bff47..4e04fd5 100644 --- a/src/ui/primitives/height.rs +++ b/src/ui/primitives/height.rs @@ -7,27 +7,27 @@ use super::{ }; #[allow(non_snake_case)] -pub fn Height<T: UIWidget + 'static>( - size: Either<ScreenValue, Fraction>, +pub fn Height<T: UIWidget + 'static, S: Into<Either<ScreenValue, Fraction>>>( + size: S, inner: T, ) -> HeightWidget { - HeightWidget(Box::new(inner), size, 0) + HeightWidget(Box::new(inner), size.into(), 0) } #[allow(non_snake_case)] -pub fn MinHeight<T: UIWidget + 'static>( - size: Either<ScreenValue, Fraction>, +pub fn MinHeight<T: UIWidget + 'static, S: Into<Either<ScreenValue, Fraction>>>( + size: S, inner: T, ) -> HeightWidget { - HeightWidget(Box::new(inner), size, 1) + HeightWidget(Box::new(inner), size.into(), 1) } #[allow(non_snake_case)] -pub fn MaxHeight<T: UIWidget + 'static>( - size: Either<ScreenValue, Fraction>, +pub fn MaxHeight<T: UIWidget + 'static, S: Into<Either<ScreenValue, Fraction>>>( + size: S, inner: T, ) -> HeightWidget { - HeightWidget(Box::new(inner), size, 2) + HeightWidget(Box::new(inner), size.into(), 2) } pub struct HeightWidget(Box<dyn UIWidget>, Either<ScreenValue, Fraction>, u8); diff --git a/src/ui/primitives/width.rs b/src/ui/primitives/width.rs index b5e662e..f31f0cb 100644 --- a/src/ui/primitives/width.rs +++ b/src/ui/primitives/width.rs @@ -7,24 +7,27 @@ use super::{ }; #[allow(non_snake_case)] -pub fn Width<T: UIWidget + 'static>(size: Either<ScreenValue, Fraction>, inner: T) -> WidthWidget { - WidthWidget(Box::new(inner), size, 0) +pub fn Width<T: UIWidget + 'static, S: Into<Either<ScreenValue, Fraction>>>( + size: S, + inner: T, +) -> WidthWidget { + WidthWidget(Box::new(inner), size.into(), 0) } #[allow(non_snake_case)] -pub fn MinWidth<T: UIWidget + 'static>( - size: Either<ScreenValue, Fraction>, +pub fn MinWidth<T: UIWidget + 'static, S: Into<Either<ScreenValue, Fraction>>>( + size: S, inner: T, ) -> WidthWidget { - WidthWidget(Box::new(inner), size, 1) + WidthWidget(Box::new(inner), size.into(), 1) } #[allow(non_snake_case)] -pub fn MaxWidth<T: UIWidget + 'static>( - size: Either<ScreenValue, Fraction>, +pub fn MaxWidth<T: UIWidget + 'static, S: Into<Either<ScreenValue, Fraction>>>( + size: S, inner: T, ) -> WidthWidget { - WidthWidget(Box::new(inner), size, 2) + WidthWidget(Box::new(inner), size.into(), 2) } pub struct WidthWidget(Box<dyn UIWidget>, Either<ScreenValue, Fraction>, u8); From 15e70da5121b865c6d4c80172945dcdf25347551 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Fri, 17 Jan 2025 15:02:27 +0100 Subject: [PATCH 20/45] fix --- src/ui/primitives/div.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/primitives/div.rs b/src/ui/primitives/div.rs index 05e8eb1..3175d57 100644 --- a/src/ui/primitives/div.rs +++ b/src/ui/primitives/div.rs @@ -44,9 +44,9 @@ impl DivWidget { /// let div = Div().push(Some("hello"), |value| Text(value)); /// ``` #[must_use] - pub fn push_some<T: UIWidget + 'static, X, U: Fn(&X) -> T>( + pub fn push_some<T: UIWidget + 'static, X, U: Fn(X) -> T>( mut self, - option: Option<&X>, + option: Option<X>, then: U, ) -> Self { if let Some(val) = option { From 302daacc822b554c1daacacaf0934701eb06df0e Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Fri, 17 Jan 2025 16:28:56 +0100 Subject: [PATCH 21/45] add screen wrapper --- examples/ui.rs | 6 +++ src/ui/mod.rs | 4 +- src/ui/wrapper/hover.rs | 66 ----------------------- src/ui/wrapper/mod.rs | 116 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 123 insertions(+), 69 deletions(-) delete mode 100644 src/ui/wrapper/hover.rs diff --git a/examples/ui.rs b/examples/ui.rs index e52b8db..b2256f7 100644 --- a/examples/ui.rs +++ b/examples/ui.rs @@ -14,6 +14,12 @@ pub async fn index_page(ctx: RequestContext) -> StringResponse { let content = html!( h1 { "Hello World!" }; + ( + Screen::medium(Hover(Background(Red::_700, Nothing()))).on( + Background(Blue::_700, Text("HELLO!")) + ) + ) + (Hover( Cursor::NorthEastResize.on( Padding(Text("").color(&Gray::_400)).x(ScreenValue::_10) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 280dfa5..4812097 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -48,7 +48,9 @@ pub mod prelude { pub use super::primitives::visibility::Visibility; pub use super::primitives::width::{MaxWidth, MinWidth, Width}; pub use super::primitives::zindex::ZIndex; - pub use super::wrapper::Hover; + pub use super::wrapper::{ + _2XLScreen, Hover, LargeScreen, MediumScreen, Screen, SmallScreen, XLScreen, + }; } use crate::request::{RequestContext, StringResponse}; diff --git a/src/ui/wrapper/hover.rs b/src/ui/wrapper/hover.rs deleted file mode 100644 index b6d069f..0000000 --- a/src/ui/wrapper/hover.rs +++ /dev/null @@ -1,66 +0,0 @@ -use maud::{Markup, Render, html}; - -use crate::ui::UIWidget; - -#[allow(non_snake_case)] -pub fn Hover<I: UIWidget + 'static>(inherit: I) -> HoverWrapper { - HoverWrapper(None, Box::new(inherit)) -} - -pub struct HoverWrapper(Option<Box<dyn UIWidget>>, Box<dyn UIWidget>); - -impl HoverWrapper { - fn hovered_class(&self) -> Vec<String> { - self.1 - .extended_class() - .into_iter() - .filter(|x| !x.is_empty()) - .map(|x| format!("hover:{x}")) - .collect::<Vec<_>>() - } - - pub fn on<T: UIWidget + 'static>(mut self, inner: T) -> Self { - self.0 = Some(Box::new(inner)); - self - } -} - -impl Render for HoverWrapper { - fn render(&self) -> Markup { - self.render_with_class("") - } -} - -impl UIWidget for HoverWrapper { - fn can_inherit(&self) -> bool { - true - } - - fn base_class(&self) -> Vec<String> { - self.hovered_class() - } - - fn extended_class(&self) -> Vec<String> { - let mut ret = self.base_class(); - if let Some(inner) = &self.0 { - ret.extend_from_slice(&inner.extended_class()); - } - - ret - } - - fn render_with_class(&self, class: &str) -> Markup { - if self.0.as_ref().unwrap().can_inherit() { - self.0 - .as_ref() - .unwrap() - .render_with_class(&format!("{} {class}", self.hovered_class().join(" "))) - } else { - html! { - div class=(format!("{} {class}", self.hovered_class().join(" "))) { - (self.0.as_ref().unwrap()) - } - } - } - } -} diff --git a/src/ui/wrapper/mod.rs b/src/ui/wrapper/mod.rs index b1312e7..4fac146 100644 --- a/src/ui/wrapper/mod.rs +++ b/src/ui/wrapper/mod.rs @@ -1,4 +1,116 @@ -pub mod hover; -pub use hover::Hover; +use crate::ui::UIWidget; +use maud::{Markup, Render, html}; + +macro_rules! wrapper { + ($constr:ident, $widgetname:ident, $class:literal) => { + #[allow(non_snake_case)] + pub fn $constr<I: UIWidget + 'static>(inherit: I) -> $widgetname { + $widgetname(None, Box::new(inherit)) + } + + pub struct $widgetname(Option<Box<dyn UIWidget>>, Box<dyn UIWidget>); + + impl $widgetname { + fn wrapped_class(&self) -> Vec<String> { + self.1 + .extended_class() + .into_iter() + .filter(|x| !x.is_empty()) + .map(|x| { + let mut s = $class.to_string(); + s.push_str(":"); + s.push_str(&x); + s + }) + .collect::<Vec<_>>() + } + + pub fn on<T: UIWidget + 'static>(mut self, inner: T) -> Self { + self.0 = Some(Box::new(inner)); + self + } + } + + impl Render for $widgetname { + fn render(&self) -> Markup { + self.render_with_class("") + } + } + + impl UIWidget for $widgetname { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + self.wrapped_class() + } + + fn extended_class(&self) -> Vec<String> { + let mut ret = self.base_class(); + if let Some(inner) = &self.0 { + ret.extend_from_slice(&inner.extended_class()); + } + + ret + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().unwrap().can_inherit() { + self.0 + .as_ref() + .unwrap() + .render_with_class(&format!("{} {class}", self.wrapped_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.wrapped_class().join(" "))) { + (self.0.as_ref().unwrap()) + } + } + } + } + } + }; +} + +wrapper!(Hover, HoverWrapper, "hover"); + +wrapper!(SmallScreen, SmallScreenWrapper, "sm"); +wrapper!(MediumScreen, MediumScreenWrapper, "md"); +wrapper!(LargeScreen, LargeScreenWrapper, "lg"); +wrapper!(XLScreen, XLScreenWrapper, "xl"); +wrapper!(_2XLScreen, _2XLScreenWrapper, "2xl"); // TODO : responsive media + +// TODO : arbitrary values "min-[320px]:text-center max-[600px]:bg-sky-300" + +#[allow(non_snake_case)] +pub mod Screen { + use crate::ui::UIWidget; + + use super::{ + _2XLScreen, _2XLScreenWrapper, LargeScreen, LargeScreenWrapper, MediumScreen, + MediumScreenWrapper, SmallScreen, SmallScreenWrapper, XLScreen, XLScreenWrapper, + }; + + pub fn small<I: UIWidget + 'static>(inherit: I) -> SmallScreenWrapper { + SmallScreen(inherit) + } + + pub fn medium<I: UIWidget + 'static>(inherit: I) -> MediumScreenWrapper { + MediumScreen(inherit) + } + + pub fn large<I: UIWidget + 'static>(inherit: I) -> LargeScreenWrapper { + LargeScreen(inherit) + } + + pub fn xl<I: UIWidget + 'static>(inherit: I) -> XLScreenWrapper { + XLScreen(inherit) + } + + pub fn _2xl<I: UIWidget + 'static>(inherit: I) -> _2XLScreenWrapper { + _2XLScreen(inherit) + } +} From 28fa0f21dcae5ef6f1355232968a62eabc697244 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Fri, 17 Jan 2025 16:47:14 +0100 Subject: [PATCH 22/45] fix --- src/ui/primitives/text.rs | 106 +++++++++++++++++++++++++++++--------- 1 file changed, 83 insertions(+), 23 deletions(-) diff --git a/src/ui/primitives/text.rs b/src/ui/primitives/text.rs index d57f132..3d4e093 100644 --- a/src/ui/primitives/text.rs +++ b/src/ui/primitives/text.rs @@ -1,5 +1,5 @@ use crate::ui::{UIWidget, color::UIColor}; -use maud::{Markup, Render, html}; +use maud::{Markup, PreEscaped, Render}; use super::{Nothing, space::ScreenValue}; @@ -11,6 +11,7 @@ pub fn Text(txt: &str) -> TextWidget { inner: None, txt: txt.to_string(), family: String::new(), + title: None, font: String::new(), color: String::new(), style: Vec::new(), @@ -30,15 +31,22 @@ pub fn Text(txt: &str) -> TextWidget { align: None, vert_align: None, list_style: None, - span: false, + kind: TextKind::Paragraph, } } +enum TextKind { + Paragraph, + Span, + Pre, +} + #[allow(non_snake_case)] /// HTML `<p>` Paragraph pub fn Paragraph<T: UIWidget + 'static>(inner: T) -> TextWidget { TextWidget { inner: Some(Box::new(inner)), + title: None, font: String::new(), family: String::new(), color: String::new(), @@ -60,7 +68,7 @@ pub fn Paragraph<T: UIWidget + 'static>(inner: T) -> TextWidget { list_style: None, clamp: None, align: None, - span: false, + kind: TextKind::Paragraph, } } @@ -70,6 +78,7 @@ pub fn Paragraph<T: UIWidget + 'static>(inner: T) -> TextWidget { pub fn Span(txt: &str) -> TextWidget { TextWidget { inner: None, + title: None, txt: txt.to_string(), family: String::new(), font: String::new(), @@ -91,7 +100,39 @@ pub fn Span(txt: &str) -> TextWidget { clamp: None, align: None, pseudo: None, - span: true, + kind: TextKind::Span, + } +} + +#[allow(non_snake_case)] +/// `<pre>` element +#[must_use] +pub fn Code(txt: &str) -> TextWidget { + TextWidget { + inner: None, + title: None, + txt: txt.to_string(), + family: String::new(), + font: String::new(), + style: Vec::new(), + color: String::new(), + list_style: None, + size: String::new(), + indent: None, + overflow: None, + whitespace: None, + wordbreak: None, + hyphens: None, + decoration: None, + transform: None, + wrap: None, + vert_align: None, + spacing: None, + line_height: None, + clamp: None, + align: None, + pseudo: None, + kind: TextKind::Pre, } } @@ -114,11 +155,12 @@ pub struct TextWidget { wordbreak: Option<TextWordBreak>, hyphens: Option<TextHyphens>, size: String, - span: bool, + kind: TextKind, spacing: Option<LetterSpacing>, pseudo: Option<TextContent>, align: Option<TextAlignment>, clamp: Option<LineClamp>, + title: Option<String>, } impl TextWidget { @@ -289,6 +331,12 @@ impl TextWidget { self } + #[must_use] + pub fn title(mut self, title: &str) -> Self { + self.title = Some(title.to_string()); + self + } + // Sizes #[must_use] @@ -490,25 +538,37 @@ impl UIWidget for TextWidget { } fn render_with_class(&self, class: &str) -> Markup { - if let Some(inner) = &self.inner { - if self.span { - html! { - span class=(format!("{} {}", class, self.base_class().join(" "))) { (inner) } - } - } else { - html! { - p class=(format!("{} {}", class, self.base_class().join(" "))) { (inner) } - } - } - } else if self.span { - html! { - span class=(format!("{} {}", class, self.base_class().join(" "))) { (self.txt) } - } - } else { - html! { - p class=(format!("{} {}", class, self.base_class().join(" "))) { (self.txt) } - } + let element = match self.kind { + TextKind::Paragraph => "p", + TextKind::Span => "span", + TextKind::Pre => "pre", + }; + + let mut ret = "<".to_string(); + ret.push_str(&element); + ret.push_str(" class='"); + ret.push_str(&format!("{} {}", class, self.base_class().join(" "))); + ret.push_str("'"); + + if let Some(title) = &self.title { + ret.push_str(" title='"); + ret.push_str(&title); + ret.push_str("'"); } + + ret.push_str("> "); + + if let Some(inner) = &self.inner { + ret.push_str(&inner.render().0); + } else { + ret.push_str(&self.txt); + } + + ret.push_str("</"); + ret.push_str(&element); + ret.push_str(">"); + + PreEscaped(ret) } } From 79f08fd202abcfbc52cbab09be7dccc02f4c7c01 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Fri, 17 Jan 2025 17:12:52 +0100 Subject: [PATCH 23/45] fix --- src/ui/mod.rs | 8 ++++---- src/ui/primitives/text.rs | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4812097..0f5427a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -40,10 +40,10 @@ pub mod prelude { pub use super::primitives::sized::Sized; pub use super::primitives::space::{ScreenValue, SpaceBetween}; pub use super::primitives::text::{ - DecorationKind, DecorationStyle, DecorationThickness, LetterSpacing, LineClamp, LineHeight, - ListStyle, NumberStyle, Paragraph, Span, Text, TextAlignment, TextContent, TextDecoration, - TextHyphens, TextOverflow, TextTransform, TextWhitespace, TextWordBreak, TextWrap, - UnderlineOffset, VerticalTextAlignment, + Code, DecorationKind, DecorationStyle, DecorationThickness, LetterSpacing, LineClamp, + LineHeight, ListStyle, NumberStyle, Paragraph, Span, Text, TextAlignment, TextContent, + TextDecoration, TextHyphens, TextOverflow, TextTransform, TextWhitespace, TextWordBreak, + TextWrap, UnderlineOffset, VerticalTextAlignment, }; pub use super::primitives::visibility::Visibility; pub use super::primitives::width::{MaxWidth, MinWidth, Width}; diff --git a/src/ui/primitives/text.rs b/src/ui/primitives/text.rs index 3d4e093..82d9b4f 100644 --- a/src/ui/primitives/text.rs +++ b/src/ui/primitives/text.rs @@ -182,6 +182,21 @@ impl TextWidget { self } + #[must_use] + pub fn underlined(self) -> Self { + self.decoration(TextDecoration(DecorationKind::Underline)) + } + + #[must_use] + pub fn overlined(self) -> Self { + self.decoration(TextDecoration(DecorationKind::Overline)) + } + + #[must_use] + pub fn strikethrough(self) -> Self { + self.decoration(TextDecoration(DecorationKind::LineThrough)) + } + // Weight #[must_use] @@ -743,7 +758,7 @@ impl LineHeight { // Decoration #[allow(non_snake_case)] -pub fn TextDecoration<T: UIWidget + 'static>(kind: DecorationKind) -> DecorationWidget { +pub fn TextDecoration(kind: DecorationKind) -> DecorationWidget { DecorationWidget { kind: kind.to_value().to_string(), color: None, From 11e1bd975f7637e2eb93a0907b91c4e727aaae67 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Mon, 20 Jan 2025 03:20:44 +0100 Subject: [PATCH 24/45] update --- src/ui/color.rs | 2 ++ src/ui/mod.rs | 37 +++++++++++++++++++++++++++++++++++++ src/ui/primitives/div.rs | 6 +----- src/ui/primitives/text.rs | 2 +- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/ui/color.rs b/src/ui/color.rs index 13ba7cb..c42e898 100644 --- a/src/ui/color.rs +++ b/src/ui/color.rs @@ -12,6 +12,8 @@ pub trait ColorCircle { fn next(&self) -> Self; } +// todo : specific colors rgb + macro_rules! color_map { ($name:ident, $id:literal) => { #[derive(Debug, Clone)] diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0f5427a..acf089f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,6 @@ use components::Shell; use maud::{Markup, PreEscaped, Render}; +use prelude::Text; // UI @@ -133,6 +134,42 @@ impl UIWidget for PreEscaped<String> { } } +impl UIWidget for String { + fn can_inherit(&self) -> bool { + Text(&self).can_inherit() + } + + fn base_class(&self) -> Vec<String> { + Text(&self).base_class() + } + + fn extended_class(&self) -> Vec<String> { + Text(&self).extended_class() + } + + fn render_with_class(&self, class: &str) -> Markup { + Text(&self).render_with_class(class) + } +} + +impl UIWidget for &str { + fn can_inherit(&self) -> bool { + Text(&self).can_inherit() + } + + fn base_class(&self) -> Vec<String> { + Text(&self).base_class() + } + + fn extended_class(&self) -> Vec<String> { + Text(&self).extended_class() + } + + fn render_with_class(&self, class: &str) -> Markup { + Text(&self).render_with_class(class) + } +} + /// Trait for an element which can add new `attrs` pub trait AttrExtendable { #[must_use] diff --git a/src/ui/primitives/div.rs b/src/ui/primitives/div.rs index 3175d57..0d1c8d6 100644 --- a/src/ui/primitives/div.rs +++ b/src/ui/primitives/div.rs @@ -116,11 +116,7 @@ impl UIWidget for DivWidget { } fn extended_class(&self) -> Vec<String> { - if self.1 { - self.extended_class_() - } else { - vec![] - } + vec![] } fn render_with_class(&self, class: &str) -> Markup { diff --git a/src/ui/primitives/text.rs b/src/ui/primitives/text.rs index 82d9b4f..5230a1f 100644 --- a/src/ui/primitives/text.rs +++ b/src/ui/primitives/text.rs @@ -348,7 +348,7 @@ impl TextWidget { #[must_use] pub fn title(mut self, title: &str) -> Self { - self.title = Some(title.to_string()); + self.title = Some(title.replace('\'', "\\'")); self } From ec10e5a89dd4e1edf4abdfb7de08b6e2353449a9 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Mon, 20 Jan 2025 05:05:04 +0100 Subject: [PATCH 25/45] add transforms --- src/ui/mod.rs | 3 + src/ui/primitives/mod.rs | 3 + src/ui/primitives/transform.rs | 384 +++++++++++++++++++++++++++++++++ 3 files changed, 390 insertions(+) create mode 100644 src/ui/primitives/transform.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs index acf089f..602b811 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -46,6 +46,9 @@ pub mod prelude { TextDecoration, TextHyphens, TextOverflow, TextTransform, TextWhitespace, TextWordBreak, TextWrap, UnderlineOffset, VerticalTextAlignment, }; + pub use super::primitives::transform::{ + RenderTransformCPU, RenderTransformGPU, Rotate, Scale, Skew, Transform, TransformOrigin, + }; pub use super::primitives::visibility::Visibility; pub use super::primitives::width::{MaxWidth, MinWidth, Width}; pub use super::primitives::zindex::ZIndex; diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index d99d291..0e1c096 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -21,6 +21,7 @@ pub mod shadow; pub mod sized; pub mod space; pub mod text; +pub mod transform; pub mod visibility; pub mod width; pub mod zindex; @@ -104,6 +105,7 @@ pub enum Side { TopRight, BottomRight, BottomLeft, + Center, } impl Side { @@ -124,6 +126,7 @@ impl Side { Self::TopRight => "tr", Self::BottomRight => "br", Self::BottomLeft => "bl", + Self::Center => "center", } } } diff --git a/src/ui/primitives/transform.rs b/src/ui/primitives/transform.rs new file mode 100644 index 0000000..ff7a512 --- /dev/null +++ b/src/ui/primitives/transform.rs @@ -0,0 +1,384 @@ +use crate::ui::UIWidget; +use maud::{Markup, Render, html}; + +use super::{ + Side, + flex::Either, + space::{Fraction, ScreenValue}, +}; + +#[allow(non_snake_case)] +pub fn Scale<T: UIWidget + 'static>(size: f64, inner: T) -> ScaleWidget { + ScaleWidget(Box::new(inner), size, 0) +} + +pub struct ScaleWidget(Box<dyn UIWidget>, f64, u8); + +impl ScaleWidget { + pub fn x(mut self) -> Self { + self.2 = 1; + self + } + + pub fn y(mut self) -> Self { + self.2 = 2; + self + } +} + +impl Render for ScaleWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for ScaleWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + match self.2 { + 1 => vec![format!("scale-x-[{:.2}]", self.1)], + 2 => vec![format!("scale-y-[{:.2}]", self.1)], + _ => vec![format!("scale-[{:.2}]", self.1)], + } + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} + +#[allow(non_snake_case)] +pub fn Rotate<T: UIWidget + 'static>(deg: u32, inner: T) -> RotateWidget { + RotateWidget(Box::new(inner), deg) +} + +pub struct RotateWidget(Box<dyn UIWidget>, u32); + +impl Render for RotateWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for RotateWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec![format!("rotate-[{:.2}deg]", self.1)] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} + +#[allow(non_snake_case)] +pub fn RenderTransformCPU<T: UIWidget + 'static>(inner: T) -> HardwareAccelerationWidget { + HardwareAccelerationWidget(Box::new(inner), 0) +} + +#[allow(non_snake_case)] +pub fn RenderTransformGPU<T: UIWidget + 'static>(inner: T) -> HardwareAccelerationWidget { + HardwareAccelerationWidget(Box::new(inner), 1) +} + +pub struct HardwareAccelerationWidget(Box<dyn UIWidget>, u8); + +impl Render for HardwareAccelerationWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for HardwareAccelerationWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + match self.1 { + 1 => vec!["transform-gpu".to_string()], + _ => vec!["transform-cpu".to_string()], + } + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} + +#[allow(non_snake_case)] +pub fn Transform< + T: UIWidget + 'static, + X: Into<Either<ScreenValue, Fraction>>, + Y: Into<Either<ScreenValue, Fraction>>, +>( + x: Option<X>, + y: Option<Y>, + inner: T, +) -> TransformWidget { + TransformWidget { + inner: Box::new(inner), + x: x.map(|x| x.into()), + y: y.map(|y| y.into()), + } +} + +pub struct TransformWidget { + inner: Box<dyn UIWidget>, + x: Option<Either<ScreenValue, Fraction>>, + y: Option<Either<ScreenValue, Fraction>>, +} + +impl Render for TransformWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for TransformWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let mut ret = Vec::new(); + + if let Some(x) = &self.x { + ret.push(format!( + "translate-x-{}", + x.map(|x| x.to_value().to_string(), |x| x.to_value().to_string()) + )); + } + + if let Some(y) = &self.y { + ret.push(format!( + "translate-y-{}", + y.map(|y| y.to_value().to_string(), |y| y.to_value().to_string()) + )); + } + + ret + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.inner.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.inner.as_ref().can_inherit() { + self.inner + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.inner.as_ref()) + } + } + } + } +} + +pub enum SkewValue { + _0, + _1, + _2, + _3, + _6, + _12, +} + +impl SkewValue { + pub const fn to_value(&self) -> &str { + match self { + SkewValue::_0 => "0", + SkewValue::_1 => "1", + SkewValue::_2 => "2", + SkewValue::_3 => "3", + SkewValue::_6 => "6", + SkewValue::_12 => "12", + } + } +} + +#[allow(non_snake_case)] +pub fn Skew<T: UIWidget + 'static>( + x: Option<SkewValue>, + y: Option<SkewValue>, + inner: T, +) -> SkewWidget { + SkewWidget { + inner: Box::new(inner), + x, + y, + } +} + +pub struct SkewWidget { + inner: Box<dyn UIWidget>, + x: Option<SkewValue>, + y: Option<SkewValue>, +} + +impl Render for SkewWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for SkewWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let mut ret = Vec::new(); + + if let Some(x) = &self.x { + ret.push(format!("skew-x-{}", x.to_value())); + } + + if let Some(y) = &self.y { + ret.push(format!("skew-y-{}", y.to_value())); + } + + ret + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.inner.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.inner.as_ref().can_inherit() { + self.inner + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.inner.as_ref()) + } + } + } + } +} + +#[allow(non_snake_case)] +pub fn TransformOrigin<T: UIWidget + 'static>(origin: Side, inner: T) -> TransformOriginWidget { + TransformOriginWidget(Box::new(inner), origin) +} + +pub struct TransformOriginWidget(Box<dyn UIWidget>, Side); + +impl Render for TransformOriginWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for TransformOriginWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let side = match self.1 { + Side::Start => "top", + Side::End => "bottom", + Side::Top => "top", + Side::Right => "right", + Side::Bottom => "bottom", + Side::Left => "left", + Side::StartStart => "top-left", + Side::StartEnd => "top-right", + Side::EndEnd => "bottom-right", + Side::EndStart => "bottom-left", + Side::TopLeft => "top-left", + Side::TopRight => "top-right", + Side::BottomRight => "bottom-right", + Side::BottomLeft => "bottom-left", + Side::Center => "center", + }; + + vec![format!("origin-{side}")] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} From f7db3333c5714d5d3ec3606c682b7c3f4cc3595a Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Mon, 20 Jan 2025 05:51:22 +0100 Subject: [PATCH 26/45] add filters --- src/lib.rs | 2 + src/ui/mod.rs | 3 + src/ui/primitives/filter.rs | 265 +++++++++++++++++++++++++++++++++++ src/ui/primitives/mod.rs | 3 + src/ui/primitives/rounded.rs | 2 +- 5 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 src/ui/primitives/filter.rs diff --git a/src/lib.rs b/src/lib.rs index 1985369..809b519 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +#![feature(const_vec_string_slice)] + use tokio::sync::OnceCell; pub mod auth; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 602b811..90bb54f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -26,6 +26,9 @@ pub mod prelude { pub use super::primitives::container::Container; pub use super::primitives::cursor::Cursor; pub use super::primitives::div::Div; + pub use super::primitives::filter::{ + Blur, Brightness, Contrast, Grayscale, HueRotate, Invert, Saturate, Sepia, + }; pub use super::primitives::flex::{ Direction, Flex, FlexBasis, FlexGrow, Justify, Order, Strategy, Wrap, }; diff --git a/src/ui/primitives/filter.rs b/src/ui/primitives/filter.rs new file mode 100644 index 0000000..7598bc9 --- /dev/null +++ b/src/ui/primitives/filter.rs @@ -0,0 +1,265 @@ +use crate::ui::UIWidget; +use maud::{Markup, Render, html}; + +use super::Size; + +#[allow(non_snake_case)] +pub fn Blur<T: UIWidget + 'static>(amount: Size, inner: T) -> BlurWidget { + BlurWidget(Box::new(inner), amount, false) +} + +pub struct BlurWidget(Box<dyn UIWidget>, Size, bool); + +impl BlurWidget { + pub fn backdrop(mut self) -> Self { + self.2 = true; + self + } +} + +impl Render for BlurWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for BlurWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let class = match &self.1 { + Size::Custom(s) => &format!(" blur-[{s}]"), + Size::None => "blur-none", + Size::Small => "blur-sm", + Size::Regular => "blur", + Size::Medium => "blur-md", + Size::Large => "blur-lg", + Size::XL => "blur-xl", + Size::_2XL => "blur-2xl", + Size::_3XL => "blur-3xl", + Size::Full => "blur-3xl", + }; + + if self.2 { + return vec![format!("backdrop-{class}")]; + } + + vec![class.to_string()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} + +macro_rules! build_value_widget { + ($constr:ident, $widget:ident, $class:literal) => { + #[allow(non_snake_case)] + pub fn $constr<T: UIWidget + 'static>(value: f64, inner: T) -> $widget { + $widget(Box::new(inner), value, false) + } + + pub struct $widget(Box<dyn UIWidget>, f64, bool); + + impl $widget { + pub fn backdrop(mut self) -> Self { + self.2 = true; + self + } + } + + impl Render for $widget { + fn render(&self) -> Markup { + self.render_with_class("") + } + } + + impl UIWidget for $widget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let mut ret = $class.to_string(); + ret.push_str(&format!("-[{:.2}]", self.1)); + + if self.2 { + return vec![format!("backdrop-{ret}")]; + } + + vec![ret] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } + } + }; +} + +build_value_widget!(Brightness, BrightnessWidget, "brightness"); +build_value_widget!(Contrast, ConstrastWidget, "contrast"); +build_value_widget!(Saturate, SaturationWidget, "saturate"); + +macro_rules! build_on_off_widget { + ($constr:ident, $widget:ident, $class:literal) => { + #[allow(non_snake_case)] + pub fn $constr<T: UIWidget + 'static>(inner: T) -> $widget { + $widget(Box::new(inner), true, false) + } + + pub struct $widget(Box<dyn UIWidget>, bool, bool); + + impl $widget { + pub fn none(mut self) -> Self { + self.1 = false; + self + } + + pub fn backdrop(mut self) -> Self { + self.2 = true; + self + } + } + + impl Render for $widget { + fn render(&self) -> Markup { + self.render_with_class("") + } + } + + impl UIWidget for $widget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let class = if self.1 { + $class.to_string() + } else { + concat!($class, "-0").to_string() + }; + + if self.2 { + return vec![format!("backdrop-{class}")]; + } + + vec![class] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } + } + }; +} + +build_on_off_widget!(Grayscale, GrayscaleWidget, "grayscale"); +build_on_off_widget!(Invert, InvertWidget, "invert"); +build_on_off_widget!(Sepia, SepiaWidget, "sepia"); + +#[allow(non_snake_case)] +pub fn HueRotate<T: UIWidget + 'static>(deg: u32, inner: T) -> HueRotateWidget { + HueRotateWidget(Box::new(inner), deg, false) +} + +pub struct HueRotateWidget(Box<dyn UIWidget>, u32, bool); + +impl HueRotateWidget { + pub fn backdrop(mut self) -> Self { + self.2 = true; + self + } +} + +impl Render for HueRotateWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for HueRotateWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let class = format!("hue-rotate-[{:.2}deg]", self.1); + + if self.2 { + return vec![format!("backdrop-{class}")]; + } + + vec![class] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index 0e1c096..25fa8b7 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -8,6 +8,7 @@ pub mod background; pub mod container; pub mod cursor; pub mod div; +pub mod filter; pub mod flex; pub mod header; pub mod height; @@ -62,6 +63,7 @@ pub fn script(script: &str) -> PreEscaped<String> { } pub enum Size { + Custom(String), None, Small, Regular, @@ -77,6 +79,7 @@ impl Size { #[must_use] pub const fn to_value(&self) -> &str { match self { + Self::Custom(str) => str.as_str(), Self::None => "none", Self::Small => "sm", Self::Regular => "", diff --git a/src/ui/primitives/rounded.rs b/src/ui/primitives/rounded.rs index f716814..d448fe3 100644 --- a/src/ui/primitives/rounded.rs +++ b/src/ui/primitives/rounded.rs @@ -12,7 +12,7 @@ pub struct RoundedWidget(Box<dyn UIWidget>, Option<Size>, Option<Side>); impl RoundedWidget { #[must_use] - pub const fn size(mut self, size: Size) -> Self { + pub fn size(mut self, size: Size) -> Self { self.1 = Some(size); self } From 5d4aa21eddde93f31e52c2b44e5b72b7ca364f9a Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Mon, 20 Jan 2025 06:09:56 +0100 Subject: [PATCH 27/45] update --- src/ui/primitives/filter.rs | 214 ++++++++++++++++++++++++++++++++++++ src/ui/primitives/shadow.rs | 49 ++++++--- 2 files changed, 247 insertions(+), 16 deletions(-) diff --git a/src/ui/primitives/filter.rs b/src/ui/primitives/filter.rs index 7598bc9..3218387 100644 --- a/src/ui/primitives/filter.rs +++ b/src/ui/primitives/filter.rs @@ -263,3 +263,217 @@ impl UIWidget for HueRotateWidget { } } } + +#[allow(non_snake_case)] +pub fn Opacity<T: UIWidget + 'static>(value: f64, inner: T) -> OpacityWidget { + OpacityWidget(Box::new(inner), value, false) +} + +pub struct OpacityWidget(Box<dyn UIWidget>, f64, bool); + +impl OpacityWidget { + pub fn backdrop(mut self) -> Self { + self.2 = true; + self + } +} + +impl Render for OpacityWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for OpacityWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let class = match self.1 { + 0.0 => "opacity-0", + 0.05 => "opacity-5", + 0.1 => "opacity-10", + 0.15 => "opacity-15", + 0.2 => "opacity-20", + 0.25 => "opacity-25", + 0.3 => "opacity-30", + 0.35 => "opacity-35", + 0.4 => "opacity-40", + 0.45 => "opacity-45", + 0.5 => "opacity-50", + 0.55 => "opacity-55", + 0.6 => "opacity-60", + 0.65 => "opacity-65", + 0.7 => "opacity-70", + 0.75 => "opacity-75", + 0.8 => "opacity-80", + 0.85 => "opacity-85", + 0.9 => "opacity-90", + 0.95 => "opacity-95", + 1.0 => "opacity-100", + _ => &format!("opacity-[{:.2}]", self.1), + }; + + if self.2 { + return vec![format!("backdrop-{class}")]; + } + + vec![class.to_string()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} + +pub enum BlendMode { + Normal, + Multiply, + Screen, + Overlay, + Darken, + Lighten, + ColorDodge, + ColorBurn, + HardLight, + SoftLight, + Difference, + Exclusion, + Hue, + Saturation, + Color, + Luminosity, + PlusDarker, + PlusLighter, +} + +impl BlendMode { + pub const fn to_value(&self) -> &str { + match self { + BlendMode::Normal => "normal", + BlendMode::Multiply => "multiply", + BlendMode::Screen => "screen", + BlendMode::Overlay => "overlay", + BlendMode::Darken => "darken", + BlendMode::Lighten => "lighten", + BlendMode::ColorDodge => "color-dodge", + BlendMode::ColorBurn => "color-burn", + BlendMode::HardLight => "hard-light", + BlendMode::SoftLight => "soft-light", + BlendMode::Difference => "difference", + BlendMode::Exclusion => "exclusion", + BlendMode::Hue => "hue", + BlendMode::Saturation => "saturation", + BlendMode::Color => "color", + BlendMode::Luminosity => "luminosity", + BlendMode::PlusDarker => "plus-darker", + BlendMode::PlusLighter => "plus-lighter", + } + } +} + +#[allow(non_snake_case)] +pub fn MixBlendMode<T: UIWidget + 'static>(mode: BlendMode, inner: T) -> MixBlendModeWidget { + MixBlendModeWidget(Box::new(inner), mode) +} + +pub struct MixBlendModeWidget(Box<dyn UIWidget>, BlendMode); + +impl Render for MixBlendModeWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for MixBlendModeWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec![format!("mix-blend-{}", self.1.to_value())] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} + +#[allow(non_snake_case)] +pub fn BackgroundBlendMode<T: UIWidget + 'static>( + mode: BlendMode, + inner: T, +) -> BackgroundBlendModeWidget { + BackgroundBlendModeWidget(Box::new(inner), mode) +} + +pub struct BackgroundBlendModeWidget(Box<dyn UIWidget>, BlendMode); + +impl Render for BackgroundBlendModeWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for BackgroundBlendModeWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec![format!("bg-blend-{}", self.1.to_value())] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/primitives/shadow.rs b/src/ui/primitives/shadow.rs index 92f2323..5e3cc64 100644 --- a/src/ui/primitives/shadow.rs +++ b/src/ui/primitives/shadow.rs @@ -1,35 +1,46 @@ -use crate::ui::UIWidget; +use crate::ui::{UIWidget, color::UIColor}; use maud::{Markup, Render, html}; -pub struct Shadow(Box<dyn UIWidget>, String); +pub struct Shadow(Box<dyn UIWidget>, String, Option<Box<dyn UIColor>>); impl Shadow { - pub fn medium<T: UIWidget + 'static>(inner: T) -> Self { - Self(Box::new(inner), "md".to_owned()) - } - pub fn small<T: UIWidget + 'static>(inner: T) -> Self { - Self(Box::new(inner), "sm".to_owned()) + Self(Box::new(inner), "sm".to_owned(), None) } pub fn regular<T: UIWidget + 'static>(inner: T) -> Self { - Self(Box::new(inner), String::new()) + Self(Box::new(inner), String::new(), None) + } + + pub fn medium<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "md".to_owned(), None) } pub fn large<T: UIWidget + 'static>(inner: T) -> Self { - Self(Box::new(inner), "lg".to_owned()) - } - - pub fn none<T: UIWidget + 'static>(inner: T) -> Self { - Self(Box::new(inner), "none".to_owned()) + Self(Box::new(inner), "lg".to_owned(), None) } pub fn xl<T: UIWidget + 'static>(inner: T) -> Self { - Self(Box::new(inner), "xl".to_owned()) + Self(Box::new(inner), "xl".to_owned(), None) } pub fn _2xl<T: UIWidget + 'static>(inner: T) -> Self { - Self(Box::new(inner), "2xl".to_owned()) + Self(Box::new(inner), "2xl".to_owned(), None) + } + + pub fn inner<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "inner".to_owned(), None) + } + + pub fn none<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "none".to_owned(), None) + } +} + +impl Shadow { + pub fn color<C: UIColor + 'static>(mut self, color: C) -> Self { + self.2 = Some(Box::new(color)); + self } } @@ -45,11 +56,17 @@ impl UIWidget for Shadow { } fn base_class(&self) -> Vec<String> { - if self.1.is_empty() { + let mut ret = if self.1.is_empty() { vec!["shadow".to_string()] } else { vec![format!("shadow-{}", self.1)] + }; + + if let Some(color) = &self.2 { + ret.push(format!("shadow-{}", color.color_class())); } + + ret } fn extended_class(&self) -> Vec<String> { From 35669c423cb0007d2fa577519cbbd9a7dfbfcd3d Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Mon, 20 Jan 2025 06:44:06 +0100 Subject: [PATCH 28/45] add border --- src/ui/mod.rs | 1 + src/ui/primitives/border.rs | 166 ++++++++++++++++++++++++++++++++++++ src/ui/primitives/flex.rs | 109 ++++++++++++++++++++++- src/ui/primitives/mod.rs | 1 + 4 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 src/ui/primitives/border.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 90bb54f..3bce85f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -23,6 +23,7 @@ pub mod prelude { pub use super::primitives::animation::{Animated, Animation, Delay, Duration, Scope, Timing}; pub use super::primitives::aspect::Aspect; pub use super::primitives::background::Background; + pub use super::primitives::border::{Border, BorderSide, BorderSize, BorderStyle}; pub use super::primitives::container::Container; pub use super::primitives::cursor::Cursor; pub use super::primitives::div::Div; diff --git a/src/ui/primitives/border.rs b/src/ui/primitives/border.rs new file mode 100644 index 0000000..12bfd56 --- /dev/null +++ b/src/ui/primitives/border.rs @@ -0,0 +1,166 @@ +use maud::{Markup, Render, html}; + +use crate::ui::{UIWidget, color::UIColor}; + +pub enum BorderSize { + _0, + _2, + _4, + _8, +} + +impl BorderSize { + pub const fn to_value(&self) -> &str { + match self { + BorderSize::_0 => "0", + BorderSize::_2 => "2", + BorderSize::_4 => "4", + BorderSize::_8 => "8", + } + } +} + +pub enum BorderSide { + X, + Y, + Start, + End, + Top, + Right, + Bottom, + Left, +} + +impl BorderSide { + pub const fn to_value(&self) -> &str { + match self { + BorderSide::X => "x", + BorderSide::Y => "y", + BorderSide::Start => "s", + BorderSide::End => "e", + BorderSide::Top => "t", + BorderSide::Right => "r", + BorderSide::Bottom => "b", + BorderSide::Left => "l", + } + } +} + +pub enum BorderStyle { + Solid, + Dashed, + Dotted, + Double, + Hidden, + None, +} + +impl BorderStyle { + pub const fn to_value(&self) -> &str { + match self { + BorderStyle::Solid => "border-solid", + BorderStyle::Dashed => "border-dashed", + BorderStyle::Dotted => "border-dotted", + BorderStyle::Double => "border-double", + BorderStyle::Hidden => "border-hidden", + BorderStyle::None => "border-none", + } + } +} + +#[allow(non_snake_case)] +pub fn Border<T: UIWidget + 'static>(inner: T) -> BorderWidget { + BorderWidget(Box::new(inner), None, None, None, None) +} + +pub struct BorderWidget( + Box<dyn UIWidget>, + Option<BorderSize>, + Option<BorderSide>, + Option<Box<dyn UIColor>>, + Option<BorderStyle>, +); + +impl BorderWidget { + #[must_use] + pub fn size(mut self, size: BorderSize) -> Self { + self.1 = Some(size); + self + } + + #[must_use] + pub const fn side(mut self, side: BorderSide) -> Self { + self.2 = Some(side); + self + } + + #[must_use] + pub const fn style(mut self, style: BorderStyle) -> Self { + self.4 = Some(style); + self + } + + #[must_use] + pub fn color<C: UIColor + 'static>(mut self, color: C) -> Self { + self.3 = Some(Box::new(color)); + self + } + + fn border_class(&self) -> String { + if let Some(side) = &self.2 { + if let Some(size) = &self.1 { + return format!("border-{}-{}", side.to_value(), size.to_value()); + } + } else if let Some(size) = &self.1 { + return format!("border-{}", size.to_value()); + } + + "border".to_owned() + } +} + +impl Render for BorderWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for BorderWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let mut ret = vec![self.border_class()]; + + if let Some(color) = &self.3 { + ret.push(format!("border-{}", color.color_class())); + } + + if let Some(style) = &self.4 { + ret.push(style.to_value().to_string()); + } + + ret + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/primitives/flex.rs b/src/ui/primitives/flex.rs index b662362..2c9013a 100644 --- a/src/ui/primitives/flex.rs +++ b/src/ui/primitives/flex.rs @@ -1,11 +1,11 @@ -use crate::ui::UIWidget; +use crate::ui::{UIWidget, color::UIColor}; use maud::{Markup, Render, html}; use super::space::{Fraction, ScreenValue}; #[allow(non_snake_case)] pub fn Flex<T: UIWidget + 'static>(inner: T) -> FlexWidget { - FlexWidget(Box::new(inner), vec![], false) + FlexWidget(Box::new(inner), vec![], false, None) } pub enum Justify { @@ -19,7 +19,7 @@ pub enum Justify { Stretch, } -pub struct FlexWidget(Box<dyn UIWidget>, Vec<String>, bool); +pub struct FlexWidget(Box<dyn UIWidget>, Vec<String>, bool, Option<Direction>); impl Render for FlexWidget { fn render(&self) -> Markup { @@ -41,9 +41,65 @@ impl FlexWidget { self } + #[must_use] + pub fn divide_style(mut self, style: DivideStyle) -> Self { + self.1.push(style.to_value().to_string()); + self + } + + #[must_use] + pub fn divide_color<C: UIColor + 'static>(mut self, color: C) -> Self { + self.1.push(format!("divide-{}", color.color_class())); + self + } + + #[must_use] + pub fn divide_x(mut self, width: DivideWidth) -> Self { + let reversed = self + .3 + .as_ref() + .map(|x| match x { + Direction::Row => false, + Direction::RowReverse => true, + Direction::Column => false, + Direction::ColumnReverse => true, + }) + .unwrap_or_default(); + + self.1.push(format!("divide-x-{}", width.to_value())); + + if reversed { + self.1.push("divide-x-reverse".to_string()); + } + + self + } + + #[must_use] + pub fn divide_y(mut self, width: DivideWidth) -> Self { + let reversed = self + .3 + .as_ref() + .map(|x| match x { + Direction::Row => false, + Direction::RowReverse => true, + Direction::Column => false, + Direction::ColumnReverse => true, + }) + .unwrap_or_default(); + + self.1.push(format!("divide-y-{}", width.to_value())); + + if reversed { + self.1.push("divide-y-reverse".to_string()); + } + + self + } + #[must_use] pub fn direction(mut self, direction: Direction) -> Self { - self.1.push(format!("flex-{}", direction.to_value())); + self.3 = Some(direction); self } @@ -95,6 +151,26 @@ impl FlexWidget { } } +pub enum DivideWidth { + Custom(u64), + _0, + _2, + _4, + _8, +} + +impl DivideWidth { + pub fn to_value(&self) -> String { + match self { + DivideWidth::Custom(s) => format!("[{s}px]"), + DivideWidth::_0 => "0".to_string(), + DivideWidth::_2 => "2".to_string(), + DivideWidth::_4 => "4".to_string(), + DivideWidth::_8 => "8".to_string(), + } + } +} + pub enum Direction { Row, RowReverse, @@ -136,6 +212,11 @@ impl UIWidget for FlexWidget { fn base_class(&self) -> Vec<String> { let mut res = vec!["flex".to_string()]; + + if let Some(direction) = &self.3 { + res.push(format!("flex-{}", direction.to_value())); + } + res.extend_from_slice(&self.1); res } @@ -399,3 +480,23 @@ impl UIWidget for OrderWidget { } } } + +pub enum DivideStyle { + Solid, + Dashed, + Dotted, + Double, + None, +} + +impl DivideStyle { + pub const fn to_value(&self) -> &str { + match self { + DivideStyle::Solid => "divide-solid", + DivideStyle::Dashed => "divide-dashed", + DivideStyle::Dotted => "divide-dotted", + DivideStyle::Double => "divide-double", + DivideStyle::None => "divide-none", + } + } +} diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index 25fa8b7..c2ae3dc 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -5,6 +5,7 @@ use super::UIWidget; pub mod animation; pub mod aspect; pub mod background; +pub mod border; pub mod container; pub mod cursor; pub mod div; From bc27b457ea69eaef41c4c2dd77b668de5cb7423e Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Mon, 20 Jan 2025 08:41:20 +0100 Subject: [PATCH 29/45] add svg --- src/ui/components/shell.rs | 2 + src/ui/mod.rs | 1 + src/ui/primitives/mod.rs | 1 + src/ui/primitives/svg.rs | 76 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 src/ui/primitives/svg.rs diff --git a/src/ui/components/shell.rs b/src/ui/components/shell.rs index bdcca62..2bc0396 100644 --- a/src/ui/components/shell.rs +++ b/src/ui/components/shell.rs @@ -1,5 +1,7 @@ use maud::{PreEscaped, html}; +// TODO : refactor shell + /// Represents the HTML structure of a page shell, including the head, body class, and body content. /// /// This structure is used to construct the overall HTML structure of a page, making it easier to generate consistent HTML pages dynamically. diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 3bce85f..9b06d13 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -44,6 +44,7 @@ pub mod prelude { pub use super::primitives::shadow::Shadow; pub use super::primitives::sized::Sized; pub use super::primitives::space::{ScreenValue, SpaceBetween}; + pub use super::primitives::svg::SVG; pub use super::primitives::text::{ Code, DecorationKind, DecorationStyle, DecorationThickness, LetterSpacing, LineClamp, LineHeight, ListStyle, NumberStyle, Paragraph, Span, Text, TextAlignment, TextContent, diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index c2ae3dc..1ddc144 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -22,6 +22,7 @@ pub mod rounded; pub mod shadow; pub mod sized; pub mod space; +pub mod svg; pub mod text; pub mod transform; pub mod visibility; diff --git a/src/ui/primitives/svg.rs b/src/ui/primitives/svg.rs new file mode 100644 index 0000000..79de1cf --- /dev/null +++ b/src/ui/primitives/svg.rs @@ -0,0 +1,76 @@ +use maud::{Markup, Render, html}; + +use crate::ui::{UIWidget, color::UIColor}; + +#[allow(non_snake_case)] +pub fn SVG<T: UIWidget + 'static>(inner: T) -> SVGWidget { + SVGWidget(Box::new(inner), None, None, None) +} + +pub struct SVGWidget( + Box<dyn UIWidget>, + Option<Box<dyn UIColor>>, + Option<Box<dyn UIColor>>, + Option<u32>, +); + +impl SVGWidget { + pub fn fill<C: UIColor + 'static>(mut self, color: C) -> Self { + self.1 = Some(Box::new(color)); + self + } + + pub fn stroke<C: UIColor + 'static>(mut self, color: C) -> Self { + self.2 = Some(Box::new(color)); + self + } + + pub fn stroke_width(mut self, width: u32) -> Self { + self.3 = Some(width); + self + } +} + +impl Render for SVGWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for SVGWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + let mut ret = vec![]; + + if let Some(fill) = &self.1 { + ret.push(format!("fill-{}", fill.color_class())); + } + + if let Some(stroke) = &self.2 { + ret.push(format!("stroke-{}", stroke.color_class())); + } + + if let Some(stroke_width) = &self.3 { + ret.push(format!("stroke-[{stroke_width}px]")); + } + + ret + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, _: &str) -> Markup { + html! { + svg class=(self.base_class().join(" ")) { + (self.0.as_ref()) + } + } + } +} From ddd2e363c2f1a22c15527404dc986bc0e01910ff Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Mon, 20 Jan 2025 09:20:15 +0100 Subject: [PATCH 30/45] add more borders --- src/ui/mod.rs | 4 +- src/ui/primitives/border.rs | 231 ++++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 1 deletion(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9b06d13..bb8a803 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -23,7 +23,9 @@ pub mod prelude { pub use super::primitives::animation::{Animated, Animation, Delay, Duration, Scope, Timing}; pub use super::primitives::aspect::Aspect; pub use super::primitives::background::Background; - pub use super::primitives::border::{Border, BorderSide, BorderSize, BorderStyle}; + pub use super::primitives::border::{ + Border, BorderSide, BorderSize, BorderStyle, Outline, OutlineStyle, Ring, + }; pub use super::primitives::container::Container; pub use super::primitives::cursor::Cursor; pub use super::primitives::div::Div; diff --git a/src/ui/primitives/border.rs b/src/ui/primitives/border.rs index 12bfd56..c7e14c3 100644 --- a/src/ui/primitives/border.rs +++ b/src/ui/primitives/border.rs @@ -164,3 +164,234 @@ impl UIWidget for BorderWidget { } } } + +pub enum OutlineStyle { + Solid, + Dashed, + Dotted, + Double, + None, +} + +impl OutlineStyle { + pub const fn to_value(&self) -> &str { + match self { + OutlineStyle::Solid => "outline", + OutlineStyle::Dashed => "outline-dashed", + OutlineStyle::Dotted => "outline-dotted", + OutlineStyle::Double => "outline-double", + OutlineStyle::None => "outline-none", + } + } +} + +#[allow(non_snake_case)] +pub fn Outline<T: UIWidget + 'static>(width: u32, inner: T) -> OutlineWidget { + OutlineWidget(Box::new(inner), width, None, None, 0) +} + +pub struct OutlineWidget( + Box<dyn UIWidget>, + u32, + Option<Box<dyn UIColor>>, + Option<OutlineStyle>, + u32, +); + +impl OutlineWidget { + #[must_use] + pub const fn offset(mut self, offset: u32) -> Self { + self.4 = offset; + self + } + + #[must_use] + pub const fn style(mut self, style: OutlineStyle) -> Self { + self.3 = Some(style); + self + } + + #[must_use] + pub fn color<C: UIColor + 'static>(mut self, color: C) -> Self { + self.2 = Some(Box::new(color)); + self + } +} + +impl Render for OutlineWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for OutlineWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let class = match self.1 { + 0 => "outline-0", + 1 => "outline-1", + 2 => "outline-2", + 4 => "outline-4", + 8 => "outline-8", + _ => &format!("outline-[{}px]", self.1), + }; + + let mut ret = vec![class.to_string()]; + + if let Some(color) = &self.2 { + ret.push(format!("outline-{}", color.color_class())); + } + + if let Some(style) = &self.3 { + ret.push(style.to_value().to_string()); + } + + ret.push(match self.4 { + 0 => "outline-offset-0".to_string(), + 1 => "outline-offset-1".to_string(), + 2 => "outline-offset-2".to_string(), + 4 => "outline-offset-4".to_string(), + 8 => "outline-offset-8".to_string(), + _ => format!("outline-offset-[{}px]", self.4), + }); + + ret + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} + +#[allow(non_snake_case)] +pub fn Ring<T: UIWidget + 'static>(width: u32, inner: T) -> RingWidget { + RingWidget(Box::new(inner), width, None, false, 0, None) +} + +pub struct RingWidget( + // Inner + Box<dyn UIWidget>, + // Size + u32, + // Color + Option<Box<dyn UIColor>>, + // Inset + bool, + // Offset Width + u32, + // Offset Color + Option<Box<dyn UIColor>>, +); + +impl RingWidget { + #[must_use] + pub const fn inset(mut self) -> Self { + self.3 = true; + self + } + + #[must_use] + pub const fn offset_width(mut self, offset: u32) -> Self { + self.4 = offset; + self + } + + #[must_use] + pub fn offset_color<C: UIColor + 'static>(mut self, color: C) -> Self { + self.5 = Some(Box::new(color)); + self + } + + #[must_use] + pub fn color<C: UIColor + 'static>(mut self, color: C) -> Self { + self.2 = Some(Box::new(color)); + self + } +} + +impl Render for RingWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for RingWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let class = match self.1 { + 0 => "ring-0", + 1 => "ring-1", + 2 => "ring-2", + 4 => "ring-4", + 8 => "ring-8", + _ => &format!("ring-[{}px]", self.1), + }; + + let mut ret = vec![class.to_string()]; + + if let Some(color) = &self.2 { + ret.push(format!("ring-{}", color.color_class())); + } + + if self.3 { + ret.push("ring-inset".to_string()); + } + + ret.push(match self.4 { + 0 => "ring-offset-0".to_string(), + 1 => "ring-offset-1".to_string(), + 2 => "ring-offset-2".to_string(), + 4 => "ring-offset-4".to_string(), + 8 => "ring-offset-8".to_string(), + _ => format!("ring-offset-[{}px]", self.4), + }); + + if let Some(color) = &self.5 { + ret.push(format!("ring-offset-{}", color.color_class())); + } + + ret + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} From 01e33afd938bb0553ec4aba0bc7fa42b9cabff47 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Mon, 20 Jan 2025 12:18:27 +0100 Subject: [PATCH 31/45] background --- src/ui/color.rs | 78 +++++++++ src/ui/components/appbar.rs | 10 +- src/ui/primitives/background.rs | 288 +++++++++++++++++++++++++++++++- 3 files changed, 363 insertions(+), 13 deletions(-) diff --git a/src/ui/color.rs b/src/ui/color.rs index c42e898..4671712 100644 --- a/src/ui/color.rs +++ b/src/ui/color.rs @@ -133,3 +133,81 @@ impl UIColor for Colors { } } } + +// TODO : Gradient + +pub struct Gradient { + start: Box<dyn UIColor>, + middle: Option<Box<dyn UIColor>>, + end: Option<Box<dyn UIColor>>, + pos_start: Option<u8>, + pos_middle: Option<u8>, + pos_end: Option<u8>, +} + +impl Gradient { + pub fn from<C: UIColor + 'static>(start: C) -> Self { + Self { + start: Box::new(start), + middle: None, + end: None, + pos_end: None, + pos_middle: None, + pos_start: None, + } + } + + pub fn via<C: UIColor + 'static>(mut self, middle: C) -> Self { + self.middle = Some(Box::new(middle)); + self + } + + pub fn to<C: UIColor + 'static>(mut self, end: C) -> Self { + self.end = Some(Box::new(end)); + self + } + + pub fn step_start(mut self, percentage: u8) -> Self { + assert!(percentage <= 100, "Percentage should be under 100%"); + self.pos_start = Some(percentage); + self + } + + pub fn step_middle(mut self, percentage: u8) -> Self { + assert!(percentage <= 100, "Percentage should be under 100%"); + self.pos_middle = Some(percentage); + self + } + + pub fn step_end(mut self, percentage: u8) -> Self { + assert!(percentage <= 100, "Percentage should be under 100%"); + self.pos_end = Some(percentage); + self + } + + pub fn color_class(&self) -> Vec<String> { + let mut classes = vec![format!("from-{}", self.start.color_class())]; + + if let Some(via) = &self.middle { + classes.push(format!("via-{}", via.color_class())); + } + + if let Some(end) = &self.end { + classes.push(format!("to-{}", end.color_class())); + } + + if let Some(step) = &self.pos_start { + classes.push(format!("from-{step}%")); + } + + if let Some(step) = &self.pos_middle { + classes.push(format!("via-{step}%")); + } + + if let Some(step) = &self.pos_end { + classes.push(format!("to-{step}%")); + } + + classes + } +} diff --git a/src/ui/components/appbar.rs b/src/ui/components/appbar.rs index 50fbbb8..8145568 100644 --- a/src/ui/components/appbar.rs +++ b/src/ui/components/appbar.rs @@ -38,9 +38,8 @@ impl UIWidget for AppBarWidget { } fn render_with_class(&self, _: &str) -> Markup { - Padding(Shadow::medium(Background( - Gray::_800, - Header( + Padding(Shadow::medium( + Background(Header( Padding( Flex( Div() @@ -70,8 +69,9 @@ impl UIWidget for AppBarWidget { .items_center(), ) .x(ScreenValue::_6), - ), - ))) + )) + .color(Gray::_800), + )) .y(ScreenValue::_2) .render() } diff --git a/src/ui/primitives/background.rs b/src/ui/primitives/background.rs index e07503e..dbd000f 100644 --- a/src/ui/primitives/background.rs +++ b/src/ui/primitives/background.rs @@ -1,16 +1,98 @@ use maud::{Markup, Render, html}; -use crate::ui::{UIWidget, color::UIColor}; +use crate::ui::{ + UIWidget, + color::{Gradient, UIColor}, +}; #[allow(non_snake_case)] -pub fn Background<T: UIWidget + 'static, C: UIColor + 'static>( - color: C, - inner: T, -) -> BackgroundWidget { - BackgroundWidget(Box::new(inner), Box::new(color)) +pub fn Background<T: UIWidget + 'static>(inner: T) -> BackgroundWidget { + BackgroundWidget( + Box::new(inner), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ) } -pub struct BackgroundWidget(Box<dyn UIWidget>, Box<dyn UIColor>); +pub struct BackgroundWidget( + // Inner + Box<dyn UIWidget>, + // Background Color + Option<Box<dyn UIColor>>, + // Background Attachment + Option<BackgroundScrollAttachment>, + Option<BackgroundClip>, + Option<BackgroundOrigin>, + Option<BackgroundRepeat>, + Option<BackgroundSize>, + // Background Image URL + Option<String>, + // Gradient + Option<BackgroundGradient>, + Option<Gradient>, + Option<BackgroundPosition>, +); + +impl BackgroundWidget { + pub fn color<C: UIColor + 'static>(mut self, color: C) -> Self { + self.1 = Some(Box::new(color)); + self + } + + pub fn image(mut self, url: &str) -> Self { + self.7 = Some(url.to_string()); + self + } + + pub fn none(mut self) -> Self { + self.8 = Some(BackgroundGradient::None); + self + } + + pub fn gradient(mut self, direction: BackgroundGradient, gradient: Gradient) -> Self { + self.8 = Some(direction); + self.9 = Some(gradient); + self + } + + pub fn position(mut self, position: BackgroundPosition) -> Self { + self.10 = Some(position); + self + } + + pub fn scroll(mut self, attachment: BackgroundScrollAttachment) -> Self { + self.2 = Some(attachment); + self + } + + pub fn clip(mut self, clip: BackgroundClip) -> Self { + self.3 = Some(clip); + self + } + + pub fn origin(mut self, origin: BackgroundOrigin) -> Self { + self.4 = Some(origin); + self + } + + pub fn repeat(mut self, repeat: BackgroundRepeat) -> Self { + self.5 = Some(repeat); + self + } + + pub fn size(mut self, size: BackgroundSize) -> Self { + self.6 = Some(size); + self + } +} impl Render for BackgroundWidget { fn render(&self) -> Markup { @@ -24,7 +106,49 @@ impl UIWidget for BackgroundWidget { } fn base_class(&self) -> Vec<String> { - vec![format!("bg-{}", self.1.color_class())] + let mut ret = Vec::new(); + + if let Some(color) = &self.1 { + ret.push(format!("bg-{}", color.color_class())); + } + + if let Some(attachment) = &self.2 { + ret.push(attachment.to_value().to_string()); + } + + if let Some(clip) = &self.3 { + ret.push(clip.to_value().to_string()); + } + + if let Some(origin) = &self.4 { + ret.push(origin.to_value().to_string()); + } + + if let Some(repeat) = &self.5 { + ret.push(repeat.to_value().to_string()); + } + + if let Some(size) = &self.6 { + ret.push(size.to_value().to_string()); + } + + if let Some(image) = &self.7 { + ret.push(format!("bg-[url('{image}')]")); + } + + if let Some(gradient) = &self.8 { + ret.push(gradient.to_value().to_string()); + } + + if let Some(gradient) = &self.9 { + ret.extend_from_slice(&gradient.color_class()); + } + + if let Some(position) = &self.10 { + ret.push(position.to_value().to_string()); + } + + ret } fn extended_class(&self) -> Vec<String> { @@ -47,3 +171,151 @@ impl UIWidget for BackgroundWidget { } } } + +/// Controlling how a background image behaves when scrolling. +pub enum BackgroundScrollAttachment { + /// Fix the background image relative to the viewport. + Fixed, + /// Scroll the background image with the container and the viewport. + Local, + /// Scroll the background image with the viewport, but not with the container. + Scroll, +} + +impl BackgroundScrollAttachment { + pub const fn to_value(&self) -> &str { + match self { + BackgroundScrollAttachment::Fixed => "bg-fixed", + BackgroundScrollAttachment::Local => "bg-local", + BackgroundScrollAttachment::Scroll => "bg-scroll", + } + } +} + +pub enum BackgroundClip { + Border, + Padding, + Content, + Text, +} + +impl BackgroundClip { + pub const fn to_value(&self) -> &str { + match self { + BackgroundClip::Border => "bg-clip-border", + BackgroundClip::Padding => "bg-clip-padding", + BackgroundClip::Content => "bg-clip-content", + BackgroundClip::Text => "bg-clip-text", + } + } +} + +pub enum BackgroundOrigin { + Border, + Padding, + Content, +} + +impl BackgroundOrigin { + pub const fn to_value(&self) -> &str { + match self { + BackgroundOrigin::Border => "bg-origin-border", + BackgroundOrigin::Padding => "bg-origin-padding", + BackgroundOrigin::Content => "bg-origin-content", + } + } +} + +pub enum BackgroundRepeat { + Repeat, + NoRepeat, + RepeatX, + RepeatY, + Round, + Space, +} + +impl BackgroundRepeat { + pub const fn to_value(&self) -> &str { + match self { + BackgroundRepeat::Repeat => "bg-repeat", + BackgroundRepeat::NoRepeat => "bg-no-repeat", + BackgroundRepeat::RepeatX => "bg-repeat-x", + BackgroundRepeat::RepeatY => "bg-repeat-y", + BackgroundRepeat::Round => "bg-repeat-round", + BackgroundRepeat::Space => "bg-repeat-space", + } + } +} + +pub enum BackgroundSize { + Auto, + Cover, + Contain, +} + +impl BackgroundSize { + pub const fn to_value(&self) -> &str { + match self { + BackgroundSize::Auto => "bg-auto", + BackgroundSize::Cover => "bg-cover", + BackgroundSize::Contain => "bg-contain", + } + } +} + +pub enum BackgroundGradient { + None, + ToTop, + ToTopRight, + ToRight, + ToBottomRight, + ToBottom, + ToBottomLeft, + ToLeft, + ToTopLeft, +} + +impl BackgroundGradient { + pub const fn to_value(&self) -> &str { + match self { + BackgroundGradient::None => "bg-none", + BackgroundGradient::ToTop => "bg-gradient-to-t", + BackgroundGradient::ToTopRight => "bg-gradient-to-tr", + BackgroundGradient::ToRight => "bg-gradient-to-r", + BackgroundGradient::ToBottomRight => "bg-gradient-to-br", + BackgroundGradient::ToBottom => "bg-gradient-to-b", + BackgroundGradient::ToBottomLeft => "bg-gradient-to-bl", + BackgroundGradient::ToLeft => "bg-gradient-to-l", + BackgroundGradient::ToTopLeft => "bg-gradient-to-tl", + } + } +} + +pub enum BackgroundPosition { + Bottom, + Center, + Left, + LeftBottom, + LeftTop, + Right, + RightBottom, + RightTop, + Top, +} + +impl BackgroundPosition { + pub const fn to_value(&self) -> &str { + match self { + BackgroundPosition::Bottom => "bg-bottom", + BackgroundPosition::Center => "bg-center", + BackgroundPosition::Left => "bg-left", + BackgroundPosition::LeftBottom => "bg-left-bottom", + BackgroundPosition::LeftTop => "bg-left-top", + BackgroundPosition::Right => "bg-right", + BackgroundPosition::RightBottom => "bg-right-bottom", + BackgroundPosition::RightTop => "bg-right-top", + BackgroundPosition::Top => "bg-top", + } + } +} From e98242addf2055c2bd48265e696a96c3d8864a76 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Mon, 20 Jan 2025 22:18:21 +0100 Subject: [PATCH 32/45] update modifiers --- src/ui/wrapper/mod.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/ui/wrapper/mod.rs b/src/ui/wrapper/mod.rs index 4fac146..06654a3 100644 --- a/src/ui/wrapper/mod.rs +++ b/src/ui/wrapper/mod.rs @@ -74,6 +74,29 @@ macro_rules! wrapper { } wrapper!(Hover, HoverWrapper, "hover"); +wrapper!(DarkMode, DarkModeWrapper, "dark"); +wrapper!(Active, ActiveWrapper, "active"); +wrapper!(Focus, FocusWrapper, "focus"); +wrapper!(First, FirstWrapper, "first"); +wrapper!(Odd, OddWrapper, "odd"); +wrapper!(Even, EvenWrapper, "even"); + +wrapper!(Required, RequiredWrapper, "required"); +wrapper!(Invalid, InvalidWrapper, "invalid"); +wrapper!(Disabled, DisabledWrapper, "disabled"); +wrapper!(Placeholder, PlaceholderWrapper, "placeholder"); +wrapper!(FileButton, FileButtonWrapper, "file"); +wrapper!(Marker, MarkerWrapper, "marker"); +wrapper!(Selection, SelectionWrapper, "selection"); +wrapper!(FirstLine, FirstLineWrapper, "first-line"); +wrapper!(FirstLetter, FirstLetterWrapper, "first-letter"); + +wrapper!(Portrait, PortraitWrapper, "portrait"); +wrapper!(Landscape, LandscapeWrapper, "landscape"); +wrapper!(Print, PrintWrapper, "print"); +wrapper!(LeftToRight, LeftToRightWrapper, "ltr"); +wrapper!(RightToLeft, RightToLeftWrapper, "rtl"); +wrapper!(Opened, OpenWrapper, "open"); wrapper!(SmallScreen, SmallScreenWrapper, "sm"); wrapper!(MediumScreen, MediumScreenWrapper, "md"); From b752d77815a4ac44343e2c12aa1553164b81d0e9 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Mon, 20 Jan 2025 23:09:36 +0100 Subject: [PATCH 33/45] add position --- src/ui/mod.rs | 1 + src/ui/primitives/mod.rs | 4 +- src/ui/primitives/position.rs | 210 ++++++++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 src/ui/primitives/position.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs index bb8a803..3e37b3b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -41,6 +41,7 @@ pub mod prelude { pub use super::primitives::link::Link; pub use super::primitives::margin::Margin; pub use super::primitives::padding::Padding; + pub use super::primitives::position::{Position, PositionKind}; pub use super::primitives::rounded::Rounded; pub use super::primitives::script; pub use super::primitives::shadow::Shadow; diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index 1ddc144..d0acf64 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -1,6 +1,5 @@ -use maud::{PreEscaped, html}; - use super::UIWidget; +use maud::{PreEscaped, html}; pub mod animation; pub mod aspect; @@ -18,6 +17,7 @@ pub mod input; pub mod link; pub mod margin; pub mod padding; +pub mod position; pub mod rounded; pub mod shadow; pub mod sized; diff --git a/src/ui/primitives/position.rs b/src/ui/primitives/position.rs new file mode 100644 index 0000000..75531ce --- /dev/null +++ b/src/ui/primitives/position.rs @@ -0,0 +1,210 @@ +use maud::{Markup, Render, html}; + +use crate::ui::UIWidget; + +#[allow(non_snake_case)] +pub fn Position<T: UIWidget + 'static>(kind: PositionKind, inner: T) -> Positioned { + Positioned { + inner: Box::new(inner), + kind, + inset: None, + inset_x: None, + inset_y: None, + start: None, + end: None, + top: None, + right: None, + bottom: None, + left: None, + } +} + +pub struct Positioned { + inner: Box<dyn UIWidget>, + kind: PositionKind, + inset: Option<i64>, + inset_x: Option<i64>, + inset_y: Option<i64>, + start: Option<i64>, + end: Option<i64>, + top: Option<i64>, + right: Option<i64>, + bottom: Option<i64>, + left: Option<i64>, +} + +impl Positioned { + pub fn inset(mut self, value: i64) -> Self { + self.inset = Some(value); + self + } + pub fn inset_x(mut self, value: i64) -> Self { + self.inset_x = Some(value); + self + } + + pub fn inset_y(mut self, value: i64) -> Self { + self.inset_y = Some(value); + self + } + + pub fn start(mut self, value: i64) -> Self { + self.start = Some(value); + self + } + + pub fn end(mut self, value: i64) -> Self { + self.end = Some(value); + self + } + + pub fn top(mut self, value: i64) -> Self { + self.top = Some(value); + self + } + + pub fn right(mut self, value: i64) -> Self { + self.right = Some(value); + self + } + + pub fn bottom(mut self, value: i64) -> Self { + self.bottom = Some(value); + self + } + + pub fn left(mut self, value: i64) -> Self { + self.left = Some(value); + self + } +} + +impl Render for Positioned { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for Positioned { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let mut ret = vec![self.kind.to_value().to_string()]; + + if let Some(inset) = &self.inset { + if inset.is_negative() { + ret.push(format!("-inset-[{inset}px]")); + } else { + ret.push(format!("inset-[{inset}px]")); + } + } + + if let Some(inset) = &self.inset_x { + if inset.is_negative() { + ret.push(format!("-inset-x-[{inset}px]")); + } else { + ret.push(format!("inset-x-[{inset}px]")); + } + } + + if let Some(inset) = &self.inset_y { + if inset.is_negative() { + ret.push(format!("-inset-y-[{inset}px]")); + } else { + ret.push(format!("inset-y-[{inset}px]")); + } + } + + if let Some(start) = &self.start { + if start.is_negative() { + ret.push(format!("-start-[{start}px]")); + } else { + ret.push(format!("start-[{start}px]")); + } + } + + if let Some(end) = &self.end { + if end.is_negative() { + ret.push(format!("-end-[{end}px]")); + } else { + ret.push(format!("end-[{end}px]")); + } + } + + if let Some(value) = &self.top { + if value.is_negative() { + ret.push(format!("-top-[{value}px]")); + } else { + ret.push(format!("top-[{value}px]")); + } + } + + if let Some(value) = &self.right { + if value.is_negative() { + ret.push(format!("-right-[{value}px]")); + } else { + ret.push(format!("right-[{value}px]")); + } + } + + if let Some(value) = &self.bottom { + if value.is_negative() { + ret.push(format!("-bottom-[{value}px]")); + } else { + ret.push(format!("bottom-[{value}px]")); + } + } + + if let Some(value) = &self.left { + if value.is_negative() { + ret.push(format!("-left-[{value}px]")); + } else { + ret.push(format!("left-[{value}px]")); + } + } + + ret + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.inner.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.inner.as_ref().can_inherit() { + self.inner + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.inner.as_ref()) + } + } + } + } +} + +pub enum PositionKind { + Static, + Fixed, + Absolute, + Relative, + Sticky, +} + +impl PositionKind { + pub const fn to_value(&self) -> &str { + match self { + PositionKind::Static => "static", + PositionKind::Fixed => "fixed", + PositionKind::Absolute => "absolute", + PositionKind::Relative => "relative", + PositionKind::Sticky => "sticky", + } + } +} From 95ceaa8231ea89b648880cc71cdb6dc5f6d47623 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Tue, 21 Jan 2025 00:52:29 +0100 Subject: [PATCH 34/45] update --- src/ui/color.rs | 2 +- src/ui/mod.rs | 14 ++-- src/ui/primitives/cursor.rs | 73 +++++++++++++++++++ src/ui/primitives/mod.rs | 45 +++++++++++- src/ui/primitives/position.rs | 131 ++++++++++++++++++++++++++++++++++ src/ui/primitives/text.rs | 84 +++++++++++++++++++++- 6 files changed, 340 insertions(+), 9 deletions(-) diff --git a/src/ui/color.rs b/src/ui/color.rs index 4671712..e7baa05 100644 --- a/src/ui/color.rs +++ b/src/ui/color.rs @@ -12,7 +12,7 @@ pub trait ColorCircle { fn next(&self) -> Self; } -// todo : specific colors rgb +// todo : specific colors rgb -[#50d71e] macro_rules! color_map { ($name:ident, $id:literal) => { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 3e37b3b..af4fedf 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -17,6 +17,7 @@ pub mod components; pub mod prelude { pub use super::color::*; pub use super::primitives::Context; + pub use super::primitives::NoBrowserAppearance; pub use super::primitives::Nothing; pub use super::primitives::Side; pub use super::primitives::Size; @@ -27,7 +28,7 @@ pub mod prelude { Border, BorderSide, BorderSize, BorderStyle, Outline, OutlineStyle, Ring, }; pub use super::primitives::container::Container; - pub use super::primitives::cursor::Cursor; + pub use super::primitives::cursor::{Action, Cursor, TouchAction}; pub use super::primitives::div::Div; pub use super::primitives::filter::{ Blur, Brightness, Contrast, Grayscale, HueRotate, Invert, Saturate, Sepia, @@ -41,7 +42,7 @@ pub mod prelude { pub use super::primitives::link::Link; pub use super::primitives::margin::Margin; pub use super::primitives::padding::Padding; - pub use super::primitives::position::{Position, PositionKind}; + pub use super::primitives::position::{Position, PositionKind, Resize, Resizeable}; pub use super::primitives::rounded::Rounded; pub use super::primitives::script; pub use super::primitives::shadow::Shadow; @@ -49,10 +50,11 @@ pub mod prelude { pub use super::primitives::space::{ScreenValue, SpaceBetween}; pub use super::primitives::svg::SVG; pub use super::primitives::text::{ - Code, DecorationKind, DecorationStyle, DecorationThickness, LetterSpacing, LineClamp, - LineHeight, ListStyle, NumberStyle, Paragraph, Span, Text, TextAlignment, TextContent, - TextDecoration, TextHyphens, TextOverflow, TextTransform, TextWhitespace, TextWordBreak, - TextWrap, UnderlineOffset, VerticalTextAlignment, + AccentColor, Code, DecorationKind, DecorationStyle, DecorationThickness, LetterSpacing, + LineClamp, LineHeight, ListStyle, NumberStyle, Paragraph, Span, Text, TextAlignment, + TextContent, TextCursorColor, TextDecoration, TextHyphens, TextOverflow, TextSelection, + TextTransform, TextWhitespace, TextWordBreak, TextWrap, UnderlineOffset, + VerticalTextAlignment, }; pub use super::primitives::transform::{ RenderTransformCPU, RenderTransformGPU, Rotate, Scale, Skew, Transform, TransformOrigin, diff --git a/src/ui/primitives/cursor.rs b/src/ui/primitives/cursor.rs index 1ab521e..d6d5141 100644 --- a/src/ui/primitives/cursor.rs +++ b/src/ui/primitives/cursor.rs @@ -122,3 +122,76 @@ impl UIWidget for CursorWidget { } } } + +#[allow(non_snake_case)] +pub fn TouchAction<T: UIWidget + 'static>(action: Action, inner: T) -> TouchActionWidget { + TouchActionWidget(Box::new(inner), action) +} + +pub struct TouchActionWidget(Box<dyn UIWidget>, Action); + +impl Render for TouchActionWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for TouchActionWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + vec![self.1.to_value().to_string()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} + +pub enum Action { + Auto, + None, + PanX, + PanLeft, + PanRight, + PanY, + PanUp, + PanDown, + PinchZoom, + Manipulation, +} + +impl Action { + pub const fn to_value(&self) -> &str { + match self { + Action::Auto => "touch-auto", + Action::None => "touch-none", + Action::PanX => "touch-pan-x", + Action::PanLeft => "touch-pan-left", + Action::PanRight => "touch-pan-right", + Action::PanY => "touch-pan-y", + Action::PanUp => "touch-pan-up", + Action::PanDown => "touch-pan-down", + Action::PinchZoom => "touch-pinch-zoom", + Action::Manipulation => "touch-manipulation", + } + } +} diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index d0acf64..89bdd20 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -1,5 +1,5 @@ use super::UIWidget; -use maud::{PreEscaped, html}; +use maud::{Markup, PreEscaped, Render, html}; pub mod animation; pub mod aspect; @@ -135,3 +135,46 @@ impl Side { } } } + +#[allow(non_snake_case)] +pub fn NoBrowserAppearance<T: UIWidget + 'static>(inner: T) -> NoBrowserAppearanceWidget { + NoBrowserAppearanceWidget(Box::new(inner)) +} + +pub struct NoBrowserAppearanceWidget(Box<dyn UIWidget>); + +impl Render for NoBrowserAppearanceWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for NoBrowserAppearanceWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec!["appearance-none".to_string()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("appearance-none {class}")) + } else { + html! { + div class=(format!("appearance-none {class}")) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/primitives/position.rs b/src/ui/primitives/position.rs index 75531ce..2019981 100644 --- a/src/ui/primitives/position.rs +++ b/src/ui/primitives/position.rs @@ -2,6 +2,8 @@ use maud::{Markup, Render, html}; use crate::ui::UIWidget; +use super::Side; + #[allow(non_snake_case)] pub fn Position<T: UIWidget + 'static>(kind: PositionKind, inner: T) -> Positioned { Positioned { @@ -208,3 +210,132 @@ impl PositionKind { } } } + +#[allow(non_snake_case)] +pub fn ObjectPosition<T: UIWidget + 'static>(side: Side, inner: T) -> ObjectPositioned { + ObjectPositioned { + inner: Box::new(inner), + side, + } +} + +pub struct ObjectPositioned { + inner: Box<dyn UIWidget>, + side: Side, +} + +impl Render for ObjectPositioned { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for ObjectPositioned { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec![ + match self.side { + Side::Start => "object-top", + Side::End => "object-bottom", + Side::Top => "object-top", + Side::Right => "object-right", + Side::Bottom => "object-bottom", + Side::Left => "object-left", + Side::StartStart => "object-left-top", + Side::StartEnd => "object-right-top", + Side::EndEnd => "object-right-bottom", + Side::EndStart => "object-left-bottom", + Side::TopLeft => "object-left-top", + Side::TopRight => "object-right-top", + Side::BottomRight => "object-right-bottom", + Side::BottomLeft => "object-left-bottom", + Side::Center => "object-center", + } + .to_string(), + ] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.inner.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.inner.as_ref().can_inherit() { + self.inner + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.inner.as_ref()) + } + } + } + } +} + +pub enum Resize { + None, + Y, + X, + Both, +} + +impl Resize { + pub const fn to_value(&self) -> &str { + match self { + Resize::None => "resize-none", + Resize::Y => "resize-y", + Resize::X => "resize-x", + Resize::Both => "resize", + } + } +} + +#[allow(non_snake_case)] +pub fn Resizeable<T: UIWidget + 'static>(mode: Resize, inner: T) -> ResizeableWidget { + ResizeableWidget(Box::new(inner), mode) +} + +pub struct ResizeableWidget(Box<dyn UIWidget>, Resize); + +impl Render for ResizeableWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for ResizeableWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + vec![self.1.to_value().to_string()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} diff --git a/src/ui/primitives/text.rs b/src/ui/primitives/text.rs index 5230a1f..43befe7 100644 --- a/src/ui/primitives/text.rs +++ b/src/ui/primitives/text.rs @@ -1,5 +1,5 @@ use crate::ui::{UIWidget, color::UIColor}; -use maud::{Markup, PreEscaped, Render}; +use maud::{Markup, PreEscaped, Render, html}; use super::{Nothing, space::ScreenValue}; @@ -31,6 +31,7 @@ pub fn Text(txt: &str) -> TextWidget { align: None, vert_align: None, list_style: None, + select: None, kind: TextKind::Paragraph, } } @@ -68,6 +69,7 @@ pub fn Paragraph<T: UIWidget + 'static>(inner: T) -> TextWidget { list_style: None, clamp: None, align: None, + select: None, kind: TextKind::Paragraph, } } @@ -100,6 +102,7 @@ pub fn Span(txt: &str) -> TextWidget { clamp: None, align: None, pseudo: None, + select: None, kind: TextKind::Span, } } @@ -132,6 +135,7 @@ pub fn Code(txt: &str) -> TextWidget { clamp: None, align: None, pseudo: None, + select: None, kind: TextKind::Pre, } } @@ -160,10 +164,17 @@ pub struct TextWidget { pseudo: Option<TextContent>, align: Option<TextAlignment>, clamp: Option<LineClamp>, + select: Option<TextSelection>, title: Option<String>, } impl TextWidget { + #[must_use] + pub fn select(mut self, select: TextSelection) -> Self { + self.select = Some(select); + self + } + #[must_use] pub fn whitespace(mut self, whitespace: TextWhitespace) -> Self { self.whitespace = Some(whitespace); @@ -527,6 +538,7 @@ impl UIWidget for TextWidget { add_option!(list_style, ret); add_option!(pseudo, ret); add_option!(line_height, ret); + add_option!(select, ret); if let Some(decoration) = &self.decoration { ret.extend_from_slice(&decoration.base_class()); @@ -1061,3 +1073,73 @@ impl TextHyphens { } } } + +pub enum TextSelection { + None, + Text, + All, + Auto, +} + +impl TextSelection { + pub const fn to_value(&self) -> &str { + match self { + TextSelection::None => "select-none", + TextSelection::Text => "select-text", + TextSelection::All => "select-all", + TextSelection::Auto => "select-auto ", + } + } +} + +macro_rules! color_widget { + ($constr:ident, $widget:ident, $class:literal) => { + #[allow(non_snake_case)] + pub fn $constr<T: UIWidget + 'static, C: UIColor + 'static>(color: C, inner: T) -> $widget { + $widget(Box::new(inner), Box::new(color)) + } + + pub struct $widget(Box<dyn UIWidget>, Box<dyn UIColor>); + + impl Render for $widget { + fn render(&self) -> Markup { + self.render_with_class("") + } + } + + impl UIWidget for $widget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + let mut class = $class.to_string(); + class.push_str(&format!("-{}", self.1.color_class())); + vec![class] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } + } + }; +} + +color_widget!(TextCursorColor, CaretColorWidget, "caret"); +color_widget!(AccentColor, AccentColorWidget, "accent"); From a9a8b8b951d9556b9bcd22bc5002b0d6c9dbb6a0 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Tue, 21 Jan 2025 09:00:45 +0100 Subject: [PATCH 35/45] update --- examples/ui.rs | 4 +- src/ui/mod.rs | 5 + src/ui/primitives/display.rs | 283 +++++++++++++++++++++++++++++++++++ src/ui/primitives/mod.rs | 2 + src/ui/primitives/scroll.rs | 234 +++++++++++++++++++++++++++++ 5 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 src/ui/primitives/display.rs create mode 100644 src/ui/primitives/scroll.rs diff --git a/examples/ui.rs b/examples/ui.rs index b2256f7..e2f92d3 100644 --- a/examples/ui.rs +++ b/examples/ui.rs @@ -15,8 +15,8 @@ pub async fn index_page(ctx: RequestContext) -> StringResponse { h1 { "Hello World!" }; ( - Screen::medium(Hover(Background(Red::_700, Nothing()))).on( - Background(Blue::_700, Text("HELLO!")) + Screen::medium(Hover(Background(Nothing()).color(Red::_700))).on( + Background(Text("HELLO!")).color(Blue::_700) ) ) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index af4fedf..d158a54 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -29,6 +29,10 @@ pub mod prelude { }; pub use super::primitives::container::Container; pub use super::primitives::cursor::{Action, Cursor, TouchAction}; + pub use super::primitives::display::{ + BoxDecorationBreak, BoxSizing, BreakAfter, BreakBefore, BreakInside, BreakInsideValue, + BreakValue, Clear, Display, Float, ObjectFit, Overflow, + }; pub use super::primitives::div::Div; pub use super::primitives::filter::{ Blur, Brightness, Contrast, Grayscale, HueRotate, Invert, Saturate, Sepia, @@ -45,6 +49,7 @@ pub mod prelude { pub use super::primitives::position::{Position, PositionKind, Resize, Resizeable}; pub use super::primitives::rounded::Rounded; pub use super::primitives::script; + pub use super::primitives::scroll::{Overscroll, Scroll, SnapAlign, SnapType}; pub use super::primitives::shadow::Shadow; pub use super::primitives::sized::Sized; pub use super::primitives::space::{ScreenValue, SpaceBetween}; diff --git a/src/ui/primitives/display.rs b/src/ui/primitives/display.rs new file mode 100644 index 0000000..14fddee --- /dev/null +++ b/src/ui/primitives/display.rs @@ -0,0 +1,283 @@ +use crate::ui::UIWidget; +use maud::{Markup, Render, html}; + +macro_rules! string_class_widget { + ($name:ident) => { + pub struct $name(Box<dyn UIWidget>, String); + + impl Render for $name { + fn render(&self) -> Markup { + self.render_with_class("") + } + } + + impl UIWidget for $name { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec![self.1.clone()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } + } + }; +} + +macro_rules! constructor { + ($name:ident, $class:literal) => { + #[allow(non_snake_case)] + pub fn $name<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), $class.to_string()) + } + }; +} + +pub enum BreakValue { + Auto, + Avoid, + All, + AvoidPage, + Page, + Left, + Right, + Column, +} + +impl BreakValue { + pub const fn to_value(&self) -> &str { + match self { + BreakValue::Auto => "auto", + BreakValue::Avoid => "avoid", + BreakValue::All => "all", + BreakValue::AvoidPage => "break-page", + BreakValue::Page => "page", + BreakValue::Left => "left", + BreakValue::Right => "right", + BreakValue::Column => "column", + } + } +} + +#[allow(non_snake_case)] +pub fn BreakAfter<T: UIWidget + 'static>(value: BreakValue, inner: T) -> BreakWidget { + BreakWidget(Box::new(inner), false, value) +} + +#[allow(non_snake_case)] +pub fn BreakBefore<T: UIWidget + 'static>(value: BreakValue, inner: T) -> BreakWidget { + BreakWidget(Box::new(inner), true, value) +} + +pub struct BreakWidget(Box<dyn UIWidget>, bool, BreakValue); + +impl Render for BreakWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for BreakWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + if self.1 { + vec![format!("break-before-{}", self.2.to_value())] + } else { + vec![format!("break-after-{}", self.2.to_value())] + } + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} + +pub enum BreakInsideValue { + Auto, + Avoid, + AvoidPage, + AvoidColumn, +} + +impl BreakInsideValue { + pub const fn to_value(&self) -> &str { + match self { + BreakInsideValue::Auto => "break-inside-auto", + BreakInsideValue::Avoid => "break-inside-avoid", + BreakInsideValue::AvoidPage => "break-inside-avoid-page", + BreakInsideValue::AvoidColumn => "break-inside-avoid-column", + } + } +} + +#[allow(non_snake_case)] +pub fn BreakInside<T: UIWidget + 'static>(value: BreakValue, inner: T) -> BreakWidget { + BreakWidget(Box::new(inner), true, value) +} + +pub struct BreakInsideWidget(Box<dyn UIWidget>, BreakValue); + +impl Render for BreakInsideWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for BreakInsideWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec![self.1.to_value().to_string()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} + +string_class_widget!(BoxDecorationBreak); + +impl BoxDecorationBreak { + constructor!(Clone, "box-decoration-clone"); + constructor!(Slice, "box-decoration-slice"); +} + +string_class_widget!(BoxSizing); + +impl BoxSizing { + constructor!(Border, "box-border"); + constructor!(Content, "box-content"); +} + +string_class_widget!(Display); + +impl Display { + constructor!(Block, "block"); + constructor!(InlineBlock, "inline-block"); + constructor!(Inline, "inline"); + constructor!(Flex, "flex"); + constructor!(InlineFlex, "inline-flex"); + constructor!(Table, "table"); + constructor!(InlineTable, "inline-table"); + constructor!(TableCaption, "table-caption"); + constructor!(TableCell, "table-cell"); + constructor!(TableColumn, "table-column"); + constructor!(TableColumnGroup, "table-column-group"); + constructor!(TableFooterGroup, "table-footer-group"); + constructor!(TableHeaderGroup, "table-header-group"); + constructor!(TableRowGroup, "table-row-group"); + constructor!(TableRow, "table-row"); + constructor!(FlowRoot, "flow-root"); + constructor!(Grid, "grid"); + constructor!(InlineGrid, "inline-grid"); + constructor!(Contents, "contents"); + constructor!(ListItem, "list-item"); + constructor!(Hidden, "hidden"); +} + +string_class_widget!(Float); + +impl Float { + constructor!(Start, "float-start"); + constructor!(End, "float-end"); + constructor!(Left, "float-left"); + constructor!(Right, "float-right"); + constructor!(None, "float-none"); +} + +string_class_widget!(Clear); + +impl Clear { + constructor!(Start, "clear-start"); + constructor!(End, "clear-end"); + constructor!(Left, "clear-left"); + constructor!(Right, "clear-right"); + constructor!(Both, "clear-both"); + constructor!(None, "clear-none"); +} + +string_class_widget!(ObjectFit); + +impl ObjectFit { + constructor!(Contain, "object-contain"); + constructor!(Cover, "object-cover"); + constructor!(Fill, "object-fill"); + constructor!(None, "object-none"); + constructor!(ScaleDown, "object-scale-down"); +} + +string_class_widget!(Overflow); + +impl Overflow { + constructor!(Auto, "overflow-auto"); + constructor!(Hidden, "overflow-hidden"); + constructor!(Clip, "overflow-clip"); + constructor!(Visible, "overflow-visible"); + constructor!(Scroll, "overflow-scroll"); + constructor!(XAuto, "overflow-x-auto"); + constructor!(YAuto, "overflow-y-auto"); + constructor!(XHidden, "overflow-x-hidden"); + constructor!(YHidden, "overflow-y-hidden"); + constructor!(XClip, "overflow-x-clip"); + constructor!(YClip, "overflow-y-clip"); + constructor!(XVisible, "overflow-x-visible"); + constructor!(YVisible, "overflow-y-visible"); + constructor!(XScroll, "overflow-x-scroll"); + constructor!(YScroll, "overflow-y-scroll"); +} diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index 89bdd20..3234ef4 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -7,6 +7,7 @@ pub mod background; pub mod border; pub mod container; pub mod cursor; +pub mod display; pub mod div; pub mod filter; pub mod flex; @@ -19,6 +20,7 @@ pub mod margin; pub mod padding; pub mod position; pub mod rounded; +pub mod scroll; pub mod shadow; pub mod sized; pub mod space; diff --git a/src/ui/primitives/scroll.rs b/src/ui/primitives/scroll.rs new file mode 100644 index 0000000..720ffe2 --- /dev/null +++ b/src/ui/primitives/scroll.rs @@ -0,0 +1,234 @@ +use super::{margin::Margin, padding::PaddingWidget}; +use crate::ui::UIWidget; +use maud::{Markup, Render, html}; + +#[allow(non_snake_case)] +pub fn Scroll<T: UIWidget + 'static>(inner: T) -> ScrollWidget { + ScrollWidget(Box::new(inner), true, None, None, None, None, false) +} + +pub struct ScrollWidget( + Box<dyn UIWidget>, + bool, + Option<Margin>, + Option<PaddingWidget>, + Option<Overscroll>, + Option<SnapType>, + bool, +); + +impl ScrollWidget { + pub fn smooth(mut self, value: bool) -> Self { + self.1 = value; + self + } + + pub fn scroll_margin(mut self, margin: Margin) -> Self { + self.2 = Some(margin); + self + } + + pub fn scroll_padding(mut self, padding: PaddingWidget) -> Self { + self.3 = Some(padding); + self + } + + pub fn overscroll(mut self, behaviour: Overscroll) -> Self { + self.4 = Some(behaviour); + self + } + + pub fn snap(mut self, kind: SnapType) -> Self { + self.5 = Some(kind); + self + } + + pub fn skip_snap(mut self) -> Self { + self.6 = true; + self + } +} + +impl Render for ScrollWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for ScrollWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let mut ret = Vec::new(); + + if self.1 { + ret.push("scroll-smooth".to_string()); + } + + if let Some(margin) = &self.2 { + let classes = margin + .base_class() + .into_iter() + .map(|x| format!("scroll-{x}")) + .collect::<Vec<_>>(); + ret.extend_from_slice(&classes); + } + + if let Some(padding) = &self.3 { + let classes = padding + .base_class() + .into_iter() + .map(|x| format!("scroll-{x}")) + .collect::<Vec<_>>(); + ret.extend_from_slice(&classes); + } + + if let Some(overscroll) = &self.4 { + ret.push(overscroll.to_value().to_string()); + } + + if let Some(snap) = &self.5 { + ret.push(snap.to_value().to_string()); + } + + if self.6 { + ret.push("snap-normal".to_string()); + } else { + ret.push("snap-always".to_string()); + } + + ret + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} + +pub enum Overscroll { + Auto, + Contain, + None, + YAuto, + YContain, + YNone, + XAuto, + XContain, + XNone, +} + +impl Overscroll { + pub const fn to_value(&self) -> &str { + match self { + Overscroll::Auto => "overscroll-auto", + Overscroll::Contain => "overscroll-contain", + Overscroll::None => "overscroll-none", + Overscroll::YAuto => "overscroll-y-auto", + Overscroll::YContain => "overscroll-y-contain", + Overscroll::YNone => "overscroll-y-none", + Overscroll::XAuto => "overscroll-x-auto", + Overscroll::XContain => "overscroll-x-contain", + Overscroll::XNone => "overscroll-x-none", + } + } +} + +pub enum SnapType { + None, + X, + Y, + Both, + Mandatory, + Proximity, +} + +impl SnapType { + pub const fn to_value(&self) -> &str { + match self { + SnapType::None => "snap-none", + SnapType::X => "snap-x", + SnapType::Y => "snap-y", + SnapType::Both => "snap-both", + SnapType::Mandatory => "snap-mandatory", + SnapType::Proximity => "snap-proximity", + } + } +} + +pub struct SnapAlign(Box<dyn UIWidget>, String); + +impl SnapAlign { + #[allow(non_snake_case)] + pub fn Start<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "snap-start".to_string()) + } + + #[allow(non_snake_case)] + pub fn End<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "snap-end".to_string()) + } + + #[allow(non_snake_case)] + pub fn Center<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "snap-center".to_string()) + } + + #[allow(non_snake_case)] + pub fn None<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), "snap-align-none".to_string()) + } +} + +impl Render for SnapAlign { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for SnapAlign { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec![self.1.clone()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} From e02def6bc16dfe61937816d669514c9d4300ae1a Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Tue, 21 Jan 2025 13:40:56 +0100 Subject: [PATCH 36/45] update --- src/ui/mod.rs | 23 +- src/ui/primitives/display.rs | 31 +++ src/ui/primitives/flex.rs | 84 +++++++ src/ui/primitives/grid.rs | 461 +++++++++++++++++++++++++++++++++++ src/ui/primitives/list.rs | 103 ++++++++ src/ui/primitives/mod.rs | 3 + src/ui/primitives/table.rs | 204 ++++++++++++++++ 7 files changed, 897 insertions(+), 12 deletions(-) create mode 100644 src/ui/primitives/grid.rs create mode 100644 src/ui/primitives/list.rs create mode 100644 src/ui/primitives/table.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d158a54..ab1717d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,5 @@ use components::Shell; -use maud::{Markup, PreEscaped, Render}; -use prelude::Text; +use maud::{Markup, PreEscaped, Render, html}; // UI @@ -154,37 +153,37 @@ impl UIWidget for PreEscaped<String> { impl UIWidget for String { fn can_inherit(&self) -> bool { - Text(&self).can_inherit() + false } fn base_class(&self) -> Vec<String> { - Text(&self).base_class() + Vec::new() } fn extended_class(&self) -> Vec<String> { - Text(&self).extended_class() + Vec::new() } - fn render_with_class(&self, class: &str) -> Markup { - Text(&self).render_with_class(class) + fn render_with_class(&self, _: &str) -> Markup { + html!((self)) } } impl UIWidget for &str { fn can_inherit(&self) -> bool { - Text(&self).can_inherit() + false } fn base_class(&self) -> Vec<String> { - Text(&self).base_class() + Vec::new() } fn extended_class(&self) -> Vec<String> { - Text(&self).extended_class() + Vec::new() } - fn render_with_class(&self, class: &str) -> Markup { - Text(&self).render_with_class(class) + fn render_with_class(&self, _: &str) -> Markup { + html!((self)) } } diff --git a/src/ui/primitives/display.rs b/src/ui/primitives/display.rs index 14fddee..287bd59 100644 --- a/src/ui/primitives/display.rs +++ b/src/ui/primitives/display.rs @@ -281,3 +281,34 @@ impl Overflow { constructor!(XScroll, "overflow-x-scroll"); constructor!(YScroll, "overflow-y-scroll"); } + +string_class_widget!(JustifySelf); + +impl JustifySelf { + constructor!(Auto, "justify-self-auto"); + constructor!(Start, "justify-self-start"); + constructor!(End, "justify-self-end"); + constructor!(Center, "justify-self-center"); + constructor!(Stretch, "justify-self-stretch"); +} + +string_class_widget!(PlaceSelf); + +impl PlaceSelf { + constructor!(Auto, "place-self-auto"); + constructor!(Start, "place-self-start"); + constructor!(End, "place-self-end"); + constructor!(Center, "place-self-center"); + constructor!(Stretch, "place-self-stretch"); +} + +string_class_widget!(AlignSelf); + +impl AlignSelf { + constructor!(Auto, "self-auto"); + constructor!(Start, "self-start"); + constructor!(End, "self-end"); + constructor!(Center, "self-center"); + constructor!(Stretch, "self-stretch"); + constructor!(Baseline, "self-baseline"); +} diff --git a/src/ui/primitives/flex.rs b/src/ui/primitives/flex.rs index 2c9013a..35b231e 100644 --- a/src/ui/primitives/flex.rs +++ b/src/ui/primitives/flex.rs @@ -109,6 +109,24 @@ impl FlexWidget { self } + #[must_use] + pub fn justify_items(mut self, justify: JustifyItems) -> Self { + self.1.push(justify.to_value().to_string()); + self + } + + #[must_use] + pub fn align_content(mut self, align: AlignContent) -> Self { + self.1.push(align.to_value().to_string()); + self + } + + #[must_use] + pub fn align_items(mut self, align: AlignItems) -> Self { + self.1.push(align.to_value().to_string()); + self + } + #[must_use] pub fn justify(mut self, value: Justify) -> Self { let class = match value { @@ -500,3 +518,69 @@ impl DivideStyle { } } } + +pub enum JustifyItems { + Start, + End, + Center, + Stretch, +} + +impl JustifyItems { + pub const fn to_value(&self) -> &str { + match self { + JustifyItems::Start => "justify-items-start", + JustifyItems::End => "justify-items-end", + JustifyItems::Center => "justify-items-center", + JustifyItems::Stretch => "justify-items-stretch", + } + } +} + +pub enum AlignContent { + Normal, + Center, + Start, + End, + Between, + Around, + Evenly, + Baseline, + Stretch, +} + +impl AlignContent { + pub const fn to_value(&self) -> &str { + match self { + AlignContent::Normal => "content-normal", + AlignContent::Center => "content-center", + AlignContent::Start => "content-start", + AlignContent::End => "content-end", + AlignContent::Between => "content-between", + AlignContent::Around => "content-around", + AlignContent::Evenly => "content-evenly", + AlignContent::Baseline => "content-baseline", + AlignContent::Stretch => "content-stretch", + } + } +} + +pub enum AlignItems { + Start, + End, + Center, + Baseline, + Stretch, +} + +impl AlignItems { + pub const fn to_value(&self) -> &str { + match self { + AlignItems::Start => "items-start", + AlignItems::End => "items-end", + AlignItems::Center => "items-center", + AlignItems::Baseline => "items-baseline", + AlignItems::Stretch => "items-stretch", + } + } +} diff --git a/src/ui/primitives/grid.rs b/src/ui/primitives/grid.rs new file mode 100644 index 0000000..cd1e345 --- /dev/null +++ b/src/ui/primitives/grid.rs @@ -0,0 +1,461 @@ +use crate::ui::{UIWidget, color::UIColor}; +use maud::{Markup, Render, html}; + +use super::{ + flex::{AlignContent, AlignItems, DivideStyle, DivideWidth, Justify, JustifyItems}, + space::ScreenValue, +}; + +#[allow(non_snake_case)] +pub fn Grid<T: UIWidget + 'static>(inner: T) -> GridWidget { + GridWidget(Box::new(inner), vec![], false) +} + +pub struct GridWidget(Box<dyn UIWidget>, Vec<String>, bool); + +impl Render for GridWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +pub enum GridAmount { + _1, + _2, + _3, + _4, + _5, + _6, + _7, + _8, + _9, + _10, + _11, + _12, + None, + Subgrid, +} + +impl GridAmount { + pub const fn to_value(&self) -> &str { + match self { + GridAmount::_1 => "1", + GridAmount::_2 => "2", + GridAmount::_3 => "3", + GridAmount::_4 => "4", + GridAmount::_5 => "5", + GridAmount::_6 => "6", + GridAmount::_7 => "7", + GridAmount::_8 => "8", + GridAmount::_9 => "9", + GridAmount::_10 => "10", + GridAmount::_11 => "11", + GridAmount::_12 => "12", + GridAmount::None => "none", + GridAmount::Subgrid => "subgrid", + } + } +} + +impl GridWidget { + #[must_use] + pub fn columns(mut self, amount: GridAmount) -> Self { + self.1.push(format!("grid-cols-{}", amount.to_value())); + self + } + + #[must_use] + pub fn rows(mut self, amount: GridAmount) -> Self { + self.1.push(format!("grid-rows-{}", amount.to_value())); + self + } + + #[must_use] + pub fn auto_flow(mut self, flow: GridAutoFlow) -> Self { + self.1.push(flow.to_value().to_string()); + self + } + + #[must_use] + pub fn auto_columns(mut self, size: GridAutoSize) -> Self { + self.1.push(format!("auto-cols-{}", size.to_value())); + self + } + + #[must_use] + pub fn auto_rows(mut self, size: GridAutoSize) -> Self { + self.1.push(format!("auto-rows-{}", size.to_value())); + self + } + + #[must_use] + pub fn full_center(mut self) -> Self { + self.1.push("items-center".to_owned()); + self.1.push("justify-center".to_owned()); + self + } + + #[must_use] + pub const fn group(mut self) -> Self { + self.2 = true; + self + } + + #[must_use] + pub fn divide_style(mut self, style: DivideStyle) -> Self { + self.1.push(style.to_value().to_string()); + self + } + + #[must_use] + pub fn divide_color<C: UIColor + 'static>(mut self, color: C) -> Self { + self.1.push(format!("divide-{}", color.color_class())); + self + } + + #[must_use] + pub fn divide_x(mut self, width: DivideWidth) -> Self { + self.1.push(format!("divide-x-{}", width.to_value())); + self + } + + #[must_use] + pub fn divide_y(mut self, width: DivideWidth) -> Self { + self.1.push(format!("divide-y-{}", width.to_value())); + self + } + + #[must_use] + pub fn justify_items(mut self, justify: JustifyItems) -> Self { + self.1.push(justify.to_value().to_string()); + self + } + + #[must_use] + pub fn align_content(mut self, align: AlignContent) -> Self { + self.1.push(align.to_value().to_string()); + self + } + + #[must_use] + pub fn align_items(mut self, align: AlignItems) -> Self { + self.1.push(align.to_value().to_string()); + self + } + + #[must_use] + pub fn justify(mut self, value: Justify) -> Self { + let class = match value { + Justify::Center => "justify-center".to_string(), + Justify::Between => "justify-between".to_string(), + Justify::Normal => "justify-normal".to_string(), + Justify::Start => "justify-start".to_string(), + Justify::End => "justify-end".to_string(), + Justify::Around => "justify-around".to_string(), + Justify::Evenly => "justify-evenly".to_string(), + Justify::Stretch => "justify-stretch".to_string(), + }; + + self.1.push(class); + self + } + + #[must_use] + pub fn items_center(mut self) -> Self { + self.1.push("items-center".to_owned()); + self + } + + #[must_use] + pub fn gap(mut self, amount: ScreenValue) -> Self { + self.1.push(format!("gap-{}", amount.to_value())); + self + } + + #[must_use] + pub fn gap_x(mut self, amount: ScreenValue) -> Self { + self.1.push(format!("gap-x-{}", amount.to_value())); + self + } + + #[must_use] + pub fn gap_y(mut self, amount: ScreenValue) -> Self { + self.1.push(format!("gap-y-{}", amount.to_value())); + self + } +} + +impl UIWidget for GridWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let mut res = vec!["grid".to_string()]; + res.extend_from_slice(&self.1); + res + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() && !self.2 { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} + +pub enum GridAutoFlow { + Row, + Column, + Dense, + RowDense, + ColumnDense, +} + +impl GridAutoFlow { + pub const fn to_value(&self) -> &str { + match self { + GridAutoFlow::Row => "grid-flow-row", + GridAutoFlow::Column => "grid-flow-col", + GridAutoFlow::Dense => "grid-flow-dense", + GridAutoFlow::RowDense => "grid-flow-row-dense", + GridAutoFlow::ColumnDense => "grid-flow-col-dense", + } + } +} + +pub enum GridAutoSize { + Auto, + Min, + Max, + Fr, +} + +impl GridAutoSize { + pub const fn to_value(&self) -> &str { + match self { + GridAutoSize::Auto => "auto", + GridAutoSize::Min => "min", + GridAutoSize::Max => "max", + GridAutoSize::Fr => "fr", + } + } +} + +#[allow(non_snake_case)] +pub fn GridElementColumn<T: UIWidget + 'static>(inner: T) -> GridElement { + GridElement(Box::new(inner), Vec::new(), "col".to_string()) +} + +#[allow(non_snake_case)] +pub fn GridElementRow<T: UIWidget + 'static>(inner: T) -> GridElement { + GridElement(Box::new(inner), Vec::new(), "row".to_string()) +} + +pub struct GridElement(Box<dyn UIWidget>, Vec<String>, String); + +impl GridElement { + pub fn auto(mut self) -> Self { + self.1.push(format!("{}-auto", self.2)); + self + } + + pub fn span(mut self, value: GridElementValue) -> Self { + self.1.push(format!("{}-span-{}", self.2, match value { + GridElementValue::_1 => "1", + GridElementValue::_2 => "2", + GridElementValue::_3 => "3", + GridElementValue::_4 => "4", + GridElementValue::_5 => "5", + GridElementValue::_6 => "6", + GridElementValue::_7 => "7", + GridElementValue::_8 => "8", + GridElementValue::_9 => "9", + GridElementValue::_10 => "10", + GridElementValue::_11 => "11", + GridElementValue::_12 => "12", + GridElementValue::Auto => "full", + })); + self + } + + pub fn start(mut self, value: GridElementValue) -> Self { + self.1 + .push(format!("{}-start-{}", self.2, value.to_value())); + self + } + + pub fn end(mut self, value: GridElementValue) -> Self { + self.1.push(format!("{}-end-{}", self.2, value.to_value())); + self + } +} + +impl Render for GridElement { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for GridElement { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + let mut res = vec!["grid".to_string()]; + res.extend_from_slice(&self.1); + res + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } +} + +pub enum GridElementValue { + _1, + _2, + _3, + _4, + _5, + _6, + _7, + _8, + _9, + _10, + _11, + _12, + Auto, +} + +impl GridElementValue { + pub const fn to_value(&self) -> &str { + match self { + GridElementValue::_1 => "1", + GridElementValue::_2 => "2", + GridElementValue::_3 => "3", + GridElementValue::_4 => "4", + GridElementValue::_5 => "5", + GridElementValue::_6 => "6", + GridElementValue::_7 => "7", + GridElementValue::_8 => "8", + GridElementValue::_9 => "9", + GridElementValue::_10 => "10", + GridElementValue::_11 => "11", + GridElementValue::_12 => "12", + GridElementValue::Auto => "auto", + } + } +} + +macro_rules! string_class_widget { + ($name:ident) => { + pub struct $name(Box<dyn UIWidget>, String); + + impl Render for $name { + fn render(&self) -> Markup { + self.render_with_class("") + } + } + + impl UIWidget for $name { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec![self.1.clone()] + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + if self.0.as_ref().can_inherit() { + self.0 + .as_ref() + .render_with_class(&format!("{} {class}", self.base_class().join(" "))) + } else { + html! { + div class=(format!("{} {class}", self.base_class().join(" "))) { + (self.0.as_ref()) + } + } + } + } + } + }; +} + +macro_rules! constructor { + ($name:ident, $class:literal) => { + #[allow(non_snake_case)] + pub fn $name<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), $class.to_string()) + } + }; +} + +string_class_widget!(Columns); + +impl Columns { + constructor!(_1, "columns-1"); + constructor!(_2, "columns-2"); + constructor!(_3, "columns-3"); + constructor!(_4, "columns-4"); + constructor!(_5, "columns-5"); + constructor!(_6, "columns-6"); + constructor!(_7, "columns-7"); + constructor!(_8, "columns-8"); + constructor!(_9, "columns-9"); + constructor!(_10, "columns-10"); + constructor!(_11, "columns-11"); + constructor!(_12, "columns-12"); + constructor!(Auto, "columns-auto"); + constructor!(_3XS, "columns-3xs"); + constructor!(_2XS, "columns-2xs"); + constructor!(XS, "columns-xs"); + constructor!(Small, "columns-sm"); + constructor!(Medium, "columns-md"); + constructor!(Large, "columns-lg"); + constructor!(XL, "columns-xl"); + constructor!(_2XL, "columns-2xl"); + constructor!(_3XL, "columns-3xl"); + constructor!(_4XL, "columns-4xl"); + constructor!(_5XL, "columns-5xl"); + constructor!(_6XL, "columns-6xl"); + constructor!(_7XL, "columns-7xl"); +} diff --git a/src/ui/primitives/list.rs b/src/ui/primitives/list.rs new file mode 100644 index 0000000..630a766 --- /dev/null +++ b/src/ui/primitives/list.rs @@ -0,0 +1,103 @@ +use crate::ui::UIWidget; +use maud::{Markup, Render, html}; + +#[allow(non_snake_case)] +#[must_use] +pub fn OrderedList() -> ListWidget { + ListWidget(Vec::new(), true) +} + +#[allow(non_snake_case)] +#[must_use] +pub fn UnorderedList() -> ListWidget { + ListWidget(Vec::new(), false) +} + +pub struct ListWidget(Vec<Box<dyn UIWidget>>, bool); + +impl ListWidget { + #[must_use] + pub fn push<T: UIWidget + 'static>(mut self, element: T) -> Self { + self.0.push(Box::new(element)); + self + } + + #[must_use] + pub fn push_some<T: UIWidget + 'static, X, U: Fn(X) -> T>( + mut self, + option: Option<X>, + then: U, + ) -> Self { + if let Some(val) = option { + self.0.push(Box::new(then(val))); + } + self + } + + #[must_use] + pub fn push_if<T: UIWidget + 'static, U: Fn() -> T>( + mut self, + condition: bool, + then: U, + ) -> Self { + if condition { + self.0.push(Box::new(then())); + } + self + } + + #[must_use] + pub fn push_for_each<T, X, F>(mut self, items: &[X], mut action: F) -> Self + where + T: UIWidget + 'static, + F: FnMut(&X) -> T, + { + for item in items { + self.0.push(Box::new(action(item))); + } + + self + } +} + +impl Render for ListWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for ListWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec![] + } + + fn extended_class(&self) -> Vec<String> { + vec![] + } + + fn render_with_class(&self, class: &str) -> Markup { + let inner = html! { + @for e in &self.0 { + li { (e.as_ref()) }; + } + }; + + if self.1 { + html! { + ol class=(class) { + (inner); + } + } + } else { + html! { + ul class=(class) { + (inner); + } + } + } + } +} diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index 3234ef4..d2d21f5 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -11,11 +11,13 @@ pub mod display; pub mod div; pub mod filter; pub mod flex; +pub mod grid; pub mod header; pub mod height; pub mod image; pub mod input; pub mod link; +pub mod list; pub mod margin; pub mod padding; pub mod position; @@ -25,6 +27,7 @@ pub mod shadow; pub mod sized; pub mod space; pub mod svg; +pub mod table; pub mod text; pub mod transform; pub mod visibility; diff --git a/src/ui/primitives/table.rs b/src/ui/primitives/table.rs new file mode 100644 index 0000000..60bf8a6 --- /dev/null +++ b/src/ui/primitives/table.rs @@ -0,0 +1,204 @@ +use maud::{Markup, Render, html}; + +use crate::ui::UIWidget; + +use super::{div::Div, space::ScreenValue}; + +#[allow(non_snake_case)] +pub fn Table<T: UIWidget + 'static + Clone>(inner: Vec<Vec<T>>) -> TableWidget { + let inner = Div().vanish().push_for_each(&inner, |row| { + TableRow( + Div() + .vanish() + .push_for_each(&row, |col| TableData(col.clone())), + ) + }); + + TableWidget(Box::new(inner), Vec::new(), None, None) +} + +pub struct TableWidget( + Box<dyn UIWidget>, + Vec<String>, + Option<Box<dyn UIWidget>>, + Option<Caption>, +); + +impl TableWidget { + pub fn header<T: UIWidget + 'static>(mut self, header: T) -> Self { + self.2 = Some(Box::new(header)); + self + } + + pub fn caption(mut self, caption: Caption) -> Self { + self.3 = Some(caption); + self + } + + pub fn border_collapse(mut self) -> Self { + self.1.push("border-collapse".to_string()); + self + } + + pub fn border_seperate(mut self) -> Self { + self.1.push("border-separate".to_string()); + self + } + + pub fn border_spacing(mut self, spacing: ScreenValue) -> Self { + self.1 + .push(format!("border-spacing-{}", spacing.to_value())); + self + } + + pub fn border_spacing_x(mut self, spacing: ScreenValue) -> Self { + self.1 + .push(format!("border-spacing-x-{}", spacing.to_value())); + self + } + + pub fn border_spacing_y(mut self, spacing: ScreenValue) -> Self { + self.1 + .push(format!("border-spacing-y-{}", spacing.to_value())); + self + } + + pub fn layout_fixed(mut self) -> Self { + self.1.push("table-fixed".to_string()); + self + } + + pub fn layout_auto(mut self) -> Self { + self.1.push("table-auto".to_string()); + self + } +} + +impl Render for TableWidget { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for TableWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + self.1.clone() + } + + fn extended_class(&self) -> Vec<String> { + self.base_class() + } + + fn render_with_class(&self, class: &str) -> maud::Markup { + html! { + table class=(format!("{} {class}", self.base_class().join(" "))) { + @if let Some(caption) = &self.3 { + (caption) + } + + @if let Some(header) = &self.2 { + thead { + (header) + }; + }; + + (self.0.as_ref()) + }; + } + } +} + +pub struct Caption(Box<dyn UIWidget>, bool); + +impl Caption { + #[allow(non_snake_case)] + pub fn Top<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), true) + } + + #[allow(non_snake_case)] + pub fn Bottom<T: UIWidget + 'static>(inner: T) -> Self { + Self(Box::new(inner), false) + } +} + +impl Render for Caption { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for Caption { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + if self.1 { + vec!["caption-top".to_string()] + } else { + vec!["caption-bottom".to_string()] + } + } + + fn extended_class(&self) -> Vec<String> { + self.base_class() + } + + fn render_with_class(&self, _: &str) -> maud::Markup { + html! { + caption class=(self.base_class().join(" ")) { + (self.0.as_ref()) + }; + } + } +} + +macro_rules! element_widget { + ($name:ident, $widget:ident, $element:ident) => { + #[allow(non_snake_case)] + pub fn $name<T: UIWidget + 'static>(inner: T) -> $widget { + $widget(Box::new(inner)) + } + + pub struct $widget(Box<dyn UIWidget>); + + impl Render for $widget { + fn render(&self) -> Markup { + self.render_with_class("") + } + } + + impl UIWidget for $widget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + let mut c = self.base_class(); + c.extend_from_slice(&self.0.extended_class()); + c + } + + fn render_with_class(&self, class: &str) -> Markup { + html! { + $element class=(class) { + (self.0.as_ref()) + } + } + } + } + }; +} + +element_widget!(TableRow, TableRowWidget, tr); +element_widget!(TableHead, TableHeadWidget, th); +element_widget!(TableData, TableDataWidget, td); From f3880d77d2124aea293a13b13dd1abbe7f9ce814 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Tue, 21 Jan 2025 16:39:47 +0100 Subject: [PATCH 37/45] update --- Cargo.toml | 1 - examples/basic.rs | 12 +-- examples/ui.rs | 28 +++--- src/{htmx.rs => asset.rs} | 12 ++- src/lib.rs | 3 +- src/ui/components/shell.rs | 71 ++++++++++++--- src/ui/mod.rs | 45 ---------- src/ui/primitives/image.rs | 178 ++++++++++++++++++++++++++++++++++++- 8 files changed, 265 insertions(+), 85 deletions(-) rename src/{htmx.rs => asset.rs} (52%) diff --git a/Cargo.toml b/Cargo.toml index b506a28..b627b77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,4 +31,3 @@ reqwest = { version = "0.11", features = ["blocking"] } [features] cache = [] -htmx = [] diff --git a/examples/basic.rs b/examples/basic.rs index 9524ee0..f7b46ad 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -1,7 +1,7 @@ use based::get_pg; use based::request::{RequestContext, StringResponse}; use based::ui::components::Shell; -use based::ui::render_page; +use based::ui::prelude::Nothing; use maud::html; use rocket::get; use rocket::routes; @@ -12,13 +12,9 @@ pub async fn index_page(ctx: RequestContext) -> StringResponse { h1 { "Hello World!" }; ); - render_page( - content, - "Hello World", - ctx, - &Shell::new(html! {}, html! {}, Some(String::new())), - ) - .await + Shell::new(Nothing(), Nothing(), Nothing()) + .render_page(content, "Hello World", ctx) + .await } #[rocket::launch] diff --git a/examples/ui.rs b/examples/ui.rs index e2f92d3..d084bdb 100644 --- a/examples/ui.rs +++ b/examples/ui.rs @@ -1,14 +1,15 @@ +use based::asset::AssetRoutes; use based::request::{RequestContext, StringResponse}; use based::ui::components::{AppBar, Shell}; use based::ui::htmx::{Event, HTMXAttributes}; -use based::ui::{prelude::*, render_page}; +use based::ui::prelude::*; use maud::Render; use maud::html; -use rocket::get; use rocket::routes; +use rocket::{State, get}; #[get("/")] -pub async fn index_page(ctx: RequestContext) -> StringResponse { +pub async fn index_page(ctx: RequestContext, shell: &State<Shell>) -> StringResponse { let content = AppBar("MyApp", None).render(); let content = html!( @@ -36,19 +37,7 @@ pub async fn index_page(ctx: RequestContext) -> StringResponse { ); - render_page( - content, - "Hello World", - ctx, - &Shell::new( - html! { - script src="https://cdn.tailwindcss.com" {}; - }, - html! {}, - Some(String::new()), - ), - ) - .await + shell.render_page(content, "Hello World", ctx).await } #[rocket::launch] @@ -56,5 +45,10 @@ async fn launch() -> _ { // Logging env_logger::init(); - rocket::build().mount("/", routes![index_page]) + let shell = Shell::new(Nothing(), Nothing(), Nothing()).use_ui(); + + rocket::build() + .mount("/", routes![index_page]) + .mount_assets() // Mount included assets routes + .manage(shell) // Manage global shell reference } diff --git a/src/htmx.rs b/src/asset.rs similarity index 52% rename from src/htmx.rs rename to src/asset.rs index 950e3ca..659380e 100644 --- a/src/htmx.rs +++ b/src/asset.rs @@ -1,5 +1,5 @@ use crate::request::assets::DataResponse; -use rocket::get; +use rocket::{Build, get, routes}; #[get("/assets/htmx.min.js")] pub fn htmx_script_route() -> DataResponse { @@ -9,3 +9,13 @@ pub fn htmx_script_route() -> DataResponse { Some(60 * 60 * 24 * 3), ) } + +pub trait AssetRoutes { + fn mount_assets(self) -> Self; +} + +impl AssetRoutes for rocket::Rocket<Build> { + fn mount_assets(self) -> Self { + self.mount("/", routes![crate::asset::htmx_script_route]) + } +} diff --git a/src/lib.rs b/src/lib.rs index 809b519..a5eb06c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,10 +2,9 @@ use tokio::sync::OnceCell; +pub mod asset; pub mod auth; pub mod format; -#[cfg(feature = "htmx")] -pub mod htmx; pub mod request; pub mod result; pub mod ui; diff --git a/src/ui/components/shell.rs b/src/ui/components/shell.rs index 2bc0396..351ba9d 100644 --- a/src/ui/components/shell.rs +++ b/src/ui/components/shell.rs @@ -1,5 +1,10 @@ use maud::{PreEscaped, html}; +use crate::{ + request::{RequestContext, StringResponse}, + ui::UIWidget, +}; + // TODO : refactor shell /// Represents the HTML structure of a page shell, including the head, body class, and body content. @@ -9,9 +14,10 @@ pub struct Shell { /// The HTML content for the `<head>` section of the page. head: PreEscaped<String>, /// An optional class attribute for the `<body>` element. - body_class: Option<String>, + body_class: String, /// The HTML content for the static body portion. body_content: PreEscaped<String>, + ui: bool, } impl Shell { @@ -25,18 +31,24 @@ impl Shell { /// # Returns /// A `Shell` instance encapsulating the provided HTML content and attributes. #[must_use] - pub const fn new( - head: PreEscaped<String>, - body_content: PreEscaped<String>, - body_class: Option<String>, + pub fn new<T: UIWidget + 'static, C: UIWidget + 'static, B: UIWidget + 'static>( + head: T, + body_content: B, + body_class: C, ) -> Self { Self { - head, - body_class, - body_content, + head: head.render(), + body_class: body_class.extended_class().join(" "), + body_content: body_content.render(), + ui: false, } } + pub fn use_ui(mut self) -> Self { + self.ui = true; + self + } + /// Renders the full HTML page using the shell structure, with additional content and a title. /// /// # Arguments @@ -51,10 +63,15 @@ impl Shell { html { head { title { (title) }; + @if self.ui { + script src="https://cdn.tailwindcss.com" {}; + script src="/assets/htmx.min.js" {}; + meta name="viewport" content="width=device-width, initial-scale=1.0"; + }; (self.head) }; - @if self.body_class.is_some() { - body class=(self.body_class.as_ref().unwrap()) { + @if !self.body_class.is_empty() { + body class=(self.body_class) { (self.body_content); div id="main_content" { @@ -73,4 +90,38 @@ impl Shell { } } } + + /// Renders a full page or an HTMX-compatible fragment based on the request context. + /// + /// If the request is not an HTMX request, this function uses the provided shell to generate + /// a full HTML page. If it is an HTMX request, only the provided content is rendered. + /// + /// # Arguments + /// * `content` - The HTML content to render. + /// * `title` - The title of the page for full-page rendering. + /// * `ctx` - The `RequestContext` containing request metadata. + /// + /// # Returns + /// A `StringResponse` + pub async fn render_page( + &self, + content: PreEscaped<String>, + title: &str, + ctx: RequestContext, + ) -> StringResponse { + if ctx.is_htmx { + ( + rocket::http::Status::Ok, + (rocket::http::ContentType::HTML, content.into_string()), + ) + } else { + ( + rocket::http::Status::Ok, + ( + rocket::http::ContentType::HTML, + self.render(content, title).into_string(), + ), + ) + } + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ab1717d..a314169 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,3 @@ -use components::Shell; use maud::{Markup, PreEscaped, Render, html}; // UI @@ -71,50 +70,6 @@ pub mod prelude { }; } -use crate::request::{RequestContext, StringResponse}; - -use rocket::http::{ContentType, Status}; - -/// Renders a full page or an HTMX-compatible fragment based on the request context. -/// -/// If the request is not an HTMX request, this function uses the provided shell to generate -/// a full HTML page. If it is an HTMX request, only the provided content is rendered. -/// -/// # Arguments -/// * `content` - The HTML content to render. -/// * `title` - The title of the page for full-page rendering. -/// * `ctx` - The `RequestContext` containing request metadata. -/// * `shell` - The `Shell` instance used for full-page rendering. -/// -/// # Returns -/// A `StringResponse` -pub async fn render_page( - content: PreEscaped<String>, - title: &str, - ctx: RequestContext, - shell: &Shell, -) -> StringResponse { - if ctx.is_htmx { - (Status::Ok, (ContentType::HTML, content.into_string())) - } else { - ( - Status::Ok, - ( - ContentType::HTML, - shell.render(content, title).into_string(), - ), - ) - } -} - -// Grids - -// ListViews - -// ListTiles - -// Cards - /// Generic UI Widget pub trait UIWidget: Render { /// Indicating if the widget supports inheriting classes diff --git a/src/ui/primitives/image.rs b/src/ui/primitives/image.rs index 913c60e..7070a16 100644 --- a/src/ui/primitives/image.rs +++ b/src/ui/primitives/image.rs @@ -1,5 +1,5 @@ use crate::ui::UIWidget; -use maud::{Markup, Render, html}; +use maud::{Markup, PreEscaped, Render, html}; #[allow(non_snake_case)] #[must_use] @@ -48,3 +48,179 @@ impl UIWidget for ImageWidget { } } } + +#[allow(non_snake_case)] +#[must_use] +pub fn Video() -> VideoWidget { + VideoWidget { + src: Vec::new(), + controls: false, + autoplay: false, + looping: false, + muted: false, + poster: None, + width: None, + height: None, + } +} + +pub struct VideoWidget { + src: Vec<SourceWidget>, + controls: bool, + autoplay: bool, + looping: bool, + muted: bool, + poster: Option<String>, + width: Option<u32>, + height: Option<u32>, +} + +impl Render for VideoWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl VideoWidget { + pub fn add_src(mut self, src: SourceWidget) -> Self { + self.src.push(src); + self + } + + pub fn controls(mut self) -> Self { + self.controls = true; + self + } + + pub fn autoplay(mut self) -> Self { + self.autoplay = true; + self + } + + pub fn looping(mut self) -> Self { + self.looping = true; + self + } + + pub fn muted(mut self) -> Self { + self.muted = true; + self + } + + pub fn poster(mut self, poster: &str) -> Self { + self.poster = Some(poster.to_string()); + self + } + + pub fn width(mut self, w: u32) -> Self { + self.width = Some(w); + self + } + + pub fn height(mut self, h: u32) -> Self { + self.height = Some(h); + self + } +} + +impl UIWidget for VideoWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec![] + } + + fn extended_class(&self) -> Vec<String> { + self.base_class() + } + + fn render_with_class(&self, class: &str) -> Markup { + let mut ret = "<video".to_string(); + + if self.controls { + ret.push_str(" controls"); + } + + if self.autoplay { + ret.push_str(" autoplay"); + } + + if self.looping { + ret.push_str(" loop"); + } + + if self.muted { + ret.push_str(" muted"); + } + + if let Some(poster) = &self.poster { + ret.push_str(&format!(" poster=\"{}\"", poster.replace("\"", "\\\""))); + } + + if let Some(w) = &self.width { + ret.push_str(&format!(" width=\"{}\"", w)); + } + + if let Some(h) = &self.height { + ret.push_str(&format!(" height=\"{}\"", h)); + } + + ret.push_str(&format!(" class=\"{class}\"")); + + ret.push_str("> "); + + for src in &self.src { + ret.push_str(&src.render().0); + } + + ret.push_str("\nYour browser does not support the video tag.\n</video>"); + + PreEscaped(ret) + } +} + +#[allow(non_snake_case)] +#[must_use] +pub fn Source(src: &str, mime: Option<String>) -> SourceWidget { + SourceWidget { + src: src.to_owned(), + mime, + } +} + +pub struct SourceWidget { + src: String, + mime: Option<String>, +} + +impl Render for SourceWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for SourceWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + vec![] + } + + fn extended_class(&self) -> Vec<String> { + self.base_class() + } + + fn render_with_class(&self, _: &str) -> Markup { + html! { + @if let Some(mime) = &self.mime { + source src=(self.src) type=(mime); + } else { + source src=(self.src); + }; + } + } +} From fb4a142c9ca1e09466a18fbcbe9a562063f9ca1d Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Tue, 21 Jan 2025 17:25:45 +0100 Subject: [PATCH 38/45] refactor --- src/ui/mod.rs | 49 +++++++++++--------- src/ui/primitives/animation.rs | 49 ++++++++++---------- src/ui/primitives/aspect.rs | 73 ------------------------------ src/ui/primitives/display.rs | 20 +++++++++ src/ui/primitives/header.rs | 40 ----------------- src/ui/primitives/mod.rs | 3 -- src/ui/primitives/table.rs | 1 + src/ui/primitives/zindex.rs | 82 ---------------------------------- 8 files changed, 75 insertions(+), 242 deletions(-) delete mode 100644 src/ui/primitives/aspect.rs delete mode 100644 src/ui/primitives/header.rs delete mode 100644 src/ui/primitives/zindex.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a314169..477f3a4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -14,44 +14,54 @@ pub mod components; // Preludes pub mod prelude { pub use super::color::*; - pub use super::primitives::Context; - pub use super::primitives::NoBrowserAppearance; - pub use super::primitives::Nothing; - pub use super::primitives::Side; - pub use super::primitives::Size; pub use super::primitives::animation::{Animated, Animation, Delay, Duration, Scope, Timing}; - pub use super::primitives::aspect::Aspect; - pub use super::primitives::background::Background; + pub use super::primitives::background::{ + Background, BackgroundClip, BackgroundGradient, BackgroundOrigin, BackgroundPosition, + BackgroundRepeat, BackgroundScrollAttachment, BackgroundSize, + }; pub use super::primitives::border::{ Border, BorderSide, BorderSize, BorderStyle, Outline, OutlineStyle, Ring, }; pub use super::primitives::container::Container; pub use super::primitives::cursor::{Action, Cursor, TouchAction}; pub use super::primitives::display::{ - BoxDecorationBreak, BoxSizing, BreakAfter, BreakBefore, BreakInside, BreakInsideValue, - BreakValue, Clear, Display, Float, ObjectFit, Overflow, + AlignSelf, Aspect, BoxDecorationBreak, BoxSizing, BreakAfter, BreakBefore, BreakInside, + BreakInsideValue, BreakValue, Clear, Display, Float, JustifySelf, ObjectFit, Overflow, + PlaceSelf, ZIndex, }; pub use super::primitives::div::Div; pub use super::primitives::filter::{ - Blur, Brightness, Contrast, Grayscale, HueRotate, Invert, Saturate, Sepia, + BackgroundBlendMode, BlendMode, Blur, Brightness, Contrast, Grayscale, HueRotate, Invert, + MixBlendMode, Opacity, Saturate, Sepia, }; pub use super::primitives::flex::{ - Direction, Flex, FlexBasis, FlexGrow, Justify, Order, Strategy, Wrap, + AlignContent, AlignItems, Direction, DivideStyle, DivideWidth, Flex, FlexBasis, FlexGrow, + Justify, JustifyItems, Order, Strategy, Wrap, + }; + pub use super::primitives::grid::{ + Columns, Grid, GridAmount, GridAutoFlow, GridAutoSize, GridElementColumn, GridElementRow, + GridElementValue, }; - pub use super::primitives::header::Header; pub use super::primitives::height::{Height, MaxHeight, MinHeight}; - pub use super::primitives::image::Image; + pub use super::primitives::image::{Image, Source, Video}; + pub use super::primitives::width::{MaxWidth, MinWidth, Width}; + pub use super::primitives::{Context, NoBrowserAppearance, Nothing, Side, Size, script}; + // TODO : + pub use super::primitives::input::*; pub use super::primitives::link::Link; + pub use super::primitives::list::{OrderedList, UnorderedList}; pub use super::primitives::margin::Margin; pub use super::primitives::padding::Padding; - pub use super::primitives::position::{Position, PositionKind, Resize, Resizeable}; + pub use super::primitives::position::{ + ObjectPosition, Position, PositionKind, Resize, Resizeable, + }; pub use super::primitives::rounded::Rounded; - pub use super::primitives::script; pub use super::primitives::scroll::{Overscroll, Scroll, SnapAlign, SnapType}; - pub use super::primitives::shadow::Shadow; + pub use super::primitives::shadow::{DropShadow, Shadow}; pub use super::primitives::sized::Sized; - pub use super::primitives::space::{ScreenValue, SpaceBetween}; + pub use super::primitives::space::{Fraction, ScreenValue, SpaceBetween}; pub use super::primitives::svg::SVG; + pub use super::primitives::table::{Caption, Header, Table, TableData, TableHead, TableRow}; pub use super::primitives::text::{ AccentColor, Code, DecorationKind, DecorationStyle, DecorationThickness, LetterSpacing, LineClamp, LineHeight, ListStyle, NumberStyle, Paragraph, Span, Text, TextAlignment, @@ -60,11 +70,10 @@ pub mod prelude { VerticalTextAlignment, }; pub use super::primitives::transform::{ - RenderTransformCPU, RenderTransformGPU, Rotate, Scale, Skew, Transform, TransformOrigin, + RenderTransformCPU, RenderTransformGPU, Rotate, Scale, Skew, SkewValue, Transform, + TransformOrigin, }; pub use super::primitives::visibility::Visibility; - pub use super::primitives::width::{MaxWidth, MinWidth, Width}; - pub use super::primitives::zindex::ZIndex; pub use super::wrapper::{ _2XLScreen, Hover, LargeScreen, MediumScreen, Screen, SmallScreen, XLScreen, }; diff --git a/src/ui/primitives/animation.rs b/src/ui/primitives/animation.rs index d95dd24..64e668c 100644 --- a/src/ui/primitives/animation.rs +++ b/src/ui/primitives/animation.rs @@ -23,31 +23,32 @@ pub struct AnimatedWidget { animation: Option<Animation>, } +macro_rules! setter { + ($fnname:ident, $varname:ident, $vartype:ident, $internal_var:ident) => { + #[must_use] + pub fn $fnname(mut self, $varname: $vartype) -> Self { + self.$internal_var = $varname; + self + } + }; +} + +macro_rules! setter_opt { + ($fnname:ident, $varname:ident, $vartype:ident, $internal_var:ident) => { + #[must_use] + pub fn $fnname(mut self, $varname: $vartype) -> Self { + self.$internal_var = Some($varname); + self + } + }; +} + impl AnimatedWidget { - pub fn scope(mut self, scope: Scope) -> Self { - self.scope = scope; - self - } - - pub fn timing(mut self, timing: Timing) -> Self { - self.timing = Some(timing); - self - } - - pub fn delay(mut self, delay: Delay) -> Self { - self.delay = Some(delay); - self - } - - pub fn duration(mut self, duration: Duration) -> Self { - self.duration = Some(duration); - self - } - - pub fn animate(mut self, animation: Animation) -> Self { - self.animation = Some(animation); - self - } + setter!(scope, scope, Scope, scope); + setter_opt!(timing, timing, Timing, timing); + setter_opt!(delay, delay, Delay, delay); + setter_opt!(duration, duration, Duration, duration); + setter_opt!(animate, animation, Animation, animation); } impl Render for AnimatedWidget { diff --git a/src/ui/primitives/aspect.rs b/src/ui/primitives/aspect.rs deleted file mode 100644 index 4723067..0000000 --- a/src/ui/primitives/aspect.rs +++ /dev/null @@ -1,73 +0,0 @@ -use maud::{Markup, Render, html}; - -use crate::ui::UIWidget; - -pub struct Aspect { - kind: u8, - inner: Box<dyn UIWidget>, -} - -impl Aspect { - pub fn auto<T: UIWidget + 'static>(inner: T) -> Self { - Self { - kind: 0, - inner: Box::new(inner), - } - } - - pub fn square<T: UIWidget + 'static>(inner: T) -> Self { - Self { - kind: 1, - inner: Box::new(inner), - } - } - - pub fn video<T: UIWidget + 'static>(inner: T) -> Self { - Self { - kind: 2, - inner: Box::new(inner), - } - } -} - -impl Render for Aspect { - fn render(&self) -> Markup { - self.render_with_class("") - } -} - -impl UIWidget for Aspect { - fn can_inherit(&self) -> bool { - true - } - - fn base_class(&self) -> Vec<String> { - let class = match self.kind { - 0 => "aspect-auto", - 1 => "aspect-square", - 2 => "aspect-video", - _ => "", - }; - vec![class.to_string()] - } - - fn extended_class(&self) -> Vec<String> { - let mut c = self.base_class(); - c.extend_from_slice(&self.inner.extended_class()); - c - } - - fn render_with_class(&self, class: &str) -> Markup { - if self.inner.as_ref().can_inherit() { - html! { - (self.inner.as_ref().render_with_class(&format!("{class} {}", self.base_class().join(" ")))) - } - } else { - html! { - div class=(format!("{class} {}", self.base_class().join(" "))) { - (self.inner.as_ref()) - } - } - } - } -} diff --git a/src/ui/primitives/display.rs b/src/ui/primitives/display.rs index 287bd59..d780744 100644 --- a/src/ui/primitives/display.rs +++ b/src/ui/primitives/display.rs @@ -312,3 +312,23 @@ impl AlignSelf { constructor!(Stretch, "self-stretch"); constructor!(Baseline, "self-baseline"); } + +string_class_widget!(Aspect); + +impl Aspect { + constructor!(Auto, "aspect-auto"); + constructor!(Square, "aspect-square"); + constructor!(Video, "aspect-video"); +} + +string_class_widget!(ZIndex); + +impl ZIndex { + constructor!(Auto, "z-auto"); + constructor!(Zero, "z-0"); + constructor!(One, "z-10"); + constructor!(Two, "z-20"); + constructor!(Three, "z-30"); + constructor!(Four, "z-40"); + constructor!(Five, "z-50"); +} diff --git a/src/ui/primitives/header.rs b/src/ui/primitives/header.rs deleted file mode 100644 index 28b5fdc..0000000 --- a/src/ui/primitives/header.rs +++ /dev/null @@ -1,40 +0,0 @@ -use maud::{Markup, Render, html}; - -use crate::ui::UIWidget; - -#[allow(non_snake_case)] -pub fn Header<T: UIWidget + 'static>(inner: T) -> HeaderWidget { - HeaderWidget(Box::new(inner)) -} - -pub struct HeaderWidget(Box<dyn UIWidget>); - -impl Render for HeaderWidget { - fn render(&self) -> Markup { - self.render_with_class("") - } -} - -impl UIWidget for HeaderWidget { - fn can_inherit(&self) -> bool { - true - } - - fn base_class(&self) -> Vec<String> { - vec![] - } - - fn extended_class(&self) -> Vec<String> { - let mut c = self.base_class(); - c.extend_from_slice(&self.0.extended_class()); - c - } - - fn render_with_class(&self, class: &str) -> Markup { - html! { - header class=(class) { - (self.0.as_ref()) - } - } - } -} diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index d2d21f5..38816ba 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -2,7 +2,6 @@ use super::UIWidget; use maud::{Markup, PreEscaped, Render, html}; pub mod animation; -pub mod aspect; pub mod background; pub mod border; pub mod container; @@ -12,7 +11,6 @@ pub mod div; pub mod filter; pub mod flex; pub mod grid; -pub mod header; pub mod height; pub mod image; pub mod input; @@ -32,7 +30,6 @@ pub mod text; pub mod transform; pub mod visibility; pub mod width; -pub mod zindex; #[allow(non_snake_case)] #[must_use] diff --git a/src/ui/primitives/table.rs b/src/ui/primitives/table.rs index 60bf8a6..3a82002 100644 --- a/src/ui/primitives/table.rs +++ b/src/ui/primitives/table.rs @@ -202,3 +202,4 @@ macro_rules! element_widget { element_widget!(TableRow, TableRowWidget, tr); element_widget!(TableHead, TableHeadWidget, th); element_widget!(TableData, TableDataWidget, td); +element_widget!(Header, HeaderWidget, header); diff --git a/src/ui/primitives/zindex.rs b/src/ui/primitives/zindex.rs deleted file mode 100644 index 2ddf0b4..0000000 --- a/src/ui/primitives/zindex.rs +++ /dev/null @@ -1,82 +0,0 @@ -use maud::{Markup, Render, html}; - -use crate::ui::UIWidget; - -pub struct ZIndex(Box<dyn UIWidget>, u8); - -impl Render for ZIndex { - fn render(&self) -> Markup { - self.render_with_class("") - } -} - -impl ZIndex { - pub fn auto<T: UIWidget + 'static>(inner: T) -> Self { - Self(Box::new(inner), 0) - } - - pub fn zero<T: UIWidget + 'static>(inner: T) -> Self { - Self(Box::new(inner), 1) - } - - pub fn one<T: UIWidget + 'static>(inner: T) -> Self { - Self(Box::new(inner), 2) - } - - pub fn two<T: UIWidget + 'static>(inner: T) -> Self { - Self(Box::new(inner), 3) - } - - pub fn three<T: UIWidget + 'static>(inner: T) -> Self { - Self(Box::new(inner), 4) - } - - pub fn four<T: UIWidget + 'static>(inner: T) -> Self { - Self(Box::new(inner), 5) - } - - pub fn five<T: UIWidget + 'static>(inner: T) -> Self { - Self(Box::new(inner), 6) - } -} - -impl UIWidget for ZIndex { - fn can_inherit(&self) -> bool { - true - } - - fn base_class(&self) -> Vec<String> { - let class = match self.1 { - 0 => "z-auto", - 1 => "z-0", - 2 => "z-10", - 3 => "z-20", - 4 => "z-30", - 5 => "z-40", - 6 => "z-50", - _ => "z-auto", - }; - - vec![class.to_string()] - } - - fn extended_class(&self) -> Vec<String> { - let mut c = self.base_class(); - c.extend_from_slice(&self.0.extended_class()); - c - } - - fn render_with_class(&self, class: &str) -> Markup { - if self.0.as_ref().can_inherit() { - self.0 - .as_ref() - .render_with_class(&format!("{} {class}", self.base_class().join(" "))) - } else { - html! { - div class=(format!("{} {class}", self.base_class().join(" "))) { - (self.0.as_ref()) - } - } - } - } -} From caeac280eb008a7a17691cb4bb86f1568eaddda1 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Tue, 21 Jan 2025 18:00:48 +0100 Subject: [PATCH 39/45] refactor --- src/ui/color.rs | 15 ++++++++++++--- src/ui/components/shell.rs | 2 -- src/ui/primitives/link.rs | 1 - src/ui/wrapper/mod.rs | 4 ---- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/ui/color.rs b/src/ui/color.rs index e7baa05..50d070c 100644 --- a/src/ui/color.rs +++ b/src/ui/color.rs @@ -12,7 +12,18 @@ pub trait ColorCircle { fn next(&self) -> Self; } -// todo : specific colors rgb -[#50d71e] +#[allow(non_snake_case)] +pub fn RGB(r: u8, g: u8, b: u8) -> RGBColor { + RGBColor(format!("[#{r:02x}{g:02x}{b:02x}]")) +} + +pub struct RGBColor(String); + +impl UIColor for RGBColor { + fn color_class(&self) -> &str { + self.0.as_ref() + } +} macro_rules! color_map { ($name:ident, $id:literal) => { @@ -134,8 +145,6 @@ impl UIColor for Colors { } } -// TODO : Gradient - pub struct Gradient { start: Box<dyn UIColor>, middle: Option<Box<dyn UIColor>>, diff --git a/src/ui/components/shell.rs b/src/ui/components/shell.rs index 351ba9d..8db6011 100644 --- a/src/ui/components/shell.rs +++ b/src/ui/components/shell.rs @@ -5,8 +5,6 @@ use crate::{ ui::UIWidget, }; -// TODO : refactor shell - /// Represents the HTML structure of a page shell, including the head, body class, and body content. /// /// This structure is used to construct the overall HTML structure of a page, making it easier to generate consistent HTML pages dynamically. diff --git a/src/ui/primitives/link.rs b/src/ui/primitives/link.rs index 0cf42e8..131537e 100644 --- a/src/ui/primitives/link.rs +++ b/src/ui/primitives/link.rs @@ -69,7 +69,6 @@ impl LinkWidget { /// Enable HTMX link capabilities #[must_use] pub fn use_htmx(self) -> Self { - // todo : investigate htmx attrs let url = self.1.clone(); self.hx_get(&url) .hx_target(Selector::Query("#main_content".to_string())) diff --git a/src/ui/wrapper/mod.rs b/src/ui/wrapper/mod.rs index 06654a3..410a9a6 100644 --- a/src/ui/wrapper/mod.rs +++ b/src/ui/wrapper/mod.rs @@ -104,10 +104,6 @@ wrapper!(LargeScreen, LargeScreenWrapper, "lg"); wrapper!(XLScreen, XLScreenWrapper, "xl"); wrapper!(_2XLScreen, _2XLScreenWrapper, "2xl"); -// TODO : responsive media - -// TODO : arbitrary values "min-[320px]:text-center max-[600px]:bg-sky-300" - #[allow(non_snake_case)] pub mod Screen { use crate::ui::UIWidget; From 5ef37275ec504dc2e406d8feadca2e388e8d7fc9 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Tue, 21 Jan 2025 21:55:27 +0100 Subject: [PATCH 40/45] add flowbite --- .gitignore | 4 +++- build.rs | 26 ++++++++++++++++++++------ src/asset.rs | 24 +++++++++++++++++++++++- src/ui/components/shell.rs | 2 ++ 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 3077a23..c76a005 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target -src/htmx.min.js \ No newline at end of file +src/htmx.min.js +src/flowbite.min.css +src/flowbite.min.js \ No newline at end of file diff --git a/build.rs b/build.rs index 24382b4..1189a1c 100644 --- a/build.rs +++ b/build.rs @@ -1,11 +1,9 @@ use std::fs; use std::path::Path; -fn main() { - let url = "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"; - let dest_path = Path::new("src/htmx.min.js"); - - println!("Downloading htmx.min.js from {url}"); +pub fn download_file(url: &str, dest_path: &str) { + println!("Downloading {dest_path} from {url}"); + let dest_path = Path::new(dest_path); let response = reqwest::blocking::get(url) .expect("Failed to send HTTP request") .error_for_status() @@ -13,7 +11,23 @@ fn main() { let content = response.bytes().expect("Failed to read response body"); - fs::write(dest_path, &content).expect("Failed to write htmx.min.js to destination"); + fs::write(dest_path, &content).expect("Failed to write file to destination"); +} + +fn main() { + download_file( + "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js", + "src/htmx.min.js", + ); + + download_file( + "https://cdn.jsdelivr.net/npm/flowbite@2.5.2/dist/flowbite.min.css", + "src/flowbite.min.css", + ); + download_file( + "https://cdn.jsdelivr.net/npm/flowbite@2.5.2/dist/flowbite.min.js", + "src/flowbite.min.js", + ); println!("cargo:rerun-if-changed=build.rs"); } diff --git a/src/asset.rs b/src/asset.rs index 659380e..0c878d7 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -10,12 +10,34 @@ pub fn htmx_script_route() -> DataResponse { ) } +#[get("/assets/flowbite.min.css")] +pub fn flowbite_css() -> DataResponse { + DataResponse::new( + include_str!("flowbite.min.css").as_bytes().to_vec(), + "text/css".to_string(), + Some(60 * 60 * 24 * 3), + ) +} + +#[get("/assets/flowbite.min.s")] +pub fn flowbite_js() -> DataResponse { + DataResponse::new( + include_str!("flowbite.min.js").as_bytes().to_vec(), + "application/javascript".to_string(), + Some(60 * 60 * 24 * 3), + ) +} + pub trait AssetRoutes { fn mount_assets(self) -> Self; } impl AssetRoutes for rocket::Rocket<Build> { fn mount_assets(self) -> Self { - self.mount("/", routes![crate::asset::htmx_script_route]) + self.mount("/", routes![ + crate::asset::htmx_script_route, + crate::asset::flowbite_css, + crate::asset::flowbite_js + ]) } } diff --git a/src/ui/components/shell.rs b/src/ui/components/shell.rs index 8db6011..0e529be 100644 --- a/src/ui/components/shell.rs +++ b/src/ui/components/shell.rs @@ -64,6 +64,8 @@ impl Shell { @if self.ui { script src="https://cdn.tailwindcss.com" {}; script src="/assets/htmx.min.js" {}; + script src="/assets/flowbite.min.js" {}; + link href="/assets/flowbite.min.css" rel="stylesheet" {}; meta name="viewport" content="width=device-width, initial-scale=1.0"; }; (self.head) From 6a39c0441d72cbaa1f9864af9c53ac41362f549e Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Wed, 22 Jan 2025 19:52:10 +0100 Subject: [PATCH 41/45] fix --- src/ui/primitives/text.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/primitives/text.rs b/src/ui/primitives/text.rs index 43befe7..ccd56fa 100644 --- a/src/ui/primitives/text.rs +++ b/src/ui/primitives/text.rs @@ -583,7 +583,7 @@ impl UIWidget for TextWidget { ret.push_str("'"); } - ret.push_str("> "); + ret.push_str(">"); if let Some(inner) = &self.inner { ret.push_str(&inner.render().0); From 3f411e071fd4d47ff0e2d7e7eeab4f638bd35799 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Sun, 26 Jan 2025 10:16:14 +0100 Subject: [PATCH 42/45] fix table --- src/ui/primitives/table.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ui/primitives/table.rs b/src/ui/primitives/table.rs index 3a82002..bbdfe77 100644 --- a/src/ui/primitives/table.rs +++ b/src/ui/primitives/table.rs @@ -17,6 +17,11 @@ pub fn Table<T: UIWidget + 'static + Clone>(inner: Vec<Vec<T>>) -> TableWidget { TableWidget(Box::new(inner), Vec::new(), None, None) } +#[allow(non_snake_case)] +pub fn TableRaw<T: UIWidget + 'static + Clone>(inner: T) -> TableWidget { + TableWidget(Box::new(inner), Vec::new(), None, None) +} + pub struct TableWidget( Box<dyn UIWidget>, Vec<String>, From 6d23afe41f55a49661c3aa5444d8a1e00978a913 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Tue, 28 Jan 2025 23:45:07 +0100 Subject: [PATCH 43/45] update --- src/ui/primitives/link.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui/primitives/link.rs b/src/ui/primitives/link.rs index 131537e..c0a7bd8 100644 --- a/src/ui/primitives/link.rs +++ b/src/ui/primitives/link.rs @@ -73,7 +73,11 @@ impl LinkWidget { self.hx_get(&url) .hx_target(Selector::Query("#main_content".to_string())) .hx_push_url() - .hx_swap(SwapStrategy::innerHTML) + .hx_swap( + SwapStrategy::innerHTML + .focus_scroll(true) + .show("window:top"), + ) .hx_boost() .hx_disabled_elt(Selector::This) } From 5ce50b76f5581129110aa8408dfe2374ad9702ac Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Wed, 29 Jan 2025 21:41:04 +0100 Subject: [PATCH 44/45] update --- src/asset.rs | 2 +- src/ui/primitives/div.rs | 3 +- src/ui/primitives/image.rs | 59 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/asset.rs b/src/asset.rs index 0c878d7..3eefe2f 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -19,7 +19,7 @@ pub fn flowbite_css() -> DataResponse { ) } -#[get("/assets/flowbite.min.s")] +#[get("/assets/flowbite.min.js")] pub fn flowbite_js() -> DataResponse { DataResponse::new( include_str!("flowbite.min.js").as_bytes().to_vec(), diff --git a/src/ui/primitives/div.rs b/src/ui/primitives/div.rs index 0d1c8d6..224e32b 100644 --- a/src/ui/primitives/div.rs +++ b/src/ui/primitives/div.rs @@ -136,8 +136,7 @@ impl UIWidget for DivWidget { .join(" "); PreEscaped(format!( - "<div class='{} {class}' {attrs}> {} </div>", - self.extended_class_().join(" "), + "<div class='{class}' {attrs}> {} </div>", inner.0 )) } diff --git a/src/ui/primitives/image.rs b/src/ui/primitives/image.rs index 7070a16..713fb35 100644 --- a/src/ui/primitives/image.rs +++ b/src/ui/primitives/image.rs @@ -7,12 +7,18 @@ pub fn Image(src: &str) -> ImageWidget { ImageWidget { src: src.to_owned(), alt: String::new(), + width: None, + height: None, + caption: None, } } pub struct ImageWidget { src: String, alt: String, + width: Option<u32>, + height: Option<u32>, + caption: Option<String>, } impl Render for ImageWidget { @@ -27,6 +33,46 @@ impl ImageWidget { self.alt = alt.to_string(); self } + + #[must_use] + pub fn width(mut self, width: u32) -> Self { + self.width = Some(width); + self + } + + #[must_use] + pub fn height(mut self, height: u32) -> Self { + self.height = Some(height); + self + } + + #[must_use] + pub fn caption(mut self, caption: &str) -> Self { + self.caption = Some(caption.to_string()); + self + } + + pub fn build_img(&self, class: &str) -> PreEscaped<String> { + let mut str = "<img".to_string(); + + str.push_str(&format!(" src=\"{}\"", self.src)); + + if !self.alt.is_empty() { + str.push_str(&format!(" alt=\"{}\"", self.alt)); + } + + if let Some(width) = self.width { + str.push_str(&format!(" width=\"{width}\"")); + } + + if let Some(height) = self.height { + str.push_str(&format!(" height=\"{height}\"")); + } + + str.push_str(&format!(" class=\"{class}\">")); + + PreEscaped(str) + } } impl UIWidget for ImageWidget { @@ -43,9 +89,16 @@ impl UIWidget for ImageWidget { } fn render_with_class(&self, class: &str) -> Markup { - html! { - img src=(self.src) alt=(self.alt) class=(class) {}; + if let Some(caption) = &self.caption { + return html! { + figure class="w-fit" { + (self.build_img(class)) + figcaption class="mt-2 text-sm text-center text-gray-500 dark:text-gray-400" { (caption) }; + } + }; } + + self.build_img(class) } } @@ -218,7 +271,7 @@ impl UIWidget for SourceWidget { html! { @if let Some(mime) = &self.mime { source src=(self.src) type=(mime); - } else { + } @else { source src=(self.src); }; } From 7ba30045a0daafe69fdb3d906f8f3d0c9f13e5f2 Mon Sep 17 00:00:00 2001 From: JMARyA <jmarya@hydrar.de> Date: Tue, 18 Feb 2025 18:29:13 +0100 Subject: [PATCH 45/45] =?UTF-8?q?=E2=9C=A8=20finalize=20ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- Cargo.lock | 120 +++-- build.rs | 5 + examples/basic.rs | 2 +- examples/ui.rs | 54 -- src/asset.rs | 22 +- src/lib.rs | 93 ++++ src/material.css | 23 + src/request/api.rs | 69 ++- src/request/context.rs | 9 + src/ui/color.rs | 61 +++ src/ui/components/appbar.rs | 82 +-- src/ui/components/avatar.rs | 171 +++++++ src/ui/components/htmx.rs | 39 ++ src/ui/components/icon.rs | 8 + src/ui/components/indicator.rs | 63 +++ src/ui/components/mod.rs | 810 ++++++++++++++++++++++++++++- src/ui/components/modal.rs | 54 ++ src/ui/components/overlay.rs | 354 +++++++++++++ src/ui/components/pagination.rs | 213 ++++++++ src/ui/components/placeholder.rs | 147 ++++++ src/ui/components/shell.rs | 502 ++++++++++++++++-- src/ui/components/timeline.rs | 272 ++++++++++ src/ui/mod.rs | 35 +- src/ui/primitives/div.rs | 16 +- src/ui/primitives/flex.rs | 27 +- src/ui/primitives/input.rs | 1 - src/ui/primitives/input/form.rs | 91 ++++ src/ui/primitives/input/mod.rs | 819 ++++++++++++++++++++++++++++++ src/ui/primitives/input/toggle.rs | 394 ++++++++++++++ src/ui/primitives/list.rs | 54 +- src/ui/primitives/mod.rs | 12 + src/ui/primitives/space.rs | 2 + src/ui/primitives/table.rs | 104 +++- src/ui/primitives/text.rs | 2 + 35 files changed, 4498 insertions(+), 235 deletions(-) delete mode 100644 examples/ui.rs create mode 100644 src/material.css create mode 100644 src/ui/components/avatar.rs create mode 100644 src/ui/components/htmx.rs create mode 100644 src/ui/components/icon.rs create mode 100644 src/ui/components/indicator.rs create mode 100644 src/ui/components/modal.rs create mode 100644 src/ui/components/overlay.rs create mode 100644 src/ui/components/pagination.rs create mode 100644 src/ui/components/placeholder.rs create mode 100644 src/ui/components/timeline.rs delete mode 100644 src/ui/primitives/input.rs create mode 100644 src/ui/primitives/input/form.rs create mode 100644 src/ui/primitives/input/mod.rs create mode 100644 src/ui/primitives/input/toggle.rs diff --git a/.gitignore b/.gitignore index c76a005..5707720 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target src/htmx.min.js src/flowbite.min.css -src/flowbite.min.js \ No newline at end of file +src/flowbite.min.js +src/material.woff2 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index fa156dc..e477273 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,7 +179,7 @@ checksum = "2b1866ecef4f2d06a0bb77880015fdf2b89e25a1c2e5addacb87e459c86dc67e" dependencies = [ "base64 0.22.1", "blowfish", - "getrandom", + "getrandom 0.2.15", "subtle", "zeroize", ] @@ -226,9 +226,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.16.0" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" [[package]] name = "bytemuck" @@ -250,9 +250,9 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cc" -version = "1.2.9" +version = "1.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8293772165d9345bdaaa39b45b2109591e63fe5e6fbc23c6ff930a048aa310b" +checksum = "13208fcbb66eaeffe09b99fffbe1af420f00a7b35aa99ad683dfc1aa76145229" dependencies = [ "shlex", ] @@ -332,9 +332,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] @@ -767,7 +767,19 @@ checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.13.3+wasi-0.2.2", + "windows-targets 0.52.6", ] [[package]] @@ -913,9 +925,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.9.5" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" +checksum = "f2d708df4e7140240a16cd6ab0ab65c972d7433ab77819ea693fde9c43811e2a" [[package]] name = "httpdate" @@ -1130,9 +1142,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" dependencies = [ "equivalent", "hashbrown 0.15.2", @@ -1156,19 +1168,19 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.10.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "is-terminal" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +checksum = "e19b23d53f35ce9f56aebc7d1bb4e6ac1e9c0db7ac85c8d1760c04379edced37" dependencies = [ "hermit-abi 0.4.0", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1330,7 +1342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -1355,9 +1367,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +checksum = "0dab59f8e050d5df8e4dd87d9206fb6f65a483e20ac9fda365ade4fab353196c" dependencies = [ "libc", "log", @@ -1460,9 +1472,9 @@ checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "openssl" -version = "0.10.68" +version = "0.10.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +checksum = "f5e534d133a060a3c19daec1eb3e98ec6f4685978834f2dbadfe2ec215bab64e" dependencies = [ "bitflags 2.8.0", "cfg-if", @@ -1486,9 +1498,9 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" @@ -1710,7 +1722,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", ] [[package]] @@ -1988,9 +2000,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" -version = "0.38.43" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ "bitflags 2.8.0", "errno", @@ -2016,9 +2028,9 @@ checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" [[package]] name = "same-file" @@ -2095,9 +2107,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.135" +version = "1.0.138" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b0d7ba2887406110130a978386c4e1befb98c674b4fba677954e4db976630d9" +checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" dependencies = [ "itoa", "memchr", @@ -2523,13 +2535,13 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.15.0" +version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" +checksum = "38c246215d7d24f48ae091a2902398798e05d978b24315d6efbc00ede9a8bb91" dependencies = [ "cfg-if", "fastrand", - "getrandom", + "getrandom 0.3.1", "once_cell", "rustix", "windows-sys 0.59.0", @@ -2716,9 +2728,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.22" +version = "0.22.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +checksum = "02a8b472d1a3d7c18e2d61a489aee3453fd9031c33e4f55bd533f4a7adca1bee" dependencies = [ "indexmap", "serde", @@ -2850,9 +2862,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.14" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" [[package]] name = "unicode-normalization" @@ -2906,19 +2918,19 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744018581f9a3454a9e15beb8a33b017183f1e7c0cd170232a2d1453b23a51c4" +checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b" dependencies = [ - "getrandom", + "getrandom 0.2.15", "serde", ] [[package]] name = "valuable" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vcpkg" @@ -2957,6 +2969,15 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasi" +version = "0.13.3+wasi-0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +dependencies = [ + "wit-bindgen-rt", +] + [[package]] name = "wasite" version = "0.1.0" @@ -3253,9 +3274,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.24" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" +checksum = "7e49d2d35d3fad69b39b94139037ecfb4f359f08958b9c11e7315ce770462419" dependencies = [ "memchr", ] @@ -3270,6 +3291,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags 2.8.0", +] + [[package]] name = "write16" version = "1.0.0" diff --git a/build.rs b/build.rs index 1189a1c..eb5d07f 100644 --- a/build.rs +++ b/build.rs @@ -29,5 +29,10 @@ fn main() { "src/flowbite.min.js", ); + download_file( + "https://fonts.gstatic.com/s/materialsymbolsoutlined/v226/kJF1BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzByHX9rA6RzaxHMPdY43zj-jCxv3fzvRNU22ZXGJpEpjC_1v-p_4MrImHCIJIZrDCvHOej.woff2", + "src/material.woff2", + ); + println!("cargo:rerun-if-changed=build.rs"); } diff --git a/examples/basic.rs b/examples/basic.rs index f7b46ad..4382481 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -1,6 +1,6 @@ use based::get_pg; use based::request::{RequestContext, StringResponse}; -use based::ui::components::Shell; +use based::ui::components::prelude::Shell; use based::ui::prelude::Nothing; use maud::html; use rocket::get; diff --git a/examples/ui.rs b/examples/ui.rs deleted file mode 100644 index d084bdb..0000000 --- a/examples/ui.rs +++ /dev/null @@ -1,54 +0,0 @@ -use based::asset::AssetRoutes; -use based::request::{RequestContext, StringResponse}; -use based::ui::components::{AppBar, Shell}; -use based::ui::htmx::{Event, HTMXAttributes}; -use based::ui::prelude::*; -use maud::Render; -use maud::html; -use rocket::routes; -use rocket::{State, get}; - -#[get("/")] -pub async fn index_page(ctx: RequestContext, shell: &State<Shell>) -> StringResponse { - let content = AppBar("MyApp", None).render(); - - let content = html!( - h1 { "Hello World!" }; - - ( - Screen::medium(Hover(Background(Nothing()).color(Red::_700))).on( - Background(Text("HELLO!")).color(Blue::_700) - ) - ) - - (Hover( - Cursor::NorthEastResize.on( - Padding(Text("").color(&Gray::_400)).x(ScreenValue::_10) - ) - ).on( - Link("/test", Text("Hello")).hx_get("/test").hx_get("/test").hx_trigger( - Event::on_load().delay("2s") - .and(Event::on_revealed()) - ) - ) - ) - - (content) - - ); - - shell.render_page(content, "Hello World", ctx).await -} - -#[rocket::launch] -async fn launch() -> _ { - // Logging - env_logger::init(); - - let shell = Shell::new(Nothing(), Nothing(), Nothing()).use_ui(); - - rocket::build() - .mount("/", routes![index_page]) - .mount_assets() // Mount included assets routes - .manage(shell) // Manage global shell reference -} diff --git a/src/asset.rs b/src/asset.rs index 3eefe2f..0239eda 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -28,6 +28,24 @@ pub fn flowbite_js() -> DataResponse { ) } +#[get("/assets/material.css")] +pub fn material_css() -> DataResponse { + DataResponse::new( + include_str!("material.css").as_bytes().to_vec(), + "text/css".to_string(), + Some(60 * 60 * 24 * 3), + ) +} + +#[get("/assets/material.woff2")] +pub fn material_font() -> DataResponse { + DataResponse::new( + include_bytes!("material.woff2").to_vec(), + "font/woff2".to_string(), + Some(60 * 60 * 24 * 3), + ) +} + pub trait AssetRoutes { fn mount_assets(self) -> Self; } @@ -37,7 +55,9 @@ impl AssetRoutes for rocket::Rocket<Build> { self.mount("/", routes![ crate::asset::htmx_script_route, crate::asset::flowbite_css, - crate::asset::flowbite_js + crate::asset::flowbite_js, + crate::asset::material_css, + crate::asset::material_font ]) } } diff --git a/src/lib.rs b/src/lib.rs index a5eb06c..3ec5623 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ #![feature(const_vec_string_slice)] +use rocket::Data; +use sqlx::FromRow; use tokio::sync::OnceCell; pub mod asset; @@ -46,3 +48,94 @@ macro_rules! get_pg { } }; } + +fn transpose<T: Clone>(matrix: Vec<Vec<T>>) -> Vec<Vec<T>> { + if matrix.is_empty() { + return vec![]; + } + + let row_len = matrix[0].len(); + let mut transposed = vec![Vec::with_capacity(matrix.len()); row_len]; + + for row in matrix { + for (i, elem) in row.into_iter().enumerate() { + transposed[i].push(elem); + } + } + + transposed +} + +// TODO : More types +#[derive(Clone)] +pub enum DatabaseType { + TEXT(String), + INTEGER(i32), +} + +impl DatabaseType { + pub fn type_name(&self) -> &str { + match self { + Self::TEXT(_) => "TEXT", + Self::INTEGER(_) => "INTEGER", + } + } + + pub fn as_integer(self) -> i32 { + match self { + DatabaseType::INTEGER(result) => return result, + _ => panic!("Wrong DB type"), + } + } + + pub fn as_text(self) -> String { + match self { + DatabaseType::TEXT(result) => return result, + _ => panic!("Wrong DB type"), + } + } +} + +pub async fn batch_insert(table: &str, values: &[String], entries: Vec<Vec<DatabaseType>>) { + assert_eq!(values.len(), entries.first().unwrap().len()); + + let mut cmd = format!("INSERT INTO {table} ("); + + cmd.push_str(&values.join(", ")); + + cmd.push_str(") SELECT * FROM UNNEST("); + + let entries = transpose(entries); + + for i in 0..values.len() { + let t = entries.get(i).unwrap().first().unwrap().type_name(); + if i == (entries.len() - 1) { + cmd.push_str(&format!("${}::{t}[]", i + 1)); + } else { + cmd.push_str(&format!("${}::{t}[],", i + 1)); + } + } + + cmd.push_str(")"); + + log::debug!("Executing batch query: {cmd}"); + + let mut query = sqlx::query(&cmd); + + for e in entries { + let first = e.first().unwrap(); + match first { + DatabaseType::TEXT(_) => { + query = query.bind(e.into_iter().map(|x| x.as_text()).collect::<Vec<_>>()); + } + DatabaseType::INTEGER(_) => { + query = query.bind(e.into_iter().map(|x| x.as_integer()).collect::<Vec<_>>()); + } + } + } + + query.execute(get_pg!()).await.unwrap(); +} + +// TODO : LibraryIndex +// index, cleanup, add_one, remove_one, get, exists, query diff --git a/src/material.css b/src/material.css new file mode 100644 index 0000000..fe4ae20 --- /dev/null +++ b/src/material.css @@ -0,0 +1,23 @@ +/* fallback */ +@font-face { + font-family: 'Material Symbols Outlined'; + font-style: normal; + font-weight: 400; + src: url(/assets/material.woff2) format('woff2'); +} + +.material-symbols-outlined { + font-family: 'Material Symbols Outlined'; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -moz-font-feature-settings: 'liga'; + -moz-osx-font-smoothing: grayscale; +} diff --git a/src/request/api.rs b/src/request/api.rs index c2a1967..4ecee27 100644 --- a/src/request/api.rs +++ b/src/request/api.rs @@ -1,6 +1,6 @@ use rocket::response::status::BadRequest; use serde_json::json; -use std::str::FromStr; +use std::{str::FromStr, sync::Arc}; /// API error response with a JSON payload. pub type ApiError = BadRequest<serde_json::Value>; @@ -85,8 +85,9 @@ pub fn api_error(msg: &str) -> ApiError { } /// A `Pager` that manages paginated items, with the ability to handle incomplete data. +#[derive(Clone)] pub struct Pager<T> { - inner: Vec<T>, + inner: Arc<Vec<T>>, pub items_per_page: u64, complete_at: Option<u64>, } @@ -101,9 +102,9 @@ impl<T> Pager<T> { /// # Returns /// A new `Pager` instance. #[must_use] - pub const fn new(items: Vec<T>, per_page: u64) -> Self { + pub fn new(items: Vec<T>, per_page: u64) -> Self { Self { - inner: items, + inner: Arc::new(items), items_per_page: per_page, complete_at: None, } @@ -119,9 +120,9 @@ impl<T> Pager<T> { /// # Returns /// A new `Pager` instance. #[must_use] - pub const fn new_incomplete(items: Vec<T>, per_page: u64, at_page: u64) -> Self { + pub fn new_incomplete(items: Vec<T>, per_page: u64, at_page: u64) -> Self { Self { - inner: items, + inner: Arc::new(items), items_per_page: per_page, complete_at: Some(at_page), } @@ -146,6 +147,25 @@ impl<T> Pager<T> { self.items_per_page * (page - 1) } + /// Estimated total size of the pager + /// + /// If the pager is generated, this is only a rough estimation since not all elements are loaded. + #[must_use] + pub fn size(&self) -> usize { + if let Some(start_page) = self.complete_at { + return (start_page * self.items_per_page) as usize + self.inner.len(); + } + + self.inner.len() + } + + /// Estimated total page count of the pager + /// + /// If the pager is generated, this is only a rough estimation since not all elements are loaded. + pub fn total_pages(&self) -> usize { + ((self.size() as f64 / self.items_per_page as f64).ceil()) as usize + } + /// Retrieves the items for a specific page. /// /// # Arguments @@ -158,6 +178,11 @@ impl<T> Pager<T> { /// A vector of items on the requested page. #[must_use] pub fn page(&self, page: u64) -> Vec<&T> { + self.page_with_context(page).0 + } + + /// Returns the elements at the current page and wether the next page has content + pub fn page_with_context(&self, page: u64) -> (Vec<&T>, bool) { if let Some(incomplete) = self.complete_at { assert!( page >= incomplete, @@ -165,11 +190,26 @@ impl<T> Pager<T> { ); } - self.inner + let res = self + .inner .iter() .skip(self.offset(page).try_into().unwrap()) - .take(self.items_per_page.try_into().unwrap()) - .collect() + .take(self.items_per_page as usize + 1); + + let has_next_page = if res.clone().count() > (self.items_per_page as usize) { + true + } else { + false + }; + + ( + res.take(self.items_per_page as usize).collect::<Vec<_>>(), + has_next_page, + ) + } + + pub fn has_next_page(&self, page: u64) -> bool { + self.page_with_context(page).1 } } @@ -186,6 +226,7 @@ where impl<T, G, I> GeneratedPager<T, G, I> where G: Fn(I, u64, u64) -> futures::future::BoxFuture<'static, Vec<T>>, + I: Clone, { /// Creates a new `GeneratedPager` instance with the provided generator and pagination settings. /// @@ -230,7 +271,7 @@ where /// A vector of items on the requested page. pub async fn page(&self, page: u64, input: I) -> Vec<T> { let offset = self.offset(page); - (self.generator)(input, offset, self.items_per_page).await + (self.generator)(input, offset, self.items_per_page + 1).await } /// Converts the `GeneratedPager` into a regular `Pager` for a given page of items. @@ -244,7 +285,13 @@ where /// # Returns /// A `Pager` instance containing the requested page of items. pub async fn pager(&self, page: u64, input: I) -> Pager<T> { - let content = self.page(page, input).await; + let mut content = Vec::new(); + + for i in 0..=5 { + let fetch = self.page(page + i, input.clone()).await; + content.extend(fetch); + } + Pager::new_incomplete(content, self.items_per_page, page) } } diff --git a/src/request/context.rs b/src/request/context.rs index 357c053..c481555 100644 --- a/src/request/context.rs +++ b/src/request/context.rs @@ -10,6 +10,8 @@ pub struct RequestContext { /// /// This is determined by checking the presence of the `HX-Request` header. pub is_htmx: bool, + pub hx_boosted: bool, + pub htmx_redirect: bool, } #[rocket::async_trait] @@ -19,6 +21,13 @@ impl<'r> FromRequest<'r> for RequestContext { async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> { rocket::outcome::Outcome::Success(RequestContext { is_htmx: req.headers().get("HX-Request").next().is_some(), + hx_boosted: req.headers().get("HX-Boosted").next().is_some(), + htmx_redirect: req + .headers() + .get("HX-Target") + .next() + .map(|x| x == "main_content") + .unwrap_or(false), }) } } diff --git a/src/ui/color.rs b/src/ui/color.rs index 50d070c..c6449cc 100644 --- a/src/ui/color.rs +++ b/src/ui/color.rs @@ -8,6 +8,15 @@ pub trait ColorCircle { #[must_use] fn previous(&self) -> Self; + #[must_use] + fn middle(&self) -> Self; + + #[must_use] + fn start(&self) -> Self; + + #[must_use] + fn end(&self) -> Self; + #[must_use] fn next(&self) -> Self; } @@ -25,6 +34,8 @@ impl UIColor for RGBColor { } } +pub trait UIColorCircle: UIColor + ColorCircle + Sized {} + macro_rules! color_map { ($name:ident, $id:literal) => { #[derive(Debug, Clone)] @@ -77,6 +88,18 @@ macro_rules! color_map { } } + fn middle(&self) -> Self { + Self::_500 + } + + fn start(&self) -> Self { + Self::_50 + } + + fn end(&self) -> Self { + Self::_950 + } + fn previous(&self) -> Self { match self { $name::_50 => $name::_950, @@ -133,6 +156,44 @@ pub enum Colors { White, } +impl ColorCircle for Colors { + fn previous(&self) -> Self { + match self { + Colors::Black => Colors::White, + Colors::White => Colors::Black, + _ => self.clone(), + } + } + + fn middle(&self) -> Self { + self.clone() + } + + fn start(&self) -> Self { + match self { + Colors::Black => Colors::White, + Colors::White => Colors::White, + _ => self.clone(), + } + } + + fn end(&self) -> Self { + match self { + Colors::Black => Colors::Black, + Colors::White => Colors::Black, + _ => self.clone(), + } + } + + fn next(&self) -> Self { + match self { + Colors::Black => Colors::White, + Colors::White => Colors::Black, + _ => self.clone(), + } + } +} + impl UIColor for Colors { fn color_class(&self) -> &str { match self { diff --git a/src/ui/components/appbar.rs b/src/ui/components/appbar.rs index 8145568..aaaf378 100644 --- a/src/ui/components/appbar.rs +++ b/src/ui/components/appbar.rs @@ -2,77 +2,23 @@ use maud::{Markup, Render}; use crate::auth::User; +use crate::ui::primitives::Optional; use crate::ui::{UIWidget, prelude::*}; +use super::shell::{NavBar, NavBarWidget}; + #[allow(non_snake_case)] #[must_use] -pub fn AppBar(name: &str, user: Option<User>) -> AppBarWidget { - AppBarWidget { - name: name.to_owned(), - user, - } -} - -pub struct AppBarWidget { - name: String, - user: Option<User>, -} - -impl Render for AppBarWidget { - fn render(&self) -> Markup { - self.render_with_class("") - } -} - -impl UIWidget for AppBarWidget { - fn can_inherit(&self) -> bool { - false - } - - fn base_class(&self) -> Vec<String> { - Vec::new() - } - - fn extended_class(&self) -> Vec<String> { - self.base_class() - } - - fn render_with_class(&self, _: &str) -> Markup { - Padding(Shadow::medium( - Background(Header( - Padding( - Flex( - Div() - .vanish() - .push( - SpaceBetween( - Flex(Link( - "/", - Div() - .vanish() - .push(Sized( - ScreenValue::_10, - ScreenValue::_10, - Rounded(Image("/favicon").alt("Logo")) - .size(Size::Medium), - )) - .push(Span(&self.name).semibold().xl().white()), - )) - .items_center(), - ) - .x(ScreenValue::_2), - ) - .push_some(self.user.as_ref(), |user| Text(&user.username).white()), - ) - .group() - .justify(Justify::Between) - .items_center(), - ) - .x(ScreenValue::_6), - )) - .color(Gray::_800), +pub fn AppBar<T, F>(name: &str, user: Option<User>, user_builder: F) -> NavBarWidget +where + T: UIWidget + 'static, + F: Fn(&User) -> T, +{ + NavBar(name) + .icon(Sized( + ScreenValue::_10, + ScreenValue::_10, + Rounded(Image("/favicon").alt("Logo")).size(Size::Medium), )) - .y(ScreenValue::_2) - .render() - } + .extra(Optional(user.as_ref(), user_builder)) } diff --git a/src/ui/components/avatar.rs b/src/ui/components/avatar.rs new file mode 100644 index 0000000..131f610 --- /dev/null +++ b/src/ui/components/avatar.rs @@ -0,0 +1,171 @@ +use maud::{Render, html}; + +use crate::ui::UIWidget; + +#[allow(non_snake_case)] +pub fn Avatar<T: Into<String>>(image: T, name: T) -> AvatarWidget { + AvatarWidget { + users: vec![(image.into(), name.into())], + ring: false, + use_initials: false, + indicator: None, + } +} + +#[allow(non_snake_case)] +pub fn AvatarStack(users: Vec<(String, String)>) -> AvatarWidget { + AvatarWidget { + users, + ring: false, + use_initials: false, + indicator: None, + } +} + +pub struct AvatarWidget { + users: Vec<(String, String)>, + ring: bool, + use_initials: bool, + indicator: Option<bool>, +} + +impl AvatarWidget { + pub fn with_ring(mut self) -> Self { + self.ring = true; + self + } + + pub fn online(mut self, value: bool) -> Self { + self.indicator = Some(value); + self + } + + pub fn use_initials(mut self) -> Self { + self.use_initials = true; + self + } + + pub fn build_avatar(&self, image: &str, name: &str) -> maud::Markup { + let mut img_class = "w-10 h-10 rounded-full".to_string(); + + if self.ring { + img_class.push_str(" ring-2 ring-gray-300 dark:ring-gray-500"); + } + + if self.indicator.is_some() { + let online = self.indicator.unwrap(); + + let indicator_class = match online { + true => { + "bottom-0 left-7 absolute w-3.5 h-3.5 bg-green-400 border-2 border-white dark:border-gray-800 rounded-full" + } + false => { + "bottom-0 left-7 absolute w-3.5 h-3.5 bg-red-400 border-2 border-white dark:border-gray-800 rounded-full" + } + }; + + return html! { + @if image.is_empty() { + @if self.use_initials { + div class="relative" title=(name) { + div class=(format!("relative inline-flex items-center justify-center w-10 h-10 overflow-hidden bg-gray-100 rounded-full dark:bg-gray-600 {img_class}")) { + span class="font-medium text-gray-600 dark:text-gray-300" { (initials(name)) }; + }; + span class=(indicator_class) {}; + } + } @else { + div class="relative" title=(name) { + div class=(format!("relative w-10 h-10 overflow-hidden bg-gray-100 rounded-full dark:bg-gray-600 {img_class}")){ + svg class="absolute w-12 h-12 text-gray-400 -left-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" { + path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" {}; + }; + }; + span class=(indicator_class) {}; + }; + }; + } @else { + div class="relative" title=(name) { + img class=(img_class) src=(image) alt=(name) {}; + span class=(indicator_class) {}; + }; + } + }; + } else { + return html! { + @if image.is_empty() { + @if self.use_initials { + div class="relative" title=(name) { + div class=(format!("relative inline-flex items-center justify-center w-10 h-10 overflow-hidden bg-gray-100 rounded-full dark:bg-gray-600 {img_class}")) { + span class="font-medium text-gray-600 dark:text-gray-300" { (initials(name)) }; + }; + } + } @else { + div class="relative" title=(name) { + div class=(format!("relative w-10 h-10 overflow-hidden bg-gray-100 rounded-full dark:bg-gray-600 {img_class}")){ + svg class="absolute w-12 h-12 text-gray-400 -left-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" { + path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" {}; + }; + }; + }; + }; + } @else { + div class="relative" title=(name) { + img class=(img_class) src=(image) alt=(name) {}; + }; + } + }; + } + } +} + +fn initials(txt: &str) -> String { + let elements = txt.split_whitespace(); + let mut initials = Vec::new(); + + for e in elements { + initials.push(e.chars().next().unwrap().to_string()); + } + + initials.join("") +} + +impl Render for AvatarWidget { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for AvatarWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> maud::Markup { + if self.users.len() == 1 { + let (image, name) = &self.users[0]; + self.build_avatar(image, name); + } + + html! { + div class="flex -space-x-4 rtl:space-x-reverse select-none" { + @for (image, name) in self.users.iter().take(4) { + (self.build_avatar(&image, &name)) + } + + @if self.users.len() > 4 { + p class="flex items-center justify-center w-11 h-11 text-xs font-medium text-white bg-gray-700 border-2 border-white rounded-full hover:bg-gray-600 dark:border-gray-800 z-10 -translate-y-[2px]" { + (format!("+{}", self.users.len()-4)) + }; + } + } + } + } +} diff --git a/src/ui/components/htmx.rs b/src/ui/components/htmx.rs new file mode 100644 index 0000000..042f387 --- /dev/null +++ b/src/ui/components/htmx.rs @@ -0,0 +1,39 @@ +use maud::{PreEscaped, Render, html}; + +use crate::ui::{ + UIWidget, + htmx::{Event, HTMXAttributes, SwapStrategy}, + prelude::Div, +}; + +#[allow(non_snake_case)] +pub fn ClickToLoad<T: UIWidget + 'static>(reference: &str, widget: T) -> PreEscaped<String> { + html! { + button hx-get=(reference) + hx-target="this" + hx-swap="outerHTML" { + (widget) + }; + } +} + +#[allow(non_snake_case)] +pub fn LazyLoad<T: UIWidget + 'static>(reference: &str, widget: T) -> PreEscaped<String> { + html! { + div hx-get=(reference) hx-trigger="load" { + div class="htmx-indicator" { + (widget) + } + }; + } +} + +#[allow(non_snake_case)] +pub fn InfinityScroll<T: UIWidget + 'static>(content: T, fetch_url: &str) -> PreEscaped<String> { + Div() + .push(content) + .hx_get(fetch_url) + .hx_swap(SwapStrategy::outerHTML) + .hx_trigger(Event::on_revealed()) + .render() +} diff --git a/src/ui/components/icon.rs b/src/ui/components/icon.rs new file mode 100644 index 0000000..240f96d --- /dev/null +++ b/src/ui/components/icon.rs @@ -0,0 +1,8 @@ +use maud::{PreEscaped, html}; + +#[allow(non_snake_case)] +pub fn MaterialIcon(identifier: &str) -> PreEscaped<String> { + html! { + span class="material-symbols-outlined select-none" { (identifier) }; + } +} diff --git a/src/ui/components/indicator.rs b/src/ui/components/indicator.rs new file mode 100644 index 0000000..bb22beb --- /dev/null +++ b/src/ui/components/indicator.rs @@ -0,0 +1,63 @@ +use maud::{PreEscaped, html}; + +use crate::ui::{UIWidget, color::UIColor}; + +use super::ColorCircle; + +pub fn Indicator<C: UIColor + 'static>(color: C) -> PreEscaped<String> { + html! { + span class=(format!("flex w-3 h-3 me-3 bg-{} rounded-full", color.color_class())) {}; + } +} + +pub fn IndicatorLegend<C: UIColor + 'static>(color: C, legend: &str) -> PreEscaped<String> { + html! { + span class="flex items-center text-sm font-medium text-gray-900 dark:text-white me-3" { + span class=(format!("flex w-2.5 h-2.5 bg-{} rounded-full me-1.5 shrink-0", color.color_class())) {}; + (legend) + }; + } +} + +pub fn NumberIndicator<T: UIWidget + 'static>(on: T, amount: u32) -> PreEscaped<String> { + html! { + div class="relative items-center max-w-fit" { + (on) + div class="absolute inline-flex items-center justify-center px-2 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 -end-2 dark:border-gray-900" { (amount) }; + } + } +} + +pub fn BadgeIndicator<C: UIColor + 'static + ColorCircle>( + color: C, + dark_color: C, + txt: &str, +) -> PreEscaped<String> { + // BG -100 + let bg_color = color.color_class(); + // Text -800 + let text_color = color.next().next().next().next().next().next().next(); + let text_color = text_color.color_class(); + // Dark BG -900 + let dark_bg = dark_color.color_class(); + // Dark Text -300 + let dark_text = dark_color + .previous() + .previous() + .previous() + .previous() + .previous() + .previous(); + let dark_text = dark_text.color_class(); + // Indicator -500 + let indicator_color = color.middle(); + let indicator_color = indicator_color.color_class(); + + html! { + span + class=(format!("inline-flex items-center bg-{bg_color} text-{text_color} text-xs font-medium px-2.5 py-0.5 rounded-full dark:bg-{dark_bg} dark:text-{dark_text}")) { + span class=(format!("w-2 h-2 me-1 bg-{indicator_color} rounded-full")) {}; + (txt) + }; + } +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index b367ee4..d3e5219 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -1,7 +1,811 @@ mod appbar; +mod avatar; +mod htmx; +mod icon; +mod indicator; +mod modal; +mod overlay; +mod pagination; +mod placeholder; mod search; mod shell; +mod timeline; -pub use appbar::AppBar; -pub use search::Search; -pub use shell::Shell; +pub mod prelude { + pub use super::appbar::AppBar; + pub use super::avatar::{Avatar, AvatarStack}; + pub use super::htmx::{ClickToLoad, InfinityScroll, LazyLoad}; + pub use super::icon::MaterialIcon; + pub use super::indicator::{BadgeIndicator, Indicator, IndicatorLegend, NumberIndicator}; + pub use super::modal::{Modal, ModalCloseButton, ModalOpenButton}; + pub use super::overlay::{ + DropDown, DropdownMenu, DropdownMenuEntry, Placement, Popover, PopoverTrigger, Tooltip, + }; + pub use super::pagination::{Pagination, PaginationButtonsOnly}; + pub use super::placeholder::{ + CardPlaceholder, ImagePlaceholder, ListPlaceholder, Placeholder, TextPlaceholder, + VideoPlaceholder, + }; + pub use super::search::Search; + pub use super::shell::{ + Alignment, BottomNavigation, BottomNavigationTile, Classic, ClassicWidget, FetchToast, + NavBar, Position, Shell, Toast, + }; + pub use super::{ + Accordion, Alert, Banner, Breadcrumb, Card, Carousel, CarouselMode, ColoredAlert, + ColoredSpinner, CopyText, FetchAlert, FnKey, HelpIcon, HorizontalLine, IconStepper, + InfoIcon, ProgressBar, Spinner, Stepper, Tabs, X_Icon, + }; +} + +use maud::{PreEscaped, Render, html}; + +use crate::ui::prelude::script; + +use super::{ + UIWidget, + color::{ColorCircle, Gray, UIColor}, + htmx::{Event, HTMXAttributes, SwapStrategy}, + prelude::Div, +}; + +#[allow(non_snake_case)] +pub fn HorizontalLine() -> PreEscaped<String> { + html! { + hr class="h-px my-8 bg-gray-200 border-0 dark:bg-gray-700" {}; + } +} + +pub fn FnKey(key: &str) -> PreEscaped<String> { + html! { + kbd class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg dark:bg-gray-600 dark:text-gray-100 dark:border-gray-500" { (key) }; + } +} + +pub fn ColoredSpinner<T: UIColor + 'static>(color: T) -> PreEscaped<String> { + let col = color.color_class(); + html! { + div role="status" { + svg aria-hidden="true" class=(format!("w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-{col}")) viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg" { + path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" {}; + path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" {}; + }; + span class="sr-only" { "Loading..." } + }; + } +} + +#[allow(non_snake_case)] +pub fn Spinner() -> PreEscaped<String> { + ColoredSpinner(super::color::Blue::_600) +} + +pub fn CopyText(txt: &str) -> PreEscaped<String> { + let id = uuid::Uuid::new_v4().to_string(); + + let gscript = format!( + " + window.addEventListener('load', function () {{ + const clipboard = FlowbiteInstances.getInstance('CopyClipboard', '{id}-copy'); + const tooltip = FlowbiteInstances.getInstance('Tooltip', '{id}-copy-tooltip'); + + const $defaultIcon = document.getElementById('default-icon'); + const $successIcon = document.getElementById('success-icon'); + + const $defaultTooltipMessage = document.getElementById('default-tooltip-message'); + const $successTooltipMessage = document.getElementById('success-tooltip-message'); + + clipboard.updateOnCopyCallback((clipboard) => {{ + showSuccess(); + + // reset to default state + setTimeout(() => {{ + resetToDefault(); + }}, 2000); + }}) + + const showSuccess = () => {{ + $defaultIcon.classList.add('hidden'); + $successIcon.classList.remove('hidden'); + $defaultTooltipMessage.classList.add('hidden'); + $successTooltipMessage.classList.remove('hidden'); + tooltip.show(); + }} + + const resetToDefault = () => {{ + $defaultIcon.classList.remove('hidden'); + $successIcon.classList.add('hidden'); + $defaultTooltipMessage.classList.remove('hidden'); + $successTooltipMessage.classList.add('hidden'); + tooltip.hide(); + }} + }}) + " + ); + + html! { + + (script(&gscript)) + + div class="w-full max-w-[16rem]" { + div class="relative" { + input id=(format!("{id}-copy")) type="text" class="col-span-6 bg-gray-50 border border-gray-300 text-gray-500 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-400 dark:focus:ring-blue-500 dark:focus:border-blue-500" value=(txt) disabled readonly {}; + button data-copy-to-clipboard-target=(format!("{id}-copy")) data-tooltip-target=(format!("{id}-copy-tooltip")) class="absolute end-2 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg p-2 inline-flex items-center justify-center" { + span id="default-icon" { + svg class="w-3.5 h-3.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 18 20" { + path d="M16 1h-3.278A1.992 1.992 0 0 0 11 0H7a1.993 1.993 0 0 0-1.722 1H2a2 2 0 0 0-2 2v15a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2Zm-3 14H5a1 1 0 0 1 0-2h8a1 1 0 0 1 0 2Zm0-4H5a1 1 0 0 1 0-2h8a1 1 0 1 1 0 2Zm0-5H5a1 1 0 0 1 0-2h2V2h4v2h2a1 1 0 1 1 0 2Z" {}; + }; + }; + span id="success-icon" class="hidden inline-flex items-center" { + svg class="w-3.5 h-3.5 text-blue-700 dark:text-blue-500" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 12" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5.917 5.724 10.5 15 1.5" {}; + }; + }; + }; + div id=(format!("{id}-copy-tooltip")) role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700" { + span id="default-tooltip-message" { "Copy to clipboard" }; + span id="success-tooltip-message" class="hidden" { "Copied!" }; + div class="tooltip-arrow" data-popper-arrow {}; + }; + }; + }; + } +} + +pub struct AccordionWidget { + collapsed: bool, + title: Box<dyn UIWidget>, + body: Box<dyn UIWidget>, +} + +impl AccordionWidget { + pub fn expanded(mut self) -> Self { + self.collapsed = false; + self + } +} + +impl Render for AccordionWidget { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for AccordionWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> maud::Markup { + let id = uuid::Uuid::new_v4().to_string(); + html! { + div id=(format!("accordion-collapse-{id}")) data-accordion="collapse" { + h2 id=(format!("accordion-collapse-heading-{id}")) { + button type="button" + class="flex items-center justify-between w-full p-5 font-medium rtl:text-right rounded-t-xl gap-3" + data-accordion-target=(format!("#accordion-collapse-body-{id}")) + aria-expanded=(if self.collapsed { "false" } else { "true" }) + aria-controls=(format!("accordion-collapse-body-{id}")) { + (self.title) + svg data-accordion-icon class="w-3 h-3 rotate-180 shrink-0" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5 5 1 1 5" {}; + }; + }; + }; + + div id=(format!("accordion-collapse-body-{id}")) class="hidden" aria-labelledby=(format!("accordion-collapse-heading-{id}")) { + div class="p-5 border border-b-0 border-gray-200 dark:border-gray-700 dark:bg-gray-900 rounded-b-md" { + (self.body) + } + }; + }; + } + } +} + +#[allow(non_snake_case)] +pub fn Accordion<H: UIWidget + 'static, B: UIWidget + 'static>( + title: H, + body: B, +) -> AccordionWidget { + AccordionWidget { + collapsed: true, + title: Box::new(title), + body: Box::new(body), + } +} + +#[allow(non_snake_case)] +pub fn InfoIcon() -> PreEscaped<String> { + html! { + svg class="shrink-0 inline w-4 h-4 me-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20" { + path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z" {}; + }; + } +} + +#[allow(non_snake_case)] +pub fn X_Icon() -> PreEscaped<String> { + html! { + svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" {}; + }; + } +} + +#[allow(non_snake_case)] +pub fn ColoredAlert<T: UIWidget + 'static, C: UIColor + ColorCircle + 'static>( + color: C, + inner: T, +) -> PreEscaped<String> { + let dark_color = color.previous().previous().previous().previous(); + let dark_color = dark_color.color_class(); + let bg_light = color.start(); + let bg_light = bg_light.color_class(); + let btn_bg_light = color + .previous() + .previous() + .previous() + .previous() + .previous() + .previous(); + let btn_bg_light = btn_bg_light.color_class(); + let ring_light = color.previous().previous().previous().previous(); + let ring_light = ring_light.color_class(); + let btn_light = color.previous().previous().previous(); + let btn_light = btn_light.color_class(); + let color = color.color_class(); + + let id = format!("alert-{}", uuid::Uuid::new_v4().to_string()); + + html! { + div id=(id) class=(format!("flex items-center p-4 mb-4 text-{color} rounded-lg bg-{bg_light} dark:bg-gray-800 dark:text-{dark_color}")) role="alert" { + (inner) + + button type="button" + class=(format!("ms-auto -mx-1.5 -my-1.5 bg-{bg_light} text-{btn_light} rounded-lg focus:ring-2 focus:ring-{ring_light} p-1.5 hover:bg-{btn_bg_light} inline-flex items-center justify-center h-8 w-8 dark:bg-gray-800 dark:text-{dark_color} dark:hover:bg-gray-700")) + data-dismiss-target=(format!("#{id}")) aria-label="Close" { + span class="sr-only" { "Close" }; + (X_Icon()) + }; + }; + } +} + +#[allow(non_snake_case)] +pub fn Alert<T: UIWidget + 'static>(inner: T) -> PreEscaped<String> { + ColoredAlert(Gray::_800, inner) +} + +#[allow(non_snake_case)] +pub fn FetchAlert(reference: &str) -> PreEscaped<String> { + Div() + .hx_get(reference) + .hx_target(super::htmx::Selector::Query( + "#notification_area".to_string(), + )) + .hx_swap(SwapStrategy::beforeend) + .hx_trigger(Event::on_load()) + .render() +} + +pub struct BreadcrumbWidget { + elements: Vec<(String, String)>, + seperator: Option<Box<dyn UIWidget>>, +} + +impl BreadcrumbWidget { + pub fn seperator<T: UIWidget + 'static>(mut self, seperator: T) -> Self { + self.seperator = Some(Box::new(seperator)); + self + } + + fn arrow_seperator() -> PreEscaped<String> { + html! { + svg class="rtl:rotate-180 w-3 h-3 text-gray-400 mx-1" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4" {}; + }; + } + } +} + +impl Render for BreadcrumbWidget { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for BreadcrumbWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> maud::Markup { + html! { + nav class="flex" aria-label="Breadcrumb" { + + ol class="inline-flex items-center space-x-1 md:space-x-2 rtl:space-x-reverse p-2" { + + @for (index, (name, url)) in self.elements.iter().enumerate() { + @if index == 0 { + li class="inline-flex items-center" { + a href=(url) class="inline-flex items-center text-sm font-medium hover:text-blue-600" { (PreEscaped(name)) }; + }; + } @else if index == (self.elements.len()-1) { + + li aria-current="page" { + div class="flex items-center" { + @if let Some(s) = self.seperator.as_ref() { + (s) + } @else { + (Self::arrow_seperator()) + } + span class="ms-1 text-sm font-medium text-gray-500 md:ms-2 dark:text-gray-400" { (PreEscaped(name)) }; + }; + }; + } @else { + li { + div class="flex items-center" { + @if let Some(s) = self.seperator.as_ref() { + (s) + } @else { + (Self::arrow_seperator()) + } + + a href=(url) class="ms-1 text-sm font-medium hover:text-blue-600 md:ms-2" { (PreEscaped(name)) }; + + }; + }; + } + }; + }; + }; + } + } +} + +#[allow(non_snake_case)] +pub fn Breadcrumb(paths: Vec<(String, String)>) -> BreadcrumbWidget { + BreadcrumbWidget { + elements: paths, + seperator: None, + } +} + +#[allow(non_snake_case)] +pub fn Card<T: UIWidget + 'static>(inner: T) -> PreEscaped<String> { + html! { + div class="w-fit p-4 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700" { + (inner) + } + } +} + +#[allow(non_snake_case)] +pub fn Banner<T: UIWidget + 'static>(inner: T) -> PreEscaped<String> { + let id = uuid::Uuid::new_v4().to_string(); + html! { + div id=(format!("banner-{id}")) tabindex="-1" class="fixed top-0 start-0 z-40 flex justify-between w-full p-4 border-b border-gray-200 bg-gray-50 dark:bg-gray-700 dark:border-gray-600" { + div class="flex items-center mx-auto" { + p class="flex items-center text-sm font-normal text-gray-500 dark:text-gray-400" { + (inner) + }; + }; + div class="flex items-center" { + button data-dismiss-target=(format!("#banner-{id}")) type="button" class="flex-shrink-0 inline-flex justify-center w-7 h-7 items-center text-gray-400 hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 dark:hover:bg-gray-600 dark:hover:text-white" { + (X_Icon()) + span class="sr-only" { "Close banner" }; + }; + }; + }; + } +} + +pub enum CarouselMode { + Slide, + Static, +} + +#[allow(non_snake_case)] +pub fn Carousel<T: UIWidget + 'static>(elements: Vec<T>) -> CarouselWidget { + let mut boxed_elements: Vec<Box<dyn UIWidget>> = Vec::with_capacity(elements.len()); + + for e in elements { + boxed_elements.push(Box::new(e)); + } + + CarouselWidget { + elements: boxed_elements, + mode: CarouselMode::Slide, + controls: false, + indicators: false, + } +} + +pub struct CarouselWidget { + elements: Vec<Box<dyn UIWidget>>, + mode: CarouselMode, + controls: bool, + indicators: bool, +} + +impl CarouselWidget { + pub fn mode(mut self, mode: CarouselMode) -> Self { + self.mode = mode; + self + } + + pub fn with_controls(mut self) -> Self { + self.controls = true; + self + } + + pub fn with_indicators(mut self) -> Self { + self.indicators = true; + self + } +} + +impl Render for CarouselWidget { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for CarouselWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> maud::Markup { + let mode = match self.mode { + CarouselMode::Slide => "slide", + CarouselMode::Static => "static", + }; + + html! { + + div id="default-carousel" class="relative w-full" data-carousel=(mode) { + + // Carousel wrapper + div class="relative h-56 overflow-hidden rounded-lg md:h-96" { + + @for element in &self.elements { + div class="hidden duration-700 ease-in-out absolute block w-full flex justify-center items-center" data-carousel-item { + (element) + }; + }; + + } + + @if self.indicators { + // Slider indicators + div class="absolute z-30 flex -translate-x-1/2 bottom-5 left-1/2 space-x-3 rtl:space-x-reverse" { + @for i in 0..self.elements.len() { + @if i == 0 { + button type="button" class="w-3 h-3 rounded-full" aria-current="true" aria-label=(format!("Slide {i}")) data-carousel-slide-to=(i) {}; + } @else { + button type="button" class="w-3 h-3 rounded-full" aria-current="false" aria-label=(format!("Slide {i}")) data-carousel-slide-to=(i) {}; + } + } + } + } + + @if self.controls { + // Slider controls + button type="button" class="absolute top-0 start-0 z-30 flex items-center justify-center h-full px-4 cursor-pointer group focus:outline-none" data-carousel-prev { + span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none" { + svg class="w-4 h-4 text-white dark:text-gray-800 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4" {}; + }; + span class="sr-only" { "Previous" }; + }; + }; + + button type="button" class="absolute top-0 end-0 z-30 flex items-center justify-center h-full px-4 cursor-pointer group focus:outline-none" data-carousel-next { + span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none" { + svg class="w-4 h-4 text-white dark:text-gray-800 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4" {}; + }; + span class="sr-only" { "Next" }; + }; + }; + } + + } + } + } +} + +#[allow(non_snake_case)] +pub fn HelpIcon() -> PreEscaped<String> { + html! { + svg class="w-4 h-4 text-gray-400 hover:text-gray-500" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" { + path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" {}; + } + } +} + +#[allow(non_snake_case)] +pub fn ProgressBar(percentage: u8, label: bool) -> PreEscaped<String> { + assert!(percentage < 100, "Percentage must be less than 100"); + html! { + @if label { + div class="w-full bg-gray-200 rounded-full dark:bg-gray-700" { + div class="bg-blue-600 text-xs font-medium text-blue-100 text-center p-0.5 leading-none rounded-full" style=(format!("width: {percentage}%")) { (format!("{percentage}%")) }; + }; + } @else { + div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700" { + div class="bg-blue-600 h-2.5 rounded-full" style=(format!("width: {percentage}%")) {}; + }; + } + } +} + +#[allow(non_snake_case)] +pub fn Stepper<S: Into<String>>(steps: Vec<S>) -> StepperWidget { + let mut boxed_elements: Vec<Box<dyn UIWidget>> = Vec::with_capacity(steps.len()); + + for e in steps { + boxed_elements.push(Box::new(e.into())); + } + + StepperWidget { + elements: boxed_elements, + icons: false, + progress: 0, + } +} + +#[allow(non_snake_case)] +pub fn IconStepper<T: UIWidget + 'static>(elements: Vec<T>) -> StepperWidget { + let mut boxed_elements: Vec<Box<dyn UIWidget>> = Vec::with_capacity(elements.len()); + + for e in elements { + boxed_elements.push(Box::new(e)); + } + + StepperWidget { + elements: boxed_elements, + icons: true, + progress: 0, + } +} + +pub struct StepperWidget { + elements: Vec<Box<dyn UIWidget>>, + icons: bool, + progress: u8, +} + +impl StepperWidget { + pub fn step(mut self, step: u8) -> Self { + if step != 0 { + self.progress = step - 1; + } else { + self.progress = step; + } + self + } + + pub fn check_icon() -> PreEscaped<String> { + html! { + svg class="w-3.5 h-3.5 sm:w-4 sm:h-4 me-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20" { + path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z" {}; + }; + } + } + + pub fn build_final(index: usize, element: &Box<dyn UIWidget>) -> PreEscaped<String> { + html! { + li class="flex items-center" { + span class="me-2" { (index+1) }; + (element) + } + } + } + + pub fn build_middle(index: usize, element: &Box<dyn UIWidget>) -> PreEscaped<String> { + html! { + li class="flex md:w-full items-center after:content-[''] after:w-full after:h-1 after:border-b after:border-gray-200 after:border-1 after:hidden sm:after:inline-block after:mx-6 xl:after:mx-10 dark:after:border-gray-700" { + span class="flex items-center after:content-['/'] sm:after:hidden after:mx-2 after:text-gray-200 dark:after:text-gray-500" { + span class="me-2" { (index+1) }; + (element) + }; + }; + } + } + + pub fn build_done(element: &Box<dyn UIWidget>) -> PreEscaped<String> { + html! { + li class="flex md:w-full items-center text-blue-600 dark:text-blue-500 sm:after:content-[''] after:w-full after:h-1 after:border-b after:border-gray-200 after:border-1 after:hidden sm:after:inline-block after:mx-6 xl:after:mx-10 dark:after:border-gray-700" { + span class="flex items-center after:content-['/'] sm:after:hidden after:mx-2 after:text-gray-200 dark:after:text-gray-500" { + (Self::check_icon()) + (element) + }; + }; + } + } + + pub fn build_final_icon(element: &Box<dyn UIWidget>) -> PreEscaped<String> { + html! { + li class="flex items-center w-full" { + span class="flex items-center justify-center w-10 h-10 bg-gray-100 rounded-full lg:h-12 lg:w-12 dark:bg-gray-700 shrink-0" { + (element) + } + } + } + } + + pub fn build_middle_icon(element: &Box<dyn UIWidget>) -> PreEscaped<String> { + html! { + li class="flex w-full items-center after:content-[''] after:w-full after:h-1 after:border-b after:border-gray-100 after:border-4 after:inline-block dark:after:border-gray-700" { + span class="flex items-center justify-center w-10 h-10 bg-gray-100 rounded-full lg:h-12 lg:w-12 dark:bg-gray-700 shrink-0" { + (element) + } + }; + } + } + + pub fn build_done_icon(element: &Box<dyn UIWidget>) -> PreEscaped<String> { + html! { + li class="flex w-full items-center text-blue-600 dark:text-blue-500 after:content-[''] after:w-full after:h-1 after:border-b after:border-blue-100 after:border-4 after:inline-block dark:after:border-blue-800" { + span class="flex items-center justify-center w-10 h-10 bg-blue-100 rounded-full lg:h-12 lg:w-12 dark:bg-blue-800 shrink-0" { + (element) + }; + }; + } + } +} + +impl Render for StepperWidget { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for StepperWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> maud::Markup { + html! { + @if self.icons { + ol class="flex items-center w-full" { + + @for (index, e) in self.elements.iter().enumerate() { + @if index == (self.elements.len()-1) { + (Self::build_final_icon(e)) + } @else if index <= self.progress as usize { + (Self::build_done_icon(e)) + } @else { + (Self::build_middle_icon(e)) + } + } + }; + + } @else { + + + ol class="flex items-center w-full text-sm font-medium text-center text-gray-500 dark:text-gray-400 sm:text-base" { + + @for (index, e) in self.elements.iter().enumerate() { + @if index == (self.elements.len()-1) { + (Self::build_final(index, e)) + } @else if index <= self.progress as usize { + (Self::build_done(e)) + } @else { + (Self::build_middle(index, e)) + } + } + } + } + } + } +} + +pub struct TabWidget { + pub content: Vec<(PreEscaped<String>, String, Box<dyn UIWidget>)>, +} + +#[allow(non_snake_case)] +pub fn Tabs() -> TabWidget { + TabWidget { + content: Vec::new(), + } +} + +impl TabWidget { + pub fn add_tab<T: UIWidget + 'static, B: UIWidget + 'static>( + mut self, + id: &str, + tab: T, + body: B, + ) -> Self { + self.content + .push((tab.render(), id.to_string(), Box::new(body))); + self + } +} + +impl Render for TabWidget { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for TabWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> maud::Markup { + let tab_id = uuid::Uuid::new_v4().to_string(); + + html! { + div class="mb-4 border-b border-gray-200 dark:border-gray-700" { + ul class="flex flex-wrap -mb-px text-sm font-medium text-center" id=(format!("tab_{tab_id}")) data-tabs-toggle=(format!("#tab_content_{tab_id}")) role="tablist" { + @for (i, (head, id, _)) in self.content.iter().enumerate() { + li class=(if i == self.content.len() { "" } else { "me-2" }) role="presentation" { + button class="inline-block p-4 border-b-2 rounded-t-lg" + data-tabs-target=(format!("#{id}")) + type="button" + role="tab" + aria-controls=(id) + aria-selected="false" { (head) }; + } + }; + } + }; + + div id=(format!("#tab_content_{tab_id}")) { + @for (_, id, body) in &self.content { + div class="hidden rounded-lg" id=(id) role="tabpanel" aria-labelledby=(format!("{id}_tab")) { + (body) + } + } + }; + } + } +} diff --git a/src/ui/components/modal.rs b/src/ui/components/modal.rs new file mode 100644 index 0000000..9a6fbe0 --- /dev/null +++ b/src/ui/components/modal.rs @@ -0,0 +1,54 @@ +use maud::{PreEscaped, Render, html}; + +use crate::ui::UIWidget; + +pub fn ModalCloseButton<T: UIWidget + 'static>(modal: &str, inner: T) -> PreEscaped<String> { + html! { + button + data-modal-hide=(modal) + { (inner) }; + } +} + +pub fn ModalOpenButton<T: UIWidget + 'static>(modal: &str, inner: T) -> PreEscaped<String> { + html! { + button + data-modal-target=(modal) + data-modal-toggle=(modal) + { (inner) }; + } +} + +pub fn Modal<T: UIWidget + 'static, E: UIWidget + 'static, F: FnOnce(String) -> E>( + title: &str, + body: T, + footer: F, +) -> (String, PreEscaped<String>) { + let id = uuid::Uuid::new_v4().to_string(); + + (format!("modal-{id}"), html! { + div id=(format!("modal-{id}")) tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full" { + div class="relative p-4 w-full max-w-2xl max-h-full" { + + div class="relative bg-white rounded-lg shadow dark:bg-gray-700" { + div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600" { + h3 class="text-xl font-semibold text-gray-900 dark:text-white" { (title) } + button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide="default-modal" { + svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" {}; + }; + span class="sr-only" { "Close modal" }; + } + }; + + div class="p-4 md:p-5 space-y-4" { + (body) + }; + + div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600" { + (footer(format!("modal-{id}"))) + }; + }; + }}; + }) +} diff --git a/src/ui/components/overlay.rs b/src/ui/components/overlay.rs new file mode 100644 index 0000000..bcd95f8 --- /dev/null +++ b/src/ui/components/overlay.rs @@ -0,0 +1,354 @@ +#[allow(non_snake_case)] +pub fn Popover<T: UIWidget + 'static, I: UIWidget + 'static>(on: T, inner: I) -> PopoverWidget { + PopoverWidget { + on: Box::new(on), + inner: Box::new(inner), + placement: Placement::Top, + arrow: true, + animated: true, + trigger: PopoverTrigger::Hover, + offset: 10, + } +} + +pub enum PopoverTrigger { + Click, + Hover, +} + +pub struct PopoverWidget { + on: Box<dyn UIWidget>, + inner: Box<dyn UIWidget>, + placement: Placement, + arrow: bool, + animated: bool, + trigger: PopoverTrigger, + offset: i32, +} + +impl PopoverWidget { + pub fn place(mut self, placement: Placement) -> Self { + self.placement = placement; + self + } + + pub fn no_arrow(mut self) -> Self { + self.arrow = false; + self + } + + pub fn animate(mut self, value: bool) -> Self { + self.animated = value; + self + } + + pub fn trigger(mut self, trigger: PopoverTrigger) -> Self { + self.trigger = trigger; + self + } + + pub fn offset(mut self, offset: i32) -> Self { + self.offset = offset; + self + } +} + +impl Render for PopoverWidget { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for PopoverWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, class: &str) -> maud::Markup { + let id = format!("popover-{}", uuid::Uuid::new_v4().to_string()); + + let el_class = "absolute z-10 invisible inline-block w-fit p-2 text-sm text-gray-500 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800"; + + let el_class = if self.animated { + format!("{el_class} transition-opacity duration-200") + } else { + el_class.to_string() + }; + + let trigger = match self.trigger { + PopoverTrigger::Click => "click", + PopoverTrigger::Hover => "hover", + }; + + html! { + + div data-popover-target=(id) + data-popover-trigger=(trigger) + data-popover-placement=(self.placement.to_value()) + data-popover-offset=(self.offset) + class=(format!("{class} my-auto max-w-fit")) { (self.on) }; + + div data-popover id=(id) role="tooltip" class=(el_class) { + (self.inner) + + @if self.arrow { + div data-popper-arrow {}; + } + } + + } + } +} + +use maud::{PreEscaped, Render, html}; + +use crate::ui::UIWidget; + +#[allow(non_snake_case)] +pub fn Tooltip<T: UIWidget + 'static, I: UIWidget + 'static>(on: T, inner: I) -> TooltipWidget { + TooltipWidget { + on: Box::new(on), + inner: Box::new(inner), + placement: Placement::Top, + arrow: true, + dark: true, + animated: true, + } +} + +pub enum Placement { + Left, + Right, + Top, + Bottom, +} + +impl Placement { + pub fn to_value(&self) -> &str { + match *self { + Placement::Left => "left", + Placement::Right => "right", + Placement::Top => "top", + Placement::Bottom => "bottom", + } + } +} + +pub struct TooltipWidget { + on: Box<dyn UIWidget>, + inner: Box<dyn UIWidget>, + placement: Placement, + arrow: bool, + dark: bool, + animated: bool, +} + +impl TooltipWidget { + pub fn place(mut self, placement: Placement) -> Self { + self.placement = placement; + self + } + + pub fn no_arrow(mut self) -> Self { + self.arrow = false; + self + } + + pub fn animate(mut self, value: bool) -> Self { + self.animated = value; + self + } + + pub fn white(mut self) -> Self { + self.dark = false; + self + } +} + +impl Render for TooltipWidget { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for TooltipWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, class: &str) -> maud::Markup { + let id = format!("tooltip-{}", uuid::Uuid::new_v4().to_string()); + + let tt_class = match self.dark { + true => { + "absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white bg-gray-900 rounded-lg shadow-xs opacity-0 tooltip dark:bg-gray-700" + } + false => { + "absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 tooltip" + } + }; + + let tt_class = if self.animated { + format!("{tt_class} transition-opacity duration-200") + } else { + tt_class.to_string() + }; + + html! { + @if self.dark { + div data-tooltip-target=(id) data-tooltip-placement=(self.placement.to_value()) + class=(format!("{class} my-auto max-w-fit")) { (self.on) }; + } @else { + div data-tooltip-target=(id) data-tooltip-style="light" data-tooltip-placement=(self.placement.to_value()) + class=(format!("{class} my-auto max-w-fit")) { (self.on) }; + } + + div id=(id) role="tooltip" class=(tt_class) { + (self.inner) + @if self.arrow { + div class="tooltip-arrow" data-popper-arrow {}; + } + }} + } +} + +#[allow(non_snake_case)] +pub fn DropDown<T: UIWidget + 'static, I: UIWidget + 'static>(on: T, inner: I) -> DropDownWidget { + DropDownWidget { + on: Box::new(on), + inner: Box::new(inner), + delay: 0, + placement: Placement::Bottom, + trigger: PopoverTrigger::Click, + distance: 10, + skidding: 0, + stretch: false, + } +} + +pub struct DropDownWidget { + on: Box<dyn UIWidget>, + inner: Box<dyn UIWidget>, + placement: Placement, + delay: u32, + trigger: PopoverTrigger, + distance: u32, + skidding: u32, + stretch: bool, +} + +impl DropDownWidget { + pub fn place(mut self, placement: Placement) -> Self { + self.placement = placement; + self + } + + pub fn delay(mut self, delay: u32) -> Self { + self.delay = delay; + self + } + + pub fn stretch(mut self) -> Self { + self.stretch = true; + self + } + + pub fn distance(mut self, distance: u32) -> Self { + self.distance = distance; + self + } + + pub fn skidding(mut self, skidding: u32) -> Self { + self.skidding = skidding; + self + } + + pub fn trigger(mut self, trigger: PopoverTrigger) -> Self { + self.trigger = trigger; + self + } +} + +impl Render for DropDownWidget { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for DropDownWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, class: &str) -> maud::Markup { + let id = format!("dropdown-{}", uuid::Uuid::new_v4().to_string()); + + let trigger = match self.trigger { + PopoverTrigger::Click => "click", + PopoverTrigger::Hover => "hover", + }; + + html! { + div + data-dropdown-toggle=(id) + data-dropdown-trigger=(trigger) + data-dropdown-delay=(self.delay) + data-dropdown-placement=(self.placement.to_value()) + data-dropdown-offset-distance=(self.distance) + data-dropdown-offset-skidding=(self.skidding) + class=(format!("{class} hover:cursor-pointer my-auto{}", if self.stretch { "" } else { " max-w-fit" })) { + (self.on) + }; + + div id=(id) class="z-50 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700" { + ul class="py-2 text-sm text-gray-700 dark:text-gray-200" { + (self.inner) + } + } + } + } +} + +#[allow(non_snake_case)] +pub fn DropdownMenuEntry<T: UIWidget + 'static>(link: &str, inner: T) -> PreEscaped<String> { + html! { + li { + a href=(link) class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white" { (inner) } + } + } +} + +#[allow(non_snake_case)] +pub fn DropdownMenu(entries: Vec<PreEscaped<String>>) -> PreEscaped<String> { + html! { + ul class="py-1 text-sm text-gray-700 dark:text-gray-200" { + @for e in entries { + (e) + } + } + } +} diff --git a/src/ui/components/pagination.rs b/src/ui/components/pagination.rs new file mode 100644 index 0000000..4b47965 --- /dev/null +++ b/src/ui/components/pagination.rs @@ -0,0 +1,213 @@ +use maud::{PreEscaped, Render, html}; + +use crate::{request::api::Pager, ui::UIWidget}; + +#[allow(non_snake_case)] +pub fn Pagination<T, F>( + current_page: usize, + pager: Pager<T>, + url_builder: F, +) -> PaginationWidget<T, F> +where + F: Fn(usize) -> String, +{ + PaginationWidget { + icons: true, + pager, + current_page, + url_builder, + } +} + +pub struct PaginationWidget<T, F> +where + F: Fn(usize) -> String, +{ + icons: bool, + pager: Pager<T>, + current_page: usize, + url_builder: F, +} + +impl<T, F> PaginationWidget<T, F> +where + F: Fn(usize) -> String, +{ + pub fn no_icons(mut self) -> Self { + self.icons = false; + self + } + + fn build_page_link_active(page_num: usize, link: &str, last: bool) -> PreEscaped<String> { + html! { + li { + a href=(link) aria-current="page" class=( + format!("flex items-center justify-center px-3 h-8 text-blue-600 border border-gray-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white {} {}", + if page_num == 1 { "rounded-s-lg" } else { "" }, + if last { "rounded-e-lg" } else { "" } + )) { (page_num) } + } + } + } + + fn build_page_link(page_num: usize, link: &str, last: bool) -> PreEscaped<String> { + html! { + li { + a href=(link) class= + (format!("flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white {}", if last { "rounded-e-lg" } else { "" })) { (page_num) } + } + } + } + + fn build_page_links(&self, current_page: usize) -> PreEscaped<String> { + let total_pages = self.pager.total_pages(); + let mut start_page = if current_page <= 3 { + 1 + } else { + current_page - 2 + }; + let mut end_page = if total_pages - current_page < 2 { + total_pages + } else { + current_page + 2 + }; + + if end_page - start_page < 4 { + if start_page > 1 { + start_page = start_page.saturating_sub(4 - (end_page - start_page)); + } else { + end_page = (start_page + 4).min(total_pages); + } + } + + html! { + @for page in start_page..=end_page { + @if page == current_page { + (Self::build_page_link_active(page, &(self.url_builder)(page), current_page == total_pages && page == current_page)) + } @else { + (Self::build_page_link(page, &(self.url_builder)(page), current_page == total_pages && page == current_page)) + } + } + } + } + + fn build(&self, page: usize) -> PreEscaped<String> { + html! { + nav { + ul class="inline-flex -space-x-px text-sm" { + @if page != 1 { + li { + a href=((self.url_builder)(page-1)) class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-e-0 border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white" { + @if self.icons { + span class="sr-only" { "Previous" }; + svg class="w-2.5 h-2.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4" {}; + } + } @else { + "Previous" + } + + }; + } + } + + (self.build_page_links(page)) + + + @if self.pager.has_next_page(page as u64) { + li { + a href=((self.url_builder)(page+1)) class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white" { + + @if self.icons { + span class="sr-only" { "Next" }; + svg class="w-2.5 h-2.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4" {}; + } + } @else { + "Next" + } + + } + } + }; + + }; + }; + + } + } +} + +impl<T, F> Render for PaginationWidget<T, F> +where + F: Fn(usize) -> String, +{ + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl<T, F> UIWidget for PaginationWidget<T, F> +where + F: Fn(usize) -> String, +{ + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> maud::Markup { + self.build(self.current_page) + } +} + +#[allow(non_snake_case)] +pub fn PaginationButtonsOnly<T, F>( + page: u64, + pager: &Pager<T>, + url_builder: F, +) -> PreEscaped<String> +where + F: Fn(usize) -> String, +{ + let previous = page > 1; + let next = pager.page_with_context(page).1; + + let buttons = html! { + @if previous { + a href=(url_builder((page-1) as usize)) class="flex items-center justify-center px-3 h-8 me-3 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white" { + svg class="w-3.5 h-3.5 me-2 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 10" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5H1m0 0 4 4M1 5l4-4" {}; + } + "Previous" + } + }; + + @if next { + a href=(url_builder((page+1) as usize)) class="flex items-center justify-center px-3 h-8 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white" { + "Next" + svg class="w-3.5 h-3.5 ms-2 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 10" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5h12m0 0L9 1m4 4L9 9" {}; + } + }; + }}; + + html! { + @if previous { + div class="flex justify-between" { + (buttons) + }; + } @else { + div class="flex justify-end" { + (buttons) + }; + } + } +} diff --git a/src/ui/components/placeholder.rs b/src/ui/components/placeholder.rs new file mode 100644 index 0000000..7523d14 --- /dev/null +++ b/src/ui/components/placeholder.rs @@ -0,0 +1,147 @@ +use maud::{PreEscaped, html}; + +#[allow(non_snake_case)] +pub fn Placeholder() -> PreEscaped<String> { + html! { + div role="status" class="max-w-sm animate-pulse" { + div class="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-48 mb-4" {}; + div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[360px] mb-2.5" {}; + div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5" {}; + div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[330px] mb-2.5" {}; + div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[300px] mb-2.5" {}; + div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[360px]" {}; + span class="sr-only" { "Loading..." }; + }; + } +} + +#[allow(non_snake_case)] +pub fn ImagePlaceholder() -> PreEscaped<String> { + html! { + div class="flex items-center justify-center w-full h-48 animate-pulse bg-gray-300 rounded-sm sm:w-96 dark:bg-gray-700" { + svg class="w-10 h-10 text-gray-200 dark:text-gray-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 18" { + path d="M18 0H2a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2Zm-5.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm4.376 10.481A1 1 0 0 1 16 15H4a1 1 0 0 1-.895-1.447l3.5-7A1 1 0 0 1 7.468 6a.965.965 0 0 1 .9.5l2.775 4.757 1.546-1.887a1 1 0 0 1 1.618.1l2.541 4a1 1 0 0 1 .028 1.011Z" {}; + }; + }; + } +} + +#[allow(non_snake_case)] +pub fn VideoPlaceholder() -> PreEscaped<String> { + html! { + div role="status" class="flex items-center justify-center h-56 max-w-sm bg-gray-300 rounded-lg animate-pulse dark:bg-gray-700" { + svg class="w-10 h-10 text-gray-200 dark:text-gray-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 20" { + path d="M5 5V.13a2.96 2.96 0 0 0-1.293.749L.879 3.707A2.98 2.98 0 0 0 .13 5H5Z" {} + path d="M14.066 0H7v5a2 2 0 0 1-2 2H0v11a1.97 1.97 0 0 0 1.934 2h12.132A1.97 1.97 0 0 0 16 18V2a1.97 1.97 0 0 0-1.934-2ZM9 13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2Zm4 .382a1 1 0 0 1-1.447.894L10 13v-2l1.553-1.276a1 1 0 0 1 1.447.894v2.764Z" {}; + }; + span class="sr-only" { "Loading..." } + }; + } +} + +#[allow(non_snake_case)] +pub fn TextPlaceholder() -> PreEscaped<String> { + html! { + div role="status" class="space-y-2.5 animate-pulse max-w-lg" { + div class="flex items-center w-full" { + div class="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-32" {}; + div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-24" {}; + div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-full" {}; + }; + + div class="flex items-center w-full max-w-[480px]" { + div class="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-full" {}; + div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-full" {}; + div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-24" {}; + }; + + div class="flex items-center w-full max-w-[400px]" { + div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-full" {}; + div class="h-2.5 ms-2 bg-gray-200 rounded-full dark:bg-gray-700 w-80" {}; + div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-full" {}; + }; + + div class="flex items-center w-full max-w-[480px]" { + div class="h-2.5 ms-2 bg-gray-200 rounded-full dark:bg-gray-700 w-full" {}; + div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-full" {}; + div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-24" {}; + }; + + div class="flex items-center w-full max-w-[440px]" { + div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-32" {}; + div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-24" {}; + div class="h-2.5 ms-2 bg-gray-200 rounded-full dark:bg-gray-700 w-full" {}; + }; + + div class="flex items-center w-full max-w-[360px]" { + div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-full" {}; + div class="h-2.5 ms-2 bg-gray-200 rounded-full dark:bg-gray-700 w-80" {}; + div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-full" {}; + }; + span class="sr-only" { "Loading..." }; + }; + } +} + +#[allow(non_snake_case)] +pub fn CardPlaceholder() -> PreEscaped<String> { + html! { + div role="status" class="max-w-sm p-4 border border-gray-200 rounded-sm shadow-sm animate-pulse md:p-6 dark:border-gray-700" { + div class="flex items-center justify-center h-48 mb-4 bg-gray-300 rounded-sm dark:bg-gray-700" { + svg class="w-10 h-10 text-gray-200 dark:text-gray-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 20" { + path d="M14.066 0H7v5a2 2 0 0 1-2 2H0v11a1.97 1.97 0 0 0 1.934 2h12.132A1.97 1.97 0 0 0 16 18V2a1.97 1.97 0 0 0-1.934-2ZM10.5 6a1.5 1.5 0 1 1 0 2.999A1.5 1.5 0 0 1 10.5 6Zm2.221 10.515a1 1 0 0 1-.858.485h-8a1 1 0 0 1-.9-1.43L5.6 10.039a.978.978 0 0 1 .936-.57 1 1 0 0 1 .9.632l1.181 2.981.541-1a.945.945 0 0 1 .883-.522 1 1 0 0 1 .879.529l1.832 3.438a1 1 0 0 1-.031.988Z" {}; + path d="M5 5V.13a2.96 2.96 0 0 0-1.293.749L.879 3.707A2.98 2.98 0 0 0 .13 5H5Z" {}; + } + } + div class="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-48 mb-4" {}; + div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5" {}; + div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5" {}; + div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700" {}; + span class="sr-only" { "Loading..." }; + }; + } +} + +#[allow(non_snake_case)] +pub fn ListPlaceholder() -> PreEscaped<String> { + html! { + div role="status" class="max-w-md p-4 space-y-4 border border-gray-200 divide-y divide-gray-200 rounded-sm shadow-sm animate-pulse dark:divide-gray-700 md:p-6 dark:border-gray-700" { + div class="flex items-center justify-between" { + div { + div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5" {}; + div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700" {}; + } + div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12" {}; + } + div class="flex items-center justify-between pt-4" { + div { + div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5" {}; + div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700" {}; + }; + div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12" {}; + } + div class="flex items-center justify-between pt-4" { + div { + div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5" {}; + div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700" {}; + } + div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12" {}; + } + div class="flex items-center justify-between pt-4" { + div { + div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5" {}; + div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700" {}; + }; + div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12" {}; + } + div class="flex items-center justify-between pt-4" { + div { + div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5" {}; + div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700" {}; + }; + div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12" {}; + } + span class="sr-only" { "Loading..." }; + } + } +} diff --git a/src/ui/components/shell.rs b/src/ui/components/shell.rs index 0e529be..ed5fd23 100644 --- a/src/ui/components/shell.rs +++ b/src/ui/components/shell.rs @@ -1,21 +1,33 @@ -use maud::{PreEscaped, html}; +use std::sync::Arc; + +use maud::{PreEscaped, Render, html}; use crate::{ request::{RequestContext, StringResponse}, - ui::UIWidget, + ui::{ + UIWidget, + color::{Gray, UIColor}, + htmx::{Event, HTMXAttributes, Selector, SwapStrategy}, + prelude::{Div, Link}, + primitives::link::LinkWidget, + }, }; /// Represents the HTML structure of a page shell, including the head, body class, and body content. /// /// This structure is used to construct the overall HTML structure of a page, making it easier to generate consistent HTML pages dynamically. +#[derive(Clone)] pub struct Shell { /// The HTML content for the `<head>` section of the page. - head: PreEscaped<String>, - /// An optional class attribute for the `<body>` element. - body_class: String, + head: Arc<PreEscaped<String>>, + /// An optional class attribute for the main container element. + main_class: Arc<String>, /// The HTML content for the static body portion. - body_content: PreEscaped<String>, + body_content: Arc<PreEscaped<String>>, ui: bool, + bottom_nav: Option<Arc<PreEscaped<String>>>, + sidebar: Option<Arc<SidebarWidget>>, + navbar: Option<Arc<NavBarWidget>>, } impl Shell { @@ -35,13 +47,43 @@ impl Shell { body_class: C, ) -> Self { Self { - head: head.render(), - body_class: body_class.extended_class().join(" "), - body_content: body_content.render(), + head: Arc::new(head.render()), + main_class: Arc::new(body_class.extended_class().join(" ")), + body_content: Arc::new(body_content.render()), ui: false, + bottom_nav: None, + sidebar: None, + navbar: None, } } + /// This function can be used to extend the Shell dynamically at runtime. + /// + /// It clones the shell, allowing you to modify it without affecting global state if you use a shared shell. + /// + /// # Example + /// ```ignore + /// shell.extend().with_navbar(...) + /// ``` + pub fn extend(&self) -> Self { + self.clone() + } + + pub fn with_navbar(mut self, navbar: NavBarWidget) -> Self { + self.navbar = Some(Arc::new(navbar)); + self + } + + pub fn with_bottom_navigation(mut self, bottom_nav: PreEscaped<String>) -> Self { + self.bottom_nav = Some(Arc::new(bottom_nav)); + self + } + + pub fn with_sidebar(mut self, inner: PreEscaped<String>) -> Self { + self.sidebar = Some(Arc::new(Sidebar(inner))); + self + } + pub fn use_ui(mut self) -> Self { self.ui = true; self @@ -57,36 +99,59 @@ impl Shell { /// A `PreEscaped<String>` containing the full HTML page content. #[must_use] pub fn render(&self, content: PreEscaped<String>, title: &str) -> PreEscaped<String> { + let mut main_class = self.main_class.as_ref().clone(); + + if self.bottom_nav.is_some() { + main_class.push_str(" pb-20"); + } + + if self.sidebar.is_some() { + main_class.push_str(" ml-[264px]"); + } + html! { - html { - head { - title { (title) }; - @if self.ui { - script src="https://cdn.tailwindcss.com" {}; - script src="/assets/htmx.min.js" {}; - script src="/assets/flowbite.min.js" {}; - link href="/assets/flowbite.min.css" rel="stylesheet" {}; - meta name="viewport" content="width=device-width, initial-scale=1.0"; + (maud::DOCTYPE) + html { + head { + title { (title) }; + @if self.ui { + script src="https://cdn.tailwindcss.com" {}; + script src="/assets/htmx.min.js" {}; + script src="/assets/flowbite.min.js" {}; + link href="/assets/flowbite.min.css" rel="stylesheet" {}; + link href="/assets/material.css" rel="stylesheet" {}; + meta name="viewport" content="width=device-width, initial-scale=1.0"; + }; + (self.head) }; - (self.head) - }; - @if !self.body_class.is_empty() { - body class=(self.body_class) { - (self.body_content); - div id="main_content" { - (content) - }; - }; - } @else { - body { - (self.body_content); - div id="main_content" { - (content) - }; - }; - } + body class=(self.main_class) { + (PreEscaped(self.navbar.as_ref().map(|x| { + if self.sidebar.is_some() { + x.as_ref().clone().sticky().render().0.clone() + } else { + x.render().0.clone() + } + }).unwrap_or_default())); + div id="notification_area" class=(format!("fixed top-{} start-0 z-50 w-full m-4", if self.navbar.is_some() { "[72px]" } else { "5" })) { + + } + + div id="toast_tl" class=(format!("fixed left-5 top-{} start-0 z-50 flex flex-col gap-4 m-4", if self.navbar.is_some() { "[72px]" } else { "5" })) {} + div id="toast_tr" class=(format!("fixed right-0 top-{} z-50 flex flex-col gap-4 m-4", if self.navbar.is_some() { "[72px]" } else { "5" })) {} + div id="toast_bl" class=(format!("fixed bottom-{} left-5 start-0 z-50 flex flex-col gap-4 m-4", if self.bottom_nav.is_some() { "[72px]" } else { "5" })) {} + div id="toast_br" class=(format!("fixed bottom-{} right-0 z-50 flex flex-col gap-4 m-4", if self.bottom_nav.is_some() { "[72px]" } else { "5" })) {} + + (PreEscaped(self.sidebar.as_ref().map(|x| x.render(self.navbar.is_some()).0).unwrap_or_default())) + + (self.body_content); + div id="main_content" class=(main_class) { + (content) + }; + + (PreEscaped(self.bottom_nav.as_ref().map(|x| x.0.as_str()).unwrap_or_default())) + } } } } @@ -125,3 +190,370 @@ impl Shell { } } } + +#[allow(non_snake_case)] +pub fn BottomNavigation<T: UIWidget + 'static>(inner: &[T]) -> PreEscaped<String> { + let elements_len = inner.len(); + + html! { + div class="fixed bottom-0 left-0 z-50 w-full h-16 bg-white border-t border-gray-200 dark:bg-gray-700 dark:border-gray-600" { + div class=(format!("grid h-full max-w-lg grid-cols-{elements_len} mx-auto font-medium")) { + @for item in inner { + (item) + } + }; + }; + } +} + +#[allow(non_snake_case)] +pub fn Classic<T: UIWidget + 'static>(class: &str, inner: T) -> ClassicWidget<T> { + ClassicWidget { + inner: inner, + class: class.to_string(), + noble: false, + } +} + +pub struct ClassicWidget<T: UIWidget> { + inner: T, + noble: bool, + class: String, +} + +impl<T: UIWidget> ClassicWidget<T> { + pub fn noble(mut self) -> Self { + self.noble = true; + self + } + + pub fn inner<F>(mut self, f: F) -> Self + where + F: Fn(T) -> T, + { + let mut inner = self.inner; + inner = f(inner); + self.inner = inner; + self + } +} + +impl<T: UIWidget> Render for ClassicWidget<T> { + fn render(&self) -> maud::Markup { + self.render_with_class(&self.class) + } +} + +impl<T: UIWidget> UIWidget for ClassicWidget<T> { + fn can_inherit(&self) -> bool { + !self.noble + } + + fn base_class(&self) -> Vec<String> { + self.class.split(" ").map(|x| x.to_string()).collect() + } + + fn extended_class(&self) -> Vec<String> { + let mut ret = self.base_class(); + ret.extend(self.inner.extended_class()); + ret + } + + fn render_with_class(&self, class: &str) -> maud::Markup { + let class = if self.noble { + self.class.clone() + } else { + format!("{} {class}", self.class) + }; + + self.inner.render_with_class(&class) + } +} + +#[allow(non_snake_case)] +pub fn BottomNavigationTile<T: UIWidget + 'static>( + reference: &str, + icon: Option<T>, + text: &str, +) -> ClassicWidget<LinkWidget> { + Classic( + "inline-flex flex-col items-center justify-center px-5 hover:bg-gray-50 dark:hover:bg-gray-800 group", + Link(reference, html! { + (icon.map(|x| x.render()).unwrap_or_default()); + span class="text-sm text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-500" { (text) }; + }), + ) +} + +#[allow(non_snake_case)] +pub fn NavBar(title: &str) -> NavBarWidget { + NavBarWidget { + icon: None, + name: title.to_string(), + menu: None, + user: None, + no_dropdown: false, + centered: false, + sticky: false, + } +} + +#[derive(Clone)] +pub struct NavBarWidget { + icon: Option<PreEscaped<String>>, + name: String, + menu: Option<PreEscaped<String>>, + user: Option<PreEscaped<String>>, + no_dropdown: bool, + centered: bool, + sticky: bool, +} + +impl NavBarWidget { + pub fn icon<T: UIWidget + 'static>(mut self, icon: T) -> Self { + self.icon = Some(icon.render()); + self + } + + pub fn sticky(mut self) -> Self { + self.sticky = true; + self + } + + pub fn menu<T: UIWidget + 'static>(mut self, menu: T) -> Self { + self.menu = Some(menu.render()); + self + } + + pub fn extra<T: UIWidget + 'static>(mut self, extra: T) -> Self { + self.user = Some(extra.render()); + self + } + + pub fn no_dropdown(mut self) -> Self { + self.no_dropdown = true; + self + } + + pub fn center(mut self) -> Self { + self.centered = true; + self + } +} + +impl Render for NavBarWidget { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for NavBarWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> maud::Markup { + let div_class = if self.centered { + "max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4" + } else { + "flex flex-wrap items-center justify-between mx-auto p-4" + }; + + let nav_class = if self.sticky { + "sticky top-0 bg-white border-gray-200 dark:bg-gray-900 h-[72px]" + } else { + "bg-white border-gray-200 dark:bg-gray-900 h-[72px]" + }; + + html! { + nav class=(nav_class) { + div class=(div_class) { + a href="/" class="flex items-center space-x-3 rtl:space-x-reverse" { + div class="h-8" { + (PreEscaped(self.icon.as_ref().map(|x| x.render().0).unwrap_or_default())) + }; + span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white" { (self.name) }; + }; + @if self.no_dropdown { + div class="md:hidden flex w-auto order-1" id="navbar-user" { + ul class="flex font-medium p-0 space-x-8 rtl:space-x-reverse flex-row mt-0 border-0 bg-white dark:bg-gray-800 dark:bg-gray-900 dark:border-gray-700" { + (PreEscaped(self.menu.as_ref().map(|x| x.render().0).unwrap_or_default())) + }; + }; + } + div class=(format!("flex items-center {}order-2 space-x-3 md:space-x-0 rtl:space-x-reverse", if self.no_dropdown { "" } else { "md:" })) { + (PreEscaped(self.user.as_ref().map(|x| x.render().0).unwrap_or_default())) + + @if !self.no_dropdown { + button data-collapse-toggle="navbar-user" type="button" class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600" aria-controls="navbar-user" aria-expanded="false" { + span class="sr-only" { "Open main menu" }; + svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" {}; + }; + }; + }; + }; + div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user" { + ul class="flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700" { + (PreEscaped(self.menu.as_ref().map(|x| x.render().0).unwrap_or_default())) + }; + }; + }; + }; + } + } +} + +pub struct SidebarWidget { + inner: PreEscaped<String>, +} + +impl SidebarWidget { + pub fn render(&self, has_navbar: bool) -> PreEscaped<String> { + let class = match has_navbar { + true => { + "fixed left-0 w-64 h-screen transition-transform -translate-x-full sm:translate-x-0 z-40" + } + false => { + "fixed top-0 left-0 w-64 h-screen transition-transform -translate-x-full sm:translate-x-0 z-40" + } + }; + + html! { + aside id="default-sidebar" class=(class) aria-label="Sidebar" { + div class="h-full px-3 py-4 overflow-y-auto bg-gray-50 dark:bg-gray-800" { + ul class="space-y-2 font-medium" { + (self.inner) + } + }; + }; + } + } +} + +#[allow(non_snake_case)] +fn Sidebar<T: UIWidget + 'static>(inner: T) -> SidebarWidget { + SidebarWidget { + inner: inner.render(), + } +} + +pub enum Position { + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +pub enum Alignment { + Horizontal, + Vertical, +} + +#[macro_export] +macro_rules! page { + ($shell:ident, $ctx:ident, $title:literal, $content:expr) => {{ + use $crate::ui::UIWidget; + let content = $content.render_with_class(""); + $shell.render_page(content, $title, $ctx).await + }}; + ($shell:ident, $ctx:ident, $title:ident, $content:expr) => {{ + use $crate::ui::UIWidget; + let content = $content.render_with_class(""); + $shell.render_page(content, &$title, $ctx).await + }}; +} + +pub struct ToastWidget { + color: Box<dyn UIColor>, + icon: Option<Box<dyn UIWidget>>, + body: Box<dyn UIWidget>, +} + +impl ToastWidget { + pub fn color<C: UIColor + 'static>(mut self, c: C) -> Self { + self.color = Box::new(c); + self + } + + pub fn icon<T: UIWidget + 'static>(mut self, icon: T) -> Self { + self.icon = Some(Box::new(icon)); + self + } +} + +impl Render for ToastWidget { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for ToastWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> maud::Markup { + let color = self.color.color_class(); + let dim_color = color; + let id = format!("toast_{}", uuid::Uuid::new_v4().to_string()); + html! { + div id=(id) class=(format!("flex items-center w-full max-w-xs p-4 text-gray-500 rounded-lg shadow dark:text-gray-400 bg-{color}")) role="alert" { + @if let Some(icon) = &self.icon { + div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-blue-500 bg-blue-100 rounded-lg dark:bg-blue-800 dark:text-blue-200" { + (icon) + }; + }; + + div class="ms-3 text-sm font-normal" { (self.body) }; + + button type="button" class=(format!("ms-auto -mx-1.5 -my-1.5 text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 inline-flex items-center justify-center h-8 w-8 dark:text-gray-500 dark:hover:text-white bg-{color} hover:bg-{dim_color}")) data-dismiss-target=(format!("#{id}")) aria-label="Close" { + span class="sr-only" { "Close" }; + svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" {}; + }; + } + }; + } + } +} + +#[allow(non_snake_case)] +pub fn Toast<B: UIWidget + 'static>(body: B) -> ToastWidget { + ToastWidget { + color: Box::new(Gray::_800), + icon: None, + body: Box::new(body), + } +} + +#[allow(non_snake_case)] +pub fn FetchToast(reference: &str, position: Position) -> PreEscaped<String> { + Div() + .hx_get(reference) + .hx_target(Selector::Query(match position { + Position::TopLeft => format!("#toast_tl"), + Position::TopRight => format!("#toast_tr"), + Position::BottomLeft => format!("#toast_bl"), + Position::BottomRight => format!("#toast_br"), + })) + .hx_swap(SwapStrategy::beforeend) + .hx_trigger(Event::on_load()) + .render() +} diff --git a/src/ui/components/timeline.rs b/src/ui/components/timeline.rs new file mode 100644 index 0000000..4c7814a --- /dev/null +++ b/src/ui/components/timeline.rs @@ -0,0 +1,272 @@ +use maud::{Render, html}; + +use crate::ui::UIWidget; + +pub struct TimelineElement { + icon: Option<Box<dyn UIWidget>>, + time_heading: bool, + title: Box<dyn UIWidget>, + time: Box<dyn UIWidget>, + body: Box<dyn UIWidget>, +} + +impl TimelineElement { + pub fn new<T: UIWidget + 'static, B: UIWidget + 'static, X: UIWidget + 'static>( + time: X, + title: T, + body: B, + ) -> Self { + TimelineElement { + icon: None, + time_heading: false, + title: Box::new(title), + time: Box::new(time), + body: Box::new(body), + } + } + + pub fn time_heading(mut self) -> Self { + self.time_heading = true; + self + } + + pub fn icon<T: UIWidget + 'static>(mut self, icon: T) -> Self { + self.icon = Some(Box::new(icon)); + self + } +} + +impl Render for TimelineElement { + fn render(&self) -> maud::Markup { + html! { + li class="mb-10 ms-6" { + @if let Some(icon) = &self.icon { + span class="absolute flex items-center justify-center w-6 h-6 bg-blue-100 rounded-full -start-3 dark:ring-gray-900 dark:bg-blue-900" { + (icon) + }; + } @else { + div class="absolute w-3 h-3 bg-gray-200 rounded-full mt-1.5 -start-1.5 border border-white dark:border-gray-900 dark:bg-gray-700" {}; + } + + @if self.time_heading { + time class="mb-1 text-sm font-normal leading-none text-gray-400 dark:text-gray-500" { (self.time) }; + h3 class="text-lg font-semibold text-gray-900 dark:text-white" { (self.title) }; + } @else { + h3 class="mb-1 text-lg font-semibold text-gray-900 dark:text-white" { (self.title) }; + time class="block mb-2 text-sm font-normal leading-none text-gray-400 dark:text-gray-500" { (self.time) }; + } + + (self.body) + }; + } + } +} + +pub struct TimelineWidget { + vanish: bool, + elements: Vec<TimelineElement>, + after: Option<Box<dyn UIWidget>>, +} + +impl TimelineWidget { + pub fn add_element(mut self, element: TimelineElement) -> Self { + self.elements.push(element); + self + } + + pub fn add_after<T: UIWidget + 'static>(mut self, after: T) -> Self { + self.after = Some(Box::new(after)); + self + } + + pub fn vanish(mut self) -> Self { + self.vanish = true; + self + } +} + +impl Render for TimelineWidget { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for TimelineWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> maud::Markup { + if self.vanish { + return html! { + @for element in &self.elements { + (element); + } + + @if let Some(after) = &self.after { + (after) + } + }; + } + + html! { + ol class="ml-6 mt-4 relative border-s border-gray-200 dark:border-gray-700 w-fit" { + @for element in &self.elements { + (element); + } + + @if let Some(after) = &self.after { + (after) + } + }; + } + } +} + +#[allow(non_snake_case)] +pub fn Timeline() -> TimelineWidget { + TimelineWidget { + vanish: false, + elements: Vec::new(), + after: None, + } +} + +pub struct ActivityLogElement { + icon: Box<dyn UIWidget>, + title: Box<dyn UIWidget>, + time: Box<dyn UIWidget>, + body: Option<Box<dyn UIWidget>>, +} + +impl ActivityLogElement { + pub fn new<T: UIWidget + 'static, X: UIWidget + 'static, I: UIWidget + 'static>( + icon: I, + time: X, + title: T, + ) -> Self { + ActivityLogElement { + icon: Box::new(icon), + title: Box::new(title), + time: Box::new(time), + body: None, + } + } + + pub fn body<T: UIWidget + 'static>(mut self, body: T) -> Self { + self.body = Some(Box::new(body)); + self + } +} + +impl Render for ActivityLogElement { + fn render(&self) -> maud::Markup { + html! { + li class="mb-10 ms-6" { + span class="absolute flex items-center justify-center w-6 h-6 bg-blue-100 rounded-full -start-3 ring-8 ring-white dark:ring-gray-900 dark:bg-blue-900" { + (self.icon) + }; + + div class=(format!("p-4 bg-white border border-gray-200 rounded-lg shadow-xs dark:bg-gray-700 dark:border-gray-600{}", if self.body.is_none() { " items-center justify-between sm:flex" } else { "" })) { + @if let Some(body) = &self.body { + div class="items-center justify-between mb-3 sm:flex" { + time class="mb-1 text-xs font-normal text-gray-400 sm:order-last sm:mb-0" { (self.time) }; + div class="text-sm font-normal text-gray-500 lex dark:text-gray-300" { (self.title) }; + }; + + (body) + } @else { + time class="mb-1 text-xs font-normal text-gray-400 sm:order-last sm:mb-0" { (self.time) }; + div class="text-sm font-normal text-gray-500 dark:text-gray-300" { (self.title) }; + } + }; + }; + } + } +} + +pub struct ActivityLogWidget { + vanish: bool, + elements: Vec<ActivityLogElement>, + after: Option<Box<dyn UIWidget>>, +} + +impl ActivityLogWidget { + pub fn add_element(mut self, element: ActivityLogElement) -> Self { + self.elements.push(element); + self + } + + pub fn add_after<T: UIWidget + 'static>(mut self, after: T) -> Self { + self.after = Some(Box::new(after)); + self + } + + pub fn vanish(mut self) -> Self { + self.vanish = true; + self + } +} + +impl Render for ActivityLogWidget { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for ActivityLogWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> maud::Markup { + if self.vanish { + return html! { + @for element in &self.elements { + (element); + } + + @if let Some(after) = &self.after { + (after) + } + }; + } + + html! { + ol class="ml-6 mt-4 relative border-s border-gray-200 dark:border-gray-700" { + @for element in &self.elements { + (element); + } + + @if let Some(after) = &self.after { + (after) + } + }; + } + } +} + +#[allow(non_snake_case)] +pub fn ActivityLog() -> ActivityLogWidget { + ActivityLogWidget { + vanish: false, + elements: Vec::new(), + after: None, + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 477f3a4..34d59cd 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,6 @@ use maud::{Markup, PreEscaped, Render, html}; +use prelude::Div; +use primitives::div::DivWidget; // UI @@ -8,11 +10,12 @@ pub mod htmx; pub mod primitives; pub mod wrapper; -// Stacked Components +// Complex Components pub mod components; // Preludes pub mod prelude { + pub use super::UIWidget; pub use super::color::*; pub use super::primitives::animation::{Animated, Animation, Delay, Duration, Scope, Timing}; pub use super::primitives::background::{ @@ -44,10 +47,11 @@ pub mod prelude { }; pub use super::primitives::height::{Height, MaxHeight, MinHeight}; pub use super::primitives::image::{Image, Source, Video}; - pub use super::primitives::width::{MaxWidth, MinWidth, Width}; - pub use super::primitives::{Context, NoBrowserAppearance, Nothing, Side, Size, script}; - // TODO : - pub use super::primitives::input::*; + pub use super::primitives::input::{ + Button, ButtonGroup, Checkbox, CustomRadio, DatePicker, FileInput, Form, FormMethod, + FormResetButton, FormSubmitButton, HiddenInput, InputAttr, NumberInput, PinInput, Radio, + Range, Select, TextArea, TextInput, TimePicker, Toggle, + }; pub use super::primitives::link::Link; pub use super::primitives::list::{OrderedList, UnorderedList}; pub use super::primitives::margin::Margin; @@ -74,9 +78,12 @@ pub mod prelude { TransformOrigin, }; pub use super::primitives::visibility::Visibility; + pub use super::primitives::width::{MaxWidth, MinWidth, Width}; + pub use super::primitives::{Context, NoBrowserAppearance, Nothing, Side, Size, script}; pub use super::wrapper::{ _2XLScreen, Hover, LargeScreen, MediumScreen, Screen, SmallScreen, XLScreen, }; + pub use maud::Render; } /// Generic UI Widget @@ -151,12 +158,26 @@ impl UIWidget for &str { } } +impl<T: UIWidget + 'static> Into<DivWidget> for Vec<T> { + fn into(self) -> DivWidget { + let mut div = Div().vanish(); + + for e in self { + div = div.push(e); + } + + div + } +} + /// Trait for an element which can add new `attrs` -pub trait AttrExtendable { +pub trait AttrExtendable: Sized { #[must_use] fn add_attr(self, key: &str, val: &str) -> Self; /// Set the `id` attribute of an element. #[must_use] - fn id(self, id: &str) -> Self; + fn id(self, id: &str) -> Self { + self.add_attr("id", id) + } } diff --git a/src/ui/primitives/div.rs b/src/ui/primitives/div.rs index 224e32b..d7ebe48 100644 --- a/src/ui/primitives/div.rs +++ b/src/ui/primitives/div.rs @@ -4,6 +4,12 @@ use maud::{Markup, PreEscaped, Render, html}; use crate::ui::{AttrExtendable, UIWidget, htmx::HTMXAttributes}; +use super::{ + margin::Margin, + space::ScreenValue, + width::{Width, WidthWidget}, +}; + #[allow(non_snake_case)] /// `<div>` element /// @@ -135,12 +141,14 @@ impl UIWidget for DivWidget { .collect::<Vec<_>>() .join(" "); - PreEscaped(format!( - "<div class='{class}' {attrs}> {} </div>", - inner.0 - )) + PreEscaped(format!("<div class='{class}' {attrs}> {} </div>", inner.0)) } } } impl HTMXAttributes for DivWidget {} + +#[allow(non_snake_case)] +pub fn Center<T: UIWidget + 'static>(inner: T) -> WidthWidget { + Width(ScreenValue::fit, Margin(inner).x(ScreenValue::auto)) +} diff --git a/src/ui/primitives/flex.rs b/src/ui/primitives/flex.rs index 35b231e..3955ea6 100644 --- a/src/ui/primitives/flex.rs +++ b/src/ui/primitives/flex.rs @@ -1,7 +1,32 @@ use crate::ui::{UIWidget, color::UIColor}; use maud::{Markup, Render, html}; -use super::space::{Fraction, ScreenValue}; +use super::{ + div::Div, + space::{Fraction, ScreenValue}, +}; + +#[allow(non_snake_case)] +pub fn Column<T: UIWidget + 'static>(inner: Vec<T>) -> FlexWidget { + let mut div = Div().vanish(); + + for e in inner { + div = div.push(e); + } + + Flex(div).direction(Direction::Column) +} + +#[allow(non_snake_case)] +pub fn Row<T: UIWidget + 'static>(inner: Vec<T>) -> FlexWidget { + let mut div = Div().vanish(); + + for e in inner { + div = div.push(e); + } + + Flex(div).direction(Direction::Row) +} #[allow(non_snake_case)] pub fn Flex<T: UIWidget + 'static>(inner: T) -> FlexWidget { diff --git a/src/ui/primitives/input.rs b/src/ui/primitives/input.rs deleted file mode 100644 index 5b791bc..0000000 --- a/src/ui/primitives/input.rs +++ /dev/null @@ -1 +0,0 @@ -// TODO : Implement input types diff --git a/src/ui/primitives/input/form.rs b/src/ui/primitives/input/form.rs new file mode 100644 index 0000000..f4258f0 --- /dev/null +++ b/src/ui/primitives/input/form.rs @@ -0,0 +1,91 @@ +use std::{collections::HashMap, fmt::Write}; + +use maud::{Markup, PreEscaped, Render, html}; + +use crate::{ + auth::{User, csrf::CSRF}, + ui::{AttrExtendable, UIWidget, prelude::HiddenInput}, +}; + +pub enum FormMethod { + GET, + POST, +} + +pub struct Form { + action: String, + method: Option<FormMethod>, + multipart: bool, + items: Vec<Box<dyn UIWidget>>, +} + +impl Form { + pub fn new(action: &str) -> Self { + Self { + action: action.to_string(), + method: None, + multipart: false, + items: Vec::new(), + } + } + + pub async fn add_csrf(self, u: &User) -> Self { + self.add_input( + HiddenInput("csrf", &u.get_csrf().await.to_string()).add_attr("class", "csrf"), + ) + } + + pub fn multipart(mut self) -> Self { + self.multipart = true; + self + } + + pub fn method(mut self, m: FormMethod) -> Self { + self.method = Some(m); + self + } + + pub fn add_input<T: UIWidget + 'static>(mut self, input: T) -> Self { + self.items.push(Box::new(input)); + self + } +} + +impl Render for Form { + fn render(&self) -> maud::Markup { + self.render_with_class("") + } +} + +impl UIWidget for Form { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, class: &str) -> Markup { + let method = self + .method + .as_ref() + .map(|x| match x { + FormMethod::GET => "get", + FormMethod::POST => "post", + }) + .unwrap_or("post"); + + html! { + form action=(self.action) method=(method) enctype=(if self.multipart { "multipart/form-data" } else { "application/x-www-form-urlencoded" }) class=(class) { + @for item in &self.items { + (item) + }; + } + } + } +} diff --git a/src/ui/primitives/input/mod.rs b/src/ui/primitives/input/mod.rs new file mode 100644 index 0000000..9402a40 --- /dev/null +++ b/src/ui/primitives/input/mod.rs @@ -0,0 +1,819 @@ +use std::collections::HashMap; + +use maud::{Markup, PreEscaped, Render, html}; + +pub mod form; +pub mod toggle; +pub use form::*; +pub use toggle::*; + +use crate::ui::{ + AttrExtendable, UIWidget, + color::{ColorCircle, UIColor}, + htmx::HTMXAttributes, + prelude::{Nothing, script}, +}; + +#[allow(non_snake_case)] +pub fn TextInput(name: &str) -> TextInputWidget { + TextInputWidget { + attrs: HashMap::new(), + password: false, + icon: None, + icon_border: false, + } + .name(name) +} + +pub struct TextInputWidget { + attrs: HashMap<String, String>, + password: bool, + icon: Option<Box<dyn UIWidget>>, + icon_border: bool, +} + +impl TextInputWidget { + pub fn placeholder(self, placeholder: &str) -> Self { + self.add_attr("placeholder", placeholder) + } + + pub fn pattern(self, pattern: &str) -> Self { + self.add_attr("pattern", pattern) + } + + pub fn maxlength(self, maxlength: &str) -> Self { + self.add_attr("maxlength", maxlength) + } + + pub fn minlength(self, minlength: &str) -> Self { + self.add_attr("minlength", minlength) + } + + pub fn icon_border(mut self) -> Self { + self.icon_border = true; + self + } + + pub fn icon<T: UIWidget + 'static>(mut self, icon: T) -> Self { + self.icon = Some(Box::new(icon)); + self + } + + pub fn password(mut self) -> Self { + self.password = true; + self + } +} + +impl InputAttr for TextInputWidget {} + +impl AttrExtendable for TextInputWidget { + fn add_attr(mut self, key: &str, val: &str) -> Self { + self.attrs.insert(key.into(), val.into()); + self + } +} + +impl Render for TextInputWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for TextInputWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, class: &str) -> Markup { + let mut attrs = self.attrs.clone(); + + if self.password { + attrs.insert("type".into(), "password".into()); + } else { + attrs.insert("type".into(), "text".into()); + } + + if self.icon_border { + attrs.insert("class".into(), + "rounded-none rounded-e-lg bg-gray-50 border text-gray-900 focus:ring-blue-500 focus:border-blue-500 block flex-1 min-w-0 w-full text-sm border-gray-300 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500".into() + ); + } else { + attrs.insert("class".into(), + format!("bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full {} p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500", if self.icon.is_some() { "ps-10" } else { "" }) + ); + } + + let input = build_element("input", &attrs, Nothing()); + + html! { + @if self.icon_border { + div class="flex" { + @if let Some(icon) = &self.icon { + span class="inline-flex items-center px-3 text-sm text-gray-900 bg-gray-200 border rounded-e-0 border-gray-300 border-e-0 rounded-s-md dark:bg-gray-600 dark:text-gray-400 dark:border-gray-600" { + (icon) + }; + }; + + (input) + + }; + } @else { + div class="relative mb-6" { + @if let Some(icon) = &self.icon { + div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none" { + (icon) + }; + }; + + (input) + + }; + } + } + } +} + +#[allow(non_snake_case)] +pub fn FileInput(name: &str) -> FileInputWidget { + FileInputWidget { + dropzone: None, + attrs: HashMap::new(), + } + .name(name) +} + +pub struct FileInputWidget { + dropzone: Option<PreEscaped<String>>, + attrs: HashMap<String, String>, +} + +impl FileInputWidget { + pub fn multiple(self) -> Self { + self.add_attr("multiple", "") + } + + pub fn accept(self, mime: &str) -> Self { + self.add_attr("accept", mime) + } + + pub fn dropzone<T: UIWidget + 'static>(mut self, inner: T) -> Self { + self.dropzone = Some(inner.render()); + self + } +} + +impl InputAttr for FileInputWidget {} + +impl AttrExtendable for FileInputWidget { + fn add_attr(mut self, key: &str, val: &str) -> Self { + self.attrs.insert(key.into(), val.into()); + self + } +} + +impl Render for FileInputWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for FileInputWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, class: &str) -> Markup { + let class = format!(" + {class} block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 + "); + + let mut attrs = self.attrs.clone(); + + if self.dropzone.is_some() { + attrs.insert("class".to_string(), "hidden".to_string()); + } else { + attrs.insert("class".to_string(), class); + } + attrs.insert("type".to_string(), "file".to_string()); + + let input_name = attrs.get("name").map(|x| x.as_str()).unwrap_or_default(); + + if let Some(dropzone) = &self.dropzone { + return html! { + div class="flex items-center justify-center w-full" { + label for=(input_name) class="flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 dark:hover:bg-gray-800 dark:bg-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:bg-gray-600" { + div class="flex flex-col items-center justify-center pt-5 pb-6" { + svg class="w-8 h-8 mb-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 16" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2" {}; + }; + p class="mb-2 text-sm text-gray-500 dark:text-gray-400" { span class="font-semibold" { "Click to upload" } }; + (dropzone) + }; + (build_element("input", &attrs, Nothing())) + }; + }; + }; + } + + build_element("input", &attrs, Nothing()) + } +} + +#[allow(non_snake_case)] +pub fn PinInput(digits: u8) -> PreEscaped<String> { + let js = r#" + function pin_move_next(event) { + const input = event.target; + if (input.value.length === 1) { + const nextInput = document.querySelector(`#${input.dataset.focusInputNext}`); + if (nextInput) nextInput.focus(); + } + } + function pin_move_back(event) { + const input = event.target; + if (event.key === "Backspace" && input.value === "") { + const prevInput = document.querySelector(`[data-focus-input-next='${input.id}']`); + if (prevInput) { + prevInput.focus(); + prevInput.setSelectionRange(1, 1); + } + } + } + function enforceNumericInput(event) { + if (!/^[0-9]$/.test(event.key)) { + event.preventDefault(); + } + } + "#; + + html! { + div class="flex mb-2 space-x-2 rtl:space-x-reverse" { + (script(js)) + + @for i in 1..=digits { + div { + label for=(format!("pin-{i}")) class="sr-only" { (format!("Pin {i}")) }; + + @if i == digits { + input type="text" + maxlength="1" + data-focus-input-init + pattern="[0-9]" + onkeypress="enforceNumericInput(event)" + id=(format!("pin-{i}")) + oninput="pin_move_next(event)" onkeydown="pin_move_back(event)" + class="block w-9 h-9 py-3 text-sm font-extrabold text-center text-gray-900 bg-white border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" required {}; + } @else { + input type="text" + maxlength="1" + pattern="[0-9]" + data-focus-input-init + onkeypress="enforceNumericInput(event)" + data-focus-input-next=(format!("pin-{}", i+1)) + id=(format!("pin-{i}")) + oninput="pin_move_next(event)" onkeydown="pin_move_back(event)" + class="block w-9 h-9 py-3 text-sm font-extrabold text-center text-gray-900 bg-white border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" required {}; + } + } + } + } + } +} + +pub struct NumberInputWidget { + inner: Option<Box<dyn UIWidget>>, + attr: HashMap<String, String>, + buttons: bool, +} + +pub fn NumberInput() -> NumberInputWidget { + NumberInputWidget { + inner: None, + attr: HashMap::new(), + buttons: false, + } +} + +impl NumberInputWidget { + pub fn with_buttons(mut self) -> Self { + self.buttons = true; + self + } + + pub fn min(self, min: u32) -> Self { + self.add_attr("min", &min.to_string()) + .add_attr("data-input-counter-min", &min.to_string()) + } + + pub fn max(self, max: u32) -> Self { + self.add_attr("max", &max.to_string()) + .add_attr("data-input-counter-max", &max.to_string()) + } + + pub fn step(self, step: u32) -> Self { + self.add_attr("step", &step.to_string()) + } + + pub fn help<T: UIWidget + 'static>(mut self, help: T) -> Self { + self.inner = Some(Box::new(help)); + self + } +} + +impl AttrExtendable for NumberInputWidget { + fn add_attr(mut self, key: &str, val: &str) -> Self { + self.attr.insert(key.into(), val.into()); + self + } +} + +impl InputAttr for NumberInputWidget {} + +impl Render for NumberInputWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for NumberInputWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> Markup { + let mut attrs = self.attr.clone(); + + attrs.insert("type".to_string(), "text".to_string()); + attrs.insert("data-input-counter".into(), "".into()); + attrs.insert("class".into(), format!( + "bg-gray-50 border-x-0 border-gray-300 h-11 font-medium text-center text-gray-900 text-sm focus:ring-blue-500 focus:border-blue-500 block w-full dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 {}", if self.inner.is_some() { "pb-6" } else { ""})); + + let input_name = attrs.get("id").map(|x| x.as_str()).unwrap_or_default(); + + let input = build_element("input", &attrs, Nothing()); + + html! { + div class="relative flex items-center max-w-[11rem]" { + @if self.buttons { + button + type="button" + id="decrement-button" + onclick=(format!("document.getElementById(\"{input_name}\").value = (+document.getElementById(\"{input_name}\").value || 0) + ((-document.getElementById(\"{input_name}\").step || 1) + 1);")) + data-input-counter-decrement=(input_name) + class="bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 dark:border-gray-600 hover:bg-gray-200 border border-gray-300 rounded-s-lg p-3 h-11 focus:ring-gray-100 dark:focus:ring-gray-700 focus:ring-2 focus:outline-none" { + svg class="w-3 h-3 text-gray-900 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 18 2" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h16" {}; + }; + }; + }; + + (input) + + @if let Some(inner) = &self.inner { + div class="absolute bottom-1 start-1/2 -translate-x-1/2 rtl:translate-x-1/2 flex items-center text-xs text-gray-400 space-x-1 rtl:space-x-reverse" { + (inner) + }; + }; + + @if self.buttons { + button type="button" id="increment-button" + onclick=(format!("document.getElementById(\"{input_name}\").value = (+document.getElementById(\"{input_name}\").value || 0) + ((+document.getElementById(\"{input_name}\").step || 1) - 1);")) + data-input-counter-increment=(input_name) class="bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 dark:border-gray-600 hover:bg-gray-200 border border-gray-300 rounded-e-lg p-3 h-11 focus:ring-gray-100 dark:focus:ring-gray-700 focus:ring-2 focus:outline-none" { + svg class="w-3 h-3 text-gray-900 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 18 18" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 1v16M1 9h16" {}; + }; + }; + }; + }; + } + } +} + +#[allow(non_snake_case)] +pub fn TimePicker(name: &str) -> DateTimePickerWidget { + DateTimePickerWidget { + attr: HashMap::new(), + date: false, + } + .name(name) +} + +#[allow(non_snake_case)] +pub fn DatePicker(name: &str) -> DateTimePickerWidget { + DateTimePickerWidget { + attr: HashMap::new(), + date: true, + } + .name(name) +} + +pub struct DateTimePickerWidget { + attr: HashMap<String, String>, + date: bool, +} + +impl DateTimePickerWidget { + pub fn min(self, min: &str) -> Self { + self.add_attr("min", min) + } + + pub fn max(self, max: &str) -> Self { + self.add_attr("max", max) + } +} + +impl UIWidget for DateTimePickerWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> Markup { + let mut attrs = self.attr.clone(); + + attrs.insert("class".into(), "bg-gray-50 border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500".into()); + + if self.date { + attrs.insert("type".into(), "date".into()); + } else { + attrs.insert("type".into(), "time".into()); + } + + let input = build_element("input", &attrs, Nothing()); + + html! { + div class="relative" { + div class="absolute inset-y-0 end-0 top-0 flex items-center pe-3.5 pointer-events-none" { + @if !self.date { + svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" { + path fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v4a1 1 0 0 0 .293.707l3 3a1 1 0 0 0 1.414-1.414L13 11.586V8Z" clip-rule="evenodd" {}; + }; + }; + }; + (input) + }; + } + } +} + +impl Render for DateTimePickerWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl InputAttr for DateTimePickerWidget {} + +impl AttrExtendable for DateTimePickerWidget { + fn add_attr(mut self, key: &str, val: &str) -> Self { + self.attr.insert(key.into(), val.into()); + self + } +} + +#[allow(non_snake_case)] +pub fn TextArea() -> TextAreaWidget { + TextAreaWidget { + content: String::new(), + attr: HashMap::new(), + } +} + +pub struct TextAreaWidget { + content: String, + attr: HashMap<String, String>, +} + +impl UIWidget for TextAreaWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, class: &str) -> Markup { + let mut attrs = self.attr.clone(); + attrs.insert("class".into(), format!( + "{class} block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" + )); + + build_element("textarea", &attrs, PreEscaped(self.content.clone())) + } +} + +impl Render for TextAreaWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl InputAttr for TextAreaWidget {} + +impl AttrExtendable for TextAreaWidget { + fn add_attr(mut self, key: &str, val: &str) -> Self { + self.attr.insert(key.into(), val.into()); + self + } +} + +impl TextAreaWidget { + pub fn content(mut self, content: String) -> Self { + self.content = content; + self + } + + pub fn placeholder(self, placeholder: &str) -> Self { + self.add_attr("placeholder", placeholder) + } + + pub fn rows(self, rows: u32) -> Self { + self.add_attr("rows", &rows.to_string()) + } + + pub fn pattern(self, pattern: &str) -> Self { + self.add_attr("pattern", pattern) + } + + pub fn maxlength(self, maxlength: &str) -> Self { + self.add_attr("maxlength", maxlength) + } + + pub fn minlength(self, minlength: &str) -> Self { + self.add_attr("minlength", minlength) + } +} + +#[allow(non_snake_case)] +pub fn Select( + label: Option<String>, + id: &str, + default: &str, + options: Vec<(String, String)>, +) -> PreEscaped<String> { + html! { + @if let Some(label) = label { + label for=(id) class="block mb-2 text-sm font-medium text-gray-900 dark:text-white" { (label) }; + }; + + select id=(id) class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" { + @for (value, label) in &options { + @if value == default { + @if value.is_empty() { + option selected { (label) }; + } @else { + option value=(value) selected { (label) }; + } + } @else { + @if value.is_empty() { + option { (label) }; + } @else { + option value=(value) { (label) }; + } + }; + }; + }; + } +} + +pub trait InputAttr: AttrExtendable + Sized { + fn name(self, name: &str) -> Self { + self.add_attr("name", name).add_attr("id", name) + } + + fn value(self, value: &str) -> Self { + self.add_attr("value", value) + } + + fn readonly(self) -> Self { + self.add_attr("readonly", "") + } + + fn disabled(self) -> Self { + self.add_attr("disabled", "") + } + + fn required(self) -> Self { + self.add_attr("required", "") + } + + fn autofocus(self) -> Self { + self.add_attr("autofocus", "") + } + + fn autocomplete(self, value: bool) -> Self { + self.add_attr("autocomplete", &value.to_string()) + } +} + +#[allow(non_snake_case)] +pub fn HiddenInput(name: &str, value: &str) -> HiddenInputWidget { + HiddenInputWidget { + attrs: HashMap::new(), + } + .name(name) + .value(value) +} + +pub struct HiddenInputWidget { + attrs: HashMap<String, String>, +} + +impl AttrExtendable for HiddenInputWidget { + fn add_attr(mut self, key: &str, val: &str) -> Self { + self.attrs.insert(key.to_string(), val.to_string()); + self + } +} + +impl InputAttr for HiddenInputWidget { + fn name(self, name: &str) -> Self { + self.add_attr("name", name) + } +} + +impl Render for HiddenInputWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for HiddenInputWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> Markup { + let mut attrs = self.attrs.clone(); + attrs.insert("type".into(), "hidden".into()); + build_element("input", &attrs, Nothing()) + } +} + +#[allow(non_snake_case)] +pub fn FormResetButton<T: UIWidget + 'static>(inner: T) -> ButtonWidget { + let btn = ButtonWidget { + inner: Box::new(inner), + attrs: HashMap::new(), + color: None, + hover_color: None, + }; + btn.add_attr("type", "reset") +} + +#[allow(non_snake_case)] +pub fn FormSubmitButton<T: UIWidget + 'static>(inner: T) -> ButtonWidget { + let btn = ButtonWidget { + inner: Box::new(inner), + attrs: HashMap::new(), + color: None, + hover_color: None, + }; + btn.add_attr("type", "submit") +} + +pub struct ButtonWidget { + inner: Box<dyn UIWidget>, + attrs: HashMap<String, String>, + color: Option<Box<dyn UIColor>>, + hover_color: Option<Box<dyn UIColor>>, +} + +impl ButtonWidget { + pub fn color<C: UIColor + ColorCircle + 'static>(mut self, color: C) -> Self { + let hover = color.next(); + self.color = Some(Box::new(color)); + self.hover_color = Some(Box::new(hover)); + self + } +} + +impl HTMXAttributes for ButtonWidget {} +impl InputAttr for ButtonWidget {} + +impl AttrExtendable for ButtonWidget { + fn add_attr(mut self, key: &str, val: &str) -> Self { + self.attrs.insert(key.into(), val.into()); + self + } +} + +impl Render for ButtonWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for ButtonWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, class: &str) -> Markup { + let mut attrs = self.attrs.clone(); + + let color = if let Some(c) = &self.color { + c.color_class() + } else { + "blue-700" + }; + + let hover_color = if let Some(c) = &self.hover_color { + c.color_class() + } else { + "blue-800" + }; + + attrs.insert("class".to_string(), format!( + "{class} px-5 py-2.5 text-sm font-medium text-white inline-flex items-center bg-{color} hover:bg-{hover_color} rounded-lg text-center" + )); + + build_element("button", &attrs, self.inner.render()) + } +} + +#[allow(non_snake_case)] +pub fn Button<T: UIWidget + 'static>(inner: T) -> ButtonWidget { + ButtonWidget { + inner: Box::new(inner), + attrs: HashMap::new(), + color: None, + hover_color: None, + } +} + +#[allow(non_snake_case)] +pub fn ButtonGroup(buttons: Vec<ButtonWidget>) -> PreEscaped<String> { + html! { + div class="inline-flex rounded-md shadow-xs" role="group" { + @for (index, element) in buttons.iter().enumerate() { + @if index == 0 { + button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white" { + (element.inner) + } + } @else if index == (buttons.len()-1) { + button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white" { + (element.inner) + } + } @else { + button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border-t border-b border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white" { + (element.inner) + } + }; + }; + }; + } +} diff --git a/src/ui/primitives/input/toggle.rs b/src/ui/primitives/input/toggle.rs new file mode 100644 index 0000000..060aaff --- /dev/null +++ b/src/ui/primitives/input/toggle.rs @@ -0,0 +1,394 @@ +use std::{collections::HashMap, fmt::Write}; + +use maud::{Markup, PreEscaped, Render, html}; + +use crate::ui::{ + AttrExtendable, UIWidget, + color::{Blue, UIColor}, + prelude::Nothing, +}; + +use super::InputAttr; + +#[allow(non_snake_case)] +pub fn Range() -> RangeWidget { + RangeWidget { + attrs: HashMap::new(), + } +} + +pub struct RangeWidget { + attrs: HashMap<String, String>, +} + +impl AttrExtendable for RangeWidget { + fn add_attr(mut self, key: &str, val: &str) -> Self { + self.attrs.insert(key.into(), val.into()); + self + } +} + +impl RangeWidget { + pub fn min(self, min: u32) -> Self { + self.add_attr("min", &min.to_string()) + } + + pub fn max(self, max: u32) -> Self { + self.add_attr("max", &max.to_string()) + } + + pub fn step(self, step: u32) -> Self { + self.add_attr("step", &step.to_string()) + } +} + +impl InputAttr for RangeWidget {} + +impl Render for RangeWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for RangeWidget { + fn can_inherit(&self) -> bool { + true + } + + fn base_class(&self) -> Vec<String> { + vec![ + "w-full".into(), + "h-2".into(), + "bg-gray-200".into(), + "rounded-lg".into(), + "appearence-none".into(), + "cursor-pointer".into(), + "dark:bg-gray-700".into(), + ] + } + + fn extended_class(&self) -> Vec<String> { + self.base_class() + } + + fn render_with_class(&self, class: &str) -> Markup { + let mut attrs = self.attrs.clone(); + attrs.insert( + "class".into(), + format!("{class} {}", self.base_class().join(" ")), + ); + attrs.insert("type".into(), "range".into()); + build_element("input", &attrs, PreEscaped(String::new())) + } +} + +pub fn build_element( + element: &str, + attrs: &HashMap<String, String>, + inner: PreEscaped<String>, +) -> PreEscaped<String> { + let mut ret = String::with_capacity(256); + write!(&mut ret, "<{element}").unwrap(); + + for (key, value) in attrs { + if value.is_empty() { + write!(&mut ret, " {key}").unwrap(); + } else { + write!(&mut ret, " {key}='{}'", value.replace("'", "\\'")).unwrap(); + }; + } + + if inner.0.is_empty() { + if element == "textarea" { + write!(&mut ret, "></textarea>").unwrap(); + } else { + write!(&mut ret, ">").unwrap(); + } + } else { + write!(&mut ret, ">{}</{element}>", inner.0).unwrap(); + } + + PreEscaped(ret) +} + +#[allow(non_snake_case)] +pub fn Toggle(title: &str) -> ToggleWidget { + ToggleWidget { + color: Box::new(Blue::_600), + checked: false, + attrs: HashMap::new(), + title: title.to_string(), + } +} + +pub struct ToggleWidget { + color: Box<dyn UIColor>, + checked: bool, + attrs: HashMap<String, String>, + title: String, +} + +impl ToggleWidget { + pub fn color<C: UIColor + 'static>(mut self, color: C) -> Self { + self.color = Box::new(color); + self + } + + pub fn checked(mut self, checked: bool) -> Self { + self.checked = checked; + self + } +} + +impl AttrExtendable for ToggleWidget { + fn add_attr(mut self, key: &str, val: &str) -> Self { + self.attrs.insert(key.to_string(), val.to_string()); + self + } +} + +impl InputAttr for ToggleWidget {} + +impl Render for ToggleWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for ToggleWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> Markup { + let color = self.color.color_class(); + let class = format!( + "relative w-11 h-6 bg-gray-200 rounded-full peer peer-focus:ring-4 peer-focus:ring-{color} dark:peer-focus:ring-blue-800 dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-{color} dark:peer-checked:bg-{color}" + ); + + let mut attrs = self.attrs.clone(); + + attrs.insert("class".into(), "sr-only peer".into()); + attrs.insert("type".into(), "checkbox".into()); + + if self.checked { + attrs.insert("checked".into(), "".into()); + } + + let input = build_element("input", &attrs, Nothing()); + + html! { + label class="inline-flex items-center cursor-pointer" { + (input) + div class=(class) {}; + span class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300" { (self.title) }; + }; + } + } +} + +#[allow(non_snake_case)] +pub fn CustomRadio<T: UIWidget + 'static>(inner: T, group_name: &str) -> RadioWidget { + RadioWidget { + color: Box::new(Blue::_600), + checked: false, + attrs: HashMap::new(), + title: inner.render().0, + custom: true, + } + .name(group_name) + .id(&format!("radio-{}", uuid::Uuid::new_v4().to_string())) +} + +#[allow(non_snake_case)] +pub fn Radio(title: &str, group_name: &str) -> RadioWidget { + RadioWidget { + color: Box::new(Blue::_600), + checked: false, + attrs: HashMap::new(), + title: title.to_string(), + custom: false, + } + .name(group_name) +} + +pub struct RadioWidget { + color: Box<dyn UIColor>, + checked: bool, + attrs: HashMap<String, String>, + title: String, + custom: bool, +} + +impl RadioWidget { + pub fn color<C: UIColor + 'static>(mut self, color: C) -> Self { + self.color = Box::new(color); + self + } + + pub fn checked(mut self, checked: bool) -> Self { + self.checked = checked; + self + } +} + +impl AttrExtendable for RadioWidget { + fn add_attr(mut self, key: &str, val: &str) -> Self { + self.attrs.insert(key.to_string(), val.to_string()); + self + } +} + +impl InputAttr for RadioWidget { + fn name(self, name: &str) -> Self { + self.add_attr("name", name) + } +} + +impl Render for RadioWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for RadioWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> Markup { + let mut attrs = self.attrs.clone(); + + let color = self.color.color_class(); + + if self.custom { + attrs.insert("class".into(), "hidden peer".into()); + } else { + attrs.insert("class".into(), format!("w-4 h-4 text-{color} bg-gray-100 border-gray-300 focus:ring-{color} dark:focus:ring-{color} dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600")); + } + attrs.insert("type".into(), "radio".into()); + + let input = build_element("input", &attrs, Nothing()); + + let input_id = attrs.get("id").map(|x| x.as_str()).unwrap_or_default(); + + let label_class = if self.custom { + format!( + "inline-flex items-center justify-between w-full p-5 text-gray-500 bg-white border border-gray-200 rounded-lg cursor-pointer dark:hover:text-gray-300 dark:border-gray-700 dark:peer-checked:text-{color} peer-checked:border-{color} dark:peer-checked:border-{color} peer-checked:text-{color} hover:text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:bg-gray-800 dark:hover:bg-gray-700" + ) + } else { + "ms-2 text-sm font-medium text-gray-900 dark:text-gray-300".to_string() + }; + + html! { + div class="flex items-center me-4" { + (input) + + label for=(input_id) class=(label_class) { + (PreEscaped(self.title.clone())) + }; + }; + } + } +} + +#[allow(non_snake_case)] +pub fn Checkbox(title: &str) -> CheckboxWidget { + CheckboxWidget { + color: Box::new(Blue::_600), + checked: false, + attrs: HashMap::new(), + title: title.to_string(), + } +} + +pub struct CheckboxWidget { + color: Box<dyn UIColor>, + checked: bool, + attrs: HashMap<String, String>, + title: String, +} + +impl CheckboxWidget { + pub fn color<C: UIColor + 'static>(mut self, color: C) -> Self { + self.color = Box::new(color); + self + } + + pub fn checked(mut self, checked: bool) -> Self { + self.checked = checked; + self + } +} + +impl AttrExtendable for CheckboxWidget { + fn add_attr(mut self, key: &str, val: &str) -> Self { + self.attrs.insert(key.to_string(), val.to_string()); + self + } +} + +impl InputAttr for CheckboxWidget {} + +impl Render for CheckboxWidget { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for CheckboxWidget { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> Markup { + let color = self.color.color_class(); + let class = format!( + "w-4 h-4 text-{color} bg-gray-100 border-gray-300 rounded-sm focus:ring-{color} dark:focus:ring-{color} dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" + ); + + let mut attrs = self.attrs.clone(); + + attrs.insert("class".into(), class); + attrs.insert("type".into(), "checkbox".into()); + + if self.checked { + attrs.insert("checked".into(), "".into()); + } + + let input = build_element("input", &attrs, Nothing()); + + let input_id = attrs.get("id").map(|x| x.as_str()).unwrap_or_default(); + + html! { + div class="flex items-center me-4" { + (input) + label for=(input_id) class="ms-2 text-sm font-medium text-gray-900 dark:text-gray-300" { (self.title) }; + }; + } + } +} diff --git a/src/ui/primitives/list.rs b/src/ui/primitives/list.rs index 630a766..447fb4c 100644 --- a/src/ui/primitives/list.rs +++ b/src/ui/primitives/list.rs @@ -1,5 +1,8 @@ -use crate::ui::UIWidget; -use maud::{Markup, Render, html}; +use crate::ui::{ + UIWidget, + components::prelude::{Classic, ClassicWidget}, +}; +use maud::{Markup, PreEscaped, Render, html}; #[allow(non_snake_case)] #[must_use] @@ -13,6 +16,15 @@ pub fn UnorderedList() -> ListWidget { ListWidget(Vec::new(), false) } +#[allow(non_snake_case)] +#[must_use] +pub fn HorizontalList(list: ListWidget) -> ClassicWidget<ListWidget> { + Classic( + "flex flex-wrap items-center justify-center text-gray-900 dark:text-white", + list, + ) +} + pub struct ListWidget(Vec<Box<dyn UIWidget>>, bool); impl ListWidget { @@ -101,3 +113,41 @@ impl UIWidget for ListWidget { } } } + +// TODO : List data backed list + reorderable + add + remove + crud + +pub fn CheckIconRounded() -> PreEscaped<String> { + html! { + svg class="w-3.5 h-3.5 me-2 text-green-500 dark:text-green-400 shrink-0" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20" { + path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z" {}; + }; + } +} + +pub fn CheckIconRoundedGray() -> PreEscaped<String> { + html! { + svg class="w-3.5 h-3.5 me-2 text-gray-500 dark:text-gray-400 shrink-0" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20" { + path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z" {} + }; + } +} + +pub fn ListEntry<T: UIWidget + 'static, I: UIWidget + 'static>( + icon: I, + inner: T, +) -> PreEscaped<String> { + html! { + li class="flex items-center" { + (icon) + (inner) + }; + } +} + +pub fn CheckIcon() -> PreEscaped<String> { + html! { + svg class="shrink-0 w-3.5 h-3.5 text-green-500 dark:text-green-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 12" { + path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5.917 5.724 10.5 15 1.5" {}; + }; + } +} diff --git a/src/ui/primitives/mod.rs b/src/ui/primitives/mod.rs index 38816ba..61eec82 100644 --- a/src/ui/primitives/mod.rs +++ b/src/ui/primitives/mod.rs @@ -180,3 +180,15 @@ impl UIWidget for NoBrowserAppearanceWidget { } } } + +#[allow(non_snake_case)] +pub fn Optional<T: UIWidget + 'static, X, U: Fn(X) -> T>( + option: Option<X>, + then: U, +) -> PreEscaped<String> { + if let Some(val) = option { + return then(val).render(); + } + + Nothing() +} diff --git a/src/ui/primitives/space.rs b/src/ui/primitives/space.rs index 2888980..d31bfc0 100644 --- a/src/ui/primitives/space.rs +++ b/src/ui/primitives/space.rs @@ -111,6 +111,7 @@ pub enum ScreenValue { min, max, fit, + fill, screen, full, auto, @@ -159,6 +160,7 @@ impl ScreenValue { Self::min => "min", Self::max => "max", Self::fit => "fit", + Self::fill => "fill", Self::screen => "screen", Self::full => "full", Self::auto => "auto", diff --git a/src/ui/primitives/table.rs b/src/ui/primitives/table.rs index bbdfe77..151def5 100644 --- a/src/ui/primitives/table.rs +++ b/src/ui/primitives/table.rs @@ -1,4 +1,4 @@ -use maud::{Markup, Render, html}; +use maud::{Markup, PreEscaped, Render, html}; use crate::ui::UIWidget; @@ -208,3 +208,105 @@ element_widget!(TableRow, TableRowWidget, tr); element_widget!(TableHead, TableHeadWidget, th); element_widget!(TableData, TableDataWidget, td); element_widget!(Header, HeaderWidget, header); + +// TODO : adv tables +// table options +// aggregate row + +pub struct AdvancedTable { + header: Option<Vec<PreEscaped<String>>>, + caption: Option<PreEscaped<String>>, + rows: Vec<Vec<PreEscaped<String>>>, + hover: bool, +} + +impl AdvancedTable { + pub fn hover(mut self) -> Self { + self.hover = true; + self + } + + pub fn caption<T: UIWidget + 'static>(mut self, caption: T) -> Self { + self.caption = Some(caption.render()); + self + } + + pub fn header(mut self, row: Vec<PreEscaped<String>>) -> Self { + self.header = Some(row); + self + } +} + +impl Render for AdvancedTable { + fn render(&self) -> Markup { + self.render_with_class("") + } +} + +impl UIWidget for AdvancedTable { + fn can_inherit(&self) -> bool { + false + } + + fn base_class(&self) -> Vec<String> { + Vec::new() + } + + fn extended_class(&self) -> Vec<String> { + Vec::new() + } + + fn render_with_class(&self, _: &str) -> Markup { + html! { + div class="relative overflow-x-auto shadow-md sm:rounded-lg" { + table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400" { + @if let Some(cap) = &self.caption { + caption class="p-5 text-lg font-semibold text-left rtl:text-right text-gray-900 bg-white dark:text-white dark:bg-gray-800" { + (cap) + }; + } + + @if let Some(header) = &self.header { + thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400" { + tr { + @for h in header { + th scope="col" class="px-6 py-3" { + (h); + } + } + }; + }; + }; + tbody { + @for row in &self.rows { + tr class=(format!("bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 {}", if self.hover { "hover:bg-gray-50 dark:hover:bg-gray-600" } else { "" })) { + @for cell in row { + td scope="row" class="px-6 py-4" { + (cell) + } + } + } + }; + }; + }; + } + } + } +} + +// selectable rows + +// sortable cols + +#[allow(non_snake_case)] +pub fn SortableIcon() -> PreEscaped<String> { + html! { + svg class="w-3 h-3 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" { + path d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z" {}; + } + } +} + +// filterable tables +// wrap data to table (crud) +// table pagination diff --git a/src/ui/primitives/text.rs b/src/ui/primitives/text.rs index ccd56fa..efed950 100644 --- a/src/ui/primitives/text.rs +++ b/src/ui/primitives/text.rs @@ -1143,3 +1143,5 @@ macro_rules! color_widget { color_widget!(TextCursorColor, CaretColorWidget, "caret"); color_widget!(AccentColor, AccentColorWidget, "accent"); + +// TODO : markdown render widget