Skip to main content

adguardian/
ui.rs

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  // Guard restores the terminal on drop, even if we return early via `?`
40  let _guard = TerminalGuard::new()?;
41  let backend = CrosstermBackend::new(stdout());
42  let mut terminal = Terminal::new(backend)?;
43  terminal.clear()?;
44
45  // Handle quit keys (q / Ctrl+C) in a separate task, so input never blocks on data
46  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          // Stop if shutdown was triggered elsewhere (e.g. channels closed)
64          _ = shutdown_rx.changed() => break,
65      }
66    }
67  });
68
69  let mut shutdown_rx = shutdown_tx.subscribe();
70
71  loop {
72    // Wait for the next batch of data, but bail out immediately on shutdown
73    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 the data for the chart
91    prepare_chart_data(&mut stats);
92
93    terminal.draw(|f| {
94      let size = f.size();
95
96      // Make the charts
97      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      // Split the top part (charts + gauge) into left (gauge + block) and right (line chart)
137      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      // Split the left part of top (gauge + block) into top (gauge) and bottom (block)
143      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      // Render the widgets to the UI
162      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  // Signal shutdown to the input task and fetcher
176  let _ = shutdown_tx.send(true);
177  let _ = input_task.await;
178  Ok(())
179}
180
181/// Enables raw mode + alternate screen, and restores them on drop.
182struct 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
199/// Returns `true` if a key event should quit the app: `q`, `Q`, or Ctrl+C.
200fn 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}