cotis-layout 0.1.0-alpha

Flexbox-style layout engine for Cotis
Documentation
use crate::layout_struct::info_types::TextConfig;
use cotis::utils::OwnedOrRef;
use cotis_defaults::element_configs::text_config::{
    TextAlignment, TextConfig as DefaultTextConfig, TextElementConfigWrapMode,
};
use cotis_utils::math::Dimensions;
use std::ops::Range;
use std::sync::Arc;

pub type TextMeasuringFun = Box<dyn Fn(&str, &DefaultTextConfig) -> Dimensions>;

pub fn wrapped_text<'text>(
    max_width: f32,
    config: &TextConfig,
    text_ref: &OwnedOrRef<'text, str>,
    measure: &TextMeasuringFun,
) -> OwnedOrRef<'text, str> {
    let text = text_ref.as_ref();
    match config.wrap_mode {
        TextElementConfigWrapMode::None => {
            // No wrapping at all
            text_ref.clone()
        }

        TextElementConfigWrapMode::Newline => {
            // Only wrap on explicit '\n'
            text_ref.clone()
        }

        TextElementConfigWrapMode::Words => {
            let mut wrapped = String::new();

            for (p_index, paragraph) in text.split('\n').enumerate() {
                if p_index > 0 {
                    wrapped.push('\n');
                }

                let mut current_line = String::new();

                for word in paragraph.split_whitespace() {
                    let candidate = if current_line.is_empty() {
                        word.to_string()
                    } else {
                        let mut tmp = current_line.clone();
                        tmp.push(' ');
                        tmp.push_str(word);
                        tmp
                    };

                    let Dimensions { width, .. } = measure(
                        &candidate,
                        &DefaultTextConfig {
                            font_id: config.font_id as u16,
                            font_size: config.font_size,
                            letter_spacing: config.letter_spacing,
                            line_height: config.line_height,
                            wrap_mode: config.wrap_mode,
                            alignment: TextAlignment::Left,
                        },
                    );

                    if width <= max_width {
                        current_line = candidate;
                    } else {
                        if !current_line.is_empty() {
                            wrapped.push_str(&current_line);
                            wrapped.push('\n');
                        }
                        current_line = word.to_string();
                    }
                }

                if !current_line.is_empty() {
                    wrapped.push_str(&current_line);
                }
            }
            OwnedOrRef::AsOwnedRef(Arc::new(wrapped))
        }
    }
}

pub fn measure_text(config: &TextConfig, text: &str, measure: &TextMeasuringFun) -> Dimensions {
    measure(
        text,
        &DefaultTextConfig {
            font_id: config.font_id as u16,
            font_size: config.font_size,
            letter_spacing: config.letter_spacing,
            line_height: config.line_height,
            wrap_mode: config.wrap_mode,
            alignment: TextAlignment::Left,
        },
    )
}

/// Monospace fallback: one `font_size` advance per character plus `letter_spacing` between glyphs.
fn monospace_text_dimensions(text: &str, style: &TextConfig) -> Dimensions {
    let n = text.chars().count() as f32;
    let width = if n <= 0.0 {
        0.0
    } else {
        n * style.font_size + (n - 1.0).max(0.0) * style.letter_spacing
    };
    let height = if style.line_height > 0.0 {
        style.line_height
    } else {
        style.font_size
    };
    Dimensions { width, height }
}

fn measure_text_fragment(
    text: &str,
    style: &TextConfig,
    measurer: Option<&TextMeasuringFun>,
) -> Dimensions {
    let line_h = if style.line_height > 0.0 {
        style.line_height
    } else {
        style.font_size
    };
    if text.is_empty() {
        return Dimensions {
            width: 0.0,
            height: line_h,
        };
    }
    match measurer {
        Some(m) => m(
            text,
            &DefaultTextConfig {
                font_id: style.font_id as u16,
                font_size: style.font_size,
                letter_spacing: style.letter_spacing,
                line_height: style.line_height,
                wrap_mode: style.wrap_mode,
                alignment: style.alignment,
            },
        ),
        None => monospace_text_dimensions(text, style),
    }
}

fn newline_line_ranges(source: &str) -> Vec<Range<usize>> {
    let mut ranges = Vec::new();
    let mut start = 0usize;
    for (i, ch) in source.char_indices() {
        if ch == '\n' {
            ranges.push(start..i);
            start = i + ch.len_utf8();
        }
    }
    ranges.push(start..source.len());
    ranges
}

/// One segment of a single display line: a word (byte range within the line) or a run of whitespace.
#[derive(Debug)]
enum LinePiece {
    Word(Range<usize>),
    /// Number of whitespace characters in this run (each maps to one `child_gap` on the row).
    SpaceRun(usize),
}

fn line_word_space_pieces(line: &str) -> Vec<LinePiece> {
    let mut out = Vec::new();
    let mut it = line.char_indices().peekable();
    while let Some((start, c)) = it.next() {
        if c.is_whitespace() {
            let mut count = 1usize;
            while let Some(&(_, c2)) = it.peek() {
                if c2.is_whitespace() {
                    it.next();
                    count += 1;
                } else {
                    break;
                }
            }
            out.push(LinePiece::SpaceRun(count));
        } else {
            let mut end = start + c.len_utf8();
            while let Some(&(_, c2)) = it.peek() {
                if c2.is_whitespace() {
                    break;
                }
                let (j, c2) = it.next().unwrap();
                end = j + c2.len_utf8();
            }
            out.push(LinePiece::Word(start..end));
        }
    }
    out
}

/// Zero-width spacer elements sit between word boxes so each whitespace character becomes one `child_gap`.
#[derive(Clone, Debug)]
enum TextLineChildSpec {
    Spacer,
    Word(Range<usize>),
}

fn text_line_child_specs(pieces: &[LinePiece]) -> Vec<TextLineChildSpec> {
    let n = pieces.len();
    if n == 0 {
        return Vec::new();
    }
    let mut out = Vec::new();
    for (idx, p) in pieces.iter().enumerate() {
        match p {
            LinePiece::SpaceRun(k) => {
                let k = *k;
                if k == 0 {
                    continue;
                }
                if n == 1 {
                    // k gaps need k + 1 children of width 0.
                    for _ in 0..k + 1 {
                        out.push(TextLineChildSpec::Spacer);
                    }
                } else if idx == 0 {
                    for _ in 0..k {
                        out.push(TextLineChildSpec::Spacer);
                    }
                } else if idx == n - 1 {
                    for _ in 0..k {
                        out.push(TextLineChildSpec::Spacer);
                    }
                } else {
                    for _ in 0..k.saturating_sub(1) {
                        out.push(TextLineChildSpec::Spacer);
                    }
                }
            }
            LinePiece::Word(r) => out.push(TextLineChildSpec::Word(r.clone())),
        }
    }
    out
}

// Todo: replace ElementConfig with crate native configuration
pub(crate) mod text_leafs;