From 13cd6767cf0bdd37b79fff88765d183325bc4524 Mon Sep 17 00:00:00 2001 From: "S. Yakupov" Date: Tue, 19 Mar 2024 17:26:10 +0300 Subject: [PATCH] feat: revert Yew legacy for compatibility (#74) --- Cargo.toml | 6 + Makefile | 10 +- src/legacy.rs | 3 + src/legacy/properties.rs | 7 + src/legacy/properties/align.rs | 27 +++ src/legacy/properties/color.rs | 310 +++++++++++++++++++++++++++++++ src/legacy/utils.rs | 134 ++++++++++++++ src/legacy/widgets.rs | 49 +++++ src/legacy/widgets/button.rs | 124 +++++++++++++ src/legacy/widgets/column.rs | 57 ++++++ src/legacy/widgets/grid.rs | 56 ++++++ src/legacy/widgets/label.rs | 60 ++++++ src/legacy/widgets/row.rs | 57 ++++++ src/legacy/widgets/slide.rs | 202 +++++++++++++++++++++ src/legacy/widgets/slideshow.rs | 226 +++++++++++++++++++++++ src/legacy/widgets/svg.rs | 71 ++++++++ src/legacy/widgets/text.rs | 311 ++++++++++++++++++++++++++++++++ src/lib.rs | 84 ++------- src/utils.rs | 71 ++++++++ 19 files changed, 1790 insertions(+), 75 deletions(-) create mode 100644 src/legacy.rs create mode 100644 src/legacy/properties.rs create mode 100644 src/legacy/properties/align.rs create mode 100644 src/legacy/properties/color.rs create mode 100644 src/legacy/utils.rs create mode 100644 src/legacy/widgets.rs create mode 100644 src/legacy/widgets/button.rs create mode 100644 src/legacy/widgets/column.rs create mode 100644 src/legacy/widgets/grid.rs create mode 100644 src/legacy/widgets/label.rs create mode 100644 src/legacy/widgets/row.rs create mode 100644 src/legacy/widgets/slide.rs create mode 100644 src/legacy/widgets/slideshow.rs create mode 100644 src/legacy/widgets/svg.rs create mode 100644 src/legacy/widgets/text.rs create mode 100644 src/utils.rs diff --git a/Cargo.toml b/Cargo.toml index 4bb4b25..754e57b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ gloo-utils = "0.2" leptos = { version = "0.6", features = ["csr"] } leptos-use = "0.10" rand = { version = "0.8", default-features = false, features = ["getrandom"] } +yew = { version = "0.21", features = ["csr"], optional = true } wasm-bindgen = "0.2.92" [dependencies.web-sys] @@ -61,3 +62,8 @@ crate-type = ["cdylib"] [[example]] name = "text" crate-type = ["cdylib"] + +[features] +legacy = [ + "yew", +] diff --git a/Makefile b/Makefile index 3ba1942..a26f87e 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ -.PHONY: all clean doc doc-open examples examples-release fmt fmt-check fmt-leptos linter prepare pre-commit serve test +.PHONY: all clean doc doc-open examples examples-release fmt fmt-check fmt-leptos legacy linter prepare pre-commit serve test all: @echo ──────────── Build release ──────────────────── - @cargo build --release + @cargo b -r clean: @echo ──────────── Clean ──────────────────────────── @@ -36,6 +36,10 @@ fmt-check: @echo ──────────── Check format ───────────────────── @cargo fmt --all -- --check +legacy: + @echo ──────────── Build legacy release ───────────── + @cargo b -rF legacy + linter: @echo ──────────── Run linter ─────────────────────── @cargo clippy --all-targets -- --no-deps -D warnings -A clippy::derive_partial_eq_without_eq @@ -52,4 +56,4 @@ serve: examples test: all @echo ──────────── Run tests ──────────────────────── - @cargo test --release + @cargo t -r diff --git a/src/legacy.rs b/src/legacy.rs new file mode 100644 index 0000000..493629c --- /dev/null +++ b/src/legacy.rs @@ -0,0 +1,3 @@ +pub mod properties; +pub mod utils; +pub mod widgets; diff --git a/src/legacy/properties.rs b/src/legacy/properties.rs new file mode 100644 index 0000000..6c30c72 --- /dev/null +++ b/src/legacy/properties.rs @@ -0,0 +1,7 @@ +//! Components properties. + +mod align; +mod color; + +pub use align::{Align, VAlign}; +pub use color::Color; diff --git a/src/legacy/properties/align.rs b/src/legacy/properties/align.rs new file mode 100644 index 0000000..b1cc981 --- /dev/null +++ b/src/legacy/properties/align.rs @@ -0,0 +1,27 @@ +/// Horizontal align. +#[derive(Clone, Default, PartialEq)] +pub enum Align { + /// Left horizontal align. + Left, + /// Center horizontal align. + #[default] + Center, + /// Right horizontal align. + Right, + /// Fill all horizontal space. + Fill, +} + +/// Vertical align. +#[derive(Clone, Default, PartialEq)] +pub enum VAlign { + /// Top vertical align. + Top, + /// Middle vertical align. + #[default] + Middle, + /// Bottom vertical align. + Bottom, + /// Fill all vertical space. + Fill, +} diff --git a/src/legacy/properties/color.rs b/src/legacy/properties/color.rs new file mode 100644 index 0000000..00586fa --- /dev/null +++ b/src/legacy/properties/color.rs @@ -0,0 +1,310 @@ +use derive_more::Display; + +// TODO: Define all colors + +/// Helper type to work with colors. +#[derive(Clone, Copy, Default, Display, PartialEq)] +pub enum Color { + /// No color. + #[default] + None, + /// Arbitrary color with the string value. + #[display(fmt = "{_0}")] + Value(&'static str), + + /// Alice Blue color. + AliceBlue, + /// Antique White color. + AntiqueWhite, + /// + Aqua, + /// + Aquamarine, + /// + Azure, + /// + Aeige, + /// + Bisque, + /// + Black, + /// + BlanchedAlmond, + /// + Blue, + /// + BlueViolet, + /// + Brown, + /// + BurlyWood, + /// + CadetBlue, + /// + Chartreuse, + /// + Chocolate, + /// + Coral, + /// + CornflowerBlue, + /// + Cornsilk, + /// + Crimson, + /// + Cyan, + /// + DarkBlue, + /// + DarkCyan, + /// + DarkGoldenrod, + /// + DarkGray, + /// + DarkGreen, + /// + DarkGrey, + /// + DarkKhaki, + /// + DarkMagenta, + /// + DarkOliveGreen, + /// + DarkOrange, + /// + DarkOrchid, + /// + DarkRed, + /// + DarkSalmon, + /// + DarkSeaGreen, + /// + DarkSlateBlue, + /// + DarkSlateGray, + /// + DarkSlateGrey, + /// + /// + DarkTurquoise, + /// + DarkViolet, + /// + DeepPink, + /// + DeepSkyBlue, + /// + DimGray, + /// + DimGrey, + /// + DodgerBlue, + /// + Firebrick, + /// + FloralWhite, + /// + ForestGreen, + /// + Fuchsia, + /// + Gainsboro, + /// + GhostWhite, + /// + Gold, + /// + Goldenrod, + /// + Gray, + /// + Green, + /// + GreenYellow, + /// + Grey, + /// + Honeydew, + /// + HotPink, + /// + IndianRed, + /// + Indigo, + /// + Ivory, + /// + Khaki, + /// + Lavender, + /// + LavenderBlush, + /// + LawnGreen, + /// + Lemonchiffon, + /// + LightBlue, + /// + LightCoral, + /// + LightCyan, + /// + LightGoldenrodYellow, + /// + LightGray, + /// + LightGreen, + /// + LightGrey, + /// + LightPink, + /// + LightSalmon, + /// + LightSeaGreen, + /// + LightSkyBlue, + /// + LightSlateGray, + /// + LightSlateGrey, + /// + LightsteelBlue, + /// + LightYellow, + /// + Lime, + /// + LimeGreen, + /// + Linen, + /// + Magenta, + /// + Maroon, + /// + MediumAquamarine, + /// + MediumBlue, + /// + MediumOrchid, + /// + MediumPurple, + /// + MediumSeaGreen, + /// + MediumSlateBlue, + /// + MediumSpringGreen, + /// + MediumTurquoise, + /// + MediumVioletRed, + /// + MidnightBlue, + /// + MintCream, + /// + MistyRose, + /// + Moccasin, + /// + NavajoWhite, + /// + Navy, + /// + Oldlace, + /// + Olive, + /// + Olivedrab, + /// + Orange, + /// + OrangeRed, + /// + Orchid, + /// + PaleGoldenrod, + /// + PaleGreen, + /// + PaleTurquoise, + /// + PaleVioletRed, + /// + Papayawhip, + /// + Peachpuff, + /// + Peru, + /// + Pink, + /// + Plum, + /// + PowderBlue, + /// + Purple, + /// + Red, + /// + RosyBrown, + /// + RoyalBlue, + /// + SaddleBrown, + /// + Salmon, + /// + SandyBrown, + /// + SeaGreen, + /// + Seashell, + /// + Sienna, + /// + Silver, + /// + SkyBlue, + /// + SlateBlue, + /// + SlateGray, + /// + SlateGrey, + /// + Snow, + /// + SpringGreen, + /// + SteelBlue, + /// + Tan, + /// + Teal, + /// + Thistle, + /// + Tomato, + /// + Turquoise, + /// + Violet, + /// + Wheat, + /// + White, + /// + WhiteSmoke, + /// + Yellow, + /// Yellow Green color + YellowGreen, +} diff --git a/src/legacy/utils.rs b/src/legacy/utils.rs new file mode 100644 index 0000000..e8d35a7 --- /dev/null +++ b/src/legacy/utils.rs @@ -0,0 +1,134 @@ +//! Utility functions and various helpers. + +use gloo_events::EventListener; +use rand::rngs::OsRng; +use std::collections::HashMap; +use wasm_bindgen::JsCast; +use yew::{html::Scope, prelude::*}; + +use yew::{BaseComponent, Renderer}; + +/// Start function. +/// +/// # Example +/// +/// ```no_run +/// use yew::prelude::*; +/// use wasm_bindgen::prelude::wasm_bindgen; +/// +/// #[function_component] +/// pub fn HelloWorld() -> Html { +/// html!("Hello, world!") +/// } +/// +/// #[wasm_bindgen(start)] +/// pub fn main() { +/// lerni::start::(); +/// } +/// ``` +pub fn start() +where + ::Properties: Default, +{ + Renderer::::new().render(); +} + +/// Debug macro. +#[macro_export] +macro_rules! debug { + ($arg:literal) => { + web_sys::console::log_1(&format!("{}", $arg).into()) + }; + ($arg:expr) => { + web_sys::console::log_1(&format!("{:?}", $arg).into()) + }; + ($fmt:literal $(, $args:expr)+) => { + web_sys::console::log_1(&format!($fmt $(, $args)+).into()) + }; +} + +/// Keyboard key codes. +pub mod keys { + /// Backspace key. + pub const BACKSPACE: u32 = 8; + /// Tab key. + pub const TAB: u32 = 9; + /// Enter key. + pub const ENTER: u32 = 13; + /// Escape key. + pub const ESCAPE: u32 = 27; + /// Space key. + pub const SPACE: u32 = 32; + /// Left arrow key. + pub const ARROW_LEFT: u32 = 37; + /// Up arrow key. + pub const ARROW_UP: u32 = 38; + /// Right arrow key. + pub const ARROW_RIGHT: u32 = 39; + /// Down arrow key. + pub const ARROW_DOWN: u32 = 40; + /// Digit 0 key. + pub const DIGIT_0: u32 = 48; + /// Digit 1 key. + pub const DIGIT_1: u32 = 49; + /// Digit 2 key. + pub const DIGIT_2: u32 = 50; + /// Digit 3 key. + pub const DIGIT_3: u32 = 51; + /// Digit 4 key. + pub const DIGIT_4: u32 = 52; + /// Digit 5 key. + pub const DIGIT_5: u32 = 53; + /// Digit 6 key. + pub const DIGIT_6: u32 = 54; + /// Digit 7 key. + pub const DIGIT_7: u32 = 55; + /// Digit 8 key. + pub const DIGIT_8: u32 = 56; + /// Digit 9 key. + pub const DIGIT_9: u32 = 57; +} + +/// Creates Random Number Generator (RNG). +pub fn rng() -> OsRng { + Default::default() +} + +/// Add keyboard handler. +/// +/// `messages` is an assotiative array with the keyboard key as a key ansd a message as a value. +pub fn add_key_handler(link: &Scope, messages: HashMap) +where + T: Component, + M: Into + Copy + 'static, +{ + let doc = web_sys::window().and_then(|win| win.document()); + if let Some(doc) = doc { + let link = link.clone(); + let event = EventListener::new(&doc, "keydown", move |e| { + if let Some(e) = e.dyn_ref::() { + let key = e.key_code(); + if messages.contains_key(&key) { + link.send_message(messages[&key]); + } + } + }); + event.forget(); + } +} + +/// Add resize event handler. +pub fn add_resize_handler(link: &Scope, message: M) +where + T: Component, + M: Into + Copy + 'static, +{ + let win = web_sys::window(); + if let Some(win) = win { + let link = link.clone(); + let event = EventListener::new(&win, "resize", move |_| { + link.send_message(message); + }); + event.forget(); + } +} diff --git a/src/legacy/widgets.rs b/src/legacy/widgets.rs new file mode 100644 index 0000000..5507e71 --- /dev/null +++ b/src/legacy/widgets.rs @@ -0,0 +1,49 @@ +//! Widgets are the main building blocks. + +mod button; +mod column; +mod grid; +mod label; +mod row; +mod slide; +mod slideshow; +mod svg; +mod text; + +pub use button::Button; +pub use column::Column; +pub use grid::Grid; +pub use label::Label; +pub use row::Row; +pub use slide::Slide; +pub use slideshow::SlideShow; +pub use svg::Svg; +pub use text::Text; + +/// Additional information provided to all slides. +#[derive(Clone, Copy, Default, Debug, PartialEq)] +pub struct Metadata { + /// Visibility flag. + pub visible: bool, + /// Teacher mode flag. + pub teacher_mode: bool, + /// Pointer on/off flag. + pub pointer: bool, +} + +/// Frame within which the widget will be rendered. +#[derive(Clone, Default, Debug, PartialEq)] +pub struct Frame { + /// X-coordinate (in pixels) of the to left corner. + pub x: i32, + /// Y-coordinate (in pixels) of the to left corner. + pub y: i32, + /// Width (in pixels). + pub width: i32, + /// Height (in pixels). + pub height: i32, + /// Screen X to SVG X transform factor. + pub fx: f32, + /// Screen Y to SVG Y transform factor. + pub fy: f32, +} diff --git a/src/legacy/widgets/button.rs b/src/legacy/widgets/button.rs new file mode 100644 index 0000000..93e3481 --- /dev/null +++ b/src/legacy/widgets/button.rs @@ -0,0 +1,124 @@ +use yew::prelude::*; + +use crate::{ + properties::{Align, Color, VAlign}, + widgets::{Frame, Label}, +}; + +const WIDTH: i32 = 400; +const HEIGHT: i32 = 150; + +/// Button properties. +#[derive(Default, Clone, Properties, PartialEq)] +pub struct Props { + #[prop_or_default] + pub text: String, + #[prop_or_default] + pub text_bold: bool, + #[prop_or_default] + pub html: Html, + #[prop_or(WIDTH)] + pub width: i32, + #[prop_or(HEIGHT)] + pub height: i32, + #[prop_or(24)] + pub radius: i32, + #[prop_or_default] + pub font: String, + #[prop_or(48)] + pub font_size: i32, + #[prop_or(Color::AliceBlue)] + pub color: Color, + #[prop_or(Color::Black)] + pub text_color: Color, + #[prop_or(12)] + pub border_width: i32, + #[prop_or(Color::RoyalBlue)] + pub border_color: Color, + #[prop_or(Align::Center)] + pub align: Align, + #[prop_or(VAlign::Middle)] + pub valign: VAlign, + #[prop_or_default] + pub onclick: Callback, +} + +/// Button widget. +#[function_component] +pub fn Button(props: &Props) -> Html { + let f = use_context::().unwrap(); + + let width = if props.align == Align::Fill { + f.width + } else { + props.width + }; + let height = if props.valign == VAlign::Fill { + f.height + } else { + props.height + }; + + let x = match props.align { + Align::Left | Align::Fill => f.x, + Align::Center => f.x + (f.width - width) / 2, + Align::Right => f.x + f.width - width, + }; + let y = match props.valign { + VAlign::Top | VAlign::Fill => f.y, + VAlign::Middle => f.y + (f.height - height) / 2, + VAlign::Bottom => f.y + f.height - height, + }; + + let mouse_down = use_state(|| false); + let onmousedown = { + let mouse_down = mouse_down.clone(); + Callback::from(move |_| mouse_down.set(true)) + }; + let onmouseup = { + let mouse_down = mouse_down.clone(); + let props = props.clone(); + Callback::from(move |_| { + if *mouse_down { + mouse_down.set(false); + props.onclick.emit(props.clone()); + } + }) + }; + let onmouseleave = { + let mouse_down = mouse_down.clone(); + Callback::from(move |_| { + mouse_down.set(false); + }) + }; + + let border_width = if *mouse_down { + props.border_width + 6 + } else { + props.border_width + }; + + let frame = Frame { + x, + y, + width, + height, + ..f + }; + let x = (x + border_width / 2).to_string(); + let y = (y + border_width / 2).to_string(); + let width = (width - border_width).to_string(); + let height = (height - border_width).to_string(); + html! { + + + context={ frame }> + > + + } +} diff --git a/src/legacy/widgets/column.rs b/src/legacy/widgets/column.rs new file mode 100644 index 0000000..24f7dea --- /dev/null +++ b/src/legacy/widgets/column.rs @@ -0,0 +1,57 @@ +use yew::prelude::*; + +use crate::{properties::Color, widgets::Frame}; + +#[derive(Clone, Default, Properties, PartialEq)] +pub struct Props { + #[prop_or_default] + pub children: Children, + #[prop_or(0)] + pub border_width: i32, + #[prop_or(Color::Black)] + pub border_color: Color, + #[prop_or_default] + pub stretch: Vec, + #[prop_or_default] + pub spacing: i32, + #[prop_or_default] + pub padding: i32, +} + +/// Column of widgets. +#[function_component] +pub fn Column(props: &Props) -> Html { + let f = use_context::().unwrap(); + + let stretch: Vec<_> = (0..props.children.len()) + .map(|i| *props.stretch.get(i).unwrap_or(&1)) + .collect(); + let denominator: i32 = stretch.iter().sum(); + + let spacing = props.spacing * (props.children.len() as i32 - 1); + let x = f.x + props.border_width / 2; + let mut y = f.y + props.border_width / 2; + let width = f.width - props.border_width; + + html! { + for props.children.iter().enumerate().map(|(i, item)| { + let height = (f.height - props.border_width - spacing) * stretch[i] / denominator; + let frame = Frame { + x: x + props.padding, + y: y + props.padding, + width: width - 2 * props.padding, + height: height - 2 * props.padding, + ..f + }; + let html = html_nested! { + context={ frame }> + + { item } + > + }; + y += height + props.spacing; + html + }) + } +} diff --git a/src/legacy/widgets/grid.rs b/src/legacy/widgets/grid.rs new file mode 100644 index 0000000..1f65de1 --- /dev/null +++ b/src/legacy/widgets/grid.rs @@ -0,0 +1,56 @@ +use yew::prelude::*; + +use crate::{properties::Color, widgets::Frame}; + +#[derive(Clone, Default, Properties, PartialEq)] +pub struct Props { + #[prop_or_default] + pub children: Children, + #[prop_or(1)] + pub rows: usize, + #[prop_or(1)] + pub cols: usize, + #[prop_or(0)] + pub border_width: i32, + #[prop_or(Color::Black)] + pub border_color: Color, + #[prop_or_default] + pub spacing: i32, + #[prop_or_default] + pub padding: i32, +} + +/// Grid layout widget. +#[function_component] +pub fn Grid(props: &Props) -> Html { + let f = use_context::().unwrap(); + + let cols = props.cols as i32; + let rows = props.rows as i32; + let hspacing = props.spacing * (cols - 1); + let vspacing = props.spacing * (rows - 1); + let width = (f.width - props.border_width - hspacing) / cols; + let height = (f.height - props.border_width - vspacing) / rows; + + let max = props.cols * props.rows; + html! { + for props.children.iter().take(max).enumerate().map(|(i, item)| { + let x = f.x + props.border_width / 2 + (width + props.spacing) * (i as i32 % cols); + let y = f.y + props.border_width / 2 + (height + props.spacing) * (i as i32 / cols); + let frame = Frame { + x: x + props.padding, + y: y + props.padding, + width: width - 2 * props.padding, + height: height - 2 * props.padding, + ..f + }; + html_nested! { + context={ frame }> + + { item } + > + } + }) + } +} diff --git a/src/legacy/widgets/label.rs b/src/legacy/widgets/label.rs new file mode 100644 index 0000000..3b93369 --- /dev/null +++ b/src/legacy/widgets/label.rs @@ -0,0 +1,60 @@ +use yew::prelude::*; + +use crate::{ + properties::{Align, Color, VAlign}, + widgets::Frame, +}; + +/// Label properties. +#[derive(Clone, Default, Properties, PartialEq)] +pub struct Props { + #[prop_or_default] + pub text: String, + #[prop_or_default] + pub bold: bool, + #[prop_or_default] + pub html: Html, + #[prop_or_default] + pub font: String, + #[prop_or(48)] + pub font_size: i32, + #[prop_or(Align::Center)] + pub align: Align, + #[prop_or(VAlign::Middle)] + pub valign: VAlign, + #[prop_or(Color::Black)] + pub color: Color, +} + +/// Label widget. +#[function_component] +pub fn Label(props: &Props) -> Html { + let f = use_context::().unwrap(); + + let (x, anchor) = match props.align { + Align::Left => (f.x, "start"), + Align::Center | Align::Fill => (f.x + f.width / 2, "middle"), + Align::Right => (f.x + f.width, "end"), + }; + let (y, baseline) = match props.valign { + VAlign::Top => (f.y, "hanging"), + VAlign::Middle | VAlign::Fill => ((f.y + f.height / 2), "central"), + VAlign::Bottom => (f.y + f.height, "text-top"), + }; + + let class = classes!(props.bold.then_some("has-text-weight-bold")); + let style = classes!((!props.font.is_empty()).then(|| format!("font-family: {};", props.font))); + + html! { + + { + if props.text.is_empty() { + props.html.clone() + } else { + props.text.clone().into() + } + } + + } +} diff --git a/src/legacy/widgets/row.rs b/src/legacy/widgets/row.rs new file mode 100644 index 0000000..3e9657e --- /dev/null +++ b/src/legacy/widgets/row.rs @@ -0,0 +1,57 @@ +use yew::prelude::*; + +use crate::{properties::Color, widgets::Frame}; + +#[derive(Clone, Default, Properties, PartialEq)] +pub struct Props { + #[prop_or_default] + pub children: Children, + #[prop_or(0)] + pub border_width: i32, + #[prop_or(Color::Black)] + pub border_color: Color, + #[prop_or_default] + pub stretch: Vec, + #[prop_or_default] + pub spacing: i32, + #[prop_or_default] + pub padding: i32, +} + +/// Row of widgets. +#[function_component] +pub fn Row(props: &Props) -> Html { + let f = use_context::().unwrap(); + + let stretch: Vec<_> = (0..props.children.len()) + .map(|i| *props.stretch.get(i).unwrap_or(&1)) + .collect(); + let denominator: i32 = stretch.iter().sum(); + + let spacing = props.spacing * (props.children.len() as i32 - 1); + let mut x = f.x + props.border_width / 2; + let y = f.y + props.border_width / 2; + let height = f.height - props.border_width; + + html! { + for props.children.iter().enumerate().map(|(i, item)| { + let width = (f.width - props.border_width - spacing) * stretch[i] / denominator; + let frame = Frame { + x: x + props.padding, + y: y + props.padding, + width: width - 2 * props.padding, + height: height - 2 * props.padding, + ..f + }; + let html = html_nested! { + context={ frame }> + + { item } + > + }; + x += width + props.spacing; + html + }) + } +} diff --git a/src/legacy/widgets/slide.rs b/src/legacy/widgets/slide.rs new file mode 100644 index 0000000..1d88905 --- /dev/null +++ b/src/legacy/widgets/slide.rs @@ -0,0 +1,202 @@ +use web_sys::SvgElement; +use yew::{prelude::*, ContextProvider}; + +use crate::{properties::Color, utils, widgets::Frame}; + +const WIDTH: i32 = 1920; +const HEIGHT: i32 = 1080; +const POINTER_SIZE: i32 = 72; + +/// Slide widget. +#[derive(Clone, Default)] +pub struct Slide { + svg_ref: NodeRef, + pointer_x: i32, + pointer_y: i32, + width: i32, +} + +#[derive(Clone, Default, Properties, PartialEq)] +pub struct Props { + #[prop_or_default] + pub children: Children, + #[prop_or(WIDTH)] + pub width: i32, + #[prop_or(HEIGHT)] + pub height: i32, + #[prop_or_default] + pub defs: Html, + #[prop_or_default] + pub background_color: Color, + #[prop_or_default] + pub background_image: String, + #[prop_or_default] + pub pointer: bool, + #[prop_or_default] + pub blur: bool, + #[prop_or(15)] + pub blur_radius: i32, + #[prop_or_default] + pub onclick: Callback<(i32, i32)>, +} + +#[derive(Clone, Copy)] +pub enum Msg { + MovePointer { x: i32, y: i32 }, + HidePointer, + Clicked { x: i32, y: i32 }, + Resize, +} + +impl Component for Slide { + type Message = Msg; + type Properties = Props; + + fn create(ctx: &Context) -> Self { + utils::add_resize_handler(ctx.link(), Msg::Resize); + Self { + width: Self::calc_width(), + ..Default::default() + } + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + let p = ctx.props(); + match msg { + Msg::MovePointer { x, y } => { + if let Some(svg) = self.svg_ref.cast::() { + self.pointer_x = x * WIDTH / svg.client_width(); + self.pointer_y = y * HEIGHT / svg.client_height(); + } + true + } + Msg::HidePointer => { + self.pointer_x = 0; + self.pointer_y = 0; + true + } + Msg::Clicked { x, y } => { + if let Some(svg) = self.svg_ref.cast::() { + let x = x * WIDTH / svg.client_width(); + let y = y * HEIGHT / svg.client_height(); + p.onclick.emit((x, y)); + } + false + } + Msg::Resize => { + self.width = Self::calc_width(); + true + } + } + } + + fn view(&self, ctx: &Context) -> Html { + let p = ctx.props(); + let view_box = format!("0 0 {} {}", p.width, p.height); + + let onmousemove = ctx.link().callback(|e: MouseEvent| Msg::MovePointer { + x: e.offset_x(), + y: e.offset_y(), + }); + + let onmouseleave = ctx.link().callback(|_| Msg::HidePointer); + + let onclick = ctx.link().callback(|e: MouseEvent| Msg::Clicked { + x: e.offset_x(), + y: e.offset_y(), + }); + + let mut fx = 0.0; + let mut fy = 0.0; + + if let Some(svg) = self.svg_ref.cast::() { + fx = WIDTH as f32 / svg.client_width() as f32; + fy = HEIGHT as f32 / svg.client_height() as f32; + } + + let frame = Frame { + x: 0, + y: 0, + width: p.width, + height: p.height, + fx, + fy, + }; + + let style = if self.width > 0 { + format!("max-width: {}px;", self.width) + } else { + ("max-width: 100%").to_string() + }; + + let radius = if p.blur { p.blur_radius } else { 0 }; + let blur = format!( + r#"-webkit-filter: blur({radius}px); + -moz-filter: blur({radius}px); + -ms-filter: blur({radius}px); + filter: blur({radius}px); transition: all .3s;"#, + ); + + let svg_style = if !p.background_image.is_empty() { + format!( + r#"background-image: url({}); + background-size: cover; + background-position: center; + background-repeat: no-repeat;"#, + &p.background_image + ) + } else { + Default::default() + }; + + html! { +
+
+
+ + { p.defs.clone() } + + { + for p.children.iter().map(|item|{ + html_nested! { + context={ frame.clone() }> + { item } + > + } + }) + } + { self.pointer_view(p.pointer) } + +
+
+
+ } + } +} + +impl Slide { + fn pointer_view(&self, pointer: bool) -> Html { + if pointer && self.pointer_x > 0 && self.pointer_y > 0 { + html_nested! { + + } + } else { + html_nested!() + } + } + + fn calc_width() -> i32 { + let elem = web_sys::window() + .and_then(|win| win.document()) + .and_then(|doc| doc.document_element()); + if let Some(elem) = elem { + let width = elem.client_width(); + let height = elem.client_height(); + width.min((height - 88) * 16 / 9) + } else { + 0 + } + } +} diff --git a/src/legacy/widgets/slideshow.rs b/src/legacy/widgets/slideshow.rs new file mode 100644 index 0000000..ec68668 --- /dev/null +++ b/src/legacy/widgets/slideshow.rs @@ -0,0 +1,226 @@ +use std::collections::{BTreeSet, HashMap}; +use yew::{html::Scope, prelude::*}; + +use crate::{ + utils::{self, keys}, + widgets::Metadata, +}; + +const BUTTON_COUNT: usize = 6; + +/// Set of slides that are to be displayed sequentially. +#[derive(Clone, Default)] +pub struct SlideShow { + current: usize, + count: usize, + width: i32, +} + +#[derive(Clone, Default, Properties, PartialEq)] +pub struct Props { + #[prop_or_default] + pub children: Children, + #[prop_or_default] + pub current: usize, + #[prop_or_default] + pub teacher_mode: bool, + #[prop_or_default] + pub pointer: bool, +} + +#[derive(Clone, Copy)] +pub enum Msg { + Prev, + Next, + SetCurrent(usize), + Resize, +} + +impl Component for SlideShow { + type Message = Msg; + type Properties = Props; + + fn create(ctx: &Context) -> Self { + let p = ctx.props(); + let link = ctx.link(); + let mut messages: HashMap<_, _> = [ + (keys::ARROW_LEFT, Msg::Prev), + (keys::ARROW_RIGHT, Msg::Next), + ] + .into(); + for k in keys::DIGIT_1..=keys::DIGIT_9 { + messages.insert(k, Msg::SetCurrent((k - keys::DIGIT_1) as _)); + } + utils::add_key_handler(link, messages); + utils::add_resize_handler(link, Msg::Resize); + + let count = p.children.len(); + let current = p.current.min(count - 1); + Self { + current, + count, + width: Self::calc_width(), + } + } + + fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { + let max = self.count - 1; + match msg { + Msg::Prev if self.current > 0 => self.current -= 1, + Msg::Next if self.current < max => self.current += 1, + Msg::SetCurrent(c) if c <= max => self.current = c, + Msg::Resize => self.width = Self::calc_width(), + _ => return false, + } + true + } + + fn view(&self, ctx: &Context) -> Html { + let p = ctx.props(); + let link = ctx.link(); + + let style = if self.width > 0 { + format!("max-width: {}px;", self.width) + } else { + ("max-width: 100%").to_string() + }; + + html! { + <> +
+ { self.pagination(link) } +
+ { + for p.children.iter().enumerate().map(|(i, item)| { + let metadata = Metadata { + visible: i == self.current, + teacher_mode: p.teacher_mode, + pointer: p.pointer, + }; + html_nested! { + + } + }) + } + + } + } +} + +impl SlideShow { + fn page_list(current: usize, count: usize) -> Vec { + if count <= BUTTON_COUNT { + (0..count).collect() + } else { + let mut pages: BTreeSet = [0].into(); + let mut add_page = |page| { + if page < count { + pages.insert(page); + } + }; + add_page(count - 1); + let center = if current == 0 { + 1 + } else if current == count - 1 { + count - 2 + } else { + current + }; + add_page(center - 1); + add_page(center); + add_page(center + 1); + pages.into_iter().collect() + } + } + + fn page_button(&self, prev: Option, index: usize, scope: &Scope) -> Html { + let class = if index == self.current { + "pagination-link button is-warning" + } else { + "pagination-link" + }; + + let button = html! { +
  • + { index + 1 } +
  • + }; + + if matches!(prev, Some(p) if index != (p + 1)) { + html!(<>
  • { '•' }
  • { button }) + } else { + button + } + } + + fn pagination(&self, scope: &Scope) -> Html { + let pages = Self::page_list(self.current, self.count); + let mut prev = None; + + html! { + + } + } + + fn calc_width() -> i32 { + let elem = web_sys::window() + .and_then(|win| win.document()) + .and_then(|doc| doc.document_element()); + if let Some(elem) = elem { + let width = elem.client_width(); + let height = elem.client_height(); + width.min((height - 88) * 16 / 9) + } else { + 0 + } + } +} + +#[cfg(test)] +mod tests { + use super::{SlideShow, BUTTON_COUNT}; + + #[test] + fn page_list() { + let c = BUTTON_COUNT; + for i in 0..c { + assert_eq!(SlideShow::page_list(i, c), (0..c).collect::>()); + } + + let c = 2 * BUTTON_COUNT; + assert_eq!(SlideShow::page_list(0, c), vec![0, 1, 2, c - 1]); + assert_eq!(SlideShow::page_list(1, c), vec![0, 1, 2, c - 1]); + assert_eq!(SlideShow::page_list(2, c), vec![0, 1, 2, 3, c - 1]); + assert_eq!(SlideShow::page_list(3, c), vec![0, 2, 3, 4, c - 1]); + assert_eq!( + SlideShow::page_list(c - 4, c), + vec![0, c - 5, c - 4, c - 3, c - 1] + ); + assert_eq!( + SlideShow::page_list(c - 3, c), + vec![0, c - 4, c - 3, c - 2, c - 1] + ); + assert_eq!(SlideShow::page_list(c - 2, c), vec![0, c - 3, c - 2, c - 1]); + assert_eq!(SlideShow::page_list(c - 1, c), vec![0, c - 3, c - 2, c - 1]); + } +} diff --git a/src/legacy/widgets/svg.rs b/src/legacy/widgets/svg.rs new file mode 100644 index 0000000..afd3802 --- /dev/null +++ b/src/legacy/widgets/svg.rs @@ -0,0 +1,71 @@ +use yew::prelude::*; + +use crate::{ + properties::{Align, VAlign}, + widgets::Frame, +}; + +/// SVG properties. +#[derive(Default, Clone, Properties, PartialEq)] +pub struct Props { + #[prop_or_default] + pub children: Children, + pub width: i32, + pub height: i32, + #[prop_or(Align::Center)] + pub align: Align, + #[prop_or(VAlign::Middle)] + pub valign: VAlign, + #[prop_or(1.0)] + pub scale: f32, + #[prop_or_default] + pub flip_x: bool, + #[prop_or_default] + pub flip_y: bool, +} + +/// SVG widget. +#[function_component] +pub fn Svg(props: &Props) -> Html { + let f = use_context::().unwrap(); + + let scale = if matches!(props.align, Align::Fill) || matches!(props.valign, VAlign::Fill) { + let sx = f.width as f32 / props.width as f32; + let sy = f.height as f32 / props.height as f32; + sx.min(sy) + } else { + props.scale + }; + + let width = (scale * props.width as f32).round() as i32; + let height = (scale * props.height as f32).round() as i32; + + let mut x = match props.align { + Align::Left => f.x, + Align::Center | Align::Fill => f.x + (f.width - width) / 2, + Align::Right => f.x + f.width - width, + }; + let mut y = match props.valign { + VAlign::Top => f.y, + VAlign::Middle | VAlign::Fill => f.y + (f.height - height) / 2, + VAlign::Bottom => f.y + f.height - height, + }; + + let mut sx = scale; + let mut sy = scale; + + if props.flip_x { + sx = -sx; + x += width; + } + if props.flip_y { + sy = -sy; + y += height; + } + + html! { + + { for props.children.iter() } + + } +} diff --git a/src/legacy/widgets/text.rs b/src/legacy/widgets/text.rs new file mode 100644 index 0000000..e8c6b98 --- /dev/null +++ b/src/legacy/widgets/text.rs @@ -0,0 +1,311 @@ +use wasm_bindgen::JsValue; +use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement}; +use yew::{prelude::*, virtual_dom::VNode}; + +use crate::{properties::Color, widgets::Frame}; + +/// Text widget. +pub struct Text { + frame: Frame, + canvas: CanvasRenderingContext2d, + words: Vec, + letter_counters: Vec, + total_letters: usize, + rects: Vec, + expand: i32, + _context_listener: ContextHandle, +} + +struct Rect { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +/// Text properties. +#[derive(Clone, Default, Properties, PartialEq)] +pub struct Props { + #[prop_or_default] + pub children: Children, + #[prop_or_default] + pub bold: bool, + #[prop_or(48)] + pub font_size: i32, + #[prop_or(Color::Black)] + pub color: Color, + #[prop_or_else(|| "sans-serif".to_string())] + pub font: String, + #[prop_or(1.2)] + pub line_height: f32, + #[prop_or(1.4)] + pub indent: f32, + #[prop_or(Color::PaleGreen)] + pub marker_color: Color, + #[prop_or_default] + pub words_read: usize, + #[prop_or_default] + pub lattice: bool, + #[prop_or_default] + pub erase_top: f32, + #[prop_or_default] + pub erase_bottom: f32, + #[prop_or_default] + pub onread: Callback<(usize, usize, usize)>, +} + +pub enum Msg { + ContextUpdated(Frame), + Clicked(i32, i32), +} + +impl Component for Text { + type Message = Msg; + type Properties = Props; + + fn create(ctx: &Context) -> Self { + let p = ctx.props(); + let (frame, _context_listener) = ctx + .link() + .context(ctx.link().callback(Msg::ContextUpdated)) + .expect("No context provided"); + + let canvas = Self::canvas_context(p); + + let expand = Self::text_width(" ", &canvas) / 2 + 1; + + let mut text = Self { + frame, + canvas, + words: Default::default(), + letter_counters: Default::default(), + total_letters: Default::default(), + rects: Default::default(), + expand, + _context_listener, + }; + text.wrap(p); + let letters = text.letter_counters.iter().take(p.words_read).sum(); + p.onread.emit((p.words_read, letters, text.total_letters)); + text + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + let p = ctx.props(); + match msg { + Msg::ContextUpdated(frame) => { + self.frame = frame; + true + } + Msg::Clicked(x, y) => { + if let Some(index) = self.find_word_index(x, y) { + let words_read = index + 1; + let letters = self.letter_counters.iter().take(words_read).sum(); + p.onread.emit((words_read, letters, self.total_letters)); + true + } else { + false + } + } + } + } + + fn view(&self, ctx: &Context) -> Html { + let p = ctx.props(); + let f = &self.frame; + + let onclick = { + let fx = f.fx; + let fy = f.fy; + ctx.link().callback(move |e: MouseEvent| { + let x = (e.offset_x() as f32 * fx).round() as i32; + let y = (e.offset_y() as f32 * fy).round() as i32; + Msg::Clicked(x, y) + }) + }; + + let class = if p.bold { "has-text-weight-bold" } else { "" }; + + let lattice = if p.lattice { + let width = p.font_size / 2; + let dx = 5 * width; + let count = f.width / dx; + html! { + for (0..count).map(|i| html_nested!()) + } + } else { + Default::default() + }; + + let word = |(i, r): (usize, &Rect)| { + html_nested! { + + { self.words[i].clone() } + + } + }; + + let erase = |r: &Rect| { + let erase_top = if p.erase_top > 0.0 { + let h = (p.erase_top * r.height as f32).round() as i32; + html_nested!() + } else { + Default::default() + }; + let erase_bottom = if p.erase_bottom > 0.0 { + let h = (p.erase_bottom * r.height as f32).round() as i32; + html_nested!() + } else { + Default::default() + }; + html_nested! { + <> + { erase_top } + { erase_bottom } + + } + }; + + html! { + <> + + { for self.rects.iter().enumerate().map(word) } + { + if p.erase_top > 0.0 || p.erase_bottom > 0.0 { + self.rects.iter().map(erase).collect::() + } else { + Default::default() + } + } + { lattice } + { + for self.rects.iter().take(p.words_read).map(|r| { + html! { + + } + }) + } + { for self.rects.iter().take(p.words_read).enumerate().map(word) } + + } + } +} + +impl Text { + fn canvas_context(props: &Props) -> CanvasRenderingContext2d { + let doc = web_sys::window() + .and_then(|win| win.document()) + .expect("Unable to get document"); + + let canvas = HtmlCanvasElement::from(JsValue::from(doc.create_element("canvas").unwrap())); + let context = CanvasRenderingContext2d::from(JsValue::from( + canvas.get_context("2d").unwrap().unwrap(), + )); + + let font_weight = if props.bold { 700 } else { 400 }; + context.set_font(&format!( + "{font_weight} {}px {}", + props.font_size, props.font + )); + context + } + + fn text_width(text: &str, canvas: &CanvasRenderingContext2d) -> i32 { + canvas.measure_text(text).unwrap().width() as i32 + } + + fn wrap(&mut self, props: &Props) { + let children = props.children.iter().map(|item| { + if let VNode::VText(node) = item { + node.text + } else { + Default::default() + } + }); + + let mut y = self.frame.y; + for child in children { + let mut words = child.split(' '); + let first_word = words.next().unwrap().to_string(); + self.letter_counters + .push(first_word.chars().filter(|c| c.is_alphabetic()).count()); + + let mut indent = (props.indent * props.font_size as f32).round() as i32; + + let dy = (props.line_height * props.font_size as f32).round() as i32; + let mut x = self.frame.x + indent; + let mut lines = Vec::new(); + let mut line = first_word.clone(); + self.rects.push(Rect { + x, + y, + width: Self::text_width(&first_word, &self.canvas), + height: props.font_size, + }); + x += Self::text_width(&first_word, &self.canvas); + self.words.push(first_word); + for word in words { + self.words.push(word.to_string()); + self.letter_counters + .push(word.chars().filter(|c| c.is_alphabetic()).count()); + if x + Self::text_width(&format!(" {word}"), &self.canvas) + > self.frame.x + self.frame.width + { + lines.push(line.clone()); + line = word.to_string(); + indent = 0; + x = self.frame.x; + y += dy; + } else { + line.push(' '); + x = self.frame.x + indent + Self::text_width(&line, &self.canvas); + line.push_str(word); + } + self.rects.push(Rect { + x, + y, + width: Self::text_width(word, &self.canvas), + height: props.font_size, + }); + x = self.frame.x + indent + Self::text_width(&line, &self.canvas); + } + if !line.is_empty() { + lines.push(line); + } + + y += dy; + } + self.total_letters = self.letter_counters.iter().sum(); + } + + fn find_word_index(&self, x: i32, y: i32) -> Option { + if x < self.frame.x + || x > self.frame.x + self.frame.width + || y < self.frame.y + || y > self.frame.y + self.frame.height + { + return None; + } + self.rects + .iter() + .enumerate() + .find(|(_, r)| x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height) + .map(|(i, _)| i) + } +} diff --git a/src/lib.rs b/src/lib.rs index 6453ee3..cbf0b59 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,84 +2,24 @@ #![warn(missing_docs)] +#[cfg(not(feature = "legacy"))] mod frame; +#[cfg(not(feature = "legacy"))] mod properties; +#[cfg(not(feature = "legacy"))] +mod utils; +#[cfg(not(feature = "legacy"))] mod widgets; +#[cfg(feature = "legacy")] +mod legacy; +#[cfg(feature = "legacy")] +pub use legacy::*; + +#[cfg(not(feature = "legacy"))] pub use frame::*; pub use properties::*; +pub use utils::*; pub use widgets::*; pub use wasm_bindgen::prelude::wasm_bindgen; - -use leptos::*; - -/// Start function. -/// -/// # Example -/// -/// ```no_run -/// use leptos::*; -/// use lerni::*; -/// -/// #[component] -/// pub fn HelloWorld() -> impl IntoView { -/// view! { -/// "Hello, world!" -/// } -/// } -/// -/// #[wasm_bindgen(start)] -/// pub fn main() { -/// lerni::start(HelloWorld); -/// } -/// ``` -pub fn start(f: F) -where - F: Fn() -> N + 'static, - N: IntoView, -{ - leptos::mount_to_body(f); -} - -/// Keyboard key codes. -pub mod keys { - /// Backspace key. - pub const BACKSPACE: u32 = 8; - /// Tab key. - pub const TAB: u32 = 9; - /// Enter key. - pub const ENTER: u32 = 13; - /// Escape key. - pub const ESCAPE: u32 = 27; - /// Space key. - pub const SPACE: u32 = 32; - /// Left arrow key. - pub const ARROW_LEFT: u32 = 37; - /// Up arrow key. - pub const ARROW_UP: u32 = 38; - /// Right arrow key. - pub const ARROW_RIGHT: u32 = 39; - /// Down arrow key. - pub const ARROW_DOWN: u32 = 40; - /// Digit 0 key. - pub const DIGIT_0: u32 = 48; - /// Digit 1 key. - pub const DIGIT_1: u32 = 49; - /// Digit 2 key. - pub const DIGIT_2: u32 = 50; - /// Digit 3 key. - pub const DIGIT_3: u32 = 51; - /// Digit 4 key. - pub const DIGIT_4: u32 = 52; - /// Digit 5 key. - pub const DIGIT_5: u32 = 53; - /// Digit 6 key. - pub const DIGIT_6: u32 = 54; - /// Digit 7 key. - pub const DIGIT_7: u32 = 55; - /// Digit 8 key. - pub const DIGIT_8: u32 = 56; - /// Digit 9 key. - pub const DIGIT_9: u32 = 57; -} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..04d7f6f --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,71 @@ +use leptos::*; + +/// Start function. +/// +/// # Example +/// +/// ```no_run +/// use leptos::*; +/// use lerni::*; +/// +/// #[component] +/// pub fn HelloWorld() -> impl IntoView { +/// view! { +/// "Hello, world!" +/// } +/// } +/// +/// #[wasm_bindgen(start)] +/// pub fn main() { +/// lerni::start(HelloWorld); +/// } +/// ``` +pub fn start(f: F) +where + F: Fn() -> N + 'static, + N: IntoView, +{ + leptos::mount_to_body(f); +} + +/// Keyboard key codes. +pub mod keys { + /// Backspace key. + pub const BACKSPACE: u32 = 8; + /// Tab key. + pub const TAB: u32 = 9; + /// Enter key. + pub const ENTER: u32 = 13; + /// Escape key. + pub const ESCAPE: u32 = 27; + /// Space key. + pub const SPACE: u32 = 32; + /// Left arrow key. + pub const ARROW_LEFT: u32 = 37; + /// Up arrow key. + pub const ARROW_UP: u32 = 38; + /// Right arrow key. + pub const ARROW_RIGHT: u32 = 39; + /// Down arrow key. + pub const ARROW_DOWN: u32 = 40; + /// Digit 0 key. + pub const DIGIT_0: u32 = 48; + /// Digit 1 key. + pub const DIGIT_1: u32 = 49; + /// Digit 2 key. + pub const DIGIT_2: u32 = 50; + /// Digit 3 key. + pub const DIGIT_3: u32 = 51; + /// Digit 4 key. + pub const DIGIT_4: u32 = 52; + /// Digit 5 key. + pub const DIGIT_5: u32 = 53; + /// Digit 6 key. + pub const DIGIT_6: u32 = 54; + /// Digit 7 key. + pub const DIGIT_7: u32 = 55; + /// Digit 8 key. + pub const DIGIT_8: u32 = 56; + /// Digit 9 key. + pub const DIGIT_9: u32 = 57; +}