cotis-layout 0.1.0-alpha.1

Flexbox-style layout engine for Cotis
Documentation
use crate::layout_management::CotisLayoutRun;
use crate::layout_struct::TextDrawPayload;
use crate::layout_struct::info_types::TextConfig as MeasureTextConfig;
use crate::layout_struct::layout_states::ChildWrapping;
use crate::layout_traits::CotisLayoutCompatible;
use crate::text::{
    TextLineChildSpec, line_word_space_pieces, measure_text_fragment, newline_line_ranges,
    text_line_child_specs,
};
use cotis::utils::ElementId;
use cotis_defaults::colors::Color;
use cotis_defaults::element_configs::style::sizing::AxisSizing;
use cotis_defaults::element_configs::style::types::{LayoutDirection, Padding};
use cotis_defaults::element_configs::style::{
    BorderElementStyle, BorderWidthStyle, CotisStyle, TRANSPARENT_BACKGROUND,
};
use cotis_defaults::element_configs::text_config::{
    TextConfig, TextElementConfig, TextElementConfigWrapMode, TextStyle,
};
use cotis_utils::math::Dimensions;
use std::ops::Range;
use std::sync::Arc;

/// A configuration that can spawn transparent synthetic children used to lay out text.
///
/// Each line, word, and inter-word spacer of a text leaf becomes one of these children, so the
/// layout engine can size and wrap them while keeping the real config type generic.
///
/// Implement this trait alongside [`CotisLayoutCompatible`](crate::layout_traits::CotisLayoutCompatible)
/// for custom config types that support [`ConfigureLeafElements`](cotis::element_configuring::ConfigureLeafElements)
/// with [`TextElementConfig`](cotis_defaults::element_configs::text_config::TextElementConfig).
///
/// The built-in implementation for [`ElementConfig`](cotis_defaults::element_configs::ElementConfig)
/// strips background, border, and padding from the parent style so synthetic nodes contribute
/// layout size only, not visuals.
///
/// # Examples
///
/// ```rust,ignore
/// use cotis_defaults::element_configs::ElementConfig;
/// use cotis_defaults::element_configs::style::sizing::AxisSizing;
/// use cotis_defaults::element_configs::style::types::LayoutDirection;
/// use cotis_layout::preamble::TextConfigurable;
///
/// let parent: ElementConfig<'_, _, _, _, _> = /* ... */;
/// let child = parent.synthetic_text_child(
///     LayoutDirection::LeftToRight,
///     4.0,
///     AxisSizing::Fixed(100.0),
///     AxisSizing::Fixed(20.0),
/// );
/// // child has transparent background and zero padding
/// ```
pub trait TextConfigurable: Sized {
    /// Build a transparent, border-less, padding-less child derived from this (parent) config.
    ///
    /// Called when expanding text leaves into line, word, and spacer nodes. The returned config
    /// is opened as a synthetic child with the given layout direction, gap, and axis sizing.
    fn synthetic_text_child(
        &self,
        layout_direction: LayoutDirection,
        child_gap: f32,
        width: AxisSizing,
        height: AxisSizing,
    ) -> Self;
}

/// Strips background, border and padding so the synthetic node only contributes layout, not visuals.
pub(crate) fn reset_text_child_style<S>(
    style: &mut CotisStyle<S>,
    layout_direction: LayoutDirection,
    child_gap: f32,
) {
    style.background_color = TRANSPARENT_BACKGROUND;
    style.layout.layout_direction = layout_direction;
    style.layout.child_gap = child_gap;
    style.layout.padding = Padding {
        top: 0.0,
        bottom: 0.0,
        left: 0.0,
        right: 0.0,
    };
    style.floating = None;
    style.border = BorderElementStyle {
        color: Color {
            r: 0.0,
            g: 0.0,
            b: 0.0,
            a: 0.0,
        },
        width: BorderWidthStyle {
            left: 0.0,
            right: 0.0,
            top: 0.0,
            bottom: 0.0,
            between_children: 0.0,
        },
    };
}

/// Native text config used only for measuring. The cotis `TextConfig` carries rendering data
/// (e.g. color) that the measuring functions don't need.
fn measuring_text_config(config: &TextConfig) -> MeasureTextConfig {
    MeasureTextConfig {
        font_id: config.font_id as usize,
        font_size: config.font_size,
        letter_spacing: config.letter_spacing,
        line_height: config.line_height,
        wrap_mode: config.wrap_mode,
        alignment: config.alignment,
    }
}

impl<'layout, 'frame, Config: TextConfigurable + CotisLayoutCompatible<'frame>>
    CotisLayoutRun<'layout, Config>
{
    /// When `intrinsic_fit` is true, uses `AxisSizing::Fit` (min 0, max measured)
    /// so the node can shrink when the parent is tight.
    fn create_text_child(
        &self,
        container_id: ElementId,
        layout_direction: LayoutDirection,
        child_gap: f32,
        measured: Dimensions,
        intrinsic_fit: bool,
    ) -> Config {
        let w = measured.width.max(0.0);
        let h = measured.height.max(0.0);

        let width = if intrinsic_fit {
            AxisSizing::Fit(0.0, w)
        } else {
            AxisSizing::Fixed(w)
        };
        let height = if intrinsic_fit {
            AxisSizing::Fit(h, f32::MAX)
        } else {
            AxisSizing::Fixed(h)
        };

        let parent = self
            .get_config(container_id)
            .expect("[Cotis Layout] text leaf needs set_config before set_leaf_config");

        parent.synthetic_text_child(layout_direction, child_gap, width, height)
    }

    fn push_spacer(&mut self, container_id: ElementId, measured: Dimensions) {
        self.new_child_element();

        let spacer_el = self.create_text_child(
            container_id,
            LayoutDirection::LeftToRight,
            0.0,
            measured,
            false,
        );

        let layout = spacer_el.get_style();
        let id = self.open_element_id();

        self.set_open_element_config(layout, id, None, spacer_el);

        self.close_open_element();
    }

    fn push_text_fragment(
        &mut self,
        container_id: ElementId,
        source: &Arc<str>,
        range: Range<usize>,
        text_config: TextConfig,
        text_style: TextStyle,
        measured: Dimensions,
    ) {
        self.new_child_element();

        let word_el = self.create_text_child(
            container_id,
            LayoutDirection::LeftToRight,
            0.0,
            measured,
            false,
        );

        let layout = word_el.get_style();
        let id = self.open_element_id();

        self.set_open_element_config(layout, id, None, word_el);

        let wid = self.open_element_id();

        self.text_fragments.insert(
            wid,
            TextDrawPayload::new_from_arc(source, range, text_config, text_style),
        );

        self.close_open_element();
    }

    pub(crate) fn apply_text_leaf(&mut self, leaf: TextElementConfig<'frame>) {
        let container_id = self.open_element_id();

        let source: Arc<str> = Arc::from(leaf.text.as_ref());

        #[allow(clippy::clone_on_copy)] // To allow or disallow complex color
        let text_style = leaf.style.clone();
        let text_config = leaf.config;
        let measure_style = measuring_text_config(&text_config);

        self.set_open_element_layout_direction(LayoutDirection::TopToBottom);

        let word_row_gap = match text_config.wrap_mode {
            TextElementConfigWrapMode::Words => {
                measure_text_fragment(" ", &measure_style, self.layout_manager.text_dim_fun()).width
            }
            _ => 0.0,
        };

        for line_range in newline_line_ranges(source.as_ref()) {
            let line_slice_full = &source[line_range.start..line_range.end];

            let (line_dims, words_specs) = match text_config.wrap_mode {
                TextElementConfigWrapMode::Words => {
                    let empty_dims = measure_text_fragment(
                        "",
                        &measure_style,
                        self.layout_manager.text_dim_fun(),
                    );

                    let pieces = line_word_space_pieces(line_slice_full);

                    let specs = text_line_child_specs(&pieces);

                    let dims = if specs.is_empty() {
                        empty_dims
                    } else {
                        let mut sum_w = 0.0f32;
                        let mut max_h = empty_dims.height;

                        for spec in &specs {
                            if let TextLineChildSpec::Word(r) = spec {
                                let fragment = &line_slice_full[r.clone()];

                                let d = measure_text_fragment(
                                    fragment,
                                    &measure_style,
                                    self.layout_manager.text_dim_fun(),
                                );

                                sum_w += d.width;
                                max_h = max_h.max(d.height);
                            }
                        }

                        let gap_total = (specs.len().saturating_sub(1)) as f32 * word_row_gap;

                        Dimensions {
                            width: sum_w + gap_total,
                            height: max_h,
                        }
                    };

                    (dims, specs)
                }

                TextElementConfigWrapMode::Newline | TextElementConfigWrapMode::None => {
                    let dims = measure_text_fragment(
                        line_slice_full,
                        &measure_style,
                        self.layout_manager.text_dim_fun(),
                    );

                    (dims, Vec::new())
                }
            };

            self.new_child_element();

            let line_el = self.create_text_child(
                container_id,
                LayoutDirection::LeftToRight,
                word_row_gap,
                line_dims,
                true,
            );

            let mut line_layout = line_el.get_style();
            line_layout.layout.wrapping = ChildWrapping::Text;

            let line_id = self.open_element_id();

            self.set_open_element_config(line_layout, line_id, None, line_el);

            match text_config.wrap_mode {
                TextElementConfigWrapMode::Words => {
                    let spacer_measured = Dimensions {
                        width: 0.0,
                        height: line_dims.height,
                    };

                    for spec in words_specs {
                        match spec {
                            TextLineChildSpec::Spacer => {
                                self.push_spacer(container_id, spacer_measured);
                            }

                            TextLineChildSpec::Word(w) => {
                                let global = line_range.start + w.start..line_range.start + w.end;

                                let word_text = &source[global.clone()];

                                let measured = measure_text_fragment(
                                    word_text,
                                    &measure_style,
                                    self.layout_manager.text_dim_fun(),
                                );

                                self.push_text_fragment(
                                    container_id,
                                    &source,
                                    global,
                                    text_config,
                                    #[allow(clippy::clone_on_copy)]
                                    // To allow or disallow complex color
                                    text_style.clone(),
                                    measured,
                                );
                            }
                        }
                    }
                }

                TextElementConfigWrapMode::Newline | TextElementConfigWrapMode::None => {
                    if !line_range.is_empty() {
                        let global = line_range.clone();

                        let measured = measure_text_fragment(
                            &source[global.clone()],
                            &measure_style,
                            self.layout_manager.text_dim_fun(),
                        );

                        self.push_text_fragment(
                            container_id,
                            &source,
                            global,
                            text_config,
                            #[allow(clippy::clone_on_copy)] // To allow or disallow complex color
                            text_style.clone(),
                            measured,
                        );
                    }
                }
            }

            self.close_open_element();
        }
    }
}