Satellite tracking TUI using ratatui
1 2use std::sync::{Arc, Mutex}; 3 4use ratatui::{layout::{Constraint, Direction, Layout, Rect}, style::{palette::tailwind::SLATE, Color, Modifier, Style}, text::{Line, Span, Text}, widgets::{canvas::{Canvas, Circle, Map, MapResolution, Points}, Block, Borders, List, ListItem, Paragraph}, Frame}; 5use ratatui::prelude::Stylize; 6 7use crate::{App, CurrentScreen}; 8 9const SELECTED_STYLE: Style = Style::new().bg(SLATE.c800).add_modifier(Modifier::BOLD); 10 11pub fn ui(frame: &mut Frame, app: &mut Arc<Mutex<App>>) { 12 let app_shared = Arc::clone(&app); 13 let mut app_shared = app_shared.lock().unwrap(); 14 let chunks = Layout::default() 15 .direction(Direction::Vertical) 16 .constraints([ 17 Constraint::Length(3), 18 Constraint::Min(1), 19 Constraint::Length(3), 20 ]) 21 .split(frame.area()); 22 23 let title_block = Block::default() 24 .borders(Borders::ALL) 25 .style(Style::default()); 26 27 let title = Paragraph::new(Text::styled( 28 "ISS Locator", 29 Style::default().fg(ratatui::style::Color::Green), 30 )) 31 .block(title_block); 32 33 34 frame.render_widget(title, chunks[0]); 35 36 // MAP 37 38 let map = Canvas::default() 39 .block(Block::bordered().title("World Map")) 40 .marker(ratatui::symbols::Marker::Braille) 41 .paint(|ctx| { 42 ctx.draw(&Map { 43 color: Color::Green, 44 resolution: MapResolution::High, 45 }); 46 47 if let Some(loc) = &app_shared.iss_location { 48 //allegedly the area visible from the ISS but we all know its not, there is a way 49 //to create a new widget that, depending on input location would give the 50 //appropriate shape, I however didn't care enough yet to do so. 51 ctx.draw(&Circle { 52 color: Color::Blue, 53 radius: 13.0, 54 x: loc.longitude.into(), 55 y: loc.latitude.into() 56 }); 57 } 58 59 60 for (i,city) in app_shared.cities.cities.iter().enumerate() { 61 62 ctx.draw(&Points { 63 coords: &[(city.long.into(),city.lat.into())], 64 color: Color::Red 65 66 }); 67 68 if let Some(j) = app_shared.cities.state.selected(){ 69 if i == j { 70 // ctx.draw(&Points { 71 // coords: &[(city.long.into(),city.lat.into())], 72 // color: Color::Blue 73 // }); 74 75 ctx.draw(&Circle { 76 color: Color::Yellow, 77 radius: 0.7, 78 x: city.long.into(), 79 y: city.lat.into() 80 }); 81 82 }} 83 } 84 85 }) 86 .x_bounds([-180.0, 180.0]) 87 .y_bounds([-90.0, 90.0]); 88 89 90 frame.render_widget(map, chunks[1]); 91 92 93 // text on the bottom 94 // 95 96 let nav_text = vec![ 97 match app_shared.current_screen { 98 CurrentScreen::Map => Span::styled("Map", Style::default().fg(Color::Green)), 99 CurrentScreen::CityChoice => Span::styled("Location Choice", Style::default().fg(Color::Green)), 100 CurrentScreen::Exiting => Span::styled("Exiting", Style::default().fg(Color::Green)) 101 }.to_owned(), 102 103 Span::styled(" | ", Style::default().fg(Color::White)), 104 105 Span::styled(format!("Current location: {}",{ 106 if let Some(i) = app_shared.cities.state.selected() { 107 &app_shared.cities.cities[i].name 108 } else { 109 "Not selected" 110 } 111 }), Style::default().fg(Color::Green)), 112 113 114 Span::styled(" | ", Style::default().fg(Color::White)), 115 116 Span::styled(format!("iss location: {} {} ",{ 117 if let Some(i) = &app_shared.iss_location { 118 i.latitude.clone().to_string() 119 } else { 120 "?".to_string() 121 }}, 122 { 123 if let Some(i) = &app_shared.iss_location { 124 i.longitude.clone().to_string() 125 } else { 126 "?".to_string() 127 } 128 } 129 ), Style::default().fg(Color::Green)), 130 Span::styled(" | ", Style::default().fg(Color::White)), 131 Span::styled(format!(" {}",{ 132 if app_shared.iss_constant.is_some() { 133 "Y" 134 } else { 135 "N" 136 } 137 }), Style::default().fg(Color::Green)) 138 ]; 139 140 let mode_footer = Paragraph::new(Line::from(nav_text)) 141 .block(Block::default().borders(Borders::ALL)); 142 143 144 let curr_key_hint = { 145 match app_shared.current_screen { 146 CurrentScreen::Map => Span::styled( 147 "(q) to quit / (c) to change the location", Style::default().fg(Color::Red)), 148 CurrentScreen::CityChoice => Span::styled( 149 "(ESC) to cancel / (Enter) to confirm choive", Style::default().fg(Color::Red)), 150 CurrentScreen::Exiting => Span::styled( 151 "(y) to quit / (ESC) to cancel" ,Style::default().fg(Color::Red)), 152 } 153}; 154 155 let hint_footer = Paragraph::new(Line::from(curr_key_hint)).block(Block::default().borders(Borders::ALL)); 156 157 158 let footer_chunks = Layout::default() 159 .direction(Direction::Horizontal) 160 .constraints([Constraint::Percentage(50),Constraint::Percentage(50)]) 161 .split(chunks[2]); 162 163 frame.render_widget(mode_footer, footer_chunks[0]); 164 frame.render_widget(hint_footer, footer_chunks[1]); 165 166 167// probably better to rewrite as a match statement 168 if let CurrentScreen::CityChoice = app_shared.current_screen { 169 let popup_block = Block::new() 170 .title(Line::raw("Cities Option").centered()) 171 .borders(Borders::ALL); 172 173 174 let items: Vec<ListItem> = app_shared.cities.cities.iter() 175 .map(|item| { 176 ListItem::from(item) 177 }) 178 .collect(); 179 180 let list = List::new(items) 181 .block(popup_block) 182 .style(SELECTED_STYLE) 183 .highlight_symbol(">") 184 .highlight_spacing(ratatui::widgets::HighlightSpacing::Always); 185 186 let area = centered_rect(40, 40, frame.area()); 187 188 frame.render_stateful_widget(list, area,&mut app_shared.cities.state); 189 190 } 191 192 193 if let CurrentScreen::Exiting = app_shared.current_screen { 194 195 let exit_popup = Block::new() 196 .title(Line::raw("Quit?").centered()) 197 .bg(Color::Gray) 198 .borders(Borders::ALL); 199 200 let exit_text = Text::styled( 201 "Would you like to leave?", Style::default().fg(Color::Red)); 202 203 let exit_widget = Paragraph::new(exit_text).centered() 204 .block(exit_popup); 205 206 207 let area = centered_rect(30, 20, frame.area()); 208 frame.render_widget(exit_widget, area); 209 } 210 211} 212 213 214 215 216fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 217 // Cut the given rectangle into three vertical pieces 218 let popup_layout = Layout::default() 219 .direction(Direction::Vertical) 220 .constraints([ 221 Constraint::Percentage((100 - percent_y) / 2), 222 Constraint::Percentage(percent_y), 223 Constraint::Percentage((100 - percent_y) / 2), 224 ]) 225 .split(r); 226 227 // Then cut the middle vertical piece into three width-wise pieces 228 Layout::default() 229 .direction(Direction::Horizontal) 230 .constraints([ 231 Constraint::Percentage((100 - percent_x) / 2), 232 Constraint::Percentage(percent_x), 233 Constraint::Percentage((100 - percent_x) / 2), 234 ]) 235 .split(popup_layout[1])[1] // Return the middle chunk 236}