diff --git a/Cargo.toml b/Cargo.toml index 77c44e7..c73cdc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,5 +4,7 @@ members = [ "macros", "examples/stdweb", "examples/rocket", + "examples/dodrio/counter", + "examples/dodrio/todomvc", "ui", ] diff --git a/examples/dodrio/counter/Cargo.toml b/examples/dodrio/counter/Cargo.toml new file mode 100644 index 0000000..5de2d14 --- /dev/null +++ b/examples/dodrio/counter/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "dodrio-counter" +version = "0.1.0" +authors = ["Nick Fitzgerald "] +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" diff --git a/examples/dodrio/counter/README.md b/examples/dodrio/counter/README.md new file mode 100644 index 0000000..8701b81 --- /dev/null +++ b/examples/dodrio/counter/README.md @@ -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 +``` diff --git a/examples/dodrio/counter/index.html b/examples/dodrio/counter/index.html new file mode 100644 index 0000000..78ec376 --- /dev/null +++ b/examples/dodrio/counter/index.html @@ -0,0 +1,13 @@ + + + + + Counter + + + + + + diff --git a/examples/dodrio/counter/src/lib.rs b/examples/dodrio/counter/src/lib.rs new file mode 100644 index 0000000..44d2841 --- /dev/null +++ b/examples/dodrio/counter/src/lib.rs @@ -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, +
+ + { vec![text(count.into_bump_str())] } + +
+ ) + } +} + +#[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 ``. + 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 ``. + let vdom = dodrio::Vdom::new(&body, counter); + + // Run the virtual DOM and its listeners forever. + vdom.forget(); +} diff --git a/examples/dodrio/todomvc/Cargo.toml b/examples/dodrio/todomvc/Cargo.toml new file mode 100644 index 0000000..52ae20c --- /dev/null +++ b/examples/dodrio/todomvc/Cargo.toml @@ -0,0 +1,49 @@ +[package] +authors = ["Nick Fitzgerald "] +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"] diff --git a/examples/dodrio/todomvc/README.md b/examples/dodrio/todomvc/README.md new file mode 100644 index 0000000..0f63c67 --- /dev/null +++ b/examples/dodrio/todomvc/README.md @@ -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 +``` diff --git a/examples/dodrio/todomvc/index.html b/examples/dodrio/todomvc/index.html new file mode 100644 index 0000000..89131e4 --- /dev/null +++ b/examples/dodrio/todomvc/index.html @@ -0,0 +1,21 @@ + + + + + + dodrio • TodoMVC + + + + +
+
+
+

Double-click to edit a todo

+
+ + + + diff --git a/examples/dodrio/todomvc/src/controller.rs b/examples/dodrio/todomvc/src/controller.rs new file mode 100644 index 0000000..a347afa --- /dev/null +++ b/examples/dodrio/todomvc/src/controller.rs @@ -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::(); + 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 + } +} diff --git a/examples/dodrio/todomvc/src/keys.rs b/examples/dodrio/todomvc/src/keys.rs new file mode 100644 index 0000000..0f4d7aa --- /dev/null +++ b/examples/dodrio/todomvc/src/keys.rs @@ -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; diff --git a/examples/dodrio/todomvc/src/lib.rs b/examples/dodrio/todomvc/src/lib.rs new file mode 100755 index 0000000..124fa75 --- /dev/null +++ b/examples/dodrio/todomvc/src/lib.rs @@ -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::::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. + } + } +} diff --git a/examples/dodrio/todomvc/src/router.rs b/examples/dodrio/todomvc/src/router.rs new file mode 100644 index 0000000..f3242c4 --- /dev/null +++ b/examples/dodrio/todomvc/src/router.rs @@ -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::(); + // 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); + let window = utils::window(); + window + .add_event_listener_with_callback("hashchange", on_hash_change.as_ref().unchecked_ref()) + .unwrap_throw(); + on_hash_change.forget(); +} diff --git a/examples/dodrio/todomvc/src/todo.rs b/examples/dodrio/todomvc/src/todo.rs new file mode 100644 index 0000000..589a46a --- /dev/null +++ b/examples/dodrio/todomvc/src/todo.rs @@ -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 { + id: usize, + title: String, + completed: bool, + + #[serde(skip)] + edits: Option, + + #[serde(skip)] + _controller: PhantomData, +} + +/// 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 Todo { + /// Construct a new `Todo` with the given identifier and title. + pub fn new>(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>(&mut self, title: S) { + self.title = title.into(); + } + + /// Set the edits for this todo. + pub fn set_edits>(&mut self, edits: Option) { + self.edits = edits.map(Into::into); + } + + /// Take this todo's edits, leaving `None` in their place. + pub fn take_edits(&mut self) -> Option { + self.edits.take() + } +} + +impl Render for Todo { + 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, +
  • +
    + + +
    + (); + 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::(); + match event.key_code() { + keys::ENTER => C::finish_edits(root, vdom, id), + keys::ESCAPE => C::cancel_edits(root, vdom, id), + _ => {} + } + }}/> +
  • + ) + } +} diff --git a/examples/dodrio/todomvc/src/todos.rs b/examples/dodrio/todomvc/src/todos.rs new file mode 100644 index 0000000..71b598f --- /dev/null +++ b/examples/dodrio/todomvc/src/todos.rs @@ -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 { + todos: Vec>, + + #[serde(skip)] + draft: String, + + #[serde(skip)] + visibility: Visibility, + + #[serde(skip)] + _controller: PhantomData, +} + +/// 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 Todos { + /// 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 { + 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) { + 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] { + &self.todos + } + + /// Get an exclusive slice of the underlying set of todo items. + pub fn todos_mut(&mut self) -> &mut [Todo] { + &mut self.todos + } + + /// Set the draft todo item text. + pub fn set_draft>(&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 Todos { + fn header<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump> + where + 'a: 'bump, + { + dodrio!(bump, +
    +

    "todos"

    + (); + C::update_draft(root, vdom, input.value()); + }} onkeydown={|root, vdom, event| { + let event = event.unchecked_into::(); + 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()}/> +
    + ) + } + + 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, +
    + + +
      + { todos } +
    +
    + ) + } + + 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, +
    + + { + bumpalo::vec![in ≎ text(incomplete_count.into_bump_str())] + } + { bumpalo::vec![in ≎ text(items_left)] } + +
      + { bumpalo::vec![in ≎ + self.visibility_swap(bump, "#/", Visibility::All), + self.visibility_swap(bump, "#/active", Visibility::Active), + self.visibility_swap(bump, "#/completed", Visibility::Completed) + ] } +
    + +
    + ) + } + + fn visibility_swap<'a, 'bump>( + &'a self, + bump: &'bump Bump, + url: &'static str, + target_vis: Visibility, + ) -> Node<'bump> + where + 'a: 'bump, + { + dodrio!(bump, +
  • + { bumpalo::vec![in ≎ text(target_vis.label())] } +
  • + ) + } +} + +impl Render for Todos { + fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump> + where + 'a: 'bump, + { + dodrio!(bump, +
    { bumpalo::vec![in ≎ + self.header(bump), self.todos_list(bump), self.footer(bump) + ] }
    + ) + } +} diff --git a/examples/dodrio/todomvc/src/utils.rs b/examples/dodrio/todomvc/src/utils.rs new file mode 100644 index 0000000..0eaced2 --- /dev/null +++ b/examples/dodrio/todomvc/src/utils.rs @@ -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 { + 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() +} diff --git a/examples/dodrio/todomvc/src/visibility.rs b/examples/dodrio/todomvc/src/visibility.rs new file mode 100644 index 0000000..ad53c14 --- /dev/null +++ b/examples/dodrio/todomvc/src/visibility.rs @@ -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 { + 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", + } + } +} diff --git a/macros/Cargo.toml b/macros/Cargo.toml index e81e109..bbf2811 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -25,3 +25,6 @@ quote = "0.6.10" [build-dependencies] lalrpop = "0.16.1" version_check = "0.1.5" + +[features] +dodrio = [] diff --git a/macros/src/config.rs b/macros/src/config.rs index 15b8876..ee47176 100644 --- a/macros/src/config.rs +++ b/macros/src/config.rs @@ -21,11 +21,11 @@ pub fn global_attrs(span: Span) -> StringyMap { 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"); diff --git a/macros/src/grammar.lalrpop b/macros/src/grammar.lalrpop index f339325..172313e 100644 --- a/macros/src/grammar.lalrpop +++ b/macros/src/grammar.lalrpop @@ -174,6 +174,10 @@ pub NodeWithType: (Node, Option>) = { }, }; +pub NodeWithBump: (Ident, Node) = { + "," , +}; + // The declare macro diff --git a/macros/src/html.rs b/macros/src/html.rs index 684116d..a1db35d 100644 --- a/macros/src/html.rs +++ b/macros/src/html.rs @@ -60,6 +60,26 @@ impl Node { } } } + + #[cfg(feature = "dodrio")] + pub fn into_dodrio_token_stream( + self, + bump: &Ident, + is_req_child: bool, + ) -> Result { + 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, } +#[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) -> StringyMap { let mut data = StringyMap::new(); let keys: Vec = 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>) -> Result { 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 { + 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::, 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::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>), 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)) +} diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 32b4633..c9e7893 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -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] diff --git a/macros/src/map.rs b/macros/src/map.rs index f5ea70a..68339ce 100644 --- a/macros/src/map.rs +++ b/macros/src/map.rs @@ -28,6 +28,11 @@ where pub fn keys(&self) -> impl Iterator { self.0.values().map(|(k, _)| k) } + + #[allow(dead_code)] + pub fn len(&self) -> usize { + self.0.len() + } } impl From> for StringyMap diff --git a/typed-html/Cargo.toml b/typed-html/Cargo.toml index bbecfef..add8a04 100644 --- a/typed-html/Cargo.toml +++ b/typed-html/Cargo.toml @@ -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"] diff --git a/typed-html/src/elements.rs b/typed-html/src/elements.rs index e7bc9b9..7dacf70 100644 --- a/typed-html/src/elements.rs +++ b/typed-html/src/elements.rs @@ -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, // 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, @@ -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; diff --git a/typed-html/src/lib.rs b/typed-html/src/lib.rs index 2f66881..40b4dc0 100644 --- a/typed-html/src/lib.rs +++ b/typed-html/src/lib.rs @@ -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; diff --git a/typed-html/src/output/dodrio.rs b/typed-html/src/output/dodrio.rs new file mode 100644 index 0000000..5e8bfbf --- /dev/null +++ b/typed-html/src/output/dodrio.rs @@ -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!() + } +} diff --git a/typed-html/src/output/mod.rs b/typed-html/src/output/mod.rs index 75eaf35..6243b47 100644 --- a/typed-html/src/output/mod.rs +++ b/typed-html/src/output/mod.rs @@ -1,2 +1,4 @@ #[cfg(feature = "stdweb")] pub mod stdweb; +#[cfg(feature = "dodrio_macro")] +pub mod dodrio; diff --git a/typed-html/src/types/class.rs b/typed-html/src/types/class.rs index b8201fb..a1bb969 100644 --- a/typed-html/src/types/class.rs +++ b/typed-html/src/types/class.rs @@ -69,6 +69,12 @@ impl From 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) diff --git a/typed-html/src/types/id.rs b/typed-html/src/types/id.rs index 74ab24e..61e9b8d 100644 --- a/typed-html/src/types/id.rs +++ b/typed-html/src/types/id.rs @@ -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 for Id { fn from(c: Class) -> Self { Id(c.to_string()) diff --git a/typed-html/src/types/mod.rs b/typed-html/src/types/mod.rs index a3a29cc..c9ec8bb 100644 --- a/typed-html/src/types/mod.rs +++ b/typed-html/src/types/mod.rs @@ -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")] diff --git a/typed-html/src/types/spacedset.rs b/typed-html/src/types/spacedset.rs index 3ce45ef..803995b 100644 --- a/typed-html/src/types/spacedset.rs +++ b/typed-html/src/types/spacedset.rs @@ -33,6 +33,11 @@ impl SpacedSet { pub fn new() -> Self { SpacedSet(BTreeSet::new()) } + + /// Add a value to the `SpacedSet`. + pub fn add>(&mut self, value: T) -> bool { + self.0.insert(value.into()) + } } impl Default for SpacedSet {