revw 0.2.5

A vim-like TUI for managing notes and resources
Documentation
use ratatui::style::Color;
use unicode_width::UnicodeWidthChar;

#[derive(Clone, Debug, Default)]
pub struct RelfLineStyle {
    pub fg: Option<Color>,
    pub bg: Option<Color>,
    pub bold: bool,
}

#[derive(Clone, Debug)]
pub struct RelfEntry {
    pub lines: Vec<String>, // For backward compatibility and inside entries
    pub original_index: usize, // Index in the original JSON (before filtering)
    // Fields for corner layout (outside entries)
    pub name: Option<String>,
    pub url: Option<String>,
    pub context: Option<String>,
    pub percentage: Option<i64>,
    // Fields for inside entries
    pub date: Option<String>,
}

#[derive(Clone, Debug, Default)]
pub struct RelfRenderResult {
    pub lines: Vec<String>,
    pub styles: Vec<RelfLineStyle>,
    pub entries: Vec<RelfEntry>,
}

pub struct Renderer;

impl Renderer {
    pub fn display_width_str(s: &str) -> usize {
        s.chars()
            .map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
            .sum()
    }

    pub fn prefix_display_width(s: &str, char_pos: usize) -> usize {
        s.chars()
            .take(char_pos)
            .map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
            .sum()
    }

    pub fn slice_columns(s: &str, start_cols: usize, width_cols: usize) -> String {
        if width_cols == 0 {
            return String::new();
        }
        let mut sum = 0usize;
        let mut start_idx = 0usize;
        for (i, c) in s.chars().enumerate() {
            let w = UnicodeWidthChar::width(c).unwrap_or(0);
            if sum + w > start_cols {
                // This character extends past start_cols, so start here
                start_idx = i;
                break;
            }
            sum += w;
            start_idx = i + 1;
        }
        let mut out = String::new();
        let mut used = 0usize;
        for c in s.chars().skip(start_idx) {
            let w = UnicodeWidthChar::width(c).unwrap_or(0);
            if used + w > width_cols {
                break;
            }
            out.push(c);
            used += w;
        }
        out
    }

    pub fn render_relf(json_input: &str, filter_pattern: &str) -> RelfRenderResult {
        if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(json_input) {
            let mut result = RelfRenderResult::default();

            if let Some(obj) = json_value.as_object() {
                let mut global_index = 0; // Track the original index across all entries

                for (section_key, section_value) in obj {
                    if section_key == "outside" || section_key == "inside" {
                        if let Some(section_array) = section_value.as_array() {
                            for item in section_array {
                                let original_index = global_index;
                                global_index += 1;

                                if let Some(item_obj) = item.as_object() {
                                    if section_key == "outside" {

                                        let mut entry_lines = Vec::new();

                                        let name = item_obj
                                            .get("name")
                                            .and_then(|v| v.as_str())
                                            .unwrap_or("");
                                        let context = item_obj
                                            .get("context")
                                            .and_then(|v| v.as_str())
                                            .unwrap_or("");
                                        let url = item_obj
                                            .get("url")
                                            .and_then(|v| v.as_str())
                                            .unwrap_or("");
                                        let percentage = item_obj
                                            .get("percentage")
                                            .and_then(|v| v.as_i64());

                                        entry_lines.push(name.to_string());
                                        if !context.is_empty() {
                                            entry_lines.push(context.to_string());
                                        }
                                        if !url.is_empty() {
                                            entry_lines.push(url.to_string());
                                        }
                                        // Add percentage line, defaulting to 0% if not specified
                                        let pct = percentage.unwrap_or(0);
                                        entry_lines.push(format!("{}%", pct));

                                        // Apply filter if pattern is provided
                                        if !filter_pattern.is_empty() {
                                            let matches = entry_lines.iter().any(|line| {
                                                line.to_lowercase().contains(&filter_pattern.to_lowercase())
                                            });
                                            if !matches {
                                                continue; // Skip this entry
                                            }
                                        }

                                        result.entries.push(RelfEntry {
                                            lines: entry_lines,
                                            original_index,
                                            name: Some(name.to_string()),
                                            url: if !url.is_empty() { Some(url.to_string()) } else { None },
                                            context: if !context.is_empty() { Some(context.to_string()) } else { None },
                                            percentage,
                                            date: None,
                                        });
                                    } else if section_key == "inside" {
                                        let date = item_obj
                                            .get("date")
                                            .and_then(|v| v.as_str())
                                            .unwrap_or("");
                                        let context = item_obj
                                            .get("context")
                                            .and_then(|v| v.as_str())
                                            .unwrap_or("");

                                        let mut entry_lines = Vec::new();
                                        if !date.is_empty() {
                                            entry_lines.push(date.to_string());
                                        }
                                        if !context.is_empty() {
                                            entry_lines.push(context.to_string());
                                        }

                                        // Apply filter if pattern is provided
                                        if !filter_pattern.is_empty() {
                                            let matches = entry_lines.iter().any(|line| {
                                                line.to_lowercase().contains(&filter_pattern.to_lowercase())
                                            });
                                            if !matches {
                                                continue; // Skip this entry
                                            }
                                        }

                                        result.entries.push(RelfEntry {
                                            lines: entry_lines,
                                            original_index,
                                            name: None,
                                            url: None,
                                            context: if !context.is_empty() { Some(context.to_string()) } else { None },
                                            percentage: None,
                                            date: if !date.is_empty() { Some(date.to_string()) } else { None },
                                        });
                                    }
                                }
                            }
                        }
                    }
                }
            }

            return result;
        }

        let lines: Vec<String> = json_input
            .lines()
            .enumerate()
            .take(50)
            .map(|(i, line)| format!("{:4}: {}", i + 1, line))
            .collect();

        let mut result = RelfRenderResult::default();
        result
            .lines
            .push("⚠ Not valid JSON - showing raw text file content".to_string());
        result.styles.push(RelfLineStyle {
            fg: Some(Color::Yellow),
            bg: None,
            bold: true,
        });
        result.lines.push("".to_string());
        result.styles.push(RelfLineStyle::default());
        for line in lines {
            result.lines.push(line);
            result.styles.push(RelfLineStyle::default());
        }
        result
    }

    pub fn render_json(json_input: &str) -> Vec<String> {
        json_input.lines().map(|line| line.to_string()).collect()
    }
}