use usize_cast::IntoUsize as _;
pub(crate) mod mbt;
mod rendering;
mod scan;
mod state;
#[cfg(test)]
mod tests;
mod tile;
use std::collections::HashSet;
use std::io::Write as _;
use std::path::PathBuf;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use anyhow::bail;
use clap::Args;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use crossterm::execute;
use mlt_core::GeometryType;
use mlt_core::geo_types::{Coord, Geometry, Polygon};
use mlt_core::geojson::FeatureCollection;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders};
use rstar::{AABB, PointDistance, RTreeObject};
use crate::ls::{
FileAlgorithm, FileSortColumn, LsFlags, LsRow, is_mbt_extension, is_tile_extension,
};
use crate::ui::mbt::MbtilesState;
use crate::ui::rendering::files::{
render_file_browser, render_file_filter_panel, render_file_info_panel,
render_tile_preview_panel,
};
use crate::ui::rendering::help::{render_error_popup, render_help_overlay};
use crate::ui::rendering::layers::{
render_mbtiles_hover_panel, render_properties_panel, render_tree_panel,
};
use crate::ui::rendering::map::{render_map_panel, render_mbtiles_map_panel};
use crate::ui::scan::start_scan;
use crate::ui::state::{App, HoveredInfo, LayerGroup, ResizeHandle, TreeItem, ViewMode};
use crate::ui::tile::{TileCache, polygon_coord_count};
pub const CLR_POINT: Color = Color::Magenta;
pub const CLR_MULTI_POINT: Color = Color::LightMagenta;
pub const CLR_LINE: Color = Color::Cyan;
pub const CLR_MULTI_LINE: Color = Color::LightCyan;
pub const CLR_POLYGON: Color = Color::Blue;
pub const CLR_MULTI_POLYGON: Color = Color::LightBlue;
pub const CLR_INNER_RING: Color = Color::Red;
pub const CLR_BAD_WINDING: Color = Color::LightRed;
pub const CLR_EXTENT: Color = Color::DarkGray;
pub const CLR_SELECTED: Color = Color::Yellow;
pub const CLR_HOVERED: Color = Color::White;
pub const CLR_HOVERED_TREE: Color = Color::LightGreen;
pub const CLR_DIMMED: Color = Color::DarkGray;
pub const CLR_INNER_RING_SEL: Color = Color::Rgb(255, 150, 120);
pub const CLR_LABEL: Color = Color::Cyan;
pub const CLR_HINT: Color = Color::DarkGray;
pub const CLR_CONTEXT_LAYER: Color = Color::DarkGray;
pub const CLR_CONTEXT_FEATURE: Color = Color::Gray;
pub const CLR_TRIANGLE: Color = Color::DarkGray;
pub const STYLE_SELECTED: Style = Style::new().fg(CLR_SELECTED).add_modifier(Modifier::BOLD);
pub const STYLE_LABEL: Style = Style::new().fg(CLR_LABEL);
pub const STYLE_BOLD: Style = Style::new().add_modifier(Modifier::BOLD);
const HOVER_REDRAW_THROTTLE: Duration = Duration::from_millis(32);
const TREE_HSCROLL_STEP: u16 = 4;
#[derive(Args)]
pub struct UiArgs {
path: PathBuf,
#[arg(long = "center-tile", value_name = "Z/X/Y")]
center_tile: Option<String>,
}
const SCAN_FLAGS: LsFlags = LsFlags {
gzip: true,
algorithms: true,
validate: false,
};
pub fn ui(args: &UiArgs) -> anyhow::Result<()> {
run_app(build_app(args)?)
}
fn build_app(args: &UiArgs) -> anyhow::Result<App> {
if args.center_tile.is_some() && !is_mbt_extension(&args.path) {
bail!("--center-tile is only supported when opening an .mbtiles file");
}
let cache = TileCache::default();
let mut app = if is_mbt_extension(&args.path) {
let mut mbt = MbtilesState::new(args.path.clone());
if let Some(ref s) = args.center_tile {
let (z, x, y) = parse_center_tile_xyz(s)?;
mbt.set_viewport_to_tile(z, x, y)
.map_err(|e| anyhow::anyhow!(e))?;
}
App::new_mbtiles(mbt, args.path.clone())
} else if args.path.is_dir() {
let rx = start_scan(args.path.clone(), SCAN_FLAGS);
App::new_file_browser(Vec::new(), Some(rx), args.path.clone())
} else if args.path.is_file() {
let tile = cache.load(&args.path)?;
App::new_single_file(tile, Some(args.path.clone()))
} else {
bail!("Path is not a file or directory");
};
app.tile_cache = cache;
Ok(app)
}
fn parse_center_tile_xyz(spec: &str) -> anyhow::Result<(u8, u32, u32)> {
let parts: Vec<&str> = spec
.split('/')
.map(str::trim)
.filter(|p| !p.is_empty())
.collect();
if parts.len() != 3 {
bail!("--center-tile must be z/x/y (three integers), got {spec:?}");
}
let zoom: u8 = parts[0]
.parse()
.map_err(|_| anyhow::anyhow!("invalid zoom z in --center-tile: {:?}", parts[0]))?;
let tile_x: u32 = parts[1]
.parse()
.map_err(|_| anyhow::anyhow!("invalid tile x in --center-tile: {:?}", parts[1]))?;
let tile_y: u32 = parts[2]
.parse()
.map_err(|_| anyhow::anyhow!("invalid tile y in --center-tile: {:?}", parts[2]))?;
let grid_n = 1u32
.checked_shl(u32::from(zoom))
.ok_or_else(|| anyhow::anyhow!("zoom z={zoom} is too large"))?;
if tile_x >= grid_n || tile_y >= grid_n {
bail!(
"tile x/y must be < 2^z for z={zoom} (max index {})",
grid_n.saturating_sub(1)
);
}
Ok((zoom, tile_x, tile_y))
}
fn refresh_tile_preview(app: &mut App) {
let is_tile = app
.get_selected_file()
.is_some_and(|r| is_tile_extension(r.path()));
if !is_tile {
app.preview_tile_path = None;
app.preview = None;
app.preview_error = None;
app.preview_load_requested = None;
}
}
fn group_by_layer(fc: &FeatureCollection) -> Vec<LayerGroup> {
let mut groups: Vec<LayerGroup> = Vec::new();
for (i, f) in fc.features.iter().enumerate() {
let name = f
.properties
.get("_layer")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let extent = f
.properties
.get("_extent")
.and_then(serde_json::Value::as_u64)
.map_or(4096, |v| u32::try_from(v).expect("_extent is valid u32"));
if let Some(g) = groups.iter_mut().find(|g| g.name == name) {
g.feature_indices.push(i);
} else {
groups.push(LayerGroup::new(name.to_string(), extent, vec![i]));
}
}
groups
}
fn auto_expand(groups: &[LayerGroup]) -> Vec<bool> {
if groups.len() == 1 {
vec![true]
} else {
vec![false; groups.len()]
}
}
fn point_in_rect(col: u16, row: u16, r: Rect) -> bool {
col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height
}
fn click_row_in_area(col: u16, row: u16, area: Rect, scroll: usize) -> Option<usize> {
let top = area.y + 1;
let bot = area.y + area.height.saturating_sub(1);
(col >= area.x && col < area.x + area.width && row >= top && row < bot)
.then(|| (row - top).into_usize() + scroll)
}
const HIGHLIGHT_SYMBOL_WIDTH: u16 = 3;
const COLUMN_SPACING: u16 = 1;
fn file_header_click_column(
area: Rect,
widths: &[Constraint; 6],
col: u16,
row: u16,
) -> Option<FileSortColumn> {
if row != area.y + 1 {
return None;
}
let inner = area.inner(Margin {
vertical: 1,
horizontal: 1,
});
let fixed: u16 = widths
.iter()
.map(|c| match c {
Constraint::Length(l) => *l,
Constraint::Min(_)
| Constraint::Max(_)
| Constraint::Percentage(_)
| Constraint::Ratio(..)
| Constraint::Fill(_) => 0,
})
.sum();
let remaining = inner
.width
.saturating_sub(HIGHLIGHT_SYMBOL_WIDTH + fixed + 5 * COLUMN_SPACING);
let mut resolved = [0u16; 6];
for (i, c) in widths.iter().enumerate() {
resolved[i] = match c {
Constraint::Length(l) => *l,
Constraint::Min(_) => remaining,
Constraint::Max(_)
| Constraint::Percentage(_)
| Constraint::Ratio(..)
| Constraint::Fill(_) => 0,
};
}
let cols = [
FileSortColumn::File,
FileSortColumn::Size,
FileSortColumn::EncPct,
FileSortColumn::Layers,
FileSortColumn::Features,
];
let mut x = inner.x + HIGHLIGHT_SYMBOL_WIDTH;
for (i, &w) in resolved.iter().enumerate() {
let end = if i == resolved.len() - 1 {
inner.x + inner.width
} else {
x + w
};
if col >= x && col < end {
return cols.get(i).copied();
}
x = end + COLUMN_SPACING;
}
None
}
fn block_with_title(title: impl Into<Line<'static>>) -> Block<'static> {
Block::default().borders(Borders::ALL).title(title)
}
fn map_canvas_area(outer: Rect) -> Rect {
block_with_title(" ").inner(outer)
}
fn stat_line(name: &str, val: &dyn std::fmt::Display) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{name}: "), STYLE_LABEL),
Span::raw(val.to_string()),
])
}
const DIVIDER_GRAB: u16 = 2;
const DIVIDER_GRAB_ROWS: u16 = 1;
fn divider_hit(
col: u16,
row: u16,
left: Rect,
tree: Rect,
geom: Option<Rect>,
) -> Option<ResizeHandle> {
let horiz_hit = |dy: u16| {
row >= dy.saturating_sub(DIVIDER_GRAB_ROWS)
&& row < dy.saturating_add(DIVIDER_GRAB_ROWS)
&& col >= left.x
&& col < left.x + left.width
};
if geom.is_some_and(|g| horiz_hit(g.y)) {
return Some(ResizeHandle::PropertiesGeometry);
}
if horiz_hit(tree.y + tree.height) {
return Some(ResizeHandle::FeaturesProperties);
}
let dx = left.x + left.width;
if col >= dx.saturating_sub(DIVIDER_GRAB)
&& col < dx.saturating_add(DIVIDER_GRAB)
&& row >= left.y
&& row < left.y + left.height
{
return Some(ResizeHandle::LeftRight);
}
None
}
fn set_pointer_cursor(pointer: bool) {
let seq: &[u8] = if pointer {
b"\x1b]22;default\x1b\\"
} else {
b"\x1b]22;\x1b\\"
};
let _ = std::io::stdout().write_all(seq);
let _ = std::io::stdout().flush();
}
fn run_app(mut app: App) -> anyhow::Result<()> {
let mut terminal = ratatui::init();
set_pointer_cursor(true);
let result = (|| {
execute!(terminal.backend_mut(), EnableMouseCapture)?;
run_app_loop(&mut terminal, &mut app)
})();
let _ = execute!(terminal.backend_mut(), DisableMouseCapture);
set_pointer_cursor(false);
ratatui::restore();
result
}
fn pct_at(pos: u16, origin: u16, span: u16) -> u16 {
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let pct =
(f32::from(pos.saturating_sub(origin)) / f32::from(span.max(1)) * 100.0).round() as u16;
pct.clamp(10, 90)
}
const FILE_RIGHT_PANEL_MIN: u16 = 20;
fn file_browser_preview_pct_clamped(pct: u16, filter_pct: u16) -> u16 {
pct.clamp(
FILE_RIGHT_PANEL_MIN,
100u16
.saturating_sub(FILE_RIGHT_PANEL_MIN)
.saturating_sub(filter_pct),
)
}
fn file_browser_filter_pct_clamped(pct: u16, preview_pct: u16) -> u16 {
pct.clamp(
FILE_RIGHT_PANEL_MIN,
100u16
.saturating_sub(FILE_RIGHT_PANEL_MIN)
.saturating_sub(preview_pct),
)
}
#[derive(Debug, Default, Clone, Copy)]
struct PanelAreas {
map: Option<Rect>,
tree: Option<Rect>,
props: Option<Rect>,
geom: Option<Rect>,
left: Option<Rect>,
filter: Option<Rect>,
info: Option<Rect>,
preview: Option<Rect>,
right_col: Option<Rect>,
file_left: Option<Rect>,
}
fn render_frame(f: &mut Frame<'_>, app: &mut App, areas: &mut PanelAreas) {
match app.mode {
ViewMode::FileBrowser => {
refresh_tile_preview(app);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(app.file_left_pct),
Constraint::Percentage(100u16.saturating_sub(app.file_left_pct)),
])
.split(f.area());
let right_pct =
file_browser_preview_pct_clamped(app.file_preview_pct, app.file_filter_pct);
let filter_pct = file_browser_filter_pct_clamped(app.file_filter_pct, right_pct);
app.file_preview_pct = right_pct;
app.file_filter_pct = filter_pct;
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(right_pct),
Constraint::Percentage(filter_pct),
Constraint::Percentage(
100u16.saturating_sub(right_pct).saturating_sub(filter_pct),
),
])
.split(cols[1]);
render_file_browser(f, cols[0], app);
render_tile_preview_panel(f, right[0], app);
render_file_filter_panel(f, right[1], app);
render_file_info_panel(f, right[2], app);
areas.file_left = Some(cols[0]);
areas.right_col = Some(cols[1]);
areas.preview = Some(right[0]);
areas.filter = Some(right[1]);
areas.info = Some(right[2]);
}
ViewMode::LayerOverview => {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(app.left_pct),
Constraint::Percentage(100u16.saturating_sub(app.left_pct)),
])
.split(f.area());
let left = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(app.features_pct),
Constraint::Percentage(100u16.saturating_sub(app.features_pct)),
])
.split(cols[0]);
render_tree_panel(f, left[0], app);
let ga = render_properties_panel(f, left[1], app);
render_map_panel(f, cols[1], app);
areas.tree = Some(left[0]);
areas.props = Some(left[1]);
areas.geom = Some(ga);
areas.left = Some(cols[0]);
areas.map = Some(map_canvas_area(cols[1]));
}
ViewMode::MbtilesMap => {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(app.left_pct),
Constraint::Percentage(100u16.saturating_sub(app.left_pct)),
])
.split(f.area());
render_mbtiles_hover_panel(f, cols[0], app);
render_mbtiles_map_panel(f, cols[1], app);
areas.left = Some(cols[0]);
areas.map = Some(map_canvas_area(cols[1]));
}
}
if app.error_popup.is_some() {
render_error_popup(f, app);
} else if app.show_help {
render_help_overlay(f, app);
}
}
fn handle_key(app: &mut App, key: KeyEvent) -> bool {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return true;
}
if app.error_popup.is_some() {
app.error_popup = None;
app.invalidate();
return false;
}
if app.show_help {
#[expect(clippy::wildcard_enum_match_arm, reason = "any other key closes help")]
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
app.help_scroll = app.help_scroll.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
app.help_scroll = app.help_scroll.saturating_add(1);
}
KeyCode::PageUp => {
app.help_scroll = app.help_scroll.saturating_sub(10);
}
KeyCode::PageDown => {
app.help_scroll = app.help_scroll.saturating_add(10);
}
KeyCode::Home => app.help_scroll = 0,
KeyCode::End => app.help_scroll = u16::MAX,
_ => app.show_help = false,
}
app.invalidate();
return false;
}
#[expect(clippy::wildcard_enum_match_arm, reason = "unbound keys do nothing")]
match key.code {
KeyCode::Char('q') => return true,
KeyCode::Esc if app.handle_escape() => return true,
KeyCode::Char('?') | KeyCode::F(1) => app.open_help(),
KeyCode::Char('h') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
app.open_help();
}
KeyCode::Enter => app.handle_enter(),
KeyCode::Char('>') => scroll_tree_sideways(app, false),
KeyCode::Char('<') => scroll_tree_sideways(app, true),
KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => {
scroll_tree_sideways(app, false);
}
KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => {
scroll_tree_sideways(app, true);
}
KeyCode::Char('+' | '=') | KeyCode::Right => app.handle_plus(),
KeyCode::Char('-') => app.handle_minus(),
KeyCode::Char('*') => app.handle_star(),
KeyCode::Up | KeyCode::Char('k') => app.move_up_by(1),
KeyCode::Down | KeyCode::Char('j') => app.move_down_by(1),
KeyCode::Left => app.handle_left_arrow(),
KeyCode::PageUp => {
app.move_up_by(app.page_size().saturating_sub(1).max(1));
}
KeyCode::PageDown => {
app.move_down_by(app.page_size().saturating_sub(1).max(1));
}
KeyCode::Home => app.move_to_start(),
KeyCode::End => app.move_to_end(),
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.left_pct = app.left_pct.saturating_sub(5).max(10);
app.invalidate();
}
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.left_pct = (app.left_pct + 5).min(90);
app.invalidate();
}
KeyCode::Char('J') if key.modifiers.contains(KeyModifiers::SHIFT) => {
app.features_pct = app.features_pct.saturating_sub(5).max(10);
app.invalidate();
}
KeyCode::Char('K') if key.modifiers.contains(KeyModifiers::SHIFT) => {
app.features_pct = (app.features_pct + 5).min(90);
app.invalidate();
}
_ => {}
}
false
}
fn tick(app: &mut App) {
if app.mode == ViewMode::MbtilesMap
&& let Some(ref mut mbt) = app.mbt_state
{
if mbt.process_results() {
app.needs_redraw = true;
}
if let Some(msg) = mbt.take_loader_fatal() {
let title = app
.current_file
.as_ref()
.map_or_else(|| "MBTiles".to_string(), |p| p.display().to_string());
app.error_popup = Some((title, msg));
app.needs_redraw = true;
}
let visible = mbt.visible_tiles();
for (z, x, y) in visible {
mbt.request_tile_with_ancestors(z, x, y);
}
mbt.prune_tile_cache_if_needed();
}
app.poll_scan();
if let Some((path, result)) = app.preview_rx.as_mut().and_then(|rx| rx.try_recv().ok()) {
app.preview_rx = None;
app.preview_load_requested = None;
if app.get_selected_file().map(LsRow::path) == Some(path.as_path()) {
app.preview_tile_path = Some(path);
match result {
Ok(tile) => {
app.preview = Some(tile);
app.preview_error = None;
}
Err(e) => {
app.preview = None;
app.preview_error = Some(e);
}
}
}
app.invalidate();
}
if app.mode == ViewMode::FileBrowser
&& let Some(selected) = app.get_selected_file()
{
let path = selected.path().to_path_buf();
if is_tile_extension(&path)
&& app.preview_tile_path.as_ref() != Some(&path)
&& app.preview_load_requested.as_ref() != Some(&path)
{
if let Some(tile) = app.tile_cache.get(&path) {
app.preview_tile_path = Some(path);
app.preview = Some(tile);
app.preview_error = None;
app.invalidate();
} else {
let (tx, rx) = mpsc::channel();
app.preview_rx = Some(rx);
app.preview_load_requested = Some(path.clone());
let cache = app.tile_cache.clone();
thread::spawn(move || {
let result = cache.load(&path).map_err(|e| e.to_string());
let _ = tx.send((path, result));
});
}
}
}
}
fn run_app_loop(terminal: &mut ratatui::DefaultTerminal, app: &mut App) -> anyhow::Result<()> {
let mut areas = PanelAreas::default();
let mut clicks = MouseState::default();
loop {
tick(app);
if app.needs_redraw {
app.needs_redraw = false;
terminal.draw(|f| render_frame(f, app, &mut areas))?;
}
if event::poll(Duration::from_millis(16))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if handle_key(app, key) {
break;
}
}
Event::Mouse(mouse) => {
let screen = terminal.get_frame().area();
handle_mouse(app, &areas, screen, mouse, &mut clicks)?;
}
Event::Resize(_, _) => app.invalidate(),
Event::FocusGained | Event::FocusLost | Event::Key(_) | Event::Paste(_) => {}
}
}
}
Ok(())
}
fn scroll_by(val: u16, step: u16, up: bool) -> u16 {
if up {
val.saturating_sub(step)
} else {
val.saturating_add(step)
}
}
fn scroll_tree_sideways(app: &mut App, left: bool) {
app.tree_hscroll = scroll_by(app.tree_hscroll, TREE_HSCROLL_STEP, left);
app.invalidate();
}
fn handle_filter_click(app: &mut App, row: usize) {
let exts = collect_extensions(&app.files);
let geoms = collect_file_geometries(&app.files);
let algos = collect_file_algorithms(&app.files);
let ext_start = 3;
let ext_end = ext_start + exts.len();
let geom_start = ext_end + 2;
let geom_end = geom_start + geoms.len();
let algo_start = geom_end + 2;
let algo_end = algo_start + algos.len();
if row == 0 {
app.ext_filters.clear();
app.geom_filters.clear();
app.algo_filters.clear();
} else if row >= ext_start && row < ext_end {
toggle_set_string(&mut app.ext_filters, &exts[row - ext_start]);
} else if row >= geom_start && row < geom_end {
toggle_set(&mut app.geom_filters, geoms[row - geom_start]);
} else if row >= algo_start && row < algo_end {
toggle_set(&mut app.algo_filters, algos[row - algo_start]);
}
app.rebuild_filtered_files();
}
fn toggle_set<T: Eq + std::hash::Hash>(set: &mut HashSet<T>, val: T) {
if !set.remove(&val) {
set.insert(val);
}
}
fn toggle_set_string(set: &mut HashSet<String>, val: &str) {
if !set.remove(val) {
set.insert(val.to_string());
}
}
pub(crate) fn collect_file_geometries(files: &[LsRow]) -> Vec<GeometryType> {
let mut set = HashSet::new();
for row in files {
if let LsRow::Info { info, .. } = row {
for g in &info.geometries {
set.insert(*g);
}
}
}
let mut v: Vec<_> = set.into_iter().collect();
v.sort_unstable();
v
}
pub(crate) fn collect_file_algorithms(files: &[LsRow]) -> Vec<FileAlgorithm> {
let mut set = HashSet::new();
for row in files {
if let LsRow::Info { info, .. } = row {
for a in &info.algorithms {
set.insert(*a);
}
}
}
let mut v: Vec<_> = set.into_iter().collect();
v.sort_by_key(FileAlgorithm::to_string);
v
}
fn collect_extensions(files: &[LsRow]) -> Vec<String> {
let mut set = HashSet::new();
for row in files {
if let Some(ext) = row.path().extension().and_then(|e| e.to_str()) {
set.insert(ext.to_lowercase());
}
}
let mut v: Vec<_> = set.into_iter().collect();
v.sort_unstable();
v
}
fn geometry_type_name(geom: &Geometry<i32>) -> &'static str {
GeometryType::try_from(geom).map_or("Unknown", Into::into)
}
fn geometry_color(geom: &Geometry<i32>) -> Color {
match geom {
Geometry::<i32>::MultiPoint(_) => CLR_MULTI_POINT,
Geometry::<i32>::LineString(_) => CLR_LINE,
Geometry::<i32>::MultiLineString(_) => CLR_MULTI_LINE,
Geometry::<i32>::Polygon(_) | Geometry::<i32>::MultiPolygon(_) if has_bad_winding(geom) => {
CLR_BAD_WINDING
}
Geometry::<i32>::Polygon(_) => CLR_POLYGON,
Geometry::<i32>::MultiPolygon(_) => CLR_MULTI_POLYGON,
Geometry::<i32>::Point(_)
| Geometry::<i32>::Line(_)
| Geometry::<i32>::GeometryCollection(_)
| Geometry::<i32>::Rect(_)
| Geometry::<i32>::Triangle(_) => CLR_POINT,
}
}
fn multi_part_count(geom: &Geometry<i32>) -> usize {
match geom {
Geometry::<i32>::MultiPoint(mp) => mp.0.len(),
Geometry::<i32>::MultiLineString(mls) => mls.0.len(),
Geometry::<i32>::MultiPolygon(mpoly) => mpoly.0.len(),
Geometry::<i32>::Point(_)
| Geometry::<i32>::Line(_)
| Geometry::<i32>::LineString(_)
| Geometry::<i32>::Polygon(_)
| Geometry::<i32>::GeometryCollection(_)
| Geometry::<i32>::Rect(_)
| Geometry::<i32>::Triangle(_) => 0,
}
}
fn poly_ring_stats(poly: &Polygon<i32>) -> (usize, usize) {
(polygon_coord_count(poly), 1 + poly.interiors().len())
}
fn feature_suffix(geom: &Geometry<i32>) -> String {
let n = multi_part_count(geom);
if n > 0 {
return format!(" ({n} parts)");
}
match geom {
Geometry::<i32>::LineString(ls) => format!(" ({}v)", ls.0.len()),
Geometry::<i32>::Polygon(poly) => {
let (total, ring_count) = poly_ring_stats(poly);
if ring_count > 1 {
format!(" ({total}v, {ring_count} rings)")
} else {
format!(" ({total}v)")
}
}
Geometry::<i32>::Point(_)
| Geometry::<i32>::Line(_)
| Geometry::<i32>::MultiPoint(_)
| Geometry::<i32>::MultiLineString(_)
| Geometry::<i32>::MultiPolygon(_)
| Geometry::<i32>::GeometryCollection(_)
| Geometry::<i32>::Rect(_)
| Geometry::<i32>::Triangle(_) => String::new(),
}
}
fn sub_feature_suffix(geom: &Geometry<i32>, part: usize) -> String {
match geom {
Geometry::<i32>::MultiLineString(mls) => mls
.0
.get(part)
.map_or(String::new(), |ls| format!(" ({}v)", ls.0.len())),
Geometry::<i32>::MultiPolygon(mpoly) => mpoly.0.get(part).map_or(String::new(), |poly| {
let (total, ring_count) = poly_ring_stats(poly);
if ring_count > 1 {
format!(" ({total}v, {ring_count} rings)")
} else {
format!(" ({total}v)")
}
}),
Geometry::<i32>::Point(_)
| Geometry::<i32>::Line(_)
| Geometry::<i32>::LineString(_)
| Geometry::<i32>::Polygon(_)
| Geometry::<i32>::MultiPoint(_)
| Geometry::<i32>::GeometryCollection(_)
| Geometry::<i32>::Rect(_)
| Geometry::<i32>::Triangle(_) => String::new(),
}
}
fn is_entry_visible(layer: usize, feat: usize, sel: &TreeItem) -> bool {
match sel {
TreeItem::All => true,
TreeItem::Layer(l) => *l == layer,
TreeItem::Feature { layer: l, feat: f }
| TreeItem::SubFeature {
layer: l, feat: f, ..
} => *l == layer && *f == feat,
}
}
fn ring_signed_area(ring: &[Coord<i32>]) -> f64 {
let mut area = 0.0;
for w in ring.windows(2) {
let [x1, y1] = coord_f64(w[0]);
let [x2, y2] = coord_f64(w[1]);
area += (x2 - x1) * (y2 + y1);
}
if let (Some(&last), Some(&first)) = (ring.last(), ring.first()) {
let [lx, ly] = coord_f64(last);
let [fx, fy] = coord_f64(first);
area += (fx - lx) * (fy + ly);
}
area
}
pub(crate) fn is_ring_ccw(ring: &[Coord<i32>]) -> bool {
ring_signed_area(ring) < 0.0
}
fn has_bad_winding(geom: &Geometry<i32>) -> bool {
let check = |poly: &Polygon<i32>| {
!is_ring_ccw(&poly.exterior().0) || poly.interiors().iter().any(|r| is_ring_ccw(&r.0))
};
match geom {
Geometry::<i32>::Polygon(poly) => check(poly),
Geometry::<i32>::MultiPolygon(mpoly) => mpoly.iter().any(check),
Geometry::<i32>::Point(_)
| Geometry::<i32>::Line(_)
| Geometry::<i32>::LineString(_)
| Geometry::<i32>::MultiPoint(_)
| Geometry::<i32>::MultiLineString(_)
| Geometry::<i32>::GeometryCollection(_)
| Geometry::<i32>::Rect(_)
| Geometry::<i32>::Triangle(_) => false,
}
}
struct GeometryIndexEntry {
layer: usize,
feat: usize,
part: Option<usize>,
vertices: Vec<[f64; 2]>,
}
impl RTreeObject for GeometryIndexEntry {
type Envelope = AABB<[f64; 2]>;
fn envelope(&self) -> Self::Envelope {
if self.vertices.is_empty() {
return AABB::from_point([0.0, 0.0]);
}
let (min_x, min_y, max_x, max_y) = self.vertices.iter().fold(
(
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
),
|(ax, ay, bx, by), v| (ax.min(v[0]), ay.min(v[1]), bx.max(v[0]), by.max(v[1])),
);
AABB::from_corners([min_x, min_y], [max_x, max_y])
}
}
impl PointDistance for GeometryIndexEntry {
fn distance_2(&self, point: &[f64; 2]) -> f64 {
self.vertices
.iter()
.map(|v| {
let dx = v[0] - point[0];
let dy = v[1] - point[1];
dx * dx + dy * dy
})
.fold(f64::INFINITY, f64::min)
}
}
#[must_use]
pub fn coord_f64(c: Coord<i32>) -> [f64; 2] {
[f64::from(c.x), f64::from(c.y)]
}
#[derive(Default)]
struct MouseState {
tree_click: Option<(Instant, usize)>,
file_click: Option<(Instant, usize)>,
hover_redraw: Option<Instant>,
}
fn handle_mouse(
app: &mut App,
areas: &PanelAreas,
screen: Rect,
mouse: MouseEvent,
clicks: &mut MouseState,
) -> anyhow::Result<()> {
if app.error_popup.is_some() {
if matches!(mouse.kind, MouseEventKind::Down(_)) {
app.error_popup = None;
app.invalidate();
}
return Ok(());
}
if app.show_help {
match mouse.kind {
MouseEventKind::ScrollUp => {
app.help_scroll = app.help_scroll.saturating_sub(1);
app.invalidate();
}
MouseEventKind::ScrollDown => {
app.help_scroll = app.help_scroll.saturating_add(1);
app.invalidate();
}
MouseEventKind::Down(_)
| MouseEventKind::Up(_)
| MouseEventKind::Drag(_)
| MouseEventKind::Moved
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight => {}
}
return Ok(());
}
match mouse.kind {
MouseEventKind::Up(_) => {
if let Some(ref mut mbt) = app.mbt_state {
mbt.map_drag_last = None;
}
if app.resizing.take().is_some() {
app.invalidate();
}
}
MouseEventKind::Moved | MouseEventKind::Drag(_) => {
if let Some(handle) = app.resizing {
let area = screen;
let la = areas.left.unwrap_or_default();
match handle {
ResizeHandle::LeftRight => {
app.left_pct = pct_at(mouse.column, area.x, area.width);
}
ResizeHandle::FeaturesProperties => {
app.features_pct = pct_at(mouse.row, la.y, la.height);
}
ResizeHandle::PropertiesGeometry => {
if let Some(pa) = areas.props {
app.properties_pct = pct_at(mouse.row, pa.y, pa.height);
}
}
ResizeHandle::FileBrowserLeftRight => {
app.file_left_pct = pct_at(mouse.column, area.x, area.width);
}
ResizeHandle::FileBrowserPreviewFilter => {
if let Some(rc) = areas.right_col {
app.file_preview_pct = file_browser_preview_pct_clamped(
pct_at(mouse.row, rc.y, rc.height),
app.file_filter_pct,
);
}
}
ResizeHandle::FileBrowserFilterInfo => {
if let Some(rc) = areas.right_col {
app.file_filter_pct = file_browser_filter_pct_clamped(
pct_at(mouse.row, rc.y, rc.height)
.saturating_sub(app.file_preview_pct),
app.file_preview_pct,
);
}
}
}
app.invalidate();
return Ok(());
}
if app.mode == ViewMode::MbtilesMap
&& let MouseEventKind::Drag(MouseButton::Left) = mouse.kind
&& let (Some(area), Some(ref mut mbt)) = (areas.map, app.mbt_state.as_mut())
&& let Some((lc, lr)) = mbt.map_drag_last
{
let dc = i32::from(mouse.column) - i32::from(lc);
let dr = i32::from(mouse.row) - i32::from(lr);
if dc != 0 || dr != 0 {
mbt.pan_by_pixels(area.width, area.height, dc, dr);
mbt.map_drag_last = Some((mouse.column, mouse.row));
app.needs_redraw = true;
}
}
let prev = app.hovered.clone();
app.hovered = None;
if app.mode == ViewMode::LayerOverview {
let hover_ok = !matches!(
app.tree_items.get(app.selected_index),
Some(TreeItem::Feature { layer, feat })
if !app.expanded_features.contains(&(*layer, *feat))
);
if hover_ok
&& let Some(area) = areas.tree
&& let Some(row) = click_row_in_area(
mouse.column,
mouse.row,
area,
app.tree_scroll.into_usize(),
)
&& let Some(item) = app.tree_items.get(row)
&& *item != TreeItem::All
{
app.hovered = Some(HoveredInfo {
tree_idx: Some(row),
item: item.clone(),
});
}
if app.hovered.is_none()
&& let Some(area) = areas.map
&& point_in_rect(mouse.column, mouse.row, area)
{
let b = app.get_bounds();
let rx = f64::from(mouse.column - area.x) / f64::from(area.width);
let ry = f64::from(mouse.row - area.y) / f64::from(area.height);
let cx = b.0 + rx * (b.2 - b.0);
let cy = b.3 - ry * (b.3 - b.1);
app.find_hovered_feature(cx, cy, b);
}
}
if app.hovered != prev {
let allow = clicks
.hover_redraw
.is_none_or(|t| t.elapsed() >= HOVER_REDRAW_THROTTLE);
if allow {
clicks.hover_redraw = Some(Instant::now());
app.invalidate();
}
}
if app.mode == ViewMode::MbtilesMap
&& let Some(area) = areas.map
&& point_in_rect(mouse.column, mouse.row, area)
&& let Some(ref mut mbt) = app.mbt_state
{
let rx = f64::from(mouse.column - area.x) / f64::from(area.width);
let ry = f64::from(mouse.row - area.y) / f64::from(area.height);
let (wx, wy) = mbt.viewport_world_at_fracs(rx, ry);
let prev_hov = mbt.hovered.clone();
mbt.find_hovered(wx, wy);
if mbt.hovered != prev_hov {
let allow = clicks
.hover_redraw
.is_none_or(|t| t.elapsed() >= HOVER_REDRAW_THROTTLE);
if allow {
clicks.hover_redraw = Some(Instant::now());
app.invalidate();
}
}
}
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
let up = matches!(mouse.kind, MouseEventKind::ScrollUp);
let s = app.scroll_step();
let step = u16::try_from(s)?;
if app.mode == ViewMode::MbtilesMap
&& let (Some(area), Some(ref mut mbt)) = (areas.map, app.mbt_state.as_mut())
{
if point_in_rect(mouse.column, mouse.row, area) {
let rx = f64::from(mouse.column - area.x) / f64::from(area.width);
let ry = f64::from(mouse.row - area.y) / f64::from(area.height);
let (wx, wy) = mbt.viewport_world_at_fracs(rx, ry);
mbt.zoom_wheel_at(wx, wy, up);
app.needs_redraw = true;
return Ok(());
}
if areas
.left
.is_some_and(|a| point_in_rect(mouse.column, mouse.row, a))
{
app.properties_scroll = scroll_by(app.properties_scroll, step, up);
app.invalidate();
return Ok(());
}
}
if app.mode == ViewMode::FileBrowser {
if areas
.filter
.is_some_and(|a| point_in_rect(mouse.column, mouse.row, a))
{
app.filter_scroll = scroll_by(app.filter_scroll, step, up);
app.invalidate();
return Ok(());
}
if areas
.info
.is_some_and(|a| point_in_rect(mouse.column, mouse.row, a))
{
app.file_info_scroll = scroll_by(app.file_info_scroll, step, up);
app.invalidate();
return Ok(());
}
}
if app.mode == ViewMode::LayerOverview {
if areas
.geom
.is_some_and(|a| point_in_rect(mouse.column, mouse.row, a))
{
app.geometry_scroll = scroll_by(app.geometry_scroll, step, up);
app.invalidate();
return Ok(());
}
if areas
.props
.is_some_and(|a| point_in_rect(mouse.column, mouse.row, a))
{
app.properties_scroll = scroll_by(app.properties_scroll, step, up);
app.invalidate();
return Ok(());
}
if let Some(area) = areas.tree
&& point_in_rect(mouse.column, mouse.row, area)
{
if up {
app.tree_scroll = app.tree_scroll.saturating_sub(step);
} else {
let inner = area.height.saturating_sub(2).into_usize();
let max = u16::try_from(app.tree_items.len().saturating_sub(inner))?;
app.tree_scroll = app.tree_scroll.saturating_add(step).min(max);
}
app.invalidate();
return Ok(());
}
if areas
.map
.is_some_and(|a| point_in_rect(mouse.column, mouse.row, a))
{
return Ok(());
}
}
if up {
app.move_up_by(s);
} else {
app.move_down_by(s);
}
}
MouseEventKind::Down(btn) => {
if app.mode == ViewMode::MbtilesMap
&& btn == MouseButton::Left
&& let (Some(area), Some(ref mut mbt)) = (areas.map, app.mbt_state.as_mut())
&& point_in_rect(mouse.column, mouse.row, area)
{
mbt.map_drag_last = Some((mouse.column, mouse.row));
return Ok(());
}
if app.mode == ViewMode::FileBrowser {
if let Some(fl) = areas.file_left {
let dx = fl.x + fl.width;
if mouse.column >= dx.saturating_sub(DIVIDER_GRAB)
&& mouse.column < dx.saturating_add(DIVIDER_GRAB)
&& mouse.row >= fl.y
&& mouse.row < fl.y + fl.height
{
app.resizing = Some(ResizeHandle::FileBrowserLeftRight);
app.invalidate();
return Ok(());
}
}
if let (Some(pa), Some(fa)) = (areas.preview, areas.filter) {
let mut invalidate = |value: u16, resizer: ResizeHandle| {
if mouse.row >= value.saturating_sub(DIVIDER_GRAB_ROWS)
&& mouse.row < value.saturating_add(DIVIDER_GRAB_ROWS)
&& areas
.right_col
.is_some_and(|rc| point_in_rect(mouse.column, mouse.row, rc))
{
app.resizing = Some(resizer);
app.invalidate();
true
} else {
false
}
};
let dy_preview = pa.y + pa.height;
if invalidate(dy_preview, ResizeHandle::FileBrowserPreviewFilter) {
return Ok(());
}
let dy_filter = fa.y + fa.height;
if invalidate(dy_filter, ResizeHandle::FileBrowserFilterInfo) {
return Ok(());
}
}
if let Some(fa) = areas.filter
&& point_in_rect(mouse.column, mouse.row, fa)
{
let row = mouse.row.saturating_sub(fa.y + 1).into_usize()
+ app.filter_scroll.into_usize();
handle_filter_click(app, row);
return Ok(());
}
if let Some(ia) = areas.info
&& point_in_rect(mouse.column, mouse.row, ia)
&& app.filtered_file_indices.is_empty()
&& !app.files.is_empty()
{
let row = mouse.row.saturating_sub(ia.y + 1).into_usize();
if row == 2 {
app.ext_filters.clear();
app.geom_filters.clear();
app.algo_filters.clear();
app.rebuild_filtered_files();
}
return Ok(());
}
if let Some(area) = app.file_table_area {
if app.data_loaded()
&& let Some(widths) = app.file_table_widths
&& let Some(c) =
file_header_click_column(area, &widths, mouse.column, mouse.row)
{
app.handle_file_header_click(c);
return Ok(());
}
if let Some(row) = click_row_in_area(
mouse.column,
mouse.row,
area,
app.file_list_state.offset(),
)
.and_then(|r| r.checked_sub(1))
&& row < app.filtered_file_indices.len()
{
let dbl = clicks
.file_click
.is_some_and(|(t, prev)| prev == row && t.elapsed().as_millis() < 400);
clicks.file_click = Some((Instant::now(), row));
app.selected_file_index = row;
app.file_list_state.select(Some(row));
app.invalidate_bounds();
if dbl {
app.handle_enter();
}
}
}
} else if app.mode == ViewMode::LayerOverview {
if let (Some(la), Some(ta)) = (areas.left, areas.tree)
&& let Some(h) = divider_hit(mouse.column, mouse.row, la, ta, areas.geom)
{
app.resizing = Some(h);
app.invalidate();
return Ok(());
}
if let Some(area) = areas.tree
&& let Some(row) = click_row_in_area(
mouse.column,
mouse.row,
area,
app.tree_scroll.into_usize(),
)
&& row < app.tree_items.len()
{
let dbl = clicks
.tree_click
.is_some_and(|(t, prev)| prev == row && t.elapsed().as_millis() < 400);
clicks.tree_click = Some((Instant::now(), row));
if let Some((l, f, p)) =
app.tree_items.get(row).and_then(TreeItem::layer_feat_part)
{
app.handle_feature_click(l, f, p, area.height);
} else {
app.selected_index = row;
app.scroll_selected_into_view(area.height.saturating_sub(2).into_usize());
}
app.invalidate_bounds();
if dbl {
app.handle_enter();
}
}
if let Some(h) = app.hovered.clone()
&& let Some(ta) = areas.tree
&& areas
.map
.is_some_and(|m| point_in_rect(mouse.column, mouse.row, m))
{
app.handle_map_click(&h.item, ta.height);
app.invalidate_bounds();
}
}
}
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => {
if app.mode == ViewMode::LayerOverview
&& let Some(area) = areas.tree
&& point_in_rect(mouse.column, mouse.row, area)
{
scroll_tree_sideways(app, matches!(mouse.kind, MouseEventKind::ScrollLeft));
}
}
}
Ok(())
}