diff --git a/Makefile b/Makefile
index b7ce29b..3ba1942 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: all clean doc doc-open examples examples-release fmt fmt-check linter prepare pre-commit serve test
+.PHONY: all clean doc doc-open examples examples-release fmt fmt-check fmt-leptos linter prepare pre-commit serve test
all:
@echo ──────────── Build release ────────────────────
@@ -21,13 +21,17 @@ examples:
@./scripts/examples.sh
examples-release:
- @echo ──────────── Build release examples ───────────────────
+ @echo ──────────── Build release examples ───────────
@./scripts/examples.sh release
fmt:
@echo ──────────── Format ───────────────────────────
@cargo fmt --all
+fmt-leptos:
+ @echo ──────────── Format Leptos ────────────────────
+ @find src -name "*.rs" -exec leptosfmt {} \;
+
fmt-check:
@echo ──────────── Check format ─────────────────────
@cargo fmt --all -- --check
diff --git a/dist/index.html b/dist/index.html
index ea7404a..6eb2cfb 100644
--- a/dist/index.html
+++ b/dist/index.html
@@ -67,6 +67,10 @@
⚠️ Work in progress!
Rows & Columns
(source)
+
+ Text
+ (source)
+
Blur
(source)
diff --git a/examples/logo.svg b/examples/logo.svg
new file mode 100644
index 0000000..0ce6443
--- /dev/null
+++ b/examples/logo.svg
@@ -0,0 +1 @@
+
diff --git a/examples/ng_svg.rs b/examples/ng_svg.rs
index 50ead15..d689713 100644
--- a/examples/ng_svg.rs
+++ b/examples/ng_svg.rs
@@ -6,12 +6,12 @@ pub fn SvgExample() -> impl IntoView {
view! {
-
+
-
+
diff --git a/examples/ng_text.rs b/examples/ng_text.rs
index 33127d0..5f9d092 100644
--- a/examples/ng_text.rs
+++ b/examples/ng_text.rs
@@ -1,64 +1,34 @@
-use lerni::widgets::*;
-use wasm_bindgen::prelude::wasm_bindgen;
-use yew::prelude::*;
+use leptos::*;
+use lerni::ng::*;
-#[function_component]
-pub fn TextExample() -> Html {
- let words_read1 = use_state(|| 0);
- let read1 = use_state(|| "".to_string());
- let onread1 = {
- let words_read1 = words_read1.clone();
- let read1 = read1.clone();
- Callback::from(move |(words_read, letters, total)| {
- words_read1.set(words_read);
- read1.set(format!("{letters} / {total}"));
- })
- };
+#[component]
+pub fn TextExample() -> impl IntoView {
+ let (words_read1, _) = create_signal(0);
+ let (words_read2, _) = create_signal(0);
+ let (words_read3, _) = create_signal(0);
- let words_read2 = use_state(|| 0);
- let read2 = use_state(|| "".to_string());
- let onread2 = {
- let words_read2 = words_read2.clone();
- let read2 = read2.clone();
- Callback::from(move |(words_read, letters, total)| {
- words_read2.set(words_read);
- read2.set(format!("{letters} / {total}"));
- })
- };
-
- let words_read3 = use_state(|| 0);
- let read3 = use_state(|| "".to_string());
- let onread3 = {
- let words_read3 = words_read3.clone();
- let read3 = read3.clone();
- Callback::from(move |(words_read, letters, total)| {
- words_read3.set(words_read);
- read3.set(format!("{letters} / {total}"));
- })
- };
-
- html! {
+ view! {
-
-
-
+
+
+
{ "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." }
{ "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." }
-
+
-
-
+
+
{ "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur." }
{ "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." }
-
-
+
+
{ "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur." }
{ "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." }
-
+
@@ -67,5 +37,5 @@ pub fn TextExample() -> Html {
#[wasm_bindgen(start)]
pub fn main() {
- lerni::start::();
+ lerni::ng::start(TextExample);
}
diff --git a/src/ng.rs b/src/ng.rs
index 1651c3d..9114e49 100644
--- a/src/ng.rs
+++ b/src/ng.rs
@@ -33,7 +33,10 @@ mod slideshow;
pub use slideshow::SlideShow;
mod svg;
-pub use svg::Svg;
+pub use svg::{Svg, SvgFile};
+
+mod text;
+pub use text::Text;
use leptos::*;
diff --git a/src/ng/slideshow.rs b/src/ng/slideshow.rs
index c76030a..5133dcd 100644
--- a/src/ng/slideshow.rs
+++ b/src/ng/slideshow.rs
@@ -111,11 +111,7 @@ fn Pagination(page: RwSignal, count: usize) -> impl IntoView {
}
#[component]
-fn PageButton(
- index: usize,
- prev: Option,
- current: RwSignal,
-) -> impl IntoView {
+fn PageButton(index: usize, prev: Option, current: RwSignal) -> impl IntoView {
view! {
<>
diff --git a/src/ng/svg.rs b/src/ng/svg.rs
index 3afc6ee..92b5a08 100644
--- a/src/ng/svg.rs
+++ b/src/ng/svg.rs
@@ -1,6 +1,16 @@
use leptos::*;
-use crate::ng::{use_frame, Align, VAlign};
+use crate::ng::{use_frame, Align, Frame, VAlign};
+
+struct SvgProperties {
+ width: i32,
+ height: i32,
+ align: Align,
+ valign: VAlign,
+ scale: f32,
+ flip_x: bool,
+ flip_y: bool,
+}
/// SVG widget.
#[component]
@@ -15,24 +25,65 @@ pub fn Svg(
children: Children,
) -> impl IntoView {
let f = use_frame();
+ let props = SvgProperties {
+ width,
+ height,
+ align,
+ valign,
+ scale,
+ flip_x,
+ flip_y,
+ };
+ let transform = calc_transform(&f, &props);
+
+ view! { {children()} }
+}
+
+/// SVG-from-file widget.
+#[component]
+pub fn SvgFile(
+ width: i32,
+ height: i32,
+ #[prop(default = Align::Center)] align: Align,
+ #[prop(default = VAlign::Middle)] valign: VAlign,
+ #[prop(default = 1.0)] scale: f32,
+ #[prop(optional)] flip_x: bool,
+ #[prop(optional)] flip_y: bool,
+ src: &'static str,
+) -> impl IntoView {
+ let f = use_frame();
+ let props = SvgProperties {
+ width,
+ height,
+ align,
+ valign,
+ scale,
+ flip_x,
+ flip_y,
+ };
+ let transform = calc_transform(&f, &props);
- let scale = if matches!(align, Align::Fill) || matches!(valign, VAlign::Fill) {
- let sx = f.width as f32 / width as f32;
- let sy = f.height as f32 / height as f32;
+ view! { }
+}
+
+fn calc_transform(f: &Frame, props: &SvgProperties) -> String {
+ 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 {
- scale
+ props.scale
};
- let width = (scale * width as f32).round() as i32;
- let height = (scale * height as f32).round() as i32;
+ let width = (scale * props.width as f32).round() as i32;
+ let height = (scale * props.height as f32).round() as i32;
- let mut x = match align {
+ 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 valign {
+ 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,
@@ -41,14 +92,13 @@ pub fn Svg(
let mut sx = scale;
let mut sy = scale;
- if flip_x {
+ if props.flip_x {
sx = -sx;
x += width;
}
- if flip_y {
+ if props.flip_y {
sy = -sy;
y += height;
}
-
- view! { {children()} }
+ format!("translate({x} {y}) scale({sx} {sy})")
}
diff --git a/src/ng/text.rs b/src/ng/text.rs
new file mode 100644
index 0000000..f995b36
--- /dev/null
+++ b/src/ng/text.rs
@@ -0,0 +1,243 @@
+use leptos::*;
+use wasm_bindgen::JsValue;
+use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
+
+use crate::ng::{use_frame, Color, Frame};
+
+struct TextProperties<'a> {
+ bold: bool,
+ font_size: i32,
+ font: &'a str,
+ line_height: f32,
+ indent: f32,
+}
+
+struct Rect {
+ pub x: i32,
+ pub y: i32,
+ pub width: i32,
+ pub height: i32,
+}
+
+struct Output {
+ pub words: Vec,
+ pub rects: Vec,
+ pub letter_counters: Vec,
+}
+
+/// Text widget.
+#[component]
+pub fn Text(
+ #[prop(optional)] bold: bool,
+ #[prop(default = 48)] font_size: i32,
+ #[prop(default = Color::Black)] color: Color,
+ #[prop(default = "sans-serif".to_string(), into)] font: String,
+ #[prop(default = 1.2)] line_height: f32,
+ #[prop(default = 1.4)] indent: f32,
+ #[prop(default = Color::PaleGreen)] marker_color: Color,
+ #[prop(optional)] words_read: usize,
+ #[prop(optional)] lattice: bool,
+ #[prop(optional)] erase_top: f32,
+ #[prop(optional)] erase_bottom: f32,
+ children: Children,
+) -> impl IntoView {
+ let props = TextProperties {
+ bold,
+ font_size,
+ font: &font,
+ line_height,
+ indent,
+ };
+ let canvas = canvas_context(&props);
+ let children = children().nodes;
+ let f = use_frame();
+ let Output {
+ words,
+ rects,
+ letter_counters,
+ } = wrap(&children, &canvas, &props, &f);
+
+ let _total_letters: usize = letter_counters.iter().sum();
+
+ let word = |(i, r): (usize, &Rect)| {
+ view! {
+
+ {&words[i]}
+
+ }
+ };
+
+ let erase = |r: &Rect| {
+ view! {
+ {(erase_top > 0.0)
+ .then(|| {
+ let h = (erase_top * r.height as f32).round() as i32;
+ view! { }
+ })}
+
+ {(erase_bottom > 0.0)
+ .then(|| {
+ let h = (erase_bottom * r.height as f32).round() as i32;
+ view! {
+
+ }
+ })}
+ }
+ };
+
+ let expand = text_width(" ", &canvas) / 2 + 1;
+
+ view! {
+ {rects.iter().enumerate().map(word).collect_view()}
+ {(erase_top > 0.0 || erase_bottom > 0.0)
+ .then(|| { rects.iter().map(erase).collect_view() })}
+
+ {lattice
+ .then(|| {
+ let width = font_size / 2;
+ let dx = 5 * width;
+ let count = f.width / dx;
+ (0..count)
+ .map(|i| {
+ view! {
+
+ }
+ })
+ .collect_view()
+ })}
+
+ {rects
+ .iter()
+ .take(words_read)
+ .map(|r| {
+ view! {
+
+ }
+ })
+ .collect_view()}
+
+ {rects.iter().take(words_read).enumerate().map(word).collect_view()}
+ }
+}
+
+fn canvas_context(props: &TextProperties) -> 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(
+ children: &[View],
+ canvas: &CanvasRenderingContext2d,
+ props: &TextProperties,
+ frame: &Frame,
+) -> Output {
+ let children = children.iter().map(|item| {
+ if let View::Text(text) = item {
+ text.content.clone()
+ } else {
+ Default::default()
+ }
+ });
+
+ let mut y = frame.y;
+ let mut words = Vec::new();
+ let mut rects = Vec::new();
+ let mut letter_counters = Vec::new();
+ for child in children {
+ let mut sentence_words = child.split(' ');
+ let first_word = sentence_words.next().unwrap().to_string();
+ 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 = frame.x + indent;
+ let mut lines = Vec::new();
+ let mut line = first_word.clone();
+ rects.push(Rect {
+ x,
+ y,
+ width: text_width(&first_word, canvas),
+ height: props.font_size,
+ });
+ x += text_width(&first_word, canvas);
+ words.push(first_word);
+ for word in sentence_words {
+ words.push(word.to_string());
+ letter_counters.push(word.chars().filter(|c| c.is_alphabetic()).count());
+ if x + text_width(&format!(" {word}"), canvas) > frame.x + frame.width {
+ lines.push(line.clone());
+ line = word.to_string();
+ indent = 0;
+ x = frame.x;
+ y += dy;
+ } else {
+ line.push(' ');
+ x = frame.x + indent + text_width(&line, canvas);
+ line.push_str(word);
+ }
+ rects.push(Rect {
+ x,
+ y,
+ width: text_width(word, canvas),
+ height: props.font_size,
+ });
+ x = frame.x + indent + text_width(&line, canvas);
+ }
+ if !line.is_empty() {
+ lines.push(line);
+ }
+
+ y += dy;
+ }
+ Output {
+ rects,
+ words,
+ letter_counters,
+ }
+}