Merge pull request #38 from bodil/dodrio

Dodrio support with a bespoke macro.
This commit is contained in:
Bodil Stokke 2019-03-16 21:32:20 +00:00 committed by GitHub
commit 359a7a66ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1460 additions and 42 deletions

View File

@ -4,5 +4,7 @@ members = [
"macros",
"examples/stdweb",
"examples/rocket",
"examples/dodrio/counter",
"examples/dodrio/todomvc",
"ui",
]

View File

@ -0,0 +1,34 @@
[package]
name = "dodrio-counter"
version = "0.1.0"
authors = ["Nick Fitzgerald <fitzgen@gmail.com>"]
edition = "2018"
[lib]
crate-type = ["cdylib"]
[features]
[dependencies]
console_error_panic_hook = "0.1.6"
console_log = "0.1.2"
dodrio = "0.1.0"
log = "0.4.6"
wasm-bindgen = "0.2.38"
typed-html = { path = "../../../typed-html", features = ["dodrio_macro"] }
[dependencies.web-sys]
version = "0.3.15"
features = [
"console",
"Document",
"Event",
"EventTarget",
"HtmlElement",
"MouseEvent",
"Node",
"Window",
]
[dev-dependencies]
wasm-bindgen-test = "0.2.38"

View File

@ -0,0 +1,21 @@
# Counter
A counter that can be incremented and decremented.
## Source
See `src/lib.rs`.
## Build
```
wasm-pack build --target no-modules
```
## Serve
Use any HTTP server, for example:
```
python -m SimpleHTTPServer
```

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
<title>Counter</title>
</head>
<body>
<script src="pkg/dodrio_counter.js"></script>
<script>
wasm_bindgen("pkg/dodrio_counter_bg.wasm");
</script>
</body>
</html>

View File

@ -0,0 +1,86 @@
#![recursion_limit = "128"]
use dodrio::builder::text;
use dodrio::bumpalo::{self, Bump};
use dodrio::Render;
use log::*;
use typed_html::dodrio;
use wasm_bindgen::prelude::*;
/// A counter that can be incremented and decrmented!
struct Counter {
count: isize,
}
impl Counter {
/// Construct a new, zeroed counter.
fn new() -> Counter {
Counter { count: 0 }
}
/// Increment this counter's count.
fn increment(&mut self) {
self.count += 1;
}
/// Decrement this counter's count.
fn decrement(&mut self) {
self.count -= 1;
}
}
// The `Render` implementation for `Counter`s displays the current count and has
// buttons to increment and decrement the count.
impl Render for Counter {
fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> dodrio::Node<'bump>
where
'a: 'bump,
{
// Stringify the count as a bump-allocated string.
let count = bumpalo::format!(in bump, "{}", self.count);
dodrio!(bump,
<div id="counter">
<button onclick={|root, vdom, _event| {
// Cast the root render component to a `Counter`, since
// we know that's what it is.
let counter = root.unwrap_mut::<Counter>();
// Increment the counter.
counter.increment();
// Since the count has updated, we should re-render the
// counter on the next animation frame.
vdom.schedule_render();
}}>"+"</button>
{ vec![text(count.into_bump_str())] }
<button onclick={|root, vdom, _event| {
// Same as above, but decrementing instead of incrementing.
root.unwrap_mut::<Counter>().decrement();
vdom.schedule_render();
}}>"-"</button>
</div>
)
}
}
#[wasm_bindgen(start)]
pub fn run() {
// Initialize debug logging for if/when things go wrong.
console_error_panic_hook::set_once();
console_log::init_with_level(Level::Trace).expect("should initialize logging OK");
// Get the document's `<body>`.
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let body = document.body().unwrap();
// Construct a new counter component.
let counter = Counter::new();
// Mount our counter component to the `<body>`.
let vdom = dodrio::Vdom::new(&body, counter);
// Run the virtual DOM and its listeners forever.
vdom.forget();
}

View File

@ -0,0 +1,49 @@
[package]
authors = ["Nick Fitzgerald <fitzgen@gmail.com>"]
edition = "2018"
name = "dodrio-todomvc"
version = "0.1.0"
[dependencies]
cfg-if = "0.1.7"
dodrio = "0.1.0"
futures = "0.1.25"
js-sys = "0.3.15"
serde = { features = ["derive"], version = "1.0.89" }
serde_json = "1.0.39"
wasm-bindgen = "0.2.38"
wasm-bindgen-futures = "0.3.15"
typed-html = { path = "../../../typed-html", features = ["dodrio_macro"] }
# Optional dependencies for logging.
console_error_panic_hook = { optional = true, version = "0.1.6" }
console_log = { optional = true, version = "0.1.2" }
log = { optional = true, version = "0.4.6" }
[dependencies.web-sys]
version = "0.3.15"
features = [
"Document",
"Event",
"EventTarget",
"HtmlElement",
"HtmlInputElement",
"KeyboardEvent",
"Location",
"Storage",
"Node",
"Window",
]
[dev-dependencies]
wasm-bindgen-test = "0.2.38"
[features]
logging = [
"console_log",
"console_error_panic_hook",
"log",
]
[lib]
crate-type = ["cdylib"]

View File

@ -0,0 +1,33 @@
# TodoMVC
`dodrio` implementation of the popular [TodoMVC](http://todomvc.com/) app. It
correctly and completely fulfills [the
specification](https://github.com/tastejs/todomvc/blob/master/app-spec.md) to
the best of my knowledge.
## Source
There are a number of modules in this `dodrio` implementation of TodoMVC. The
most important are:
* `src/lib.rs`: The entry point to the application.
* `src/todos.rs`: Definition of `Todos` model and its rendering.
* `src/todo.rs`: Definition of `Todo` model and its rendering.
* `src/controller.rs`: The controller handles UI interactions and translates
them into updates on the model. Finally, it triggers re-rendering after those
updates.
* `src/router.rs`: A simple URL hash-based router.
## Build
```
wasm-pack build --target no-modules
```
## Serve
Use any HTTP server, for example:
```
python -m SimpleHTTPServer
```

View File

@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>dodrio • TodoMVC</title>
<link rel="stylesheet" href="https://unpkg.com/todomvc-common@^1.0.0/base.css">
<link rel="stylesheet" href="https://unpkg.com/todomvc-app-css@^2.0.0/index.css">
</head>
<body>
<section class="todoapp">
</section>
<footer class="info">
<p>Double-click to edit a todo</p>
</footer>
<script src="pkg/dodrio_todomvc.js"></script>
<script>
wasm_bindgen("pkg/dodrio_todomvc_bg.wasm");
</script>
</body>
</html>

View File

@ -0,0 +1,140 @@
//! The controller handles UI events, translates them into updates on the model,
//! and schedules re-renders.
use crate::todo::{Todo, TodoActions};
use crate::todos::{Todos, TodosActions};
use crate::visibility::Visibility;
use dodrio::{RootRender, VdomWeak};
use serde::{Deserialize, Serialize};
use std::ops::{Deref, DerefMut};
/// The controller for the TodoMVC app.
///
/// This `Controller` struct is never actually instantiated. It is only used for
/// its `*Actions` trait implementations, none of which take a `self` parameter.
///
/// One could imagine alternative controller implementations with `*Actions`
/// trait implementations for (e.g.) testing that will assert various expected
/// action methods are called after rendering todo items and sending DOM events.
#[derive(Default, Deserialize, Serialize)]
pub struct Controller;
impl TodosActions for Controller {
fn toggle_all(root: &mut dyn RootRender, vdom: VdomWeak) {
let mut todos = AutoCommitTodos::new(root, vdom);
let all_complete = todos.todos().iter().all(|t| t.is_complete());
for t in todos.todos_mut() {
t.set_complete(!all_complete);
}
}
fn update_draft(root: &mut dyn RootRender, vdom: VdomWeak, draft: String) {
let mut todos = AutoCommitTodos::new(root, vdom);
todos.set_draft(draft);
}
fn finish_draft(root: &mut dyn RootRender, vdom: VdomWeak) {
let mut todos = AutoCommitTodos::new(root, vdom);
let title = todos.take_draft();
let title = title.trim();
if !title.is_empty() {
let id = todos.todos().len();
let new = Todo::new(id, title);
todos.add_todo(new);
}
}
fn change_visibility(root: &mut dyn RootRender, vdom: VdomWeak, vis: Visibility) {
let mut todos = AutoCommitTodos::new(root, vdom);
todos.set_visibility(vis);
}
fn delete_completed(root: &mut dyn RootRender, vdom: VdomWeak) {
let mut todos = AutoCommitTodos::new(root, vdom);
todos.delete_completed();
}
}
impl TodoActions for Controller {
fn toggle_completed(root: &mut dyn RootRender, vdom: VdomWeak, id: usize) {
let mut todos = AutoCommitTodos::new(root, vdom);
let t = &mut todos.todos_mut()[id];
let completed = t.is_complete();
t.set_complete(!completed);
}
fn delete(root: &mut dyn RootRender, vdom: VdomWeak, id: usize) {
let mut todos = AutoCommitTodos::new(root, vdom);
todos.delete_todo(id);
}
fn begin_editing(root: &mut dyn RootRender, vdom: VdomWeak, id: usize) {
let mut todos = AutoCommitTodos::new(root, vdom);
let t = &mut todos.todos_mut()[id];
let desc = t.title().to_string();
t.set_edits(Some(desc));
}
fn update_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize, edits: String) {
let mut todos = AutoCommitTodos::new(root, vdom);
let t = &mut todos.todos_mut()[id];
t.set_edits(Some(edits));
}
fn finish_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize) {
let mut todos = AutoCommitTodos::new(root, vdom);
let t = &mut todos.todos_mut()[id];
if let Some(edits) = t.take_edits() {
let edits = edits.trim();
if edits.is_empty() {
todos.delete_todo(id);
} else {
t.set_title(edits);
}
}
}
fn cancel_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize) {
let mut todos = AutoCommitTodos::new(root, vdom);
let t = &mut todos.todos_mut()[id];
let _ = t.take_edits();
}
}
/// An RAII type that dereferences to a `Todos` and once it is dropped, saves
/// the (presumably just modified) todos to local storage, and schedules a new
/// `dodrio` render.
pub struct AutoCommitTodos<'a> {
todos: &'a mut Todos,
vdom: VdomWeak,
}
impl AutoCommitTodos<'_> {
/// Construct a new `AutoCommitTodos` from the root rendering component and
/// `vdom` handle.
pub fn new(root: &mut dyn RootRender, vdom: VdomWeak) -> AutoCommitTodos {
let todos = root.unwrap_mut::<Todos>();
AutoCommitTodos { todos, vdom }
}
}
impl Drop for AutoCommitTodos<'_> {
fn drop(&mut self) {
self.todos.save_to_local_storage();
self.vdom.schedule_render();
}
}
impl Deref for AutoCommitTodos<'_> {
type Target = Todos;
fn deref(&self) -> &Todos {
&self.todos
}
}
impl DerefMut for AutoCommitTodos<'_> {
fn deref_mut(&mut self) -> &mut Todos {
&mut self.todos
}
}

View File

@ -0,0 +1,7 @@
//! Constants for `KeyboardEvent::key_code`.`
/// The key code for the enter key.
pub const ENTER: u32 = 13;
/// The key code for the escape key.
pub const ESCAPE: u32 = 27;

View File

@ -0,0 +1,61 @@
//! TodoMVC implemented in `dodrio`!
#![recursion_limit = "1024"]
#![deny(missing_docs)]
pub mod controller;
pub mod keys;
pub mod router;
pub mod todo;
pub mod todos;
pub mod utils;
pub mod visibility;
use crate::controller::Controller;
use crate::todos::Todos;
use dodrio::Vdom;
use wasm_bindgen::prelude::*;
/// Run the TodoMVC app!
///
/// Since this is marked `#[wasm_bindgen(start)]` it is automatically invoked
/// once the wasm module instantiated on the Web page.
#[wasm_bindgen(start)]
pub fn run() -> Result<(), JsValue> {
// Set up the logging for debugging if/when things go wrong.
init_logging();
// Grab the todo app container.
let document = utils::document();
let container = document
.query_selector(".todoapp")?
.ok_or_else(|| js_sys::Error::new("could not find `.todoapp` container"))?;
// Create a new `Todos` render component.
let todos = Todos::<Controller>::new();
// Create a virtual DOM and mount it and the `Todos` render component.
let vdom = Vdom::new(&container, todos);
// Start the URL router.
router::start(vdom.weak());
// Run the virtual DOM forever and don't unmount it.
vdom.forget();
Ok(())
}
cfg_if::cfg_if! {
if #[cfg(feature = "logging")] {
fn init_logging() {
console_error_panic_hook::set_once();
console_log::init_with_level(log::Level::Trace)
.expect_throw("should initialize logging OK");
}
} else {
fn init_logging() {
// Do nothing.
}
}
}

View File

@ -0,0 +1,66 @@
//! A simple `#`-fragment router.
use crate::todos::Todos;
use crate::utils;
use crate::visibility::Visibility;
use dodrio::VdomWeak;
use futures::prelude::*;
use std::str::FromStr;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
/// Start the router.
pub fn start(vdom: VdomWeak) {
// Callback fired whenever the URL's hash fragment changes. Keeps the root
// todos collection's visibility in sync with the `#` fragment.
let on_hash_change = move || {
let new_vis = utils::hash()
.and_then(|hash| {
if hash.starts_with("#/") {
Visibility::from_str(&hash[2..]).ok()
} else {
None
}
})
.unwrap_or_else(|| {
// If we couldn't parse a visibility, make sure we canonicalize
// it back to our default hash.
let v = Visibility::default();
utils::set_hash(&format!("#/{}", v));
v
});
wasm_bindgen_futures::spawn_local(
vdom.with_component({
let vdom = vdom.clone();
move |root| {
let todos = root.unwrap_mut::<Todos>();
// If the todos' visibility already matches the event's
// visibility, then there is nothing to do (ha). If they
// don't match, then we need to update the todos' visibility
// and re-render.
if todos.visibility() != new_vis {
todos.set_visibility(new_vis);
vdom.schedule_render();
}
}
})
.map_err(|_| ()),
);
};
// Call it once to handle the initial `#` fragment.
on_hash_change();
// Now listen for hash changes forever.
//
// Note that if we ever intended to unmount our todos app, we would want to
// provide a method for removing this router's event listener and cleaning
// up after ourselves.
let on_hash_change = Closure::wrap(Box::new(on_hash_change) as Box<FnMut()>);
let window = utils::window();
window
.add_event_listener_with_callback("hashchange", on_hash_change.as_ref().unchecked_ref())
.unwrap_throw();
on_hash_change.forget();
}

View File

@ -0,0 +1,154 @@
//! Type definition and `dodrio::Render` implementation for a single todo item.
use crate::keys;
use dodrio::{bumpalo::Bump, Node, Render, RootRender, VdomWeak};
use serde::{Deserialize, Serialize};
use std::marker::PhantomData;
use typed_html::dodrio;
use wasm_bindgen::{prelude::*, JsCast};
/// A single todo item.
#[derive(Serialize, Deserialize)]
pub struct Todo<C> {
id: usize,
title: String,
completed: bool,
#[serde(skip)]
edits: Option<String>,
#[serde(skip)]
_controller: PhantomData<C>,
}
/// Actions on a single todo item that can be triggered from the UI.
pub trait TodoActions {
/// Toggle the completion state of the todo item with the given id.
fn toggle_completed(root: &mut dyn RootRender, vdom: VdomWeak, id: usize);
/// Delete the todo item with the given id.
fn delete(root: &mut dyn RootRender, vdom: VdomWeak, id: usize);
/// Begin editing the todo item with the given id.
fn begin_editing(root: &mut dyn RootRender, vdom: VdomWeak, id: usize);
/// Update the edits for the todo with the given id.
fn update_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize, edits: String);
/// Finish editing the todo with the given id.
fn finish_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize);
/// Cancel editing the todo with the given id.
fn cancel_edits(root: &mut dyn RootRender, vdom: VdomWeak, id: usize);
}
impl<C> Todo<C> {
/// Construct a new `Todo` with the given identifier and title.
pub fn new<S: Into<String>>(id: usize, title: S) -> Self {
let title = title.into();
let completed = false;
let edits = None;
Todo {
id,
title,
completed,
edits,
_controller: PhantomData,
}
}
/// Set this todo item's id.
pub fn set_id(&mut self, id: usize) {
self.id = id;
}
/// Is this `Todo` complete?
pub fn is_complete(&self) -> bool {
self.completed
}
/// Mark the `Todo` as complete or not.
pub fn set_complete(&mut self, to: bool) {
self.completed = to;
}
/// Get this todo's title.
pub fn title(&self) -> &str {
&self.title
}
/// Set this todo item's title.
pub fn set_title<S: Into<String>>(&mut self, title: S) {
self.title = title.into();
}
/// Set the edits for this todo.
pub fn set_edits<S: Into<String>>(&mut self, edits: Option<S>) {
self.edits = edits.map(Into::into);
}
/// Take this todo's edits, leaving `None` in their place.
pub fn take_edits(&mut self) -> Option<String> {
self.edits.take()
}
}
impl<C: TodoActions> Render for Todo<C> {
fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump>
where
'a: 'bump,
{
use dodrio::{builder::text, bumpalo};
use typed_html::types::ClassList;
let id = self.id;
let title = self.edits.as_ref().unwrap_or(&self.title);
dodrio!(bump,
<li class={
let mut class = ClassList::new();
if self.completed {
class.add("completed");
}
if self.edits.is_some() {
class.add("editing");
}
class
}>
<div class="view">
<input class="toggle" type="checkbox" checked={self.completed}
onclick={move |root, vdom, _event| {
C::toggle_completed(root, vdom, id)
}}
/>
<label ondblclick={move |root, vdom, _event| {
C::begin_editing(root, vdom, id)
}}>
{ bumpalo::vec![in &bump; text(title)] }
</label>
<button class="destroy" onclick={move |root, vdom, _event| {
C::delete(root, vdom, id)
}}/>
</div>
<input class="edit" value={title.as_str()} name="title" id={
bumpalo::format!(in bump, "todo-{}", id).into_bump_str()
} oninput={move |root, vdom, event| {
let input = event
.target()
.unwrap_throw()
.unchecked_into::<web_sys::HtmlInputElement>();
C::update_edits(root, vdom, id, input.value());
}} onblur={move |root, vdom, _event| {
C::finish_edits(root, vdom, id)
}} onkeydown={move |root, vdom, event| {
let event = event.unchecked_into::<web_sys::KeyboardEvent>();
match event.key_code() {
keys::ENTER => C::finish_edits(root, vdom, id),
keys::ESCAPE => C::cancel_edits(root, vdom, id),
_ => {}
}
}}/>
</li>
)
}
}

View File

@ -0,0 +1,280 @@
//! Type definitions and `dodrio::Render` implementation for a collection of
//! todo items.
use crate::controller::Controller;
use crate::todo::{Todo, TodoActions};
use crate::visibility::Visibility;
use crate::{keys, utils};
use dodrio::{
builder::text,
bumpalo::{self, Bump},
Node, Render, RootRender, VdomWeak,
};
use serde::{Deserialize, Serialize};
use std::marker::PhantomData;
use std::mem;
use typed_html::dodrio;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
/// A collection of todos.
#[derive(Default, Serialize, Deserialize)]
#[serde(rename = "todos-dodrio", bound = "")]
pub struct Todos<C = Controller> {
todos: Vec<Todo<C>>,
#[serde(skip)]
draft: String,
#[serde(skip)]
visibility: Visibility,
#[serde(skip)]
_controller: PhantomData<C>,
}
/// Actions for `Todos` that can be triggered by UI interactions.
pub trait TodosActions: TodoActions {
/// Toggle the completion state of all todo items.
fn toggle_all(root: &mut dyn RootRender, vdom: VdomWeak);
/// Update the draft todo item's text.
fn update_draft(root: &mut dyn RootRender, vdom: VdomWeak, draft: String);
/// Finish the current draft todo item and add it to the collection of
/// todos.
fn finish_draft(root: &mut dyn RootRender, vdom: VdomWeak);
/// Change the todo item visibility filtering to the given `Visibility`.
fn change_visibility(root: &mut dyn RootRender, vdom: VdomWeak, vis: Visibility);
/// Delete all completed todo items.
fn delete_completed(root: &mut dyn RootRender, vdom: VdomWeak);
}
impl<C> Todos<C> {
/// Construct a new todos set.
///
/// If an existing set is available in local storage, then us that,
/// otherwise create a new set.
pub fn new() -> Self
where
C: Default,
{
Self::from_local_storage().unwrap_or_default()
}
/// Deserialize a set of todos from local storage.
pub fn from_local_storage() -> Option<Self> {
utils::local_storage()
.get("todomvc-dodrio")
.ok()
.and_then(|opt| opt)
.and_then(|json| serde_json::from_str(&json).ok())
}
/// Serialize this set of todos to local storage.
pub fn save_to_local_storage(&self) {
let serialized = serde_json::to_string(self).unwrap_throw();
utils::local_storage()
.set("todomvc-dodrio", &serialized)
.unwrap_throw();
}
/// Add a new todo item to this collection.
pub fn add_todo(&mut self, todo: Todo<C>) {
self.todos.push(todo);
}
/// Delete the todo with the given id.
pub fn delete_todo(&mut self, id: usize) {
self.todos.remove(id);
self.fix_ids();
}
/// Delete all completed todo items.
pub fn delete_completed(&mut self) {
self.todos.retain(|t| !t.is_complete());
self.fix_ids();
}
// Fix all todo identifiers so that they match their index once again.
fn fix_ids(&mut self) {
for (id, todo) in self.todos.iter_mut().enumerate() {
todo.set_id(id);
}
}
/// Get a shared slice of the underlying set of todo items.
pub fn todos(&self) -> &[Todo<C>] {
&self.todos
}
/// Get an exclusive slice of the underlying set of todo items.
pub fn todos_mut(&mut self) -> &mut [Todo<C>] {
&mut self.todos
}
/// Set the draft todo item text.
pub fn set_draft<S: Into<String>>(&mut self, draft: S) {
self.draft = draft.into();
}
/// Take the current draft text and replace it with an empty string.
pub fn take_draft(&mut self) -> String {
mem::replace(&mut self.draft, String::new())
}
/// Get the current visibility for these todos.
pub fn visibility(&self) -> Visibility {
self.visibility
}
/// Set the visibility for these todoS.
pub fn set_visibility(&mut self, vis: Visibility) {
self.visibility = vis;
}
}
/// Rendering helpers.
impl<C: TodosActions> Todos<C> {
fn header<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump>
where
'a: 'bump,
{
dodrio!(bump,
<header class="header">
<h1>"todos"</h1>
<input oninput={|root, vdom, event| {
let input = event
.target()
.unwrap_throw()
.unchecked_into::<web_sys::HtmlInputElement>();
C::update_draft(root, vdom, input.value());
}} onkeydown={|root, vdom, event| {
let event = event.unchecked_into::<web_sys::KeyboardEvent>();
if event.key_code() == keys::ENTER {
C::finish_draft(root, vdom);
}
}} class="new-todo" placeholder="What needs to be done?" autofocus=true value={self.draft.as_str()}/>
</header>
)
}
fn todos_list<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump>
where
'a: 'bump,
{
use dodrio::bumpalo::collections::Vec;
let mut todos = Vec::with_capacity_in(self.todos.len(), bump);
todos.extend(
self.todos
.iter()
.filter(|t| match self.visibility {
Visibility::All => true,
Visibility::Active => !t.is_complete(),
Visibility::Completed => t.is_complete(),
})
.map(|t| t.render(bump)),
);
dodrio!(bump,
<section class="main" style={
if self.todos.is_empty() {
"visibility: hidden"
} else {
"visibility: visible"
}
}>
<input
class="toggle-all" id="toggle-all" type="checkbox" name="toggle"
checked={self.todos.iter().all(|t| t.is_complete())}
onclick={|root, vdom, _event| C::toggle_all(root, vdom)}
/>
<label for="toggle-all">"Mark as complete"</label>
<ul class="todo-list">
{ todos }
</ul>
</section>
)
}
fn footer<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump>
where
'a: 'bump,
{
let completed_count = self.todos.iter().filter(|t| t.is_complete()).count();
let incomplete_count = self.todos.len() - completed_count;
let items_left = if incomplete_count == 1 {
" item left"
} else {
" items left"
};
let incomplete_count = bumpalo::format!(in bump, "{}", incomplete_count);
let clear_completed_text = bumpalo::format!(
in bump,
"Clear completed ({})",
self.todos.iter().filter(|t| t.is_complete()).count()
);
dodrio!(bump,
<footer class="footer" hidden={self.todos.is_empty()}>
<span class="todo-count">
<strong>{
bumpalo::vec![in &bump; text(incomplete_count.into_bump_str())]
}</strong>
{ bumpalo::vec![in &bump; text(items_left)] }
</span>
<ul class="filters">
{ bumpalo::vec![in &bump;
self.visibility_swap(bump, "#/", Visibility::All),
self.visibility_swap(bump, "#/active", Visibility::Active),
self.visibility_swap(bump, "#/completed", Visibility::Completed)
] }
</ul>
<button class="clear-completed" hidden={completed_count == 0} onclick={|root, vdom, _event| {
C::delete_completed(root, vdom);
}}>{ bumpalo::vec![in &bump; text(clear_completed_text.into_bump_str())] }</button>
</footer>
)
}
fn visibility_swap<'a, 'bump>(
&'a self,
bump: &'bump Bump,
url: &'static str,
target_vis: Visibility,
) -> Node<'bump>
where
'a: 'bump,
{
dodrio!(bump,
<li onclick={move |root, vdom, _event| {
C::change_visibility(root, vdom, target_vis);
}}>
<a href={url} class={
if self.visibility == target_vis {
"selected"
} else {
""
}
}>{ bumpalo::vec![in &bump; text(target_vis.label())] }</a>
</li>
)
}
}
impl<C: TodosActions> Render for Todos<C> {
fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump>
where
'a: 'bump,
{
dodrio!(bump,
<div>{ bumpalo::vec![in &bump;
self.header(bump), self.todos_list(bump), self.footer(bump)
] }</div>
)
}
}

View File

@ -0,0 +1,32 @@
//! Small utility functions.
use wasm_bindgen::UnwrapThrowExt;
/// Get the top-level window.
pub fn window() -> web_sys::Window {
web_sys::window().unwrap_throw()
}
/// Get the current location hash, if any.
pub fn hash() -> Option<String> {
window()
.location()
.hash()
.ok()
.and_then(|h| if h.is_empty() { None } else { Some(h) })
}
/// Set the current location hash.
pub fn set_hash(hash: &str) {
window().location().set_hash(hash).unwrap_throw();
}
/// Get the top-level document.
pub fn document() -> web_sys::Document {
window().document().unwrap_throw()
}
/// Get the top-level window's local storage.
pub fn local_storage() -> web_sys::Storage {
window().local_storage().unwrap_throw().unwrap_throw()
}

View File

@ -0,0 +1,51 @@
//! Visibility filtering.
use std::fmt;
use std::str::FromStr;
/// The visibility filtering for todo items.
#[derive(Clone, Copy, Eq, PartialEq)]
pub enum Visibility {
/// Show all todos.
All,
/// Show only active, incomplete todos.
Active,
/// Show only inactive, completed todos.
Completed,
}
impl Default for Visibility {
fn default() -> Visibility {
Visibility::All
}
}
impl FromStr for Visibility {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"all" => Ok(Visibility::All),
"active" => Ok(Visibility::Active),
"completed" => Ok(Visibility::Completed),
_ => Err(()),
}
}
}
impl fmt::Display for Visibility {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.label().to_lowercase())
}
}
impl Visibility {
/// Get a string label for this visibility.
pub fn label(self) -> &'static str {
match self {
Visibility::All => "All",
Visibility::Active => "Active",
Visibility::Completed => "Completed",
}
}
}

View File

@ -25,3 +25,6 @@ quote = "0.6.10"
[build-dependencies]
lalrpop = "0.16.1"
version_check = "0.1.5"
[features]
dodrio = []

View File

@ -21,11 +21,11 @@ pub fn global_attrs(span: Span) -> StringyMap<Ident, TokenStream> {
insert("accesskey", "String");
insert("autocapitalize", "String");
insert("contenteditable", "bool");
insert("contenteditable", "crate::types::Bool");
insert("contextmenu", "crate::types::Id");
insert("dir", "crate::types::TextDirection");
insert("draggable", "bool");
insert("hidden", "bool");
insert("draggable", "crate::types::Bool");
insert("hidden", "crate::types::Bool");
insert("is", "String");
insert("lang", "crate::types::LanguageTag");
insert("style", "String");

View File

@ -174,6 +174,10 @@ pub NodeWithType: (Node, Option<Vec<Token>>) = {
},
};
pub NodeWithBump: (Ident, Node) = {
<Ident> "," <Node>,
};
// The declare macro

View File

@ -60,6 +60,26 @@ impl Node {
}
}
}
#[cfg(feature = "dodrio")]
pub fn into_dodrio_token_stream(
self,
bump: &Ident,
is_req_child: bool,
) -> Result<TokenStream, TokenStream> {
match self {
Node::Element(el) => el.into_dodrio_token_stream(bump, is_req_child),
Node::Text(text) => Ok(dodrio_text_node(text)),
Node::Block(group) => {
let span = group.span();
let error =
"you cannot use a block as a top level element or a required child element";
Err(quote_spanned! { span=>
compile_error! { #error }
})
}
}
}
}
#[derive(Clone)]
@ -69,6 +89,12 @@ pub struct Element {
pub children: Vec<Node>,
}
#[cfg(feature = "dodrio")]
fn dodrio_text_node(text: Literal) -> TokenStream {
let text = TokenTree::Literal(text);
quote!(dodrio::builder::text(#text))
}
fn extract_data_attrs(attrs: &mut StringyMap<Ident, TokenTree>) -> StringyMap<String, TokenTree> {
let mut data = StringyMap::new();
let keys: Vec<Ident> = attrs.keys().cloned().collect();
@ -119,6 +145,16 @@ fn is_string_literal(literal: &Literal) -> bool {
literal.to_string().starts_with('"')
}
#[allow(dead_code)]
fn stringify_ident(ident: &Ident) -> String {
let s = ident.to_string();
if s.starts_with("r#") {
s[2..].to_string()
} else {
s
}
}
impl Element {
fn into_token_stream(mut self, ty: &Option<Vec<Token>>) -> Result<TokenStream, TokenStream> {
let name = self.name;
@ -249,9 +285,233 @@ impl Element {
}
))
}
#[cfg(feature = "dodrio")]
fn into_dodrio_token_stream(
mut self,
bump: &Ident,
is_req_child: bool,
) -> Result<TokenStream, TokenStream> {
let name = self.name;
let name_str = stringify_ident(&name);
let typename: TokenTree = ident::new_raw(&name_str, name.span()).into();
let tag_name = TokenTree::from(Literal::string(&name_str));
let req_names = required_children(&name_str);
if req_names.len() > self.children.len() {
let span = name.span();
let error = format!(
"<{}> requires {} children but there are only {}",
name_str,
req_names.len(),
self.children.len()
);
return Err(quote_spanned! {span=>
compile_error! { #error }
});
}
let events = extract_event_handlers(&mut self.attributes);
let data_attrs = extract_data_attrs(&mut self.attributes);
let attrs = self.attributes.iter().map(|(key, value)| {
(
key.to_string(),
TokenTree::Ident(ident::new_raw(&key.to_string(), key.span())),
value,
)
});
let opt_children = self.children.split_off(req_names.len());
let req_children = self
.children
.into_iter()
.map(|node| node.into_dodrio_token_stream(bump, true))
.collect::<Result<Vec<TokenStream>, TokenStream>>()?;
let mut set_attrs = TokenStream::new();
for (attr_str, key, value) in attrs {
match value {
TokenTree::Literal(lit) if is_string_literal(lit) => {
let mut eprintln_msg = "ERROR: ".to_owned();
#[cfg(can_show_location_of_runtime_parse_error)]
{
let span = lit.span();
eprintln_msg += &format!(
"{}:{}:{}: ",
span.unstable()
.source_file()
.path()
.to_str()
.unwrap_or("unknown"),
span.unstable().start().line,
span.unstable().start().column
);
}
eprintln_msg += &format!(
"<{} {}={}> failed to parse attribute value: {{}}",
name_str, attr_str, lit,
);
#[cfg(not(can_show_location_of_runtime_parse_error))]
{
eprintln_msg += "\nERROR: rebuild with nightly to print source location";
}
set_attrs.extend(quote!(
element.attrs.#key = Some(#lit.parse().unwrap_or_else(|err| {
eprintln!(#eprintln_msg, err);
panic!("failed to parse string literal");
}));
));
}
value => {
let value = process_value(value);
set_attrs.extend(quote!(
element.attrs.#key = Some(std::convert::Into::into(#value));
));
}
}
}
let attr_max_len = self.attributes.len() + data_attrs.len();
let mut builder = quote!(
let mut attr_list = dodrio::bumpalo::collections::Vec::with_capacity_in(#attr_max_len, #bump);
);
// Build the attributes.
for (key, _) in self.attributes.iter() {
let key_str = stringify_ident(key);
let key = ident::new_raw(&key_str, key.span());
let key_str = TokenTree::from(Literal::string(&key_str));
builder.extend(quote!(
let attr_value = dodrio::bumpalo::format!(
in &#bump, "{}", element.attrs.#key.unwrap());
if !attr_value.is_empty() {
attr_list.push(dodrio::builder::attr(#key_str, attr_value.into_bump_str()));
}
));
}
for (key, value) in data_attrs
.iter()
.map(|(k, v)| (TokenTree::from(Literal::string(&k)), v.clone()))
{
builder.extend(quote!(
attr_list.push(dodrio::builder::attr(
#key,
dodrio::bumpalo::format!(
in &#bump, "{}", #value
).into_bump_str()
));
));
}
builder.extend(quote!(
let mut node = dodrio::builder::ElementBuilder::new(#bump, #tag_name)
.attributes(attr_list)
));
// Build an array of event listeners.
let mut event_array = TokenStream::new();
for (key, value) in events.iter() {
let key = TokenTree::from(Literal::string(&stringify_ident(key)));
let value = process_value(value);
event_array.extend(quote!(
dodrio::builder::on(&#bump, #key, #value),
));
}
builder.extend(quote!(
.listeners([#event_array]);
));
// And finally an array of children, or a stream of builder commands
// if we have a group inside the child list.
let mut child_array = TokenStream::new();
let mut child_builder = TokenStream::new();
let mut static_children = true;
// Walk through required children and build them inline.
let mut make_req_children = TokenStream::new();
let mut arg_list = Vec::new();
for (index, child) in req_children.into_iter().enumerate() {
let req_child = TokenTree::from(ident::new_raw(
&format!("req_child_{}", index),
Span::call_site(),
));
let child_node = TokenTree::from(ident::new_raw(
&format!("child_node_{}", index),
Span::call_site(),
));
make_req_children.extend(quote!(
let (#req_child, #child_node) = #child;
));
child_array.extend(quote!(
#child_node,
));
child_builder.extend(quote!(
node = node.child(#child_node);
));
arg_list.push(req_child);
}
// Build optional children, test if we have groups.
for child_node in opt_children {
let child = match child_node {
Node::Text(text) => dodrio_text_node(text),
Node::Element(el) => el.into_dodrio_token_stream(bump, false)?,
Node::Block(group) => {
static_children = false;
let group: TokenTree = group.into();
child_builder.extend(quote!(
for child in #group.into_iter() {
node = node.child(child);
}
));
continue;
}
};
child_array.extend(quote!(
#child,
));
child_builder.extend(quote!(
node = node.child(#child);
));
}
if static_children {
builder.extend(quote!(
let node = node.children([#child_array]);
));
} else {
builder.extend(child_builder);
}
builder.extend(quote!(node.finish()));
if is_req_child {
builder = quote!(
(element, {#builder})
);
}
let mut args = TokenStream::new();
for arg in arg_list {
args.extend(quote!( #arg, ));
}
Ok(quote!(
{
#make_req_children
let mut element: typed_html::elements::#typename<typed_html::output::dodrio::Dodrio> =
typed_html::elements::#typename::new(#args);
#set_attrs
#builder
}
))
}
}
// FIXME report a decent error when the macro contains multiple top level elements
pub fn expand_html(input: &[Token]) -> Result<(Node, Option<Vec<Token>>), ParseError> {
grammar::NodeWithTypeParser::new().parse(Lexer::new(input))
}
#[allow(dead_code)]
pub fn expand_dodrio(input: &[Token]) -> Result<(Ident, Node), ParseError> {
grammar::NodeWithBumpParser::new().parse(Lexer::new(input))
}

View File

@ -1,12 +1,7 @@
#![recursion_limit = "128"]
#![cfg_attr(can_show_location_of_runtime_parse_error, feature(proc_macro_span))]
extern crate ansi_term;
extern crate lalrpop_util;
extern crate proc_macro;
extern crate proc_macro2;
extern crate proc_macro_hack;
extern crate quote;
use proc_macro::TokenStream;
use proc_macro_hack::proc_macro_hack;
@ -39,6 +34,26 @@ pub fn html(input: TokenStream) -> TokenStream {
})
}
/// Construct a Dodrio node.
///
/// See the crate documentation for [`typed_html`][typed_html].
///
/// [typed_html]: ../typed_html/index.html
#[cfg(feature = "dodrio")]
#[proc_macro_hack]
pub fn dodrio(input: TokenStream) -> TokenStream {
let stream = lexer::unroll_stream(input.into(), false);
let result = html::expand_dodrio(&stream);
TokenStream::from(match result {
Err(err) => error::parse_error(&stream, &err),
Ok((bump, node)) => match node.into_dodrio_token_stream(&bump, false) {
Err(err) => err,
// Ok(success) => {println!("{}", success); panic!()},
Ok(success) => success,
},
})
}
/// This macro is used by `typed_html` internally to generate types and
/// implementations for HTML elements.
#[proc_macro]

View File

@ -28,6 +28,11 @@ where
pub fn keys(&self) -> impl Iterator<Item = &K> {
self.0.values().map(|(k, _)| k)
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.0.len()
}
}
impl<K, V, OK, OV> From<Vec<(OK, OV)>> for StringyMap<K, V>

View File

@ -25,3 +25,8 @@ htmlescape = "0.3.1"
proc-macro-hack = "0.5.4"
proc-macro-nested = "0.1.3"
stdweb = { version = "0.4.14", optional = true }
dodrio = { version = "0.1.0", optional = true }
web-sys = { version = "0.3.16", optional = true, features = ["Event", "Element"] }
[features]
dodrio_macro = ["web-sys", "dodrio", "typed-html-macros/dodrio"]

View File

@ -100,11 +100,11 @@ declare_elements!{
article in [FlowContent, SectioningContent] with FlowContent;
aside in [FlowContent, SectioningContent] with FlowContent;
audio {
autoplay: bool,
controls: bool,
autoplay: Bool,
controls: Bool,
crossorigin: CrossOrigin,
loop: bool,
muted: bool,
loop: Bool,
muted: Bool,
preload: Preload,
src: Uri,
} in [FlowContent, PhrasingContent, EmbeddedContent] with MediaContent;
@ -116,13 +116,13 @@ declare_elements!{
} in [FlowContent] with FlowContent;
br in [FlowContent, PhrasingContent];
button {
autofocus: bool,
disabled: bool,
autofocus: Bool,
disabled: Bool,
form: Id,
formaction: Uri,
formenctype: FormEncodingType,
formmethod: FormMethod,
formnovalidate: bool,
formnovalidate: Bool,
formtarget: Target,
name: Id,
type: ButtonType,
@ -143,7 +143,7 @@ declare_elements!{
datetime: Datetime,
} in [FlowContent, PhrasingContent] with FlowContent;
details {
open: bool,
open: Bool,
} in [FlowContent, SectioningContent, InteractiveContent] with [summary] FlowContent;
dfn in [FlowContent, PhrasingContent] with PhrasingContent;
div in [FlowContent] with FlowContent;
@ -167,7 +167,7 @@ declare_elements!{
enctype: FormEncodingType,
method: FormMethod,
name: Id,
novalidate: bool,
novalidate: Bool,
target: Target,
} in [FlowContent] with FlowContent;
h1 in [FlowContent, HeadingContent, HGroupContent] with PhrasingContent;
@ -182,8 +182,8 @@ declare_elements!{
i in [FlowContent, PhrasingContent] with PhrasingContent;
iframe {
allow: FeaturePolicy,
allowfullscreen: bool,
allowpaymentrequest: bool,
allowfullscreen: Bool,
allowpaymentrequest: Bool,
height: usize,
name: Id,
referrerpolicy: ReferrerPolicy,
@ -197,7 +197,7 @@ declare_elements!{
crossorigin: CrossOrigin,
decoding: ImageDecoding,
height: usize,
ismap: bool,
ismap: Bool,
sizes: SpacedList<String>, // FIXME it's not really just a string
src: Uri,
srcset: String, // FIXME this is much more complicated
@ -205,16 +205,39 @@ declare_elements!{
width: usize,
} in [FlowContent, PhrasingContent, EmbeddedContent];
input {
accept: String,
alt: String,
autocomplete: String,
autofocus: bool,
disabled: bool,
autofocus: Bool,
capture: String,
checked: Bool,
disabled: Bool,
form: Id,
formaction: Uri,
formenctype: FormEncodingType,
formmethod: FormDialogMethod,
formnovalidate: Bool,
formtarget: Target,
height: isize,
list: Id,
max: String,
maxlength: usize,
min: String,
minlength: usize,
multiple: Bool,
name: Id,
required: bool,
pattern: String,
placeholder: String,
readonly: Bool,
required: Bool,
size: usize,
spellcheck: Bool,
src: Uri,
step: String,
tabindex: usize,
type: InputType,
value: String,
width: isize,
} in [FlowContent, FormContent, PhrasingContent];
ins {
cite: Uri,
@ -248,12 +271,12 @@ declare_elements!{
height: usize,
name: Id,
type: Mime,
typemustmatch: bool,
typemustmatch: Bool,
usemap: String, // TODO should be a fragment starting with '#'
width: usize,
} in [FlowContent, PhrasingContent, EmbeddedContent, InteractiveContent, FormContent] with param;
ol {
reversed: bool,
reversed: Bool,
start: isize,
type: OrderedListType,
} in [FlowContent] with li;
@ -275,11 +298,11 @@ declare_elements!{
s in [FlowContent, PhrasingContent] with PhrasingContent;
samp in [FlowContent, PhrasingContent] with PhrasingContent;
script {
async: bool,
async: Bool,
crossorigin: CrossOrigin,
defer: bool,
defer: Bool,
integrity: Integrity,
nomodule: bool,
nomodule: Bool,
nonce: Nonce,
src: Uri,
text: String,
@ -288,12 +311,12 @@ declare_elements!{
section in [FlowContent, SectioningContent] with FlowContent;
select {
autocomplete: String,
autofocus: bool,
disabled: bool,
autofocus: Bool,
disabled: Bool,
form: Id,
multiple: bool,
multiple: Bool,
name: Id,
required: bool,
required: Bool,
size: usize,
} in [FlowContent, PhrasingContent, InteractiveContent, FormContent] with SelectContent;
small in [FlowContent, PhrasingContent] with PhrasingContent;
@ -305,16 +328,16 @@ declare_elements!{
template in [MetadataContent, FlowContent, PhrasingContent, TableColumnContent] with Node;
textarea {
autocomplete: OnOff,
autofocus: bool,
autofocus: Bool,
cols: usize,
disabled: bool,
disabled: Bool,
form: Id,
maxlength: usize,
minlength: usize,
name: Id,
placeholder: String,
readonly: bool,
required: bool,
readonly: Bool,
required: Bool,
rows: usize,
spellcheck: BoolOrDefault,
wrap: Wrap,
@ -331,7 +354,7 @@ declare_elements!{
area {
alt: String,
coords: String, // TODO could perhaps be validated
download: bool,
download: Bool,
href: Uri,
hreflang: LanguageTag,
ping: SpacedList<Uri>,
@ -354,13 +377,13 @@ declare_elements!{
value: isize,
} with FlowContent;
option {
disabled: bool,
disabled: Bool,
label: String,
selected: bool,
selected: Bool,
value: String,
} in [SelectContent] with TextNode;
optgroup {
disabled: bool,
disabled: Bool,
label: String,
} in [SelectContent] with option;
param {
@ -389,7 +412,7 @@ declare_elements!{
thead in [TableContent] with tr;
tr in [TableContent] with TableColumnContent;
track {
default: bool,
default: Bool,
kind: VideoKind,
label: String,
src: Uri,
@ -407,7 +430,7 @@ declare_elements!{
loop: isize,
scrollamount: usize,
scrolldelay: usize,
truespeed: bool,
truespeed: Bool,
vspace: String, // FIXME size
width: String, // FIXME size
} in [FlowContent, PhrasingContent] with PhrasingContent;

View File

@ -199,6 +199,10 @@ use std::fmt::Display;
#[proc_macro_hack(support_nested)]
pub use typed_html_macros::html;
#[cfg(feature = "dodrio_macro")]
#[proc_macro_hack(support_nested)]
pub use typed_html_macros::dodrio;
pub mod dom;
pub mod elements;
pub mod events;

View File

@ -0,0 +1,20 @@
use std::fmt::{Display, Error, Formatter};
use crate::OutputType;
/// DOM output using the Dodrio virtual DOM
pub struct Dodrio;
impl OutputType for Dodrio {
type Events = Events;
type EventTarget = ();
type EventListenerHandle = ();
}
#[derive(Default)]
pub struct Events;
impl Display for Events {
fn fmt(&self, _: &mut Formatter) -> Result<(), Error> {
unimplemented!()
}
}

View File

@ -1,2 +1,4 @@
#[cfg(feature = "stdweb")]
pub mod stdweb;
#[cfg(feature = "dodrio_macro")]
pub mod dodrio;

View File

@ -69,6 +69,12 @@ impl From<Id> for Class {
}
}
impl<'a> From<&'a str> for Class {
fn from(str: &'a str) -> Self {
Class::from_str(str).unwrap()
}
}
impl Display for Class {
fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
Display::fmt(&self.0, f)

View File

@ -58,6 +58,12 @@ impl FromStr for Id {
}
}
impl<'a> From<&'a str> for Id {
fn from(str: &'a str) -> Self {
Id::from_str(str).unwrap()
}
}
impl From<Class> for Id {
fn from(c: Class) -> Self {
Id(c.to_string())

View File

@ -104,6 +104,16 @@ pub enum FormMethod {
Get,
}
#[derive(EnumString, Display, PartialEq, Eq, PartialOrd, Ord, AsRefStr, AsStaticStr)]
pub enum FormDialogMethod {
#[strum(to_string = "post")]
Post,
#[strum(to_string = "get")]
Get,
#[strum(to_string = "dialog")]
Dialog,
}
#[derive(EnumString, Display, PartialEq, Eq, PartialOrd, Ord, AsRefStr, AsStaticStr)]
pub enum HTTPEquiv {
#[strum(to_string = "content-security-policy")]

View File

@ -33,6 +33,11 @@ impl<A: Ord> SpacedSet<A> {
pub fn new() -> Self {
SpacedSet(BTreeSet::new())
}
/// Add a value to the `SpacedSet`.
pub fn add<T: Into<A>>(&mut self, value: T) -> bool {
self.0.insert(value.into())
}
}
impl<A: Ord> Default for SpacedSet<A> {