use std::{
f32::consts::{FRAC_PI_2, TAU},
io,
time::{Duration, Instant},
};
use anyhow::Result;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
MouseButton, MouseEvent, MouseEventKind,
},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Terminal,
backend::CrosstermBackend,
widgets::{Block, Borders},
};
use tui_globe::{Camera, Globe, MapData};
const TARGET_FPS: u64 = 30;
const RADIANS_PER_SECOND: f32 = TAU / 25.0;
const DRAG_SENSITIVITY: f32 = 0.02;
const ZOOM_STEP: f32 = 1.15;
const ZOOM_MIN: f32 = 0.5;
const ZOOM_MAX: f32 = 16.0;
const PITCH_LIMIT: f32 = FRAC_PI_2 * 0.95;
#[derive(Debug)]
struct ViewState {
user_yaw: f32,
pitch: f32,
zoom: f32,
drag_anchor: Option<(u16, u16)>,
spin_yaw: f32,
last_tick: Instant,
}
impl ViewState {
fn new(now: Instant) -> Self {
Self {
user_yaw: 0.0,
pitch: 0.0,
zoom: 1.0,
drag_anchor: None,
spin_yaw: 0.0,
last_tick: now,
}
}
}
fn main() -> Result<()> {
let map = MapData::embedded();
let mut stdout = io::stdout();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let mut term = Terminal::new(CrosstermBackend::new(stdout))?;
let result = run(&mut term, &map);
disable_raw_mode()?;
execute!(
term.backend_mut(),
DisableMouseCapture,
LeaveAlternateScreen
)?;
term.show_cursor()?;
result
}
fn run(term: &mut Terminal<CrosstermBackend<io::Stdout>>, map: &MapData) -> Result<()> {
let frame_dt = Duration::from_millis(1000 / TARGET_FPS);
let mut state = ViewState::new(Instant::now());
loop {
let now = Instant::now();
let dt = now.duration_since(state.last_tick).as_secs_f32();
state.last_tick = now;
if state.drag_anchor.is_none() {
state.spin_yaw = (state.spin_yaw + dt * RADIANS_PER_SECOND).rem_euclid(TAU);
}
let camera = Camera {
yaw: state.spin_yaw + state.user_yaw,
pitch: state.pitch,
zoom: state.zoom,
};
term.draw(|f| {
let area = f.area();
let block = Block::default()
.borders(Borders::ALL)
.title(" tui-globe - drag rotate · scroll zoom · q quit ");
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(Globe::new(map, camera), inner);
})?;
if event::poll(frame_dt)? {
match event::read()? {
Event::Key(k) if k.kind == KeyEventKind::Press => match k.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Char('c') if k.modifiers.contains(KeyModifiers::CONTROL) => {
return Ok(());
}
_ => {}
},
Event::Mouse(m) => handle_mouse(&mut state, m),
_ => {}
}
}
}
}
fn handle_mouse(state: &mut ViewState, m: MouseEvent) {
match m.kind {
MouseEventKind::Down(MouseButton::Left) => {
state.drag_anchor = Some((m.column, m.row));
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some((px, py)) = state.drag_anchor {
let dx = i32::from(m.column) - i32::from(px);
let dy = i32::from(m.row) - i32::from(py);
state.user_yaw += dx as f32 * DRAG_SENSITIVITY;
state.pitch =
(state.pitch + dy as f32 * DRAG_SENSITIVITY).clamp(-PITCH_LIMIT, PITCH_LIMIT);
state.drag_anchor = Some((m.column, m.row));
}
}
MouseEventKind::Up(_) => state.drag_anchor = None,
MouseEventKind::ScrollUp => state.zoom = (state.zoom * ZOOM_STEP).min(ZOOM_MAX),
MouseEventKind::ScrollDown => state.zoom = (state.zoom / ZOOM_STEP).max(ZOOM_MIN),
_ => {}
}
}