revue 2.71.1

A Vue-style TUI framework for Rust with CSS styling
Documentation
//! Text manipulation utilities
//!
//! Common text processing functions used across widgets.

// =============================================================================
// Character Index Utilities (UTF-8 safe)
// =============================================================================

/// Get byte index from character index in a string
///
/// This is essential for UTF-8 safe string manipulation where you need
/// to work with character positions but String operations require byte indices.
///
/// # Arguments
/// * `s` - The string to index into
/// * `char_idx` - The character index (0-based)
///
/// # Returns
/// The byte index corresponding to the character index, or string length if out of bounds
///
/// # Example
/// ```ignore
/// let s = "héllo";
/// assert_eq!(char_to_byte_index(s, 0), 0); // 'h'
/// assert_eq!(char_to_byte_index(s, 1), 1); // 'é' starts at byte 1
/// assert_eq!(char_to_byte_index(s, 2), 3); // 'l' starts at byte 3 (é is 2 bytes)
/// ```
#[inline]
pub fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
    s.char_indices()
        .nth(char_idx)
        .map(|(i, _)| i)
        .unwrap_or(s.len())
}

/// Get byte index from character index, also returning the character at that position
///
/// # Returns
/// Tuple of `(byte_index, Option<char>)` where char is None if index is out of bounds
#[inline]
pub fn char_to_byte_index_with_char(s: &str, char_idx: usize) -> (usize, Option<char>) {
    s.char_indices()
        .nth(char_idx)
        .map(|(i, c)| (i, Some(c)))
        .unwrap_or((s.len(), None))
}

/// Get character index from byte index
///
/// # Arguments
/// * `s` - The string
/// * `byte_idx` - The byte index
///
/// # Returns
/// The character index, or character count if byte_idx is at or past end
///
/// # Safety
/// This function safely handles invalid byte indices by clamping to string bounds
/// and validating UTF-8 boundaries.
#[inline]
pub fn byte_to_char_index(s: &str, byte_idx: usize) -> usize {
    // Clamp to valid range
    let byte_idx = byte_idx.min(s.len());

    // Use is_char_boundary to safely handle invalid byte positions
    // If not at a valid boundary, find the nearest valid boundary
    let safe_idx = if s.is_char_boundary(byte_idx) {
        byte_idx
    } else {
        // Find the previous valid character boundary by scanning backwards
        let mut safe = 0;
        for (i, _) in s.char_indices() {
            if i <= byte_idx && s.is_char_boundary(i) {
                safe = i;
            } else if i > byte_idx {
                break;
            }
        }
        safe
    };

    s[..safe_idx].chars().count()
}

/// Count characters in a string (more explicit than .chars().count())
#[inline]
pub fn char_count(s: &str) -> usize {
    s.chars().count()
}

/// Get a substring by character indices (not byte indices)
///
/// # Arguments
/// * `s` - The string to slice
/// * `start` - Start character index (inclusive)
/// * `end` - End character index (exclusive)
///
/// # Returns
/// The substring, or empty string if indices are invalid
///
/// # Safety
/// This function safely handles out-of-bounds indices and ensures
/// all byte indices are at valid UTF-8 character boundaries.
pub fn char_slice(s: &str, start: usize, end: usize) -> &str {
    if start >= end || start >= char_count(s) {
        return "";
    }

    let start_byte = char_to_byte_index(s, start);
    let end_byte = char_to_byte_index(s, end).min(s.len());

    // Ensure both indices are at valid UTF-8 boundaries
    if !s.is_char_boundary(start_byte) || !s.is_char_boundary(end_byte) {
        return "";
    }

    &s[start_byte..end_byte]
}

/// Insert a string at a character position
///
/// # Arguments
/// * `s` - The string to modify
/// * `char_idx` - Character position to insert at
/// * `insert` - String to insert
///
/// # Returns
/// New cursor position (char_idx + inserted char count)
pub fn insert_at_char(s: &mut String, char_idx: usize, insert: &str) -> usize {
    let byte_idx = char_to_byte_index(s, char_idx);
    s.insert_str(byte_idx, insert);
    char_idx + insert.chars().count()
}

/// Remove a character at a character position
///
/// # Arguments
/// * `s` - The string to modify
/// * `char_idx` - Character position to remove
///
/// # Returns
/// The removed character, or None if index was out of bounds
pub fn remove_char_at(s: &mut String, char_idx: usize) -> Option<char> {
    let (byte_idx, maybe_char) = char_to_byte_index_with_char(s, char_idx);
    if let Some(ch) = maybe_char {
        s.drain(byte_idx..byte_idx + ch.len_utf8());
        Some(ch)
    } else {
        None
    }
}

/// Remove a range of characters (start..end in character indices)
///
/// # Arguments
/// * `s` - The string to modify
/// * `start` - Start character index (inclusive)
/// * `end` - End character index (exclusive)
pub fn remove_char_range(s: &mut String, start: usize, end: usize) {
    if start >= end {
        return;
    }
    let start_byte = char_to_byte_index(s, start);
    let end_byte = char_to_byte_index(s, end);
    s.drain(start_byte..end_byte);
}

/// Truncate text to fit within max_width, adding ellipsis if needed
///
/// # Arguments
/// * `text` - Text to truncate
/// * `max_width` - Maximum character width
///
/// # Returns
/// Truncated string with ellipsis if truncation occurred
///
/// # Example
/// ```ignore
/// let short = truncate("Hello World", 8);
/// assert_eq!(short, "Hello…");
/// ```
pub fn truncate(text: &str, max_width: usize) -> String {
    crate::utils::unicode::truncate_with_ellipsis(text, max_width)
}

/// Truncate text from the start, adding ellipsis at beginning
///
/// # Example
/// ```ignore
/// let short = truncate_start("/home/user/documents/file.txt", 20);
/// assert_eq!(short, "…ments/file.txt");
/// ```
pub fn truncate_start(text: &str, max_width: usize) -> String {
    use crate::utils::unicode::display_width as dw;
    let width = dw(text);
    if width <= max_width {
        return text.to_string();
    }
    if max_width <= 1 {
        return String::from("");
    }
    // Walk from the end, accumulating display width
    let keep_width = max_width.saturating_sub(1); // reserve 1 for "…"
    let mut kept = Vec::new();
    let mut acc_width = 0;
    for c in text.chars().rev() {
        let cw = crate::utils::unicode::char_width(c);
        if acc_width + cw > keep_width {
            break;
        }
        acc_width += cw;
        kept.push(c);
    }
    kept.reverse();
    let mut result = String::with_capacity(acc_width * 3 + 3);
    result.push('');
    for c in kept {
        result.push(c);
    }
    result
}

/// Center text within given width
///
/// # Arguments
/// * `text` - Text to center
/// * `width` - Total width to center within
///
/// # Returns
/// Centered string padded with spaces
pub fn center(text: &str, width: usize) -> String {
    crate::utils::unicode::center_to_width(text, width)
}

/// Pad text on the left to reach target width
pub fn pad_left(text: &str, width: usize) -> String {
    crate::utils::unicode::right_align_to_width(text, width)
}

/// Pad text on the right to reach target width
pub fn pad_right(text: &str, width: usize) -> String {
    crate::utils::unicode::pad_to_width(text, width)
}

/// Wrap text to fit within max_width
///
/// # Arguments
/// * `text` - Text to wrap
/// * `max_width` - Maximum line width
///
/// # Returns
/// Vector of lines, each fitting within max_width
pub fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
    if max_width == 0 || text.is_empty() {
        return vec![];
    }
    // Handle multiple paragraphs (newlines)
    let mut lines = Vec::new();
    for paragraph in text.lines() {
        if paragraph.is_empty() {
            lines.push(String::new());
            continue;
        }
        lines.extend(crate::utils::unicode::wrap_to_width(paragraph, max_width));
    }
    if lines.is_empty() {
        lines.push(String::new());
    }
    lines
}

/// Split text into fixed-width chunks (for display in columns)
pub fn split_fixed_width(text: &str, width: usize) -> Vec<String> {
    if width == 0 {
        return vec![];
    }

    let mut chunks = Vec::new();
    let mut remaining = text;

    while !remaining.is_empty() {
        let (chunk, rest) = crate::utils::unicode::split_at_width(remaining, width);
        if chunk.is_empty() {
            // Can't fit even a single character (e.g., wide char in narrow width)
            break;
        }
        chunks.push(chunk.to_string());
        remaining = rest;
    }

    if chunks.is_empty() {
        chunks.push(String::new());
    }

    chunks
}

/// Get display width of a string (accounting for wide characters)
///
/// Delegates to [`crate::utils::unicode::display_width`] for proper Unicode handling.
pub fn display_width(text: &str) -> usize {
    crate::utils::unicode::display_width(text)
}

/// Repeat a character to create a string
pub fn repeat_char(ch: char, count: usize) -> String {
    std::iter::repeat_n(ch, count).collect()
}

/// Create a horizontal bar using block characters
pub fn progress_bar(value: f64, width: usize) -> String {
    let value = value.clamp(0.0, 1.0);
    let filled = (value * width as f64).round() as usize;
    let empty = width.saturating_sub(filled);

    // Pre-allocate exact capacity (each char is 1-3 bytes, but 3 is safe)
    let capacity = width * 3;
    let mut result = String::with_capacity(capacity);

    for _ in 0..filled {
        result.push('');
    }
    for _ in 0..empty {
        result.push('');
    }
    result
}

/// Create a horizontal bar with partial fill character
pub fn progress_bar_precise(value: f64, width: usize) -> String {
    let value = value.clamp(0.0, 1.0);
    let total_eighths = (value * width as f64 * 8.0).round() as usize;
    let full_blocks = total_eighths / 8;
    let remainder = total_eighths % 8;

    let partial = match remainder {
        0 => "",
        1 => "",
        2 => "",
        3 => "",
        4 => "",
        5 => "",
        6 => "",
        7 => "",
        _ => "",
    };

    let empty = width
        .saturating_sub(full_blocks)
        .saturating_sub(if remainder > 0 { 1 } else { 0 });

    // Pre-allocate capacity
    let capacity = (full_blocks + partial.len() + empty) * 3;
    let mut result = String::with_capacity(capacity);

    for _ in 0..full_blocks {
        result.push('');
    }
    result.push_str(partial);
    for _ in 0..empty {
        result.push(' ');
    }
    result
}