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}