use core::fmt;
use std::borrow::Cow;
use smallvec::SmallVec;
use topcoat_core::context::Cx;
use crate::{Formatter, HtmlContext, HtmlWriter};
#[derive(Debug, Default, Clone)]
pub struct View {
part: ViewPart,
}
impl View {
#[doc(hidden)]
#[inline]
#[must_use]
pub fn new(parts: ViewParts) -> Self {
Self { part: parts.into() }
}
#[inline]
#[must_use]
pub fn empty() -> Self {
Self::default()
}
#[inline]
#[must_use]
pub const fn unescaped_unchecked(body: &'static str) -> Self {
Self {
part: ViewPart::unescaped(body),
}
}
pub fn render(&self, cx: &Cx) -> String {
let mut buf = String::with_capacity(self.part.size_hint());
let mut f = Formatter::new(&mut buf);
self.part.render(cx, &mut f);
buf
}
#[inline]
pub(crate) fn into_part(self) -> ViewPart {
self.part
}
}
#[derive(Debug, Default, Clone)]
#[non_exhaustive]
pub enum ViewPart {
#[default]
Empty,
#[non_exhaustive]
Bool(bool),
#[non_exhaustive]
I8(i8),
#[non_exhaustive]
I16(i16),
#[non_exhaustive]
I32(i32),
#[non_exhaustive]
I64(i64),
#[non_exhaustive]
I128(i128),
#[non_exhaustive]
Isize(isize),
#[non_exhaustive]
U8(u8),
#[non_exhaustive]
U16(u16),
#[non_exhaustive]
U32(u32),
#[non_exhaustive]
U64(u64),
#[non_exhaustive]
U128(u128),
#[non_exhaustive]
Usize(usize),
#[non_exhaustive]
F32(f32),
#[non_exhaustive]
F64(f64),
#[non_exhaustive]
Char { value: char, context: HtmlContext },
#[non_exhaustive]
Str {
value: Cow<'static, str>,
context: HtmlContext,
},
#[non_exhaustive]
BoxDyn {
inner: Box<dyn DynViewPart>,
context: HtmlContext,
size_hint: usize,
},
#[non_exhaustive]
BoxSlice {
inner: Box<[ViewPart]>,
size_hint: usize,
},
}
impl ViewPart {
#[inline]
#[must_use]
pub fn empty() -> Self {
Self::Empty
}
#[must_use]
pub fn is_empty(&self) -> bool {
matches!(self, Self::Empty)
}
#[inline]
pub(crate) const fn unescaped(value: &'static str) -> Self {
Self::Str {
value: Cow::Borrowed(value),
context: HtmlContext::Unescaped,
}
}
pub(crate) fn render(&self, cx: &Cx, f: &mut Formatter<'_>) {
use std::fmt::Write;
match self {
Self::Empty => {}
Self::Bool(inner) => f.write_str(if *inner { "true" } else { "false" }),
Self::I8(inner) => {
let _ = write!(f, "{inner}");
}
Self::I16(inner) => {
let _ = write!(f, "{inner}");
}
Self::I32(inner) => {
let _ = write!(f, "{inner}");
}
Self::I64(inner) => {
let _ = write!(f, "{inner}");
}
Self::I128(inner) => {
let _ = write!(f, "{inner}");
}
Self::Isize(inner) => {
let _ = write!(f, "{inner}");
}
Self::U8(inner) => {
let _ = write!(f, "{inner}");
}
Self::U16(inner) => {
let _ = write!(f, "{inner}");
}
Self::U32(inner) => {
let _ = write!(f, "{inner}");
}
Self::U64(inner) => {
let _ = write!(f, "{inner}");
}
Self::U128(inner) => {
let _ = write!(f, "{inner}");
}
Self::Usize(inner) => {
let _ = write!(f, "{inner}");
}
Self::F32(inner) => {
let _ = write!(f, "{inner}");
}
Self::F64(inner) => {
let _ = write!(f, "{inner}");
}
Self::Char { value, context } => context.writer(f).write_char(*value),
Self::Str { value, context } => context.writer(f).write_str(value),
Self::BoxDyn { inner, context, .. } => inner.render(cx, &mut context.writer(f)),
Self::BoxSlice { inner, .. } => {
for part in inner {
part.render(cx, f);
}
}
}
}
pub(crate) fn size_hint(&self) -> usize {
#[allow(clippy::match_same_arms)]
match self {
Self::Empty => 0,
Self::Bool(_) => 5,
Self::I8(_) => 3,
Self::I16(_) => 4,
Self::I32(_) => 6,
Self::I64(_) => 11,
Self::I128(_) => 21,
Self::Isize(_) => 11,
Self::U8(_) => 2,
Self::U16(_) => 3,
Self::U32(_) => 6,
Self::U64(_) => 11,
Self::U128(_) => 20,
Self::Usize(_) => 11,
Self::F32(_) => 9,
Self::F64(_) => 13,
Self::Char { .. } => 3,
Self::Str { value, context } => match context {
HtmlContext::Unescaped => value.len(),
_ => value.len() + value.len() / 8,
},
Self::BoxDyn { size_hint, .. } | Self::BoxSlice { size_hint, .. } => *size_hint,
}
}
}
pub trait DynViewPart: 'static + fmt::Debug + Send {
fn render(&self, cx: &Cx, w: &mut HtmlWriter<'_, '_>);
#[inline]
fn size_hint(&self) -> usize {
0
}
fn clone_box(&self) -> Box<dyn DynViewPart>;
}
impl Clone for Box<dyn DynViewPart> {
#[inline]
fn clone(&self) -> Self {
(**self).clone_box()
}
}
#[doc(hidden)]
#[derive(Debug, Default, Clone)]
pub struct ViewParts {
items: SmallVec<[ViewPart; 8]>,
}
impl ViewParts {
#[inline]
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[inline]
pub fn push_view(&mut self, view: View) -> &mut Self {
self.items.push(view.into_part());
self
}
#[doc(hidden)]
#[inline]
pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
self.items.push(part);
self
}
}
impl From<ViewParts> for ViewPart {
#[inline]
fn from(mut value: ViewParts) -> Self {
match value.items.len() {
0 => ViewPart::Empty,
1 => value.items.pop().unwrap(),
_ => {
let size_hint = value.items.iter().map(ViewPart::size_hint).sum();
ViewPart::BoxSlice {
inner: value.items.into_boxed_slice(),
size_hint,
}
}
}
}
}
macro_rules! impl_push_primitive {
($method:ident, $ty:ty, $variant:ident) => {
#[doc = concat!("Appends a `", stringify!($ty), "` rendered as text.")]
#[inline]
pub fn $method(&mut self, value: $ty) -> &mut Self {
self.parts.items.push(ViewPart::$variant(value));
self
}
};
}
pub struct PartsWriter<'a> {
parts: &'a mut ViewParts,
context: HtmlContext,
}
impl<'a> PartsWriter<'a> {
#[inline]
pub fn new(parts: &'a mut ViewParts, context: HtmlContext) -> Self {
Self { parts, context }
}
#[inline]
pub(crate) fn with_context(&mut self, context: HtmlContext) -> PartsWriter<'_> {
PartsWriter {
parts: self.parts,
context,
}
}
#[inline]
pub fn push_str(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
self.parts.items.push(ViewPart::Str {
value: value.into(),
context: self.context,
});
self
}
#[inline]
pub fn push_str_unescaped(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
self.parts.items.push(ViewPart::Str {
value: value.into(),
context: HtmlContext::Unescaped,
});
self
}
#[inline]
pub fn push_char(&mut self, value: char) -> &mut Self {
self.parts.items.push(ViewPart::Char {
value,
context: self.context,
});
self
}
impl_push_primitive!(push_bool, bool, Bool);
impl_push_primitive!(push_i8, i8, I8);
impl_push_primitive!(push_i16, i16, I16);
impl_push_primitive!(push_i32, i32, I32);
impl_push_primitive!(push_i64, i64, I64);
impl_push_primitive!(push_i128, i128, I128);
impl_push_primitive!(push_isize, isize, Isize);
impl_push_primitive!(push_u8, u8, U8);
impl_push_primitive!(push_u16, u16, U16);
impl_push_primitive!(push_u32, u32, U32);
impl_push_primitive!(push_u64, u64, U64);
impl_push_primitive!(push_u128, u128, U128);
impl_push_primitive!(push_usize, usize, Usize);
impl_push_primitive!(push_f32, f32, F32);
impl_push_primitive!(push_f64, f64, F64);
#[inline]
pub fn push_dyn(&mut self, part: Box<dyn DynViewPart>) -> &mut Self {
self.parts.items.push(ViewPart::BoxDyn {
size_hint: part.size_hint(),
inner: part,
context: self.context,
});
self
}
#[doc(hidden)]
#[inline]
pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
self.parts.items.push(part);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render(build: impl FnOnce(&mut ViewParts)) -> String {
let mut parts = ViewParts::new();
build(&mut parts);
View::new(parts).render(&Cx::default())
}
#[test]
fn empty_view_renders_empty() {
assert_eq!(View::empty().render(&Cx::default()), "");
}
#[test]
fn unescaped_unchecked_renders_verbatim() {
let view = View::unescaped_unchecked("<b>raw</b>");
assert_eq!(view.render(&Cx::default()), "<b>raw</b>");
}
#[test]
fn push_str_seals_the_writer_context() {
let out = render(|parts| {
PartsWriter::new(parts, HtmlContext::Text).push_str("<b> & \"q\"");
});
assert_eq!(out, "<b> & \"q\"");
let out = render(|parts| {
PartsWriter::new(parts, HtmlContext::AttributeValue).push_str("<b> & \"q\"");
});
assert_eq!(out, "<b> & "q"");
}
#[test]
fn push_str_unescaped_bypasses_the_context() {
let out = render(|parts| {
PartsWriter::new(parts, HtmlContext::Text).push_str_unescaped("<b>raw</b>");
});
assert_eq!(out, "<b>raw</b>");
}
#[test]
fn push_char_seals_the_writer_context() {
let out = render(|parts| {
PartsWriter::new(parts, HtmlContext::Text).push_char('<');
});
assert_eq!(out, "<");
}
#[test]
#[should_panic(expected = "invalid attribute key")]
fn ident_context_panics_on_forbidden_characters_at_render() {
render(|parts| {
PartsWriter::new(parts, HtmlContext::AttributeKey).push_str("on click");
});
}
#[test]
fn push_primitives_render_as_text() {
let out = render(|parts| {
let mut writer = PartsWriter::new(parts, HtmlContext::Text);
writer.push_i32(-42).push_str_unescaped(" ");
writer.push_bool(true).push_str_unescaped(" ");
writer.push_f64(1.5);
});
assert_eq!(out, "-42 true 1.5");
}
#[test]
fn push_view_splices_nested_views() {
let mut inner_parts = ViewParts::new();
PartsWriter::new(&mut inner_parts, HtmlContext::Text).push_str("a < b");
let inner = View::new(inner_parts);
let out = render(|parts| {
PartsWriter::new(parts, HtmlContext::Unescaped).push_str("<p>");
parts.push_view(inner);
PartsWriter::new(parts, HtmlContext::Unescaped).push_str("</p>");
});
assert_eq!(out, "<p>a < b</p>");
}
#[test]
fn size_hint_is_exact_for_unescaped_strings() {
let view = View::unescaped_unchecked("<b>raw</b>");
assert_eq!(view.part.size_hint(), 10);
}
}