# kmlcli Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a terminal KML/KMZ viewer with full TUI (tree + braille map + details) and non-interactive subcommands.

**Architecture:** Single Rust binary. Parse KML/KMZ via `kml` crate into internal model, then either render in ratatui TUI (default) or output JSON for non-interactive subcommands. Canvas widget with `Marker::Braille` for map rendering.

**Tech Stack:** Rust, kml, ratatui, crossterm, clap, serde, serde_json, color-eyre

---

## File Structure

```
src/
├── main.rs          # entry point, CLI routing
├── cli.rs           # clap derive structs
├── parser.rs        # kml crate → internal model conversion
├── model.rs         # KmlDocument, Feature, Geometry, Style, Coord
├── projection.rs    # Mercator projection + viewport
├── tui/
│   ├── mod.rs       # re-exports
│   ├── app.rs       # App state, event loop, run()
│   ├── tree.rs      # tree widget + TreeState
│   ├── map.rs       # braille canvas widget
│   ├── details.rs   # selected element info widget
│   └── input.rs     # keyboard handler, Action enum
└── commands/
    ├── mod.rs        # re-exports
    ├── info.rs       # info subcommand
    ├── list.rs       # list subcommand
    └── tree.rs       # tree subcommand
tests/
├── parser_test.rs   # parsing KML/KMZ → model
├── projection_test.rs # coordinate projection
├── model_test.rs    # model helpers (bounding box, flatten, etc.)
└── fixtures/
    ├── simple.kml    # one placemark with a point
    ├── multi.kml     # folders, lines, polygons, multi-geometry
    └── empty.kml     # valid KML with no features
```

---

### Task 1: Project Scaffold + Model

**Files:**
- Create: `Cargo.toml`
- Create: `src/main.rs`
- Create: `src/model.rs`
- Create: `tests/model_test.rs`
- Create: `tests/fixtures/simple.kml`

- [ ] **Step 1: Initialize Cargo project**

```bash
cd /home/hoka/ghq/github.com/hokanosekai/kmlcli
cargo init --name kmlcli
```

- [ ] **Step 2: Set up dependencies in Cargo.toml**

Replace the `[dependencies]` section in `Cargo.toml`:

```toml
[dependencies]
kml = { version = "0.13", features = ["zip"] }
ratatui = "0.29"
crossterm = "0.28"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
color-eyre = "0.6"

[dev-dependencies]
pretty_assertions = "1"
```

- [ ] **Step 3: Create the test fixture `tests/fixtures/simple.kml`**

```xml
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
  <Document>
    <name>Test Document</name>
    <Folder>
      <name>Folder 1</name>
      <Placemark>
        <name>Point A</name>
        <description>A test point</description>
        <styleUrl>#style1</styleUrl>
        <Point>
          <coordinates>-1.6778,48.1173,0</coordinates>
        </Point>
      </Placemark>
    </Folder>
    <Placemark>
      <name>Line B</name>
      <LineString>
        <coordinates>
          -1.6778,48.1173,0
          -1.6800,48.1200,0
          -1.6850,48.1250,0
        </coordinates>
      </LineString>
    </Placemark>
  </Document>
</kml>
```

- [ ] **Step 4: Write the model types in `src/model.rs`**

```rust
use std::collections::HashMap;

use serde::Serialize;

#[derive(Debug, Clone, Serialize)]
pub struct Coord {
    pub lon: f64,
    pub lat: f64,
    pub alt: Option<f64>,
}

#[derive(Debug, Clone, Serialize)]
pub enum Geometry {
    Point(Coord),
    LineString(Vec<Coord>),
    Polygon(Vec<Vec<Coord>>),
    MultiGeometry(Vec<Geometry>),
}

#[derive(Debug, Clone, Serialize)]
pub enum Feature {
    Folder {
        name: String,
        features: Vec<Feature>,
    },
    Placemark {
        name: String,
        geometry: Option<Geometry>,
        style_id: Option<String>,
        description: Option<String>,
    },
}

#[derive(Debug, Clone, Serialize)]
pub struct Style {
    pub id: String,
    pub line_color: Option<String>,
    pub line_width: Option<f64>,
    pub poly_color: Option<String>,
    pub icon_href: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct KmlDocument {
    pub name: Option<String>,
    pub features: Vec<Feature>,
    pub styles: HashMap<String, Style>,
}

#[derive(Debug, Clone, Copy)]
pub struct BoundingBox {
    pub min_lon: f64,
    pub max_lon: f64,
    pub min_lat: f64,
    pub max_lat: f64,
}

impl KmlDocument {
    /// Collect all coordinates from all features recursively.
    pub fn all_coords(&self) -> Vec<&Coord> {
        let mut coords = Vec::new();
        for feature in &self.features {
            collect_coords(feature, &mut coords);
        }
        coords
    }

    /// Compute bounding box of all coordinates. Returns None if no coords.
    pub fn bounding_box(&self) -> Option<BoundingBox> {
        let coords = self.all_coords();
        if coords.is_empty() {
            return None;
        }
        let mut bbox = BoundingBox {
            min_lon: f64::MAX,
            max_lon: f64::MIN,
            min_lat: f64::MAX,
            max_lat: f64::MIN,
        };
        for c in &coords {
            bbox.min_lon = bbox.min_lon.min(c.lon);
            bbox.max_lon = bbox.max_lon.max(c.lon);
            bbox.min_lat = bbox.min_lat.min(c.lat);
            bbox.max_lat = bbox.max_lat.max(c.lat);
        }
        // Guard against all points at same location
        if (bbox.max_lon - bbox.min_lon).abs() < 1e-9 {
            bbox.min_lon -= 0.001;
            bbox.max_lon += 0.001;
        }
        if (bbox.max_lat - bbox.min_lat).abs() < 1e-9 {
            bbox.min_lat -= 0.001;
            bbox.max_lat += 0.001;
        }
        Some(bbox)
    }

    /// Flatten all features into a list of (path, &Feature) for tree display.
    pub fn flatten(&self) -> Vec<(Vec<usize>, &Feature)> {
        let mut result = Vec::new();
        for (i, feature) in self.features.iter().enumerate() {
            flatten_recursive(feature, vec![i], &mut result);
        }
        result
    }
}

fn collect_coords<'a>(feature: &'a Feature, coords: &mut Vec<&'a Coord>) {
    match feature {
        Feature::Folder { features, .. } => {
            for f in features {
                collect_coords(f, coords);
            }
        }
        Feature::Placemark { geometry, .. } => {
            if let Some(geom) = geometry {
                collect_geom_coords(geom, coords);
            }
        }
    }
}

fn collect_geom_coords<'a>(geom: &'a Geometry, coords: &mut Vec<&'a Coord>) {
    match geom {
        Geometry::Point(c) => coords.push(c),
        Geometry::LineString(cs) => coords.extend(cs),
        Geometry::Polygon(rings) => {
            for ring in rings {
                coords.extend(ring);
            }
        }
        Geometry::MultiGeometry(geoms) => {
            for g in geoms {
                collect_geom_coords(g, coords);
            }
        }
    }
}

fn flatten_recursive<'a>(
    feature: &'a Feature,
    path: Vec<usize>,
    result: &mut Vec<(Vec<usize>, &'a Feature)>,
) {
    result.push((path.clone(), feature));
    if let Feature::Folder { features, .. } = feature {
        for (i, child) in features.iter().enumerate() {
            let mut child_path = path.clone();
            child_path.push(i);
            flatten_recursive(child, child_path, result);
        }
    }
}
```

- [ ] **Step 5: Write model tests in `tests/model_test.rs`**

```rust
use kmlcli::model::*;
use std::collections::HashMap;

fn make_doc() -> KmlDocument {
    KmlDocument {
        name: Some("Test".to_string()),
        features: vec![
            Feature::Folder {
                name: "Folder 1".to_string(),
                features: vec![Feature::Placemark {
                    name: "Point A".to_string(),
                    geometry: Some(Geometry::Point(Coord {
                        lon: -1.6778,
                        lat: 48.1173,
                        alt: None,
                    })),
                    style_id: None,
                    description: None,
                }],
            },
            Feature::Placemark {
                name: "Line B".to_string(),
                geometry: Some(Geometry::LineString(vec![
                    Coord { lon: -1.6778, lat: 48.1173, alt: None },
                    Coord { lon: -1.6800, lat: 48.1200, alt: None },
                ])),
                style_id: None,
                description: None,
            },
        ],
        styles: HashMap::new(),
    }
}

#[test]
fn test_all_coords_returns_all_points() {
    let doc = make_doc();
    let coords = doc.all_coords();
    // Point A + 2 line coords = 3
    assert_eq!(coords.len(), 3);
}

#[test]
fn test_bounding_box_covers_all_coords() {
    let doc = make_doc();
    let bbox = doc.bounding_box().unwrap();
    assert!(bbox.min_lon <= -1.6800);
    assert!(bbox.max_lon >= -1.6778);
    assert!(bbox.min_lat <= 48.1173);
    assert!(bbox.max_lat >= 48.1200);
}

#[test]
fn test_bounding_box_single_point() {
    let doc = KmlDocument {
        name: None,
        features: vec![Feature::Placemark {
            name: "Solo".to_string(),
            geometry: Some(Geometry::Point(Coord {
                lon: 2.0,
                lat: 48.0,
                alt: None,
            })),
            style_id: None,
            description: None,
        }],
        styles: HashMap::new(),
    };
    let bbox = doc.bounding_box().unwrap();
    // Should have a small padding to avoid zero-size bbox
    assert!(bbox.max_lon > bbox.min_lon);
    assert!(bbox.max_lat > bbox.min_lat);
}

#[test]
fn test_bounding_box_empty() {
    let doc = KmlDocument {
        name: None,
        features: vec![],
        styles: HashMap::new(),
    };
    assert!(doc.bounding_box().is_none());
}

#[test]
fn test_flatten_returns_all_features_with_paths() {
    let doc = make_doc();
    let flat = doc.flatten();
    // Folder 1 at [0], Point A at [0,0], Line B at [1]
    assert_eq!(flat.len(), 3);
    assert_eq!(flat[0].0, vec![0]); // Folder
    assert_eq!(flat[1].0, vec![0, 0]); // Point A (child of Folder)
    assert_eq!(flat[2].0, vec![1]); // Line B
}
```

- [ ] **Step 6: Set up `src/main.rs` as minimal entry point**

```rust
mod model;

fn main() {
    println!("kmlcli");
}
```

Add `pub` to model module — update `src/main.rs`:

```rust
pub mod model;

fn main() {
    println!("kmlcli");
}
```

Also add to `Cargo.toml` so tests can import:

```toml
[lib]
name = "kmlcli"
path = "src/lib.rs"

[[bin]]
name = "kmlcli"
path = "src/main.rs"
```

Create `src/lib.rs`:

```rust
pub mod model;
```

- [ ] **Step 7: Run tests**

```bash
cargo test --test model_test
```

Expected: all 5 tests pass.

- [ ] **Step 8: Commit**

```bash
git add -A
git commit -m "scaffold project with model types and tests"
```

---

### Task 2: KML/KMZ Parser

**Files:**
- Create: `src/parser.rs`
- Create: `tests/parser_test.rs`
- Create: `tests/fixtures/multi.kml`
- Create: `tests/fixtures/empty.kml`
- Modify: `src/lib.rs`

- [ ] **Step 1: Create `tests/fixtures/multi.kml`**

```xml
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
  <Document>
    <name>Multi Test</name>
    <Style id="redLine">
      <LineStyle>
        <color>ff0000ff</color>
        <width>2</width>
      </LineStyle>
    </Style>
    <Folder>
      <name>Routes</name>
      <Placemark>
        <name>Route 1</name>
        <styleUrl>#redLine</styleUrl>
        <LineString>
          <coordinates>
            -1.6778,48.1173,0
            -1.6800,48.1200,0
          </coordinates>
        </LineString>
      </Placemark>
    </Folder>
    <Placemark>
      <name>Zone A</name>
      <Polygon>
        <outerBoundaryIs>
          <LinearRing>
            <coordinates>
              -1.68,48.11,0
              -1.67,48.11,0
              -1.67,48.12,0
              -1.68,48.12,0
              -1.68,48.11,0
            </coordinates>
          </LinearRing>
        </outerBoundaryIs>
      </Polygon>
    </Placemark>
    <Placemark>
      <name>Multi</name>
      <MultiGeometry>
        <Point>
          <coordinates>-1.68,48.12,0</coordinates>
        </Point>
        <LineString>
          <coordinates>
            -1.68,48.12,0
            -1.69,48.13,0
          </coordinates>
        </LineString>
      </MultiGeometry>
    </Placemark>
  </Document>
</kml>
```

- [ ] **Step 2: Create `tests/fixtures/empty.kml`**

```xml
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
  <Document>
    <name>Empty</name>
  </Document>
</kml>
```

- [ ] **Step 3: Write failing parser tests in `tests/parser_test.rs`**

```rust
use kmlcli::parser::parse_file;
use std::path::Path;

#[test]
fn test_parse_simple_kml() {
    let doc = parse_file(Path::new("tests/fixtures/simple.kml")).unwrap();
    assert_eq!(doc.name.as_deref(), Some("Test Document"));
    assert_eq!(doc.features.len(), 2); // 1 folder + 1 placemark
}

#[test]
fn test_parse_multi_kml_with_styles() {
    let doc = parse_file(Path::new("tests/fixtures/multi.kml")).unwrap();
    assert_eq!(doc.name.as_deref(), Some("Multi Test"));
    assert!(doc.styles.contains_key("redLine"));
    let style = &doc.styles["redLine"];
    assert_eq!(style.line_color.as_deref(), Some("ff0000ff"));
}

#[test]
fn test_parse_empty_kml() {
    let doc = parse_file(Path::new("tests/fixtures/empty.kml")).unwrap();
    assert_eq!(doc.name.as_deref(), Some("Empty"));
    assert!(doc.features.is_empty());
}

#[test]
fn test_parse_nonexistent_file() {
    let result = parse_file(Path::new("tests/fixtures/nope.kml"));
    assert!(result.is_err());
}
```

- [ ] **Step 4: Run tests to verify they fail**

```bash
cargo test --test parser_test
```

Expected: FAIL — `parser` module doesn't exist yet.

- [ ] **Step 5: Implement `src/parser.rs`**

```rust
use std::collections::HashMap;
use std::path::Path;

use color_eyre::eyre::{eyre, Result};
use kml::reader::KmlReader;
use kml::types::{
    Geometry as KmlGeometry, Kml, LineStyle, Placemark as KmlPlacemark, Style as KmlStyle,
};

use crate::model::{Coord, Feature, Geometry, KmlDocument, Style};

pub fn parse_file(path: &Path) -> Result<KmlDocument> {
    let ext = path
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("")
        .to_lowercase();

    let kml_data: Kml<f64> = match ext.as_str() {
        "kmz" => {
            let mut reader = KmlReader::<_, f64>::from_kmz_path(path)
                .map_err(|e| eyre!("Failed to read KMZ: {e}"))?;
            reader
                .read()
                .map_err(|e| eyre!("Failed to parse KMZ: {e}"))?
        }
        _ => {
            let mut reader = KmlReader::<_, f64>::from_path(path)
                .map_err(|e| eyre!("Failed to read KML: {e}"))?;
            reader
                .read()
                .map_err(|e| eyre!("Failed to parse KML: {e}"))?
        }
    };

    let mut doc = KmlDocument {
        name: None,
        features: Vec::new(),
        styles: HashMap::new(),
    };

    convert_kml(&kml_data, &mut doc);
    Ok(doc)
}

fn convert_kml(kml: &Kml<f64>, doc: &mut KmlDocument) {
    match kml {
        Kml::KmlDocument(kml_doc) => {
            for element in &kml_doc.elements {
                convert_kml(element, doc);
            }
        }
        Kml::Document { attrs: _, elements } => {
            // Extract name from first element if it's a name-bearing structure
            for element in elements {
                convert_kml(element, doc);
            }
        }
        Kml::Folder(folder) => {
            let mut children = Vec::new();
            let mut child_doc = KmlDocument {
                name: None,
                features: Vec::new(),
                styles: HashMap::new(),
            };
            for element in &folder.elements {
                convert_kml(element, &mut child_doc);
            }
            // Move styles up to root doc
            doc.styles.extend(child_doc.styles);
            doc.features.push(Feature::Folder {
                name: folder.name.clone().unwrap_or_default(),
                features: child_doc.features,
            });
        }
        Kml::Placemark(pm) => {
            doc.features.push(convert_placemark(pm));
        }
        Kml::Style(style) => {
            if let Some(converted) = convert_style(style) {
                doc.styles.insert(converted.id.clone(), converted);
            }
        }
        // Recurse into any container-like structure
        _ => {}
    }
}

fn convert_placemark(pm: &KmlPlacemark<f64>) -> Feature {
    Feature::Placemark {
        name: pm.name.clone().unwrap_or_default(),
        geometry: pm.geometry.as_ref().map(convert_geometry),
        style_id: pm.style_url.clone().map(|s| s.trim_start_matches('#').to_string()),
        description: pm.description.clone(),
    }
}

fn convert_geometry(geom: &KmlGeometry<f64>) -> Geometry {
    match geom {
        KmlGeometry::Point(p) => Geometry::Point(convert_coord(&p.coord)),
        KmlGeometry::LineString(ls) => {
            Geometry::LineString(ls.coords.iter().map(convert_coord).collect())
        }
        KmlGeometry::Polygon(poly) => {
            let mut rings = Vec::new();
            // Outer boundary
            rings.push(poly.outer.coords.iter().map(convert_coord).collect());
            // Inner boundaries (holes)
            for inner in &poly.inner {
                rings.push(inner.coords.iter().map(convert_coord).collect());
            }
            Geometry::Polygon(rings)
        }
        KmlGeometry::MultiGeometry(mg) => {
            Geometry::MultiGeometry(mg.geometries.iter().map(convert_geometry).collect())
        }
        // Skip unsupported geometry types
        _ => Geometry::Point(Coord {
            lon: 0.0,
            lat: 0.0,
            alt: None,
        }),
    }
}

fn convert_coord(c: &kml::types::Coord<f64>) -> Coord {
    Coord {
        lon: c.x,
        lat: c.y,
        alt: c.z,
    }
}

fn convert_style(style: &KmlStyle<f64>) -> Option<Style> {
    let id = style.id.clone()?;
    Some(Style {
        id,
        line_color: style.line.as_ref().map(|l| l.color.clone()),
        line_width: style.line.as_ref().and_then(|l| Some(l.width)),
        poly_color: style.poly.as_ref().map(|p| p.color.clone()),
        icon_href: None,
    })
}
```

- [ ] **Step 6: Register parser module in `src/lib.rs`**

```rust
pub mod model;
pub mod parser;
```

- [ ] **Step 7: Run tests**

```bash
cargo test --test parser_test
```

Expected: all 4 tests pass. If the `kml` crate API differs slightly (field names, method signatures), adjust `parser.rs` to match. The types in this plan are based on `kml` 0.13 docs — minor adjustments may be needed.

- [ ] **Step 8: Commit**

```bash
git add -A
git commit -m "add KML/KMZ parser with conversion to internal model"
```

---

### Task 3: CLI Argument Parsing

**Files:**
- Create: `src/cli.rs`
- Modify: `src/main.rs`
- Modify: `src/lib.rs`

- [ ] **Step 1: Write `src/cli.rs`**

```rust
use std::path::PathBuf;

use clap::{Parser, Subcommand, ValueEnum};

#[derive(Parser, Debug)]
#[command(name = "kmlcli", about = "KML/KMZ terminal viewer")]
pub struct Cli {
    #[command(subcommand)]
    pub command: Option<Command>,

    /// KML/KMZ file to view (launches TUI)
    pub file: Option<PathBuf>,
}

#[derive(Subcommand, Debug)]
pub enum Command {
    /// Open file in TUI viewer
    View {
        /// KML/KMZ file path
        file: PathBuf,
    },
    /// Dump document metadata
    Info {
        /// KML/KMZ file path
        file: PathBuf,
        /// Output format
        #[arg(short, long, default_value = "json")]
        format: OutputFormat,
    },
    /// List all placemarks and folders
    List {
        /// KML/KMZ file path
        file: PathBuf,
        /// Output format
        #[arg(short, long, default_value = "json")]
        format: OutputFormat,
    },
    /// Print document structure as a tree
    Tree {
        /// KML/KMZ file path
        file: PathBuf,
    },
}

#[derive(ValueEnum, Debug, Clone)]
pub enum OutputFormat {
    Json,
    Table,
}

impl Cli {
    /// Resolve the file path from either the subcommand or the bare argument.
    pub fn file_path(&self) -> Option<&PathBuf> {
        match &self.command {
            Some(Command::View { file }) => Some(file),
            Some(Command::Info { file, .. }) => Some(file),
            Some(Command::List { file, .. }) => Some(file),
            Some(Command::Tree { file }) => Some(file),
            None => self.file.as_ref(),
        }
    }
}
```

- [ ] **Step 2: Update `src/main.rs`**

```rust
use clap::Parser;
use color_eyre::eyre::{eyre, Result};

mod cli;

fn main() -> Result<()> {
    color_eyre::install()?;

    let args = cli::Cli::parse();

    let file = args
        .file_path()
        .ok_or_else(|| eyre!("No file specified. Usage: kmlcli <file.kml>"))?;

    let doc = kmlcli::parser::parse_file(file)?;

    match &args.command {
        Some(cli::Command::Info { format, .. }) => {
            todo!("info command")
        }
        Some(cli::Command::List { format, .. }) => {
            todo!("list command")
        }
        Some(cli::Command::Tree { .. }) => {
            todo!("tree command")
        }
        // View or bare file → TUI
        _ => {
            todo!("TUI")
        }
    }

    Ok(())
}
```

- [ ] **Step 3: Add cli module to `src/lib.rs`**

```rust
pub mod model;
pub mod parser;
```

Note: `cli.rs` stays in `main.rs` scope only (it's a `mod cli` in main, not in lib). This is intentional — CLI parsing is binary-specific.

- [ ] **Step 4: Verify it compiles**

```bash
cargo build
```

Expected: compiles. Running `cargo run` with no args prints the error message.

- [ ] **Step 5: Verify CLI args work**

```bash
cargo run -- --help
cargo run -- tests/fixtures/simple.kml
```

Expected: `--help` shows usage. The second one hits `todo!("TUI")` and panics — expected for now.

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "add CLI argument parsing with clap"
```

---

### Task 4: Non-Interactive Subcommands

**Files:**
- Create: `src/commands/mod.rs`
- Create: `src/commands/info.rs`
- Create: `src/commands/list.rs`
- Create: `src/commands/tree.rs`
- Modify: `src/main.rs`
- Modify: `src/lib.rs`

- [ ] **Step 1: Create `src/commands/mod.rs`**

```rust
pub mod info;
pub mod list;
pub mod tree;
```

- [ ] **Step 2: Create `src/commands/info.rs`**

```rust
use color_eyre::Result;
use serde::Serialize;

use crate::model::KmlDocument;

#[derive(Serialize)]
struct DocInfo {
    name: Option<String>,
    feature_count: usize,
    placemark_count: usize,
    folder_count: usize,
    style_count: usize,
    bounding_box: Option<BboxInfo>,
}

#[derive(Serialize)]
struct BboxInfo {
    min_lon: f64,
    max_lon: f64,
    min_lat: f64,
    max_lat: f64,
}

pub fn run(doc: &KmlDocument) -> Result<()> {
    let (placemarks, folders) = count_features(&doc.features);
    let info = DocInfo {
        name: doc.name.clone(),
        feature_count: doc.features.len(),
        placemark_count: placemarks,
        folder_count: folders,
        style_count: doc.styles.len(),
        bounding_box: doc.bounding_box().map(|b| BboxInfo {
            min_lon: b.min_lon,
            max_lon: b.max_lon,
            min_lat: b.min_lat,
            max_lat: b.max_lat,
        }),
    };
    println!("{}", serde_json::to_string_pretty(&info)?);
    Ok(())
}

fn count_features(features: &[crate::model::Feature]) -> (usize, usize) {
    let mut placemarks = 0;
    let mut folders = 0;
    for f in features {
        match f {
            crate::model::Feature::Folder { features, .. } => {
                folders += 1;
                let (p, fo) = count_features(features);
                placemarks += p;
                folders += fo;
            }
            crate::model::Feature::Placemark { .. } => {
                placemarks += 1;
            }
        }
    }
    (placemarks, folders)
}
```

- [ ] **Step 3: Create `src/commands/list.rs`**

```rust
use color_eyre::Result;
use serde::Serialize;

use crate::model::{Feature, Geometry, KmlDocument};

#[derive(Serialize)]
struct ListEntry {
    name: String,
    kind: String,
    geometry: Option<String>,
    path: String,
}

pub fn run(doc: &KmlDocument) -> Result<()> {
    let mut entries = Vec::new();
    collect_entries(&doc.features, String::new(), &mut entries);
    println!("{}", serde_json::to_string_pretty(&entries)?);
    Ok(())
}

fn collect_entries(features: &[Feature], prefix: String, entries: &mut Vec<ListEntry>) {
    for feature in features {
        match feature {
            Feature::Folder { name, features } => {
                let path = if prefix.is_empty() {
                    name.clone()
                } else {
                    format!("{prefix}/{name}")
                };
                entries.push(ListEntry {
                    name: name.clone(),
                    kind: "folder".to_string(),
                    geometry: None,
                    path: path.clone(),
                });
                collect_entries(features, path, entries);
            }
            Feature::Placemark {
                name, geometry, ..
            } => {
                let geom_type = geometry.as_ref().map(|g| match g {
                    Geometry::Point(_) => "Point",
                    Geometry::LineString(_) => "LineString",
                    Geometry::Polygon(_) => "Polygon",
                    Geometry::MultiGeometry(_) => "MultiGeometry",
                });
                entries.push(ListEntry {
                    name: name.clone(),
                    kind: "placemark".to_string(),
                    geometry: geom_type.map(String::from),
                    path: if prefix.is_empty() {
                        name.clone()
                    } else {
                        format!("{prefix}/{name}")
                    },
                });
            }
        }
    }
}
```

- [ ] **Step 4: Create `src/commands/tree.rs`**

```rust
use crate::model::{Feature, Geometry};

pub fn run(doc: &crate::model::KmlDocument) {
    if let Some(name) = &doc.name {
        println!("📄 {name}");
    }
    for (i, feature) in doc.features.iter().enumerate() {
        let is_last = i == doc.features.len() - 1;
        print_feature(feature, "", is_last);
    }
}

fn print_feature(feature: &Feature, prefix: &str, is_last: bool) {
    let connector = if is_last { "└── " } else { "├── " };
    let child_prefix = if is_last {
        format!("{prefix}    ")
    } else {
        format!("{prefix}│   ")
    };

    match feature {
        Feature::Folder { name, features } => {
            println!("{prefix}{connector}📁 {name}");
            for (i, child) in features.iter().enumerate() {
                let child_is_last = i == features.len() - 1;
                print_feature(child, &child_prefix, child_is_last);
            }
        }
        Feature::Placemark {
            name, geometry, ..
        } => {
            let icon = match geometry {
                Some(Geometry::Point(_)) => "●",
                Some(Geometry::LineString(_)) => "─",
                Some(Geometry::Polygon(_)) => "◻",
                Some(Geometry::MultiGeometry(_)) => "◈",
                None => "?",
            };
            println!("{prefix}{connector}{icon} {name}");
        }
    }
}
```

- [ ] **Step 5: Register commands in `src/lib.rs`**

```rust
pub mod commands;
pub mod model;
pub mod parser;
```

- [ ] **Step 6: Wire subcommands in `src/main.rs`**

```rust
use clap::Parser;
use color_eyre::eyre::{eyre, Result};

mod cli;

fn main() -> Result<()> {
    color_eyre::install()?;

    let args = cli::Cli::parse();

    let file = args
        .file_path()
        .ok_or_else(|| eyre!("No file specified. Usage: kmlcli <file.kml>"))?;

    let doc = kmlcli::parser::parse_file(file)?;

    match &args.command {
        Some(cli::Command::Info { .. }) => {
            kmlcli::commands::info::run(&doc)?;
        }
        Some(cli::Command::List { .. }) => {
            kmlcli::commands::list::run(&doc)?;
        }
        Some(cli::Command::Tree { .. }) => {
            kmlcli::commands::tree::run(&doc);
        }
        // View or bare file → TUI
        _ => {
            todo!("TUI")
        }
    }

    Ok(())
}
```

- [ ] **Step 7: Test manually**

```bash
cargo run -- info tests/fixtures/multi.kml
cargo run -- list tests/fixtures/simple.kml
cargo run -- tree tests/fixtures/multi.kml
```

Expected: `info` prints JSON with document metadata, `list` prints JSON array of entries, `tree` prints a visual tree.

- [ ] **Step 8: Commit**

```bash
git add -A
git commit -m "add non-interactive subcommands: info, list, tree"
```

---

### Task 5: Projection + Viewport

**Files:**
- Create: `src/projection.rs`
- Create: `tests/projection_test.rs`
- Modify: `src/lib.rs`

- [ ] **Step 1: Write failing projection tests in `tests/projection_test.rs`**

```rust
use kmlcli::model::{BoundingBox, Coord};
use kmlcli::projection::Viewport;

#[test]
fn test_project_center_of_viewport_maps_to_center_of_canvas() {
    let bbox = BoundingBox {
        min_lon: -2.0,
        max_lon: 0.0,
        min_lat: 47.0,
        max_lat: 49.0,
    };
    let vp = Viewport::from_bbox(&bbox);
    let (x, y) = vp.project(&Coord {
        lon: -1.0,
        lat: 48.0,
        alt: None,
    });
    // Center should map to ~(0.5, 0.5) in normalized coords
    assert!((x - 0.5).abs() < 0.05, "x={x} expected ~0.5");
    assert!((y - 0.5).abs() < 0.05, "y={y} expected ~0.5");
}

#[test]
fn test_project_corners_map_to_bounds() {
    let bbox = BoundingBox {
        min_lon: 0.0,
        max_lon: 10.0,
        min_lat: 0.0,
        max_lat: 10.0,
    };
    let vp = Viewport::from_bbox(&bbox);

    let (x, y) = vp.project(&Coord { lon: 0.0, lat: 0.0, alt: None });
    assert!(x < 0.05, "bottom-left x={x}");
    assert!(y < 0.05, "bottom-left y={y}");

    let (x, y) = vp.project(&Coord { lon: 10.0, lat: 10.0, alt: None });
    assert!(x > 0.95, "top-right x={x}");
    assert!(y > 0.95, "top-right y={y}");
}

#[test]
fn test_zoom_in_narrows_bounds() {
    let bbox = BoundingBox {
        min_lon: 0.0,
        max_lon: 10.0,
        min_lat: 0.0,
        max_lat: 10.0,
    };
    let mut vp = Viewport::from_bbox(&bbox);
    let original_width = vp.lon_span();
    vp.zoom_in();
    assert!(vp.lon_span() < original_width);
}

#[test]
fn test_pan_shifts_center() {
    let bbox = BoundingBox {
        min_lon: 0.0,
        max_lon: 10.0,
        min_lat: 0.0,
        max_lat: 10.0,
    };
    let mut vp = Viewport::from_bbox(&bbox);
    let original_center = vp.center_lon;
    vp.pan_right();
    assert!(vp.center_lon > original_center);
}
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
cargo test --test projection_test
```

Expected: FAIL — module doesn't exist yet.

- [ ] **Step 3: Implement `src/projection.rs`**

```rust
use crate::model::{BoundingBox, Coord};

const ZOOM_FACTOR: f64 = 0.8;
const PAN_FACTOR: f64 = 0.1;

#[derive(Debug, Clone)]
pub struct Viewport {
    pub center_lon: f64,
    pub center_lat: f64,
    half_lon: f64,
    half_lat: f64,
}

impl Viewport {
    pub fn from_bbox(bbox: &BoundingBox) -> Self {
        let center_lon = (bbox.min_lon + bbox.max_lon) / 2.0;
        let center_lat = (bbox.min_lat + bbox.max_lat) / 2.0;
        let half_lon = (bbox.max_lon - bbox.min_lon) / 2.0 * 1.1; // 10% padding
        let half_lat = (bbox.max_lat - bbox.min_lat) / 2.0 * 1.1;
        Self {
            center_lon,
            center_lat,
            half_lon,
            half_lat,
        }
    }

    /// Project a coord to normalized [0, 1] x [0, 1] space.
    pub fn project(&self, coord: &Coord) -> (f64, f64) {
        let x = (coord.lon - (self.center_lon - self.half_lon)) / (2.0 * self.half_lon);
        let y = (mercator_y(coord.lat) - mercator_y(self.center_lat - self.half_lat))
            / (mercator_y(self.center_lat + self.half_lat)
                - mercator_y(self.center_lat - self.half_lat));
        (x, y)
    }

    /// Project to canvas pixel coordinates.
    pub fn project_to_canvas(&self, coord: &Coord, width: f64, height: f64) -> (f64, f64) {
        let (nx, ny) = self.project(coord);
        (nx * width, ny * height)
    }

    /// Returns the x_bounds for ratatui Canvas.
    pub fn x_bounds(&self) -> [f64; 2] {
        [self.center_lon - self.half_lon, self.center_lon + self.half_lon]
    }

    /// Returns the y_bounds for ratatui Canvas (in mercator space).
    pub fn y_bounds(&self) -> [f64; 2] {
        [
            mercator_y(self.center_lat - self.half_lat),
            mercator_y(self.center_lat + self.half_lat),
        ]
    }

    /// Project a coord for direct use with ratatui Canvas (x=lon, y=mercator(lat)).
    pub fn project_for_canvas(&self, coord: &Coord) -> (f64, f64) {
        (coord.lon, mercator_y(coord.lat))
    }

    pub fn lon_span(&self) -> f64 {
        self.half_lon * 2.0
    }

    pub fn zoom_in(&mut self) {
        self.half_lon *= ZOOM_FACTOR;
        self.half_lat *= ZOOM_FACTOR;
    }

    pub fn zoom_out(&mut self) {
        self.half_lon /= ZOOM_FACTOR;
        self.half_lat /= ZOOM_FACTOR;
    }

    pub fn pan_left(&mut self) {
        self.center_lon -= self.half_lon * PAN_FACTOR;
    }

    pub fn pan_right(&mut self) {
        self.center_lon += self.half_lon * PAN_FACTOR;
    }

    pub fn pan_up(&mut self) {
        self.center_lat += self.half_lat * PAN_FACTOR;
    }

    pub fn pan_down(&mut self) {
        self.center_lat -= self.half_lat * PAN_FACTOR;
    }

    /// Center viewport on a specific coordinate.
    pub fn center_on(&mut self, coord: &Coord) {
        self.center_lon = coord.lon;
        self.center_lat = coord.lat;
    }

    /// Check if a bounding box intersects this viewport.
    pub fn intersects(&self, bbox: &BoundingBox) -> bool {
        bbox.max_lon >= self.center_lon - self.half_lon
            && bbox.min_lon <= self.center_lon + self.half_lon
            && bbox.max_lat >= self.center_lat - self.half_lat
            && bbox.min_lat <= self.center_lat + self.half_lat
    }
}

fn mercator_y(lat: f64) -> f64 {
    let lat_rad = lat.to_radians();
    (std::f64::consts::FRAC_PI_4 + lat_rad / 2.0).tan().ln()
}
```

- [ ] **Step 4: Register in `src/lib.rs`**

```rust
pub mod commands;
pub mod model;
pub mod parser;
pub mod projection;
```

- [ ] **Step 5: Run tests**

```bash
cargo test --test projection_test
```

Expected: all 4 tests pass.

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "add Mercator projection and viewport with zoom/pan"
```

---

### Task 6: TUI App Shell + Event Loop

**Files:**
- Create: `src/tui/mod.rs`
- Create: `src/tui/app.rs`
- Create: `src/tui/input.rs`
- Modify: `src/main.rs`
- Modify: `src/lib.rs`

- [ ] **Step 1: Create `src/tui/mod.rs`**

```rust
pub mod app;
pub mod input;
```

- [ ] **Step 2: Create `src/tui/input.rs`**

```rust
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Action {
    Quit,
    MoveUp,
    MoveDown,
    ToggleExpand,
    SwitchFocus,
    ZoomIn,
    ZoomOut,
    PanLeft,
    PanRight,
    PanUp,
    PanDown,
    Search,
    None,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Focus {
    Tree,
    Map,
}

pub fn handle_key(key: KeyEvent, focus: Focus) -> Action {
    // Global bindings
    match key.code {
        KeyCode::Char('q') => return Action::Quit,
        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
            return Action::Quit
        }
        KeyCode::Tab => return Action::SwitchFocus,
        KeyCode::Char('/') => return Action::Search,
        _ => {}
    }

    match focus {
        Focus::Tree => match key.code {
            KeyCode::Char('j') | KeyCode::Down => Action::MoveDown,
            KeyCode::Char('k') | KeyCode::Up => Action::MoveUp,
            KeyCode::Enter => Action::ToggleExpand,
            _ => Action::None,
        },
        Focus::Map => match key.code {
            KeyCode::Char('h') | KeyCode::Left => Action::PanLeft,
            KeyCode::Char('j') | KeyCode::Down => Action::PanDown,
            KeyCode::Char('k') | KeyCode::Up => Action::PanUp,
            KeyCode::Char('l') | KeyCode::Right => Action::PanRight,
            KeyCode::Char('+') | KeyCode::Char('=') => Action::ZoomIn,
            KeyCode::Char('-') => Action::ZoomOut,
            _ => Action::None,
        },
    }
}
```

- [ ] **Step 3: Create `src/tui/app.rs`**

```rust
use std::io;
use std::time::Duration;

use color_eyre::Result;
use crossterm::event::{self, Event};
use crossterm::terminal::{
    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::ExecutableCommand;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Terminal;

use crate::model::KmlDocument;
use crate::projection::Viewport;

use super::input::{handle_key, Action, Focus};

pub struct App {
    doc: KmlDocument,
    viewport: Viewport,
    focus: Focus,
    selected: usize,
    tree_items: Vec<TreeItem>,
    should_quit: bool,
}

struct TreeItem {
    depth: usize,
    name: String,
    kind: String,
    expanded: bool,
    has_children: bool,
    feature_index: Vec<usize>, // path into doc.features
}

impl App {
    pub fn new(doc: KmlDocument) -> Self {
        let viewport = doc
            .bounding_box()
            .map(|bbox| Viewport::from_bbox(&bbox))
            .unwrap_or_else(|| {
                Viewport::from_bbox(&crate::model::BoundingBox {
                    min_lon: -180.0,
                    max_lon: 180.0,
                    min_lat: -85.0,
                    max_lat: 85.0,
                })
            });

        let tree_items = build_tree_items(&doc);

        Self {
            doc,
            viewport,
            focus: Focus::Tree,
            selected: 0,
            tree_items,
            should_quit: false,
        }
    }

    pub fn run(mut self) -> Result<()> {
        io::stdout().execute(EnterAlternateScreen)?;
        enable_raw_mode()?;

        let backend = CrosstermBackend::new(io::stdout());
        let mut terminal = Terminal::new(backend)?;
        terminal.clear()?;

        while !self.should_quit {
            terminal.draw(|frame| self.draw(frame))?;

            if event::poll(Duration::from_millis(100))? {
                if let Event::Key(key) = event::read()? {
                    let action = handle_key(key, self.focus);
                    self.handle_action(action);
                }
            }
        }

        disable_raw_mode()?;
        io::stdout().execute(LeaveAlternateScreen)?;
        Ok(())
    }

    fn handle_action(&mut self, action: Action) {
        match action {
            Action::Quit => self.should_quit = true,
            Action::MoveUp => {
                if self.selected > 0 {
                    self.selected -= 1;
                }
            }
            Action::MoveDown => {
                if self.selected + 1 < self.visible_item_count() {
                    self.selected += 1;
                }
            }
            Action::ToggleExpand => {
                self.toggle_expand();
            }
            Action::SwitchFocus => {
                self.focus = match self.focus {
                    Focus::Tree => Focus::Map,
                    Focus::Map => Focus::Tree,
                };
            }
            Action::ZoomIn => self.viewport.zoom_in(),
            Action::ZoomOut => self.viewport.zoom_out(),
            Action::PanLeft => self.viewport.pan_left(),
            Action::PanRight => self.viewport.pan_right(),
            Action::PanUp => self.viewport.pan_up(),
            Action::PanDown => self.viewport.pan_down(),
            Action::Search | Action::None => {}
        }
    }

    fn visible_item_count(&self) -> usize {
        self.tree_items
            .iter()
            .filter(|item| self.is_visible(item))
            .count()
    }

    fn is_visible(&self, _item: &TreeItem) -> bool {
        // For now, all items are visible. Collapse logic added in Task 7.
        true
    }

    fn toggle_expand(&mut self) {
        let visible: Vec<usize> = self
            .tree_items
            .iter()
            .enumerate()
            .filter(|(_, item)| self.is_visible(item))
            .map(|(i, _)| i)
            .collect();

        if let Some(&idx) = visible.get(self.selected) {
            self.tree_items[idx].expanded = !self.tree_items[idx].expanded;
        }
    }

    fn draw(&self, frame: &mut ratatui::Frame) {
        let size = frame.area();

        // Main layout: body + details + status bar
        let vertical = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Min(5),
                Constraint::Length(3),
                Constraint::Length(1),
            ])
            .split(size);

        // Body: tree + map
        let horizontal = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
            .split(vertical[0]);

        // Tree panel
        let tree_block = Block::default()
            .title(" Tree ")
            .borders(Borders::ALL)
            .border_style(if self.focus == Focus::Tree {
                Style::default().fg(Color::Yellow)
            } else {
                Style::default().fg(Color::DarkGray)
            });
        frame.render_widget(
            Paragraph::new("Tree placeholder").block(tree_block),
            horizontal[0],
        );

        // Map panel
        let map_block = Block::default()
            .title(" Map ")
            .borders(Borders::ALL)
            .border_style(if self.focus == Focus::Map {
                Style::default().fg(Color::Yellow)
            } else {
                Style::default().fg(Color::DarkGray)
            });
        frame.render_widget(
            Paragraph::new("Map placeholder").block(map_block),
            horizontal[1],
        );

        // Details panel
        let details_block = Block::default()
            .title(" Details ")
            .borders(Borders::ALL);
        frame.render_widget(
            Paragraph::new("No selection").block(details_block),
            vertical[1],
        );

        // Status bar
        let status = Paragraph::new(
            " [q]uit  [tab]focus  [j/k]navigate  [+/-]zoom  [h/j/k/l]pan  [/]search",
        )
        .style(Style::default().fg(Color::DarkGray));
        frame.render_widget(status, vertical[2]);
    }
}

fn build_tree_items(doc: &KmlDocument) -> Vec<TreeItem> {
    let mut items = Vec::new();
    for (i, feature) in doc.features.iter().enumerate() {
        build_tree_recursive(feature, 0, vec![i], &mut items);
    }
    items
}

fn build_tree_recursive(
    feature: &crate::model::Feature,
    depth: usize,
    path: Vec<usize>,
    items: &mut Vec<TreeItem>,
) {
    match feature {
        crate::model::Feature::Folder { name, features } => {
            items.push(TreeItem {
                depth,
                name: name.clone(),
                kind: "folder".to_string(),
                expanded: true,
                has_children: !features.is_empty(),
                feature_index: path.clone(),
            });
            for (i, child) in features.iter().enumerate() {
                let mut child_path = path.clone();
                child_path.push(i);
                build_tree_recursive(child, depth + 1, child_path, items);
            }
        }
        crate::model::Feature::Placemark {
            name, geometry, ..
        } => {
            let kind = match geometry {
                Some(crate::model::Geometry::Point(_)) => "point",
                Some(crate::model::Geometry::LineString(_)) => "line",
                Some(crate::model::Geometry::Polygon(_)) => "polygon",
                Some(crate::model::Geometry::MultiGeometry(_)) => "multi",
                None => "empty",
            };
            items.push(TreeItem {
                depth,
                name: name.clone(),
                kind: kind.to_string(),
                expanded: false,
                has_children: false,
                feature_index: path,
            });
        }
    }
}
```

- [ ] **Step 4: Register TUI module in `src/lib.rs`**

```rust
pub mod commands;
pub mod model;
pub mod parser;
pub mod projection;
pub mod tui;
```

- [ ] **Step 5: Wire TUI in `src/main.rs`**

Replace the `_ => { todo!("TUI") }` arm:

```rust
        // View or bare file → TUI
        _ => {
            kmlcli::tui::app::App::new(doc).run()?;
        }
```

- [ ] **Step 6: Test manually**

```bash
cargo run -- tests/fixtures/simple.kml
```

Expected: TUI launches with 4 panels (tree and map show placeholders). `q` quits cleanly. `Tab` switches focus (border color changes). No crashes on resize.

- [ ] **Step 7: Commit**

```bash
git add -A
git commit -m "add TUI shell with layout, event loop, and input handling"
```

---

### Task 7: Tree Widget

**Files:**
- Create: `src/tui/tree.rs`
- Modify: `src/tui/mod.rs`
- Modify: `src/tui/app.rs`

- [ ] **Step 1: Create `src/tui/tree.rs`**

```rust
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;

pub struct TreeView<'a> {
    pub items: &'a [TreeViewItem],
    pub selected: usize,
    pub offset: usize,
}

pub struct TreeViewItem {
    pub depth: usize,
    pub name: String,
    pub icon: &'static str,
    pub expanded: bool,
    pub has_children: bool,
}

impl<'a> Widget for TreeView<'a> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let max_lines = area.height as usize;

        for (i, item) in self
            .items
            .iter()
            .skip(self.offset)
            .take(max_lines)
            .enumerate()
        {
            let y = area.y + i as u16;
            if y >= area.y + area.height {
                break;
            }

            let indent = "  ".repeat(item.depth);
            let expand_marker = if item.has_children {
                if item.expanded { "▼ " } else { "▶ " }
            } else {
                "  "
            };

            let line_str = format!("{indent}{expand_marker}{} {}", item.icon, item.name);

            let style = if i + self.offset == self.selected {
                Style::default()
                    .fg(Color::Yellow)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::White)
            };

            let available_width = area.width as usize;
            let truncated: String = line_str.chars().take(available_width).collect();

            buf.set_string(area.x, y, &truncated, style);
        }
    }
}
```

- [ ] **Step 2: Add to `src/tui/mod.rs`**

```rust
pub mod app;
pub mod input;
pub mod tree;
```

- [ ] **Step 3: Update `src/tui/app.rs` to use TreeView**

Replace the tree panel rendering section in the `draw` method. Replace:

```rust
        // Tree panel
        let tree_block = Block::default()
            .title(" Tree ")
            .borders(Borders::ALL)
            .border_style(if self.focus == Focus::Tree {
                Style::default().fg(Color::Yellow)
            } else {
                Style::default().fg(Color::DarkGray)
            });
        frame.render_widget(
            Paragraph::new("Tree placeholder").block(tree_block),
            horizontal[0],
        );
```

With:

```rust
        // Tree panel
        let tree_block = Block::default()
            .title(" Tree ")
            .borders(Borders::ALL)
            .border_style(if self.focus == Focus::Tree {
                Style::default().fg(Color::Yellow)
            } else {
                Style::default().fg(Color::DarkGray)
            });
        frame.render_widget(tree_block, horizontal[0]);

        let tree_inner = horizontal[0].inner(ratatui::layout::Margin {
            vertical: 1,
            horizontal: 1,
        });

        let visible_items: Vec<super::tree::TreeViewItem> = self
            .tree_items
            .iter()
            .filter(|item| self.is_visible(item))
            .map(|item| super::tree::TreeViewItem {
                depth: item.depth,
                name: item.name.clone(),
                icon: match item.kind.as_str() {
                    "folder" => "📁",
                    "point" => "●",
                    "line" => "─",
                    "polygon" => "◻",
                    "multi" => "◈",
                    _ => "?",
                },
                expanded: item.expanded,
                has_children: item.has_children,
            })
            .collect();

        // Auto-scroll so selected item is visible
        let tree_height = tree_inner.height as usize;
        let scroll_offset = if self.selected >= tree_height {
            self.selected - tree_height + 1
        } else {
            0
        };

        frame.render_widget(
            super::tree::TreeView {
                items: &visible_items,
                selected: self.selected,
                offset: scroll_offset,
            },
            tree_inner,
        );
```

- [ ] **Step 4: Implement proper visibility (collapse logic)**

Replace `is_visible` in `app.rs`:

```rust
    fn is_visible(&self, item: &TreeItem) -> bool {
        // An item is visible if all its ancestor folders are expanded.
        // Walk the path: for each prefix, check if that ancestor is expanded.
        if item.feature_index.len() <= 1 {
            return true; // Top-level items are always visible
        }
        // Check each ancestor
        for ancestor_len in 1..item.feature_index.len() {
            let ancestor_path = &item.feature_index[..ancestor_len];
            let ancestor = self
                .tree_items
                .iter()
                .find(|ti| ti.feature_index == ancestor_path && ti.has_children);
            if let Some(ancestor) = ancestor {
                if !ancestor.expanded {
                    return false;
                }
            }
        }
        true
    }
```

- [ ] **Step 5: Test manually**

```bash
cargo run -- tests/fixtures/multi.kml
```

Expected: Tree shows folder hierarchy with icons. `j/k` navigates. `Enter` collapses/expands folders. Selected item highlighted in yellow.

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "add navigable tree widget with collapse/expand"
```

---

### Task 8: Map Widget (Braille Canvas)

**Files:**
- Create: `src/tui/map.rs`
- Modify: `src/tui/mod.rs`
- Modify: `src/tui/app.rs`

- [ ] **Step 1: Create `src/tui/map.rs`**

```rust
use ratatui::style::Color;
use ratatui::widgets::canvas::{Canvas, Line as CanvasLine, Points};
use ratatui::widgets::{Block, Borders, Widget};

use crate::model::{Feature, Geometry, KmlDocument};
use crate::projection::Viewport;

pub struct MapView<'a> {
    pub doc: &'a KmlDocument,
    pub viewport: &'a Viewport,
    pub selected_path: Option<&'a [usize]>,
    pub focus: bool,
}

impl<'a> MapView<'a> {
    pub fn widget(self) -> impl Widget + 'a {
        let vp = self.viewport;
        let doc = self.doc;
        let selected_path = self.selected_path.map(|p| p.to_vec());

        let block = Block::default()
            .title(" Map ")
            .borders(Borders::ALL)
            .border_style(if self.focus {
                ratatui::style::Style::default().fg(Color::Yellow)
            } else {
                ratatui::style::Style::default().fg(Color::DarkGray)
            });

        Canvas::default()
            .block(block)
            .marker(ratatui::symbols::Marker::Braille)
            .x_bounds(vp.x_bounds())
            .y_bounds(vp.y_bounds())
            .paint(move |ctx| {
                render_features(ctx, &doc.features, vp, &selected_path, &[]);
            })
    }
}

fn render_features(
    ctx: &mut ratatui::widgets::canvas::Context<'_>,
    features: &[Feature],
    vp: &Viewport,
    selected_path: &Option<Vec<usize>>,
    current_path: &[usize],
) {
    for (i, feature) in features.iter().enumerate() {
        let mut path = current_path.to_vec();
        path.push(i);

        let is_selected = selected_path
            .as_ref()
            .map(|sp| sp == &path)
            .unwrap_or(false);

        let color = if is_selected {
            Color::Yellow
        } else {
            Color::DarkGray
        };

        match feature {
            Feature::Folder { features, .. } => {
                render_features(ctx, features, vp, selected_path, &path);
            }
            Feature::Placemark { geometry, .. } => {
                if let Some(geom) = geometry {
                    render_geometry(ctx, geom, vp, color);
                }
            }
        }
    }
}

fn render_geometry(
    ctx: &mut ratatui::widgets::canvas::Context<'_>,
    geom: &Geometry,
    vp: &Viewport,
    color: Color,
) {
    match geom {
        Geometry::Point(coord) => {
            let (x, y) = vp.project_for_canvas(coord);
            ctx.draw(&Points {
                coords: &[(x, y)],
                color,
            });
        }
        Geometry::LineString(coords) => {
            for pair in coords.windows(2) {
                let (x1, y1) = vp.project_for_canvas(&pair[0]);
                let (x2, y2) = vp.project_for_canvas(&pair[1]);
                ctx.draw(&CanvasLine {
                    x1,
                    y1,
                    x2,
                    y2,
                    color,
                });
            }
        }
        Geometry::Polygon(rings) => {
            for ring in rings {
                for pair in ring.windows(2) {
                    let (x1, y1) = vp.project_for_canvas(&pair[0]);
                    let (x2, y2) = vp.project_for_canvas(&pair[1]);
                    ctx.draw(&CanvasLine {
                        x1,
                        y1,
                        x2,
                        y2,
                        color,
                    });
                }
            }
        }
        Geometry::MultiGeometry(geoms) => {
            for g in geoms {
                render_geometry(ctx, g, vp, color);
            }
        }
    }
}
```

- [ ] **Step 2: Add to `src/tui/mod.rs`**

```rust
pub mod app;
pub mod input;
pub mod map;
pub mod tree;
```

- [ ] **Step 3: Update `src/tui/app.rs` to use MapView**

Replace the map placeholder rendering in `draw`:

```rust
        // Map panel
        let selected_item = self
            .tree_items
            .iter()
            .filter(|item| self.is_visible(item))
            .nth(self.selected);
        let selected_path = selected_item.map(|item| item.feature_index.as_slice());

        let map = super::map::MapView {
            doc: &self.doc,
            viewport: &self.viewport,
            selected_path,
            focus: self.focus == Focus::Map,
        };
        frame.render_widget(map.widget(), horizontal[1]);
```

- [ ] **Step 4: Center map on selection when Enter is pressed**

Update the `ToggleExpand` handler in `handle_action`:

```rust
            Action::ToggleExpand => {
                let visible: Vec<&TreeItem> = self
                    .tree_items
                    .iter()
                    .filter(|item| self.is_visible(item))
                    .collect();

                if let Some(item) = visible.get(self.selected) {
                    if item.has_children {
                        self.toggle_expand();
                    } else {
                        // Center map on the selected feature's first coordinate
                        if let Some(feature) = self.get_feature(&item.feature_index) {
                            if let Feature::Placemark { geometry: Some(geom), .. } = feature {
                                if let Some(coord) = first_coord(geom) {
                                    self.viewport.center_on(coord);
                                }
                            }
                        }
                    }
                }
            }
```

Add these helper methods to `App`:

```rust
    fn get_feature(&self, path: &[usize]) -> Option<&crate::model::Feature> {
        let mut features = &self.doc.features;
        let mut result = None;
        for &idx in path {
            if idx >= features.len() {
                return None;
            }
            result = Some(&features[idx]);
            if let crate::model::Feature::Folder {
                features: children, ..
            } = &features[idx]
            {
                features = children;
            }
        }
        result
    }
```

Add this free function at the bottom of `app.rs`:

```rust
fn first_coord(geom: &crate::model::Geometry) -> Option<&crate::model::Coord> {
    match geom {
        crate::model::Geometry::Point(c) => Some(c),
        crate::model::Geometry::LineString(cs) => cs.first(),
        crate::model::Geometry::Polygon(rings) => rings.first().and_then(|r| r.first()),
        crate::model::Geometry::MultiGeometry(gs) => {
            gs.iter().find_map(first_coord)
        }
    }
}
```

Add the import at the top of `app.rs`:

```rust
use crate::model::Feature;
```

- [ ] **Step 5: Test manually**

```bash
cargo run -- tests/fixtures/multi.kml
```

Expected: Map shows braille rendering of geometries. Selected item highlighted in yellow, others in dark gray. `+/-` zooms. `Tab` to map, `h/j/k/l` pans. `Enter` on a placemark centers the map on it.

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "add braille map widget with zoom, pan, and selection highlight"
```

---

### Task 9: Details Panel

**Files:**
- Create: `src/tui/details.rs`
- Modify: `src/tui/mod.rs`
- Modify: `src/tui/app.rs`

- [ ] **Step 1: Create `src/tui/details.rs`**

```rust
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Widget};

use crate::model::{Feature, Geometry};

pub struct DetailsView<'a> {
    pub feature: Option<&'a Feature>,
}

impl<'a> DetailsView<'a> {
    pub fn widget(self) -> impl Widget + 'a {
        let block = Block::default()
            .title(" Details ")
            .borders(Borders::ALL);

        let content = match self.feature {
            None => vec![Line::from(Span::styled(
                "No selection",
                Style::default().fg(Color::DarkGray),
            ))],
            Some(Feature::Folder { name, features }) => {
                vec![
                    Line::from(vec![
                        Span::styled("📁 ", Style::default()),
                        Span::styled(name, Style::default().fg(Color::White)),
                        Span::styled(
                            format!("  ({} items)", features.len()),
                            Style::default().fg(Color::DarkGray),
                        ),
                    ]),
                ]
            }
            Some(Feature::Placemark {
                name,
                geometry,
                description,
                style_id,
            }) => {
                let mut lines = Vec::new();

                // First line: name + geometry type + coords
                let geom_info = geometry.as_ref().map(format_geometry).unwrap_or_default();
                lines.push(Line::from(vec![
                    Span::styled(name, Style::default().fg(Color::White)),
                    Span::styled(
                        format!("  {geom_info}"),
                        Style::default().fg(Color::Cyan),
                    ),
                ]));

                // Second line: description + style
                let mut details = Vec::new();
                if let Some(desc) = description {
                    details.push(Span::styled(
                        truncate(desc, 60),
                        Style::default().fg(Color::DarkGray),
                    ));
                }
                if let Some(sid) = style_id {
                    details.push(Span::styled(
                        format!("  style: #{sid}"),
                        Style::default().fg(Color::DarkGray),
                    ));
                }
                if !details.is_empty() {
                    lines.push(Line::from(details));
                }

                lines
            }
        };

        Paragraph::new(content).block(block)
    }
}

fn format_geometry(geom: &Geometry) -> String {
    match geom {
        Geometry::Point(c) => format!("Point | {:.4}°N {:.4}°W", c.lat, c.lon.abs()),
        Geometry::LineString(cs) => format!("LineString | {} points", cs.len()),
        Geometry::Polygon(rings) => {
            let total: usize = rings.iter().map(|r| r.len()).sum();
            format!("Polygon | {} rings, {} points", rings.len(), total)
        }
        Geometry::MultiGeometry(gs) => format!("MultiGeometry | {} parts", gs.len()),
    }
}

fn truncate(s: &str, max: usize) -> String {
    if s.len() > max {
        format!("{}...", &s[..max - 3])
    } else {
        s.to_string()
    }
}
```

- [ ] **Step 2: Add to `src/tui/mod.rs`**

```rust
pub mod app;
pub mod details;
pub mod input;
pub mod map;
pub mod tree;
```

- [ ] **Step 3: Update `src/tui/app.rs` to use DetailsView**

Replace the details placeholder in `draw`:

```rust
        // Details panel
        let selected_feature = selected_path
            .and_then(|path| self.get_feature(path));

        let details = super::details::DetailsView {
            feature: selected_feature,
        };
        frame.render_widget(details.widget(), vertical[1]);
```

- [ ] **Step 4: Test manually**

```bash
cargo run -- tests/fixtures/multi.kml
```

Expected: Details panel shows info about the selected item. Navigate with `j/k` — details update. Shows coords for points, point count for lines, ring info for polygons.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "add details panel showing selected feature info"
```

---

### Task 10: Terminal Size Handling + Polish

**Files:**
- Modify: `src/tui/app.rs`

- [ ] **Step 1: Add terminal size check in draw**

At the start of the `draw` method, add:

```rust
    fn draw(&self, frame: &mut ratatui::Frame) {
        let size = frame.area();

        // Terminal too small
        if size.width < 40 || size.height < 10 {
            let msg = Paragraph::new("Terminal too small (min 40x10)")
                .style(Style::default().fg(Color::Red));
            frame.render_widget(msg, size);
            return;
        }

        // Degraded layout: hide tree if terminal is narrow
        let show_tree = size.width >= 60;
```

- [ ] **Step 2: Update layout to handle degraded mode**

Replace the horizontal layout section:

```rust
        if show_tree {
            let horizontal = Layout::default()
                .direction(Direction::Horizontal)
                .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
                .split(vertical[0]);

            // Tree panel (existing code)
            // ... render tree in horizontal[0]

            // Map panel (existing code)
            // ... render map in horizontal[1]
        } else {
            // Map only
            // ... render map in vertical[0]
        }
```

- [ ] **Step 3: Add file info to status bar**

Replace the status bar rendering:

```rust
        // Status bar
        let focus_label = match self.focus {
            Focus::Tree => "TREE",
            Focus::Map => "MAP",
        };
        let filename = self
            .doc
            .name
            .as_deref()
            .unwrap_or("untitled");
        let status_text = format!(
            " {filename} | [{focus_label}] | [q]uit [tab]focus [j/k]nav [+/-]zoom [hjkl]pan [/]search"
        );
        let status = Paragraph::new(status_text)
            .style(Style::default().fg(Color::DarkGray));
        frame.render_widget(status, vertical[2]);
```

- [ ] **Step 4: Test manually**

```bash
cargo run -- tests/fixtures/multi.kml
```

Test: resize terminal below 40x10 → shows "too small" message. Resize narrow → tree hides. Status bar shows document name and current focus.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "add terminal size handling and status bar polish"
```

---

### Task 11: Integration Test with Real Files

**Files:**
- Modify: `tests/parser_test.rs`

- [ ] **Step 1: Add integration tests using user's real KML files**

```rust
#[test]
fn test_parse_and_inspect_real_kml() {
    // Run this test with: cargo test -- --ignored test_parse_and_inspect_real_kml
    // Provide your KML file path via KML_TEST_FILE env var
    if let Ok(path) = std::env::var("KML_TEST_FILE") {
        let doc = parse_file(Path::new(&path)).unwrap();
        eprintln!("Document: {:?}", doc.name);
        eprintln!("Features: {}", doc.features.len());
        eprintln!("Styles: {}", doc.styles.len());
        if let Some(bbox) = doc.bounding_box() {
            eprintln!(
                "BBox: {:.4},{:.4} → {:.4},{:.4}",
                bbox.min_lon, bbox.min_lat, bbox.max_lon, bbox.max_lat
            );
        }
        let coords = doc.all_coords();
        eprintln!("Total coords: {}", coords.len());
    }
}
```

- [ ] **Step 2: Run with a real file**

```bash
KML_TEST_FILE=/path/to/your/file.kml cargo test test_parse_and_inspect_real_kml -- --ignored --nocapture
```

Expected: prints document stats without crashing. Validates the parser handles real-world KML.

- [ ] **Step 3: Commit**

```bash
git add -A
git commit -m "add integration test for real KML files"
```

---

## Summary

| Task | What | Files |
|------|------|-------|
| 1 | Project scaffold + model | Cargo.toml, model.rs, tests |
| 2 | KML/KMZ parser | parser.rs, fixtures |
| 3 | CLI argument parsing | cli.rs, main.rs |
| 4 | Non-interactive subcommands | commands/*.rs |
| 5 | Projection + viewport | projection.rs, tests |
| 6 | TUI shell + event loop | tui/app.rs, tui/input.rs |
| 7 | Tree widget | tui/tree.rs |
| 8 | Map widget (braille) | tui/map.rs |
| 9 | Details panel | tui/details.rs |
| 10 | Size handling + polish | tui/app.rs |
| 11 | Integration test | tests |

Tasks 1-5 are the foundation (model, parser, CLI, projection). Tasks 6-9 build the TUI incrementally. Task 10 polishes. Task 11 validates with real data.
