1use crossterm::{
2 cursor::Show,
3 event::{
4 DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEvent, KeyModifiers,
5 },
6 execute,
7 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
8};
9use futures::StreamExt;
10use std::io::stdout;
11use std::sync::Arc;
12use tokio::sync::watch;
13use tui::{
14 backend::CrosstermBackend,
15 layout::{Constraint, Direction, Layout},
16 style::Color,
17 Terminal,
18};
19
20use crate::fetch::fetch_filters::{AdGuardFilteringStatus, Filter};
21use crate::fetch::fetch_query_log::Query;
22use crate::fetch::fetch_stats::StatsResponse;
23use crate::fetch::fetch_status::StatusResponse;
24
25use crate::widgets::chart::{make_history_chart, prepare_chart_data};
26use crate::widgets::filters::make_filters_list;
27use crate::widgets::gauge::make_gauge;
28use crate::widgets::list::make_list;
29use crate::widgets::status::render_status_paragraph;
30use crate::widgets::table::make_query_table;
31
32pub async fn draw_ui(
33 mut data_rx: tokio::sync::mpsc::Receiver<Vec<Query>>,
34 mut stats_rx: tokio::sync::mpsc::Receiver<StatsResponse>,
35 mut status_rx: tokio::sync::mpsc::Receiver<StatusResponse>,
36 filters: AdGuardFilteringStatus,
37 shutdown_tx: watch::Sender<bool>,
38) -> Result<(), anyhow::Error> {
39 let _guard = TerminalGuard::new()?;
41 let backend = CrosstermBackend::new(stdout());
42 let mut terminal = Terminal::new(backend)?;
43 terminal.clear()?;
44
45 let shutdown_tx = Arc::new(shutdown_tx);
47 let input_shutdown_tx = Arc::clone(&shutdown_tx);
48 let input_task = tokio::spawn(async move {
49 let mut reader = EventStream::new();
50 let mut shutdown_rx = input_shutdown_tx.subscribe();
51 loop {
52 tokio::select! {
53 maybe_event = reader.next() => {
54 match maybe_event {
55 Some(Ok(Event::Key(key))) if is_quit_key(key) => {
56 let _ = input_shutdown_tx.send(true);
57 break;
58 }
59 Some(Ok(_)) => {}
60 Some(Err(_)) | None => break,
61 }
62 }
63 _ = shutdown_rx.changed() => break,
65 }
66 }
67 });
68
69 let mut shutdown_rx = shutdown_tx.subscribe();
70
71 loop {
72 let data = tokio::select! {
74 biased;
75 _ = shutdown_rx.changed() => break,
76 maybe_data = data_rx.recv() => match maybe_data {
77 Some(data) => data,
78 None => break,
79 },
80 };
81 let mut stats = match stats_rx.recv().await {
82 Some(stats) => stats,
83 None => break,
84 };
85 let status = match status_rx.recv().await {
86 Some(status) => status,
87 None => break,
88 };
89
90 prepare_chart_data(&mut stats);
92
93 terminal.draw(|f| {
94 let size = f.size();
95
96 let gauge = make_gauge(&stats);
98 let table = make_query_table(&data, size.width);
99 let graph = make_history_chart(&stats);
100 let paragraph = render_status_paragraph(&status, &stats);
101 let filter_items: &[Filter] = filters.filters.as_deref().unwrap_or(&[]);
102 let filters_list = make_filters_list(filter_items, size.width);
103 let top_queried_domains = make_list(
104 "Top Queried Domains",
105 &stats.top_queried_domains,
106 Color::Green,
107 size.width,
108 );
109 let top_blocked_domains = make_list(
110 "Top Blocked Domains",
111 &stats.top_blocked_domains,
112 Color::Red,
113 size.width,
114 );
115 let top_clients = make_list("Top Clients", &stats.top_clients, Color::Cyan, size.width);
116
117 let constraints = if size.height > 42 {
118 vec![
119 Constraint::Percentage(30),
120 Constraint::Min(1),
121 Constraint::Percentage(20),
122 ]
123 } else {
124 vec![
125 Constraint::Percentage(30),
126 Constraint::Min(1),
127 Constraint::Percentage(0),
128 ]
129 };
130
131 let chunks = Layout::default()
132 .direction(Direction::Vertical)
133 .constraints(&*constraints)
134 .split(size);
135
136 let top_chunks = Layout::default()
138 .direction(Direction::Horizontal)
139 .constraints([Constraint::Percentage(30), Constraint::Percentage(70)].as_ref())
140 .split(chunks[0]);
141
142 let left_chunks = Layout::default()
144 .direction(Direction::Vertical)
145 .constraints([Constraint::Min(0), Constraint::Length(3)].as_ref())
146 .split(top_chunks[0]);
147
148 let bottom_chunks = Layout::default()
149 .direction(Direction::Horizontal)
150 .constraints(
151 [
152 Constraint::Percentage(25),
153 Constraint::Percentage(25),
154 Constraint::Percentage(25),
155 Constraint::Percentage(25),
156 ]
157 .as_ref(),
158 )
159 .split(chunks[2]);
160
161 f.render_widget(paragraph, left_chunks[0]);
163 f.render_widget(gauge, left_chunks[1]);
164 f.render_widget(graph, top_chunks[1]);
165 f.render_widget(table, chunks[1]);
166 if size.height > 42 {
167 f.render_widget(filters_list, bottom_chunks[0]);
168 f.render_widget(top_queried_domains, bottom_chunks[1]);
169 f.render_widget(top_blocked_domains, bottom_chunks[2]);
170 f.render_widget(top_clients, bottom_chunks[3]);
171 }
172 })?;
173 }
174
175 let _ = shutdown_tx.send(true);
177 let _ = input_task.await;
178 Ok(())
179}
180
181struct TerminalGuard;
183
184impl TerminalGuard {
185 fn new() -> Result<Self, anyhow::Error> {
186 enable_raw_mode()?;
187 execute!(stdout(), EnterAlternateScreen, EnableMouseCapture)?;
188 Ok(Self)
189 }
190}
191
192impl Drop for TerminalGuard {
193 fn drop(&mut self) {
194 let _ = execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture, Show);
195 let _ = disable_raw_mode();
196 }
197}
198
199fn is_quit_key(key: KeyEvent) -> bool {
201 match key.code {
202 KeyCode::Char('q') | KeyCode::Char('Q') => true,
203 KeyCode::Char('c') => key.modifiers.contains(KeyModifiers::CONTROL),
204 _ => false,
205 }
206}