mlt 0.3.5

MapLibre Tile Tools
Documentation
use std::collections::HashSet;

use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Rect};
use ratatui::prelude::{Line, Span, Style};
use ratatui::widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, Wrap};
use size_format::SizeFormatterSI;
use usize_cast::{FromUsize as _, IntoUsize as _};

use crate::ls::{LsRow, NA, na, path_display, row_cells_6};
use crate::ui::rendering::map;
use crate::ui::rendering::scrollbar::{Scroll, render_vscrollbar, wrapped_rows};
use crate::ui::state::App;
use crate::ui::{
    CLR_DIMMED, CLR_HINT, CLR_HOVERED, STYLE_BOLD, STYLE_LABEL, STYLE_SELECTED, block_with_title,
    collect_extensions, collect_file_algorithms, collect_file_geometries,
};

pub fn render_tile_preview_panel(f: &mut Frame<'_>, area: Rect, app: &App) {
    if let Some(ref tile) = app.preview {
        map::render_tile_preview(f, area, &tile.fc, tile.extent);
        return;
    }
    let selected = app.get_selected_file().map(LsRow::path);
    let msg = if let Some(err) = app
        .preview_error
        .as_ref()
        .filter(|_| app.preview_tile_path.as_deref() == selected)
    {
        format!("Preview failed: {err}")
    } else if selected.is_some() && app.preview_load_requested.as_deref() == selected {
        "Loading…".into()
    } else {
        "Select a tile file (.mlt / .mvt) to preview".into()
    };
    f.render_widget(
        Paragraph::new(Line::from(msg))
            .wrap(Wrap { trim: true })
            .block(block_with_title("Tile Preview")),
        area,
    );
}

pub fn render_file_browser(f: &mut Frame<'_>, area: Rect, app: &mut App) {
    app.file_table_area = Some(area);
    app.file_table_inner_height = area.height.saturating_sub(3).into_usize();

    let base = app.file_browser_base.as_deref();
    let file_w = app
        .files
        .iter()
        .map(|r| path_display(r.path(), base).len())
        .max()
        .unwrap_or(4)
        .max(4);

    let widths = [
        Constraint::Length(u16::try_from(file_w).unwrap_or_default().min(200)),
        Constraint::Length(8),
        Constraint::Length(7),
        Constraint::Length(6),
        Constraint::Length(10),
        Constraint::Min(0),
    ];
    app.file_table_widths = Some(widths);

    let header = Row::new(vec![
        Cell::from("File"),
        Cell::from(Line::from("Size").alignment(Alignment::Right)),
        Cell::from(Line::from("Enc %").alignment(Alignment::Right)),
        Cell::from(Line::from("Layers").alignment(Alignment::Right)),
        Cell::from(Line::from("Features").alignment(Alignment::Right)),
        Cell::from("Notes"),
    ])
    .style(STYLE_BOLD);

    let rows: Vec<Row> = app
        .filtered_file_indices
        .iter()
        .map(|&i| Row::new(row_cells_6(&app.files[i], base).map(Cell::from)))
        .collect();
    let row_count = rows.len();

    let status = app.scan_status();
    let sort_hint = if app.data_loaded() {
        " Click header to sort"
    } else {
        ""
    };
    let filtered = app.filtered_file_indices.len();
    let total = app.files.len();
    let count = if filtered < total {
        format!("{filtered}/{total}")
    } else {
        total.to_string()
    };
    let progress = if status.scanning {
        ", scanning…".to_string()
    } else if status.pending > 0 {
        format!(", {} analyzing…", status.pending)
    } else {
        String::new()
    };
    let count = format!("{count} found{progress}");
    let title =
        format!("MLT Files ({count}) - ↑/↓ navigate, Enter open, h help, q quit{sort_hint}");
    let table = Table::new(rows, widths)
        .header(header)
        .column_spacing(1)
        .block(block_with_title(title))
        .row_highlight_style(STYLE_SELECTED)
        .highlight_symbol(">> ")
        .highlight_spacing(HighlightSpacing::Always);
    f.render_stateful_widget(table, area, &mut app.file_list_state);
    render_vscrollbar(
        f,
        area,
        Scroll {
            content: row_count,
            view: app.file_table_inner_height,
            pos: app.file_list_state.offset(),
        },
    );
}

pub fn render_file_filter_panel(f: &mut Frame<'_>, area: Rect, app: &mut App) {
    let exts = collect_extensions(&app.files);
    let geoms = collect_file_geometries(&app.files);
    let algos = collect_file_algorithms(&app.files);
    let has_any =
        !app.ext_filters.is_empty() || !app.geom_filters.is_empty() || !app.algo_filters.is_empty();

    let selected_mlt = app.get_selected_file();
    let sel_ext: Option<String> = selected_mlt
        .and_then(|r| r.path().extension().and_then(|e| e.to_str()))
        .map(str::to_lowercase);
    let sel_info = selected_mlt.and_then(|r| match r {
        LsRow::Info { info, .. } => Some(info),
        LsRow::Error { .. } | LsRow::Loading { .. } => None,
    });

    let mut lines: Vec<Line<'static>> = Vec::new();
    let reset_style = if has_any {
        STYLE_SELECTED
    } else {
        Style::default().fg(CLR_DIMMED)
    };
    lines.push(Line::from(Span::styled("[Reset filters]", reset_style)));
    lines.push(Line::from(""));

    let check = |on: bool| if on { "[x] " } else { "[ ] " };
    let present_style =
        |yes: bool| -> Style { Style::default().fg(if yes { CLR_HOVERED } else { CLR_DIMMED }) };

    if !exts.is_empty() {
        lines.push(Line::from(Span::styled("Extensions:", STYLE_BOLD)));
        for ext in &exts {
            lines.push(Line::from(Span::styled(
                format!("  {}{ext}", check(app.ext_filters.contains(ext))),
                present_style(sel_ext.as_deref() == Some(ext.as_str())),
            )));
        }
    }
    if !geoms.is_empty() {
        lines.push(Line::from(""));
        lines.push(Line::from(Span::styled("Geometry Types:", STYLE_BOLD)));

        let sel_geoms: HashSet<_> = sel_info
            .map(|i| i.geometries.iter().copied().collect())
            .unwrap_or_default();
        for g in &geoms {
            lines.push(Line::from(Span::styled(
                format!("  {}{g}", check(app.geom_filters.contains(g))),
                present_style(sel_geoms.contains(g)),
            )));
        }
    }
    if !algos.is_empty() {
        lines.push(Line::from(""));
        lines.push(Line::from(Span::styled("Algorithms:", STYLE_BOLD)));

        let sel_algos: HashSet<_> = sel_info
            .map(|i| i.algorithms.iter().copied().collect())
            .unwrap_or_default();
        for a in &algos {
            lines.push(Line::from(Span::styled(
                format!("  {}{a}", check(app.algo_filters.contains(a))),
                present_style(sel_algos.contains(a)),
            )));
        }
    }
    if lines.is_empty() {
        lines.push(Line::from("(loading…)"));
    }

    let inner = area.height.saturating_sub(2).into_usize();
    let content = lines.len();
    app.filter_scroll = app.filter_scroll.min(Scroll::max_pos(content, inner));
    let para = Paragraph::new(lines)
        .block(block_with_title("Filter (click to toggle)"))
        .scroll((app.filter_scroll, 0));
    f.render_widget(para, area);
    render_vscrollbar(f, area, Scroll::new(content, inner, app.filter_scroll));
}

pub fn render_file_info_panel(f: &mut Frame<'_>, area: Rect, app: &mut App) {
    let info = app.get_selected_file().and_then(|r| match r {
        LsRow::Info { info, .. } => Some(info),
        LsRow::Error { .. } | LsRow::Loading { .. } => None,
    });
    let base = app
        .file_browser_base
        .as_ref()
        .map_or_else(String::new, |p| p.display().to_string());
    let status = app.scan_status();

    let lines: Vec<Line<'static>> = if let Some(info) = info {
        let sz = |n: usize| format!("{:.1}B", SizeFormatterSI::new(u64::from_usize(n)));
        let row = |name: &str, val: String, desc: &str| -> Line<'static> {
            let mut spans = vec![
                Span::styled(format!("{name}: "), STYLE_LABEL),
                Span::raw(val),
            ];
            if !desc.is_empty() {
                spans.push(Span::styled(
                    format!("  {desc}"),
                    Style::default().fg(CLR_HINT),
                ));
            }
            Line::from(spans)
        };
        vec![
            row("File", info.path.clone(), ""),
            row("Size", sz(info.size), "raw MLT file size"),
            row(
                "Encoding",
                na(info.encoding_pct.map(|p| format!("{p:.1}%"))),
                "MLT / (data + metadata)",
            ),
            row("Data", na(info.data_size.map(sz)), "decoded payload size"),
            row(
                "Metadata",
                match (info.meta_size, info.meta_pct) {
                    (Some(m), Some(p)) => format!("{} ({:.1}% of data)", sz(m), p),
                    _ => NA.to_string(),
                },
                "encoding overhead",
            ),
            row("Layers", info.layers.to_string(), "tile layer count"),
            row(
                "Features",
                info.features.to_string(),
                "total across all layers",
            ),
            row(
                "Streams",
                na(info.streams.map(|n| n.to_string())),
                "encoded data streams",
            ),
            row(
                "Geometries",
                info.geometries_display(),
                "geometry types present",
            ),
            row(
                "Algorithms",
                info.algorithms_display(),
                "compression methods",
            ),
        ]
    } else if app.files.is_empty() && status.scanning {
        vec![Line::from(format!("Scanning {base}…"))]
    } else if app.files.is_empty() {
        vec![Line::from(format!("No tile files found in {base}"))]
    } else if app.filtered_file_indices.is_empty() {
        vec![
            Line::from("No files match the current filters."),
            Line::from(""),
            Line::from(Span::styled("[Reset filters]", STYLE_SELECTED)),
        ]
    } else if matches!(app.get_selected_file(), Some(LsRow::Loading { .. })) {
        vec![Line::from("Analyzing…")]
    } else {
        vec![Line::from("Select a file to view details")]
    };

    let inner_h = area.height.saturating_sub(2).into_usize();
    let inner_w = area.width.saturating_sub(2).into_usize();
    let rows = wrapped_rows(&lines, inner_w);
    app.file_info_scroll = app.file_info_scroll.min(Scroll::max_pos(rows, inner_h));

    let para = Paragraph::new(lines)
        .block(block_with_title("File Info"))
        .wrap(Wrap { trim: false })
        .scroll((app.file_info_scroll, 0));
    f.render_widget(para, area);
    render_vscrollbar(f, area, Scroll::new(rows, inner_h, app.file_info_scroll));
}