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 use std::fmt::Write;
180
181 match self {
182 Self::Empty => {}
183 Self::Bool(inner) => f.write_str(if *inner { "true" } else { "false" }),
184 Self::I8(inner) => {
188 let _ = write!(f, "{inner}");
189 }
190 Self::I16(inner) => {
191 let _ = write!(f, "{inner}");
192 }
193 Self::I32(inner) => {
194 let _ = write!(f, "{inner}");
195 }
196 Self::I64(inner) => {
197 let _ = write!(f, "{inner}");
198 }
199 Self::I128(inner) => {
200 let _ = write!(f, "{inner}");
201 }
202 Self::Isize(inner) => {
203 let _ = write!(f, "{inner}");
204 }
205 Self::U8(inner) => {
206 let _ = write!(f, "{inner}");
207 }
208 Self::U16(inner) => {
209 let _ = write!(f, "{inner}");
210 }
211 Self::U32(inner) => {
212 let _ = write!(f, "{inner}");
213 }
214 Self::U64(inner) => {
215 let _ = write!(f, "{inner}");
216 }
217 Self::U128(inner) => {
218 let _ = write!(f, "{inner}");
219 }
220 Self::Usize(inner) => {
221 let _ = write!(f, "{inner}");
222 }
223 Self::F32(inner) => {
224 let _ = write!(f, "{inner}");
225 }
226 Self::F64(inner) => {
227 let _ = write!(f, "{inner}");
228 }
229 Self::Char { value, context } => context.writer(f).write_char(*value),
230 Self::Str { value, context } => context.writer(f).write_str(value),
231 Self::BoxDyn { inner, context, .. } => inner.render(cx, &mut context.writer(f)),
232 Self::BoxSlice { inner, .. } => {
233 for part in inner {
234 part.render(cx, f);
235 }
236 }
237 }
238 }
239
240 pub(crate) fn size_hint(&self) -> usize {
247 #[allow(clippy::match_same_arms)]
253 match self {
254 Self::Empty => 0,
255 Self::Bool(_) => 5,
256 Self::I8(_) => 3,
257 Self::I16(_) => 4,
258 Self::I32(_) => 6,
259 Self::I64(_) => 11,
260 Self::I128(_) => 21,
261 Self::Isize(_) => 11,
262 Self::U8(_) => 2,
263 Self::U16(_) => 3,
264 Self::U32(_) => 6,
265 Self::U64(_) => 11,
266 Self::U128(_) => 20,
267 Self::Usize(_) => 11,
268 Self::F32(_) => 9,
269 Self::F64(_) => 13,
270 Self::Char { .. } => 3,
272 Self::Str { value, context } => match context {
273 HtmlContext::Unescaped => value.len(),
274 _ => value.len() + value.len() / 8,
276 },
277 Self::BoxDyn { size_hint, .. } | Self::BoxSlice { size_hint, .. } => *size_hint,
278 }
279 }
280}
281
282pub trait DynViewPart: 'static + fmt::Debug + Send {
290 fn render(&self, cx: &Cx, w: &mut HtmlWriter<'_, '_>);
292
293 #[inline]
298 fn size_hint(&self) -> usize {
299 0
300 }
301
302 fn clone_box(&self) -> Box<dyn DynViewPart>;
304}
305
306impl Clone for Box<dyn DynViewPart> {
307 #[inline]
308 fn clone(&self) -> Self {
309 (**self).clone_box()
310 }
311}
312
313#[doc(hidden)]
319#[derive(Debug, Default, Clone)]
320pub struct ViewParts {
321 items: SmallVec<[ViewPart; 8]>,
322}
323
324impl ViewParts {
325 #[inline]
327 #[must_use]
328 pub fn new() -> Self {
329 Self::default()
330 }
331
332 #[inline]
334 pub fn push_view(&mut self, view: View) -> &mut Self {
335 self.items.push(view.into_part());
336 self
337 }
338
339 #[doc(hidden)]
346 #[inline]
347 pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
348 self.items.push(part);
349 self
350 }
351}
352
353impl From<ViewParts> for ViewPart {
354 #[inline]
355 fn from(mut value: ViewParts) -> Self {
356 match value.items.len() {
357 0 => ViewPart::Empty,
358 1 => value.items.pop().unwrap(),
359 _ => {
360 let size_hint = value.items.iter().map(ViewPart::size_hint).sum();
361 ViewPart::BoxSlice {
362 inner: value.items.into_boxed_slice(),
363 size_hint,
364 }
365 }
366 }
367 }
368}
369
370macro_rules! impl_push_primitive {
371 ($method:ident, $ty:ty, $variant:ident) => {
372 #[doc = concat!("Appends a `", stringify!($ty), "` rendered as text.")]
373 #[inline]
377 pub fn $method(&mut self, value: $ty) -> &mut Self {
378 self.parts.items.push(ViewPart::$variant(value));
379 self
380 }
381 };
382}
383
384pub struct PartsWriter<'a> {
401 parts: &'a mut ViewParts,
402 context: HtmlContext,
403}
404
405impl<'a> PartsWriter<'a> {
406 #[inline]
408 pub fn new(parts: &'a mut ViewParts, context: HtmlContext) -> Self {
409 Self { parts, context }
410 }
411
412 #[inline]
418 pub(crate) fn with_context(&mut self, context: HtmlContext) -> PartsWriter<'_> {
419 PartsWriter {
420 parts: self.parts,
421 context,
422 }
423 }
424
425 #[inline]
427 pub fn push_str(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
428 self.parts.items.push(ViewPart::Str {
429 value: value.into(),
430 context: self.context,
431 });
432 self
433 }
434
435 #[inline]
441 pub fn push_str_unescaped(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
442 self.parts.items.push(ViewPart::Str {
443 value: value.into(),
444 context: HtmlContext::Unescaped,
445 });
446 self
447 }
448
449 #[inline]
451 pub fn push_char(&mut self, value: char) -> &mut Self {
452 self.parts.items.push(ViewPart::Char {
453 value,
454 context: self.context,
455 });
456 self
457 }
458
459 impl_push_primitive!(push_bool, bool, Bool);
460 impl_push_primitive!(push_i8, i8, I8);
461 impl_push_primitive!(push_i16, i16, I16);
462 impl_push_primitive!(push_i32, i32, I32);
463 impl_push_primitive!(push_i64, i64, I64);
464 impl_push_primitive!(push_i128, i128, I128);
465 impl_push_primitive!(push_isize, isize, Isize);
466 impl_push_primitive!(push_u8, u8, U8);
467 impl_push_primitive!(push_u16, u16, U16);
468 impl_push_primitive!(push_u32, u32, U32);
469 impl_push_primitive!(push_u64, u64, U64);
470 impl_push_primitive!(push_u128, u128, U128);
471 impl_push_primitive!(push_usize, usize, Usize);
472 impl_push_primitive!(push_f32, f32, F32);
473 impl_push_primitive!(push_f64, f64, F64);
474
475 #[inline]
478 pub fn push_dyn(&mut self, part: Box<dyn DynViewPart>) -> &mut Self {
479 self.parts.items.push(ViewPart::BoxDyn {
480 size_hint: part.size_hint(),
481 inner: part,
482 context: self.context,
483 });
484 self
485 }
486
487 #[doc(hidden)]
492 #[inline]
493 pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
494 self.parts.items.push(part);
495 self
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 fn render(build: impl FnOnce(&mut ViewParts)) -> String {
504 let mut parts = ViewParts::new();
505 build(&mut parts);
506 View::new(parts).render(&Cx::default())
507 }
508
509 #[test]
510 fn empty_view_renders_empty() {
511 assert_eq!(View::empty().render(&Cx::default()), "");
512 }
513
514 #[test]
515 fn unescaped_unchecked_renders_verbatim() {
516 let view = View::unescaped_unchecked("<b>raw</b>");
517 assert_eq!(view.render(&Cx::default()), "<b>raw</b>");
518 }
519
520 #[test]
521 fn push_str_seals_the_writer_context() {
522 let out = render(|parts| {
523 PartsWriter::new(parts, HtmlContext::Text).push_str("<b> & \"q\"");
524 });
525 assert_eq!(out, "<b> & \"q\"");
526
527 let out = render(|parts| {
528 PartsWriter::new(parts, HtmlContext::AttributeValue).push_str("<b> & \"q\"");
529 });
530 assert_eq!(out, "<b> & "q"");
531 }
532
533 #[test]
534 fn push_str_unescaped_bypasses_the_context() {
535 let out = render(|parts| {
536 PartsWriter::new(parts, HtmlContext::Text).push_str_unescaped("<b>raw</b>");
537 });
538 assert_eq!(out, "<b>raw</b>");
539 }
540
541 #[test]
542 fn push_char_seals_the_writer_context() {
543 let out = render(|parts| {
544 PartsWriter::new(parts, HtmlContext::Text).push_char('<');
545 });
546 assert_eq!(out, "<");
547 }
548
549 #[test]
550 #[should_panic(expected = "invalid attribute key")]
551 fn ident_context_panics_on_forbidden_characters_at_render() {
552 render(|parts| {
553 PartsWriter::new(parts, HtmlContext::AttributeKey).push_str("on click");
554 });
555 }
556
557 #[test]
558 fn push_primitives_render_as_text() {
559 let out = render(|parts| {
560 let mut writer = PartsWriter::new(parts, HtmlContext::Text);
561 writer.push_i32(-42).push_str_unescaped(" ");
562 writer.push_bool(true).push_str_unescaped(" ");
563 writer.push_f64(1.5);
564 });
565 assert_eq!(out, "-42 true 1.5");
566 }
567
568 #[test]
569 fn push_view_splices_nested_views() {
570 let mut inner_parts = ViewParts::new();
571 PartsWriter::new(&mut inner_parts, HtmlContext::Text).push_str("a < b");
572 let inner = View::new(inner_parts);
573
574 let out = render(|parts| {
575 PartsWriter::new(parts, HtmlContext::Unescaped).push_str("<p>");
576 parts.push_view(inner);
577 PartsWriter::new(parts, HtmlContext::Unescaped).push_str("</p>");
578 });
579 assert_eq!(out, "<p>a < b</p>");
580 }
581
582 #[test]
583 fn size_hint_is_exact_for_unescaped_strings() {
584 let view = View::unescaped_unchecked("<b>raw</b>");
585 assert_eq!(view.part.size_hint(), 10);
586 }
587}