diff --git a/src/base/component/grid.rs b/src/base/component/grid.rs index 502c7362..7fbbcbe1 100644 --- a/src/base/component/grid.rs +++ b/src/base/component/grid.rs @@ -2,6 +2,8 @@ use crate::html::align::{Content, Gap, Items}; use crate::html::grid::{AutoFlow, AxisTrack, ContentJustify, DefaultJustify, Tracks}; use crate::prelude::*; +use std::sync::Arc; + // **< DisplayGrid >******************************************************************************** // Posicionamiento CSS Grid del contenedor `Grid`. La API pública sólo expone los constructores @@ -67,12 +69,13 @@ pub struct Grid { // público; `new()`, `at()`, `inline()` e `inline_at()` son la única forma de activarlo. #[getters(skip)] display: Option, - /// Devuelve las pistas de columna (`grid-template-columns`), por punto de corte. - #[getters(copy)] - columns: Responsive, - /// Devuelve las pistas de fila (`grid-template-rows`), por punto de corte. - #[getters(copy)] - rows: Responsive, + // Pistas de columna y de fila. Cada `Responsive` ocupa más de 1 KB (doce pistas por + // punto de corte), y suma el 80 % del tamaño del componente: se guardan aparte, tras un `Arc` + // que sólo se reserva al establecer las primeras. Clonar el `Grid` para renderizarlo sólo suma + // una referencia, y `Arc::make_mut` las duplica si se modifican estando compartidas. Sin getter + // derivado: `columns()` y `rows()` devuelven el valor por copia, como siempre. + #[getters(skip)] + templates: Option>, /// Devuelve el tamaño de las columnas generadas implícitamente (`grid-auto-columns`), por /// punto de corte. #[getters(copy)] @@ -103,6 +106,13 @@ pub struct Grid { children: Children, } +// Pistas de columna y de fila de un `Grid`; ver `Grid::templates`. +#[derive(AutoDefault, Clone, Debug)] +struct Templates { + columns: Responsive, + rows: Responsive, +} + #[async_trait] impl Component for Grid { fn new() -> Self { @@ -178,25 +188,29 @@ impl Grid { /// Establece las pistas de columna. pub fn with_columns(mut self, tracks: Tracks) -> Self { - self.columns = self.columns.set(tracks); + let templates = self.templates_mut(); + templates.columns = templates.columns.set(tracks); self } /// Establece las pistas de columna a partir del punto de corte indicado. pub fn with_columns_at(mut self, bp: Breakpoint, tracks: Tracks) -> Self { - self.columns = self.columns.set_at(bp, tracks); + let templates = self.templates_mut(); + templates.columns = templates.columns.set_at(bp, tracks); self } /// Establece las pistas de fila. pub fn with_rows(mut self, tracks: Tracks) -> Self { - self.rows = self.rows.set(tracks); + let templates = self.templates_mut(); + templates.rows = templates.rows.set(tracks); self } /// Establece las pistas de fila a partir del punto de corte indicado. pub fn with_rows_at(mut self, bp: Breakpoint, tracks: Tracks) -> Self { - self.rows = self.rows.set_at(bp, tracks); + let templates = self.templates_mut(); + templates.rows = templates.rows.set_at(bp, tracks); self } @@ -316,6 +330,30 @@ impl Grid { } impl Grid { + // **< Grid GETTERS >*************************************************************************** + + /// Devuelve las pistas de columna (`grid-template-columns`), por punto de corte. + pub fn columns(&self) -> Responsive { + self.templates + .as_deref() + .map_or_else(Default::default, |t| t.columns) + } + + /// Devuelve las pistas de fila (`grid-template-rows`), por punto de corte. + pub fn rows(&self) -> Responsive { + self.templates + .as_deref() + .map_or_else(Default::default, |t| t.rows) + } + + // **< Grid PRIVATE >*************************************************************************** + + // Pistas para modificarlas: las crea con sus valores por defecto si aún no existen y, si están + // compartidas con un clon, las duplica antes. + fn templates_mut(&mut self) -> &mut Templates { + Arc::make_mut(self.templates.get_or_insert_with(Default::default)) + } + // Calcula las clases CSS *responsive* de este contenedor Grid y las añade a `classes`, // separadas con un espacio de las que ya hubiera, para poder compartir un único acumulador con // `GridItem::apply()` (ver `Props::unpack_with_classes()`) sin cadenas intermedias. @@ -347,8 +385,9 @@ impl Grid { }; styles(cx, classes, entry, class, "display", value.into()); - apply!(cx, classes, self.columns, "_grid-columns_", "grid-template-columns", val); - apply!(cx, classes, self.rows, "_grid-rows_", "grid-template-rows", val); + let (columns, rows) = (self.columns(), self.rows()); + apply!(cx, classes, columns, "_grid-columns_", "grid-template-columns", val); + apply!(cx, classes, rows, "_grid-rows_", "grid-template-rows", val); apply!(cx, classes, self.auto_columns, "_grid-auto-columns_", "grid-auto-columns", val); apply!(cx, classes, self.auto_rows, "_grid-auto-rows_", "grid-auto-rows", val); // `val`: a diferencia de `flex::Behavior` (una sola palabra en cualquier variante), diff --git a/src/html/props/definition.rs b/src/html/props/definition.rs index 22fef29a..85806238 100644 --- a/src/html/props/definition.rs +++ b/src/html/props/definition.rs @@ -10,6 +10,7 @@ use crate::{AutoDefault, CowStr, builder_impl, trace, util}; use std::collections::HashMap; use std::fmt::Write; use std::panic::Location; +use std::sync::Arc; // **< Props >************************************************************************************** @@ -191,6 +192,17 @@ pub struct Props { styles: Vec<(CowStr, CowStr)>, attrs: Vec<(CowStr, CowStr)>, extras: HashMap<&'static str, PropsExtra>, + // Posicionamiento Flexbox/Grid y espaciado. Cada uno lleva un valor por punto de corte, lo que + // suma más de 1 KB, y casi ningún componente los usa. Se guardan aparte, tras un `Arc` que sólo + // se reserva al establecer el primero. Así `Props` (y con él cada componente, el `Context` y la + // `Page`) es diez veces menor, y clonar el componente para renderizarlo no copia esos valores, + // sólo cuenta una referencia (`Arc::make_mut` los duplica si se modifican estando compartidos). + layout: Option>, +} + +// Posicionamiento y espaciado de un componente; ver `Props::layout`. +#[derive(AutoDefault, Clone, Debug, PartialEq)] +struct Layout { flex_item: FlexItem, grid_item: GridItem, margin: Margin, @@ -344,16 +356,20 @@ impl Props { self.extras.remove(key); } PropsOp::FlexItem(placement) => { - self.flex_item = self.flex_item.merge(placement); + let layout = self.layout_mut(); + layout.flex_item = layout.flex_item.merge(placement); } PropsOp::GridItem(placement) => { - self.grid_item = self.grid_item.merge(placement); + let layout = self.layout_mut(); + layout.grid_item = layout.grid_item.merge(placement); } PropsOp::Margin(margin) => { - self.margin = self.margin.merge(margin); + let layout = self.layout_mut(); + layout.margin = layout.margin.merge(margin); } PropsOp::Padding(padding) => { - self.padding = self.padding.merge(padding); + let layout = self.layout_mut(); + layout.padding = layout.padding.merge(padding); } } self @@ -453,10 +469,10 @@ impl Props { && self.classes.is_empty() && self.styles.is_empty() && self.attrs.is_empty() - && self.flex_item == FlexItem::default() - && self.grid_item == GridItem::default() - && self.margin == Margin::default() - && self.padding == Padding::default() + && self + .layout + .as_deref() + .is_none_or(|layout| *layout == Layout::default()) } /// Devuelve `true` si la clase o **alguna** de las clases indicadas está presente. @@ -619,16 +635,24 @@ impl Props { cx: &mut Context, mut classes: String, ) -> impl RenderAttrs + 'a { - self.flex_item.apply(cx, &mut classes); - self.grid_item.apply(cx, &mut classes); - self.margin.apply(cx, &mut classes); - self.padding.apply(cx, &mut classes); + if let Some(layout) = self.layout.as_deref() { + layout.flex_item.apply(cx, &mut classes); + layout.grid_item.apply(cx, &mut classes); + layout.margin.apply(cx, &mut classes); + layout.padding.apply(cx, &mut classes); + } PropsUnpack { props: self, classes, } } + // Posicionamiento y espaciado para modificarlos: los crea con sus valores por defecto si aún no + // existen y, si están compartidos con un clon, los duplica antes. + fn layout_mut(&mut self) -> &mut Layout { + Arc::make_mut(self.layout.get_or_insert_with(Default::default)) + } + fn apply_id(&mut self, id: &str) { self.id = util::normalize_token(id); } diff --git a/src/web.rs b/src/web.rs index 0ede75a8..707b1ffc 100644 --- a/src/web.rs +++ b/src/web.rs @@ -73,16 +73,23 @@ use std::task::{Context, Poll}; /// impone Axum: un extractor que consuma el cuerpo de la petición (`Form`, `RawForm`, etc.) debe /// ir siempre el último. #[derive(Clone, Debug)] -pub struct HttpRequest { +pub struct HttpRequest(Arc); + +// Datos de la petición. Se guardan tras un `Arc` para que `HttpRequest` ocupe un puntero y clonarlo +// sea una simple cuenta de referencias: viaja dentro de `ErrorPage` y de los `Result<_, ErrorPage>` +// de los handlers, donde un tipo grande se copiaría en cada retorno. +#[derive(Debug)] +struct RequestParts { uri: http::Uri, headers: http::HeaderMap, - extensions: Arc, + extensions: http::Extensions, } impl HttpRequest { /// Devuelve la URI completa de la petición, incluyendo la *query string* si la hay. pub fn uri(&self) -> &str { - self.uri + self.0 + .uri .path_and_query() .map(|pq| pq.as_str()) .unwrap_or("/") @@ -90,19 +97,19 @@ impl HttpRequest { /// Devuelve la ruta (*path*) de la petición, sin la *query string*. pub fn path(&self) -> &str { - self.uri.path() + self.0.uri.path() } /// Devuelve la cadena de consulta (*query string*) de la petición, sin el carácter `?`. /// /// Devuelve una cadena vacía si la petición no tiene *query string*. pub fn query_string(&self) -> &str { - self.uri.query().unwrap_or("") + self.0.uri.query().unwrap_or("") } /// Devuelve las cabeceras HTTP de la petición. pub fn headers(&self) -> &http::HeaderMap { - &self.headers + &self.0.headers } /// Accede a un valor inyectado por middleware en las extensiones de la petición. @@ -112,7 +119,7 @@ impl HttpRequest { /// /// El tipo debe implementar `Send + Sync + 'static` (requisito de [`http::Extensions`]). pub fn extension(&self) -> Option<&T> { - self.extensions.get::() + self.0.extensions.get::() } } @@ -121,17 +128,17 @@ impl FromRequestParts for HttpRequest { // Clona (no toma) las extensiones inyectadas por middleware, para que otros extractores del // handler (`Path`, `Extension`, etc.) las sigan viendo intactas sin importar en qué - // posición se declare `HttpRequest`. El clon se envuelve en un `Arc` compartido para que + // posición se declare `HttpRequest`. Todo se guarda tras un `Arc` compartido para que // `HttpRequest` sea `Clone` a coste mínimo en el resto de su ciclo de vida. async fn from_request_parts( parts: &mut http::request::Parts, _state: &S, ) -> Result { - Ok(HttpRequest { + Ok(HttpRequest(Arc::new(RequestParts { uri: parts.uri.clone(), headers: parts.headers.clone(), - extensions: Arc::new(parts.extensions.clone()), - }) + extensions: parts.extensions.clone(), + }))) } } @@ -417,11 +424,11 @@ pub mod test { /// [`Context::new`](crate::core::component::Context::new) en tests unitarios de componentes. pub fn to_http_request(self) -> super::HttpRequest { let uri = self.uri.parse().unwrap(); - super::HttpRequest { + super::HttpRequest(std::sync::Arc::new(super::RequestParts { uri, headers: self.headers, - extensions: std::sync::Arc::new(self.extensions), - } + extensions: self.extensions, + })) } } diff --git a/tests/component_grid_layout.rs b/tests/component_grid_layout.rs new file mode 100644 index 00000000..81c9d4f5 --- /dev/null +++ b/tests/component_grid_layout.rs @@ -0,0 +1,57 @@ +use pagetop::prelude::*; + +// Las pistas de columna y de fila se guardan aparte para que `Grid` sea pequeño; esta prueba +// avisa si algo vuelve a inflarlo. +#[pagetop::test] +async fn grid_stays_small() { + assert!( + std::mem::size_of::() <= 1024, + "Grid ocupa {} bytes", + std::mem::size_of::() + ); +} + +#[pagetop::test] +async fn getters_return_the_default_until_tracks_are_set() { + let grid = Grid::new(); + assert_eq!(grid.columns(), Responsive::default()); + assert_eq!(grid.rows(), Responsive::default()); + + let tracks = grid::Tracks::repeat(3, grid::AxisTrack::Fraction(1.0)); + let grid = grid.with_columns(tracks); + assert_eq!(grid.columns(), Responsive::default().set(tracks)); + // Las filas siguen sin definir. + assert_eq!(grid.rows(), Responsive::default()); +} + +// Un clon comparte las pistas hasta que se modifica; entonces cada uno tiene las suyas. +#[pagetop::test] +async fn a_clone_does_not_share_later_changes() { + let three = grid::Tracks::repeat(3, grid::AxisTrack::Fraction(1.0)); + let two = grid::Tracks::repeat(2, grid::AxisTrack::Fraction(1.0)); + let original = Grid::new().with_columns(three); + let copy = original.clone().with_columns(two).with_rows(two); + + assert_eq!(original.columns(), Responsive::default().set(three)); + assert_eq!(original.rows(), Responsive::default()); + assert_eq!(copy.columns(), Responsive::default().set(two)); + assert_eq!(copy.rows(), Responsive::default().set(two)); +} + +#[pagetop::test] +async fn columns_and_rows_by_breakpoint_are_kept() { + let one = grid::Tracks::repeat(1, grid::AxisTrack::Fraction(1.0)); + let four = grid::Tracks::repeat(4, grid::AxisTrack::Fraction(1.0)); + let grid = Grid::new() + .with_columns(one) + .with_columns_at(Breakpoint::Lg, four) + .with_rows_at(Breakpoint::Md, one); + assert_eq!( + grid.columns(), + Responsive::default().set(one).set_at(Breakpoint::Lg, four) + ); + assert_eq!( + grid.rows(), + Responsive::default().set_at(Breakpoint::Md, one) + ); +} diff --git a/tests/html_props_layout.rs b/tests/html_props_layout.rs new file mode 100644 index 00000000..9580c801 --- /dev/null +++ b/tests/html_props_layout.rs @@ -0,0 +1,63 @@ +use pagetop::prelude::*; + +// El posicionamiento y el espaciado se guardan aparte para que `Props` (y con él cada componente) +// sea pequeño; esta prueba avisa si algo vuelve a inflarlo. +#[pagetop::test] +async fn props_stays_small() { + assert!( + std::mem::size_of::() <= 192, + "Props ocupa {} bytes", + std::mem::size_of::() + ); +} + +#[pagetop::test] +async fn is_empty_tracks_layout_values() { + let props = Props::default(); + assert!(props.is_empty()); + + let with_margin = props + .clone() + .with_prop(Margin::new().with_top(UnitValue::Zero)); + assert!(!with_margin.is_empty()); + let with_padding = Props::default().with_prop(Padding::new().with_x(UnitValue::Px(4))); + assert!(!with_padding.is_empty()); + + // Un valor por defecto no cuenta como definido. + assert!(Props::default().with_prop(Margin::new()).is_empty()); + assert!(Props::default().with_prop(Padding::new()).is_empty()); +} + +// Un clon comparte el posicionamiento hasta que se modifica; entonces cada uno tiene el suyo. +#[pagetop::test] +async fn a_clone_does_not_share_later_changes() { + let original = Props::default().with_prop(Margin::new().with_top(UnitValue::RelRem(1.0))); + let mut copy = original.clone(); + copy.alter_prop(Margin::new().with_top(UnitValue::RelRem(2.0))); + copy.alter_prop(Padding::new().with_start(UnitValue::Px(3))); + + let mut cx = Context::default(); + let html_original = html! { span (original.unpack(&mut cx)) {} }.into_string(); + let html_copy = html! { span (copy.unpack(&mut cx)) {} }.into_string(); + + assert!( + html_original.contains("_margin-top_1rem_"), + "{html_original}" + ); + assert!(!html_original.contains("padding"), "{html_original}"); + assert!(html_copy.contains("_margin-top_2rem_"), "{html_copy}"); + assert!(html_copy.contains("_padding-start_3px_"), "{html_copy}"); +} + +// Todos los tipos de posicionamiento conviven y se emiten en el mismo orden de siempre. +#[pagetop::test] +async fn layout_kinds_combine_in_the_usual_order() { + let props = Props::default() + .with_prop(Padding::new().with_top(UnitValue::Zero)) + .with_prop(Margin::new().with_top(UnitValue::Zero)); + let mut cx = Context::default(); + let html = html! { span (props.unpack(&mut cx)) {} }.into_string(); + let margin = html.find("_margin-top_").expect("margin"); + let padding = html.find("_padding-top_").expect("padding"); + assert!(margin < padding, "{html}"); +}