1use core::fmt;
2use std::borrow::Cow;
3
4use smallvec::SmallVec;
5use topcoat_core::context::Cx;
6
7use crate::{Formatter, HtmlContext, HtmlWriter};
8
9#[derive(Debug, Default, Clone)]
23pub struct View {
24 part: ViewPart,
25}
26
27impl View {
28 #[doc(hidden)]
33 #[inline]
34 #[must_use]
35 pub fn new(parts: ViewParts) -> Self {
36 Self { part: parts.into() }
37 }
38
39 #[inline]
41 #[must_use]
42 pub fn empty() -> Self {
43 Self::default()
44 }
45
46 #[inline]
49 #[must_use]
50 pub const fn unescaped_unchecked(body: &'static str) -> Self {
51 Self {
52 part: ViewPart::unescaped(body),
53 }
54 }
55
56 pub fn render(&self, cx: &Cx) -> String {
58 let mut buf = String::with_capacity(self.part.size_hint());
59 let mut f = Formatter::new(&mut buf);
60 self.part.render(cx, &mut f);
61 buf
62 }
63
64 #[inline]
66 pub(crate) fn into_part(self) -> ViewPart {
67 self.part
68 }
69}
70
71#[derive(Debug, Default, Clone)]
77#[non_exhaustive]
78pub enum ViewPart {
79 #[default]
81 Empty,
82 #[non_exhaustive]
84 Bool(bool),
85 #[non_exhaustive]
87 I8(i8),
88 #[non_exhaustive]
90 I16(i16),
91 #[non_exhaustive]
93 I32(i32),
94 #[non_exhaustive]
96 I64(i64),
97 #[non_exhaustive]
99 I128(i128),
100 #[non_exhaustive]
102 Isize(isize),
103 #[non_exhaustive]
105 U8(u8),
106 #[non_exhaustive]
108 U16(u16),
109 #[non_exhaustive]
111 U32(u32),
112 #[non_exhaustive]
114 U64(u64),
115 #[non_exhaustive]
117 U128(u128),
118 #[non_exhaustive]
120 Usize(usize),
121 #[non_exhaustive]
123 F32(f32),
124 #[non_exhaustive]
126 F64(f64),
127 #[non_exhaustive]
129 Char { value: char, context: HtmlContext },
130 #[non_exhaustive]
132 Str {
133 value: Cow<'static, str>,
134 context: HtmlContext,
135 },
136 #[non_exhaustive]
138 BoxDyn {
139 inner: Box<dyn DynViewPart>,
140 context: HtmlContext,
141 size_hint: usize,
142 },
143 #[non_exhaustive]
145 BoxSlice {
146 inner: Box<[ViewPart]>,
147 size_hint: usize,
148 },
149}
150
151impl ViewPart {
152 #[inline]
154 #[must_use]
155 pub fn empty() -> Self {
156 Self::Empty
157 }
158
159 #[must_use]
163 pub fn is_empty(&self) -> bool {
164 matches!(self, Self::Empty)
165 }
166
167 #[inline]
169 pub(crate) const fn unescaped(value: &'static str) -> Self {
170 Self::Str {
171 value: Cow::Borrowed(value),
172 context: HtmlContext::Unescaped,
173 }
174 }
175
176 pub(crate) fn render(&self, cx: &Cx, f: &mut Formatter<'_>) {
179 let mut int_buffer = itoa::Buffer::new();
180 let mut float_buffer = zmij::Buffer::new();
181
182 match self {
183 Self::Empty => {}
184 Self::Bool(inner) => f.write_str(if *inner { "true" } else { "false" }),
185 Self::I8(inner) => f.write_str(int_buffer.format(*inner)),
189 Self::I16(inner) => f.write_str(int_buffer.format(*inner)),
190 Self::I32(inner) => f.write_str(int_buffer.format(*inner)),
191 Self::I64(inner) => f.write_str(int_buffer.format(*inner)),
192 Self::I128(inner) => f.write_str(int_buffer.format(*inner)),
193 Self::Isize(inner) => f.write_str(int_buffer.format(*inner)),
194 Self::U8(inner) => f.write_str(int_buffer.format(*inner)),
195 Self::U16(inner) => f.write_str(int_buffer.format(*inner)),
196 Self::U32(inner) => f.write_str(int_buffer.format(*inner)),
197 Self::U64(inner) => f.write_str(int_buffer.format(*inner)),
198 Self::U128(inner) => f.write_str(int_buffer.format(*inner)),
199 Self::Usize(inner) => f.write_str(int_buffer.format(*inner)),
200 Self::F32(inner) => f.write_str(float_buffer.format(*inner)),
201 Self::F64(inner) => f.write_str(float_buffer.format(*inner)),
202 Self::Char { value, context } => context.writer(f).write_char(*value),
203 Self::Str { value, context } => context.writer(f).write_str(value),
204 Self::BoxDyn { inner, context, .. } => inner.render(cx, &mut context.writer(f)),
205 Self::BoxSlice { inner, .. } => {
206 for part in inner {
207 part.render(cx, f);
208 }
209 }
210 }
211 }
212
213 pub(crate) fn size_hint(&self) -> usize {
220 #[allow(clippy::match_same_arms)]
226 match self {
227 Self::Empty => 0,
228 Self::Bool(_) => 5,
229 Self::I8(_) => 3,
230 Self::I16(_) => 4,
231 Self::I32(_) => 6,
232 Self::I64(_) => 11,
233 Self::I128(_) => 21,
234 Self::Isize(_) => 11,
235 Self::U8(_) => 2,
236 Self::U16(_) => 3,
237 Self::U32(_) => 6,
238 Self::U64(_) => 11,
239 Self::U128(_) => 20,
240 Self::Usize(_) => 11,
241 Self::F32(_) => 9,
242 Self::F64(_) => 13,
243 Self::Char { .. } => 3,
245 Self::Str { value, context } => match context {
246 HtmlContext::Unescaped => value.len(),
247 _ => value.len() + value.len() / 8,
249 },
250 Self::BoxDyn { size_hint, .. } | Self::BoxSlice { size_hint, .. } => *size_hint,
251 }
252 }
253}
254
255pub trait DynViewPart: 'static + fmt::Debug + Send {
263 fn render(&self, cx: &Cx, w: &mut HtmlWriter<'_, '_>);
265
266 #[inline]
271 fn size_hint(&self) -> usize {
272 0
273 }
274
275 fn clone_box(&self) -> Box<dyn DynViewPart>;
277}
278
279impl Clone for Box<dyn DynViewPart> {
280 #[inline]
281 fn clone(&self) -> Self {
282 (**self).clone_box()
283 }
284}
285
286#[doc(hidden)]
292#[derive(Debug, Default, Clone)]
293pub struct ViewParts {
294 items: SmallVec<[ViewPart; 8]>,
295}
296
297impl ViewParts {
298 #[inline]
300 #[must_use]
301 pub fn new() -> Self {
302 Self::default()
303 }
304
305 #[inline]
307 pub fn push_view(&mut self, view: View) -> &mut Self {
308 self.items.push(view.into_part());
309 self
310 }
311
312 #[doc(hidden)]
319 #[inline]
320 pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
321 self.items.push(part);
322 self
323 }
324}
325
326impl From<ViewParts> for ViewPart {
327 #[inline]
328 fn from(mut value: ViewParts) -> Self {
329 match value.items.len() {
330 0 => ViewPart::Empty,
331 1 => value.items.pop().unwrap(),
332 _ => {
333 let size_hint = value.items.iter().map(ViewPart::size_hint).sum();
334 ViewPart::BoxSlice {
335 inner: value.items.into_boxed_slice(),
336 size_hint,
337 }
338 }
339 }
340 }
341}
342
343macro_rules! impl_push_primitive {
344 ($method:ident, $ty:ty, $variant:ident) => {
345 #[doc = concat!("Appends a `", stringify!($ty), "` rendered as text.")]
346 #[inline]
350 pub fn $method(&mut self, value: $ty) -> &mut Self {
351 self.parts.items.push(ViewPart::$variant(value));
352 self
353 }
354 };
355}
356
357pub struct PartsWriter<'a> {
374 parts: &'a mut ViewParts,
375 context: HtmlContext,
376}
377
378impl<'a> PartsWriter<'a> {
379 #[inline]
381 pub fn new(parts: &'a mut ViewParts, context: HtmlContext) -> Self {
382 Self { parts, context }
383 }
384
385 #[inline]
391 pub(crate) fn with_context(&mut self, context: HtmlContext) -> PartsWriter<'_> {
392 PartsWriter {
393 parts: self.parts,
394 context,
395 }
396 }
397
398 #[inline]
400 pub fn push_str(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
401 self.parts.items.push(ViewPart::Str {
402 value: value.into(),
403 context: self.context,
404 });
405 self
406 }
407
408 #[inline]
414 pub fn push_str_unescaped(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
415 self.parts.items.push(ViewPart::Str {
416 value: value.into(),
417 context: HtmlContext::Unescaped,
418 });
419 self
420 }
421
422 #[inline]
424 pub fn push_char(&mut self, value: char) -> &mut Self {
425 self.parts.items.push(ViewPart::Char {
426 value,
427 context: self.context,
428 });
429 self
430 }
431
432 impl_push_primitive!(push_bool, bool, Bool);
433 impl_push_primitive!(push_i8, i8, I8);
434 impl_push_primitive!(push_i16, i16, I16);
435 impl_push_primitive!(push_i32, i32, I32);
436 impl_push_primitive!(push_i64, i64, I64);
437 impl_push_primitive!(push_i128, i128, I128);
438 impl_push_primitive!(push_isize, isize, Isize);
439 impl_push_primitive!(push_u8, u8, U8);
440 impl_push_primitive!(push_u16, u16, U16);
441 impl_push_primitive!(push_u32, u32, U32);
442 impl_push_primitive!(push_u64, u64, U64);
443 impl_push_primitive!(push_u128, u128, U128);
444 impl_push_primitive!(push_usize, usize, Usize);
445 impl_push_primitive!(push_f32, f32, F32);
446 impl_push_primitive!(push_f64, f64, F64);
447
448 #[inline]
451 pub fn push_dyn(&mut self, part: Box<dyn DynViewPart>) -> &mut Self {
452 self.parts.items.push(ViewPart::BoxDyn {
453 size_hint: part.size_hint(),
454 inner: part,
455 context: self.context,
456 });
457 self
458 }
459
460 #[doc(hidden)]
465 #[inline]
466 pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
467 self.parts.items.push(part);
468 self
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 fn render(build: impl FnOnce(&mut ViewParts)) -> String {
477 let mut parts = ViewParts::new();
478 build(&mut parts);
479 View::new(parts).render(&Cx::default())
480 }
481
482 #[test]
483 fn empty_view_renders_empty() {
484 assert_eq!(View::empty().render(&Cx::default()), "");
485 }
486
487 #[test]
488 fn unescaped_unchecked_renders_verbatim() {
489 let view = View::unescaped_unchecked("<b>raw</b>");
490 assert_eq!(view.render(&Cx::default()), "<b>raw</b>");
491 }
492
493 #[test]
494 fn push_str_seals_the_writer_context() {
495 let out = render(|parts| {
496 PartsWriter::new(parts, HtmlContext::Text).push_str("<b> & \"q\"");
497 });
498 assert_eq!(out, "<b> & \"q\"");
499
500 let out = render(|parts| {
501 PartsWriter::new(parts, HtmlContext::AttributeValue).push_str("<b> & \"q\"");
502 });
503 assert_eq!(out, "<b> & "q"");
504 }
505
506 #[test]
507 fn push_str_unescaped_bypasses_the_context() {
508 let out = render(|parts| {
509 PartsWriter::new(parts, HtmlContext::Text).push_str_unescaped("<b>raw</b>");
510 });
511 assert_eq!(out, "<b>raw</b>");
512 }
513
514 #[test]
515 fn push_char_seals_the_writer_context() {
516 let out = render(|parts| {
517 PartsWriter::new(parts, HtmlContext::Text).push_char('<');
518 });
519 assert_eq!(out, "<");
520 }
521
522 #[test]
523 #[should_panic(expected = "invalid attribute key")]
524 fn ident_context_panics_on_forbidden_characters_at_render() {
525 render(|parts| {
526 PartsWriter::new(parts, HtmlContext::AttributeKey).push_str("on click");
527 });
528 }
529
530 #[test]
531 fn push_primitives_render_as_text() {
532 let out = render(|parts| {
533 let mut writer = PartsWriter::new(parts, HtmlContext::Text);
534 writer.push_i32(-42).push_str_unescaped(" ");
535 writer.push_bool(true).push_str_unescaped(" ");
536 writer.push_f64(1.5);
537 });
538 assert_eq!(out, "-42 true 1.5");
539 }
540
541 #[test]
542 fn push_view_splices_nested_views() {
543 let mut inner_parts = ViewParts::new();
544 PartsWriter::new(&mut inner_parts, HtmlContext::Text).push_str("a < b");
545 let inner = View::new(inner_parts);
546
547 let out = render(|parts| {
548 PartsWriter::new(parts, HtmlContext::Unescaped).push_str("<p>");
549 parts.push_view(inner);
550 PartsWriter::new(parts, HtmlContext::Unescaped).push_str("</p>");
551 });
552 assert_eq!(out, "<p>a < b</p>");
553 }
554
555 #[test]
556 fn size_hint_is_exact_for_unescaped_strings() {
557 let view = View::unescaped_unchecked("<b>raw</b>");
558 assert_eq!(view.part.size_hint(), 10);
559 }
560}