1mod bottom_bar;
2mod feed_pane;
3mod search_bar;
4mod tweet_pane;
5mod tweet_pane_stack;
6
7use crate::store::Store;
8use crate::twitter_client::{api, TwitterClient};
9use crate::ui::bottom_bar::BottomBar;
10use crate::ui::feed_pane::FeedPane;
11use crate::ui::tweet_pane::TweetPane;
12use crate::ui_framework::bounding_box::BoundingBox;
13use crate::ui_framework::{Component, Input, Render};
14use crate::user_config::UserConfig;
15use anyhow::{anyhow, Context, Error, Result};
16use crossterm::cursor;
17use crossterm::event::{Event, EventStream, KeyCode, KeyEvent};
18use crossterm::terminal;
19use crossterm::{
20 execute, queue,
21 terminal::{EnterAlternateScreen, LeaveAlternateScreen},
22};
23use futures_util::stream::FuturesUnordered;
24use futures_util::{FutureExt, StreamExt};
25use std::fs;
26use std::io::{stdout, Stdout, Write};
27use std::process;
28use std::sync::Arc;
29use tokio::sync::mpsc::{self, UnboundedReceiver};
30
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
32#[repr(u8)]
33pub enum Mode {
34 #[default]
35 Log,
36 Interactive,
37}
38
39#[derive(Debug)]
46pub enum InternalEvent {
47 RegisterTask(tokio::task::JoinHandle<()>),
48 LogTweet(String),
49 LogError(Error),
50}
51
52pub struct UI {
53 stdout: Stdout,
54 mode: Mode,
55 events: UnboundedReceiver<InternalEvent>,
56 tasks: FuturesUnordered<tokio::task::JoinHandle<()>>,
57 store: Arc<Store>,
58 feed_pane: Component<FeedPane>,
59 bottom_bar: Component<BottomBar>,
60}
61
62impl UI {
63 pub fn new(
64 twitter_client: TwitterClient,
65 twitter_user: &api::User,
66 user_config: &UserConfig,
67 ) -> Self {
68 let (cols, rows) = terminal::size().unwrap();
69 let (events_tx, events_rx) = mpsc::unbounded_channel();
70
71 let store = Arc::new(Store::new(twitter_client, twitter_user, user_config));
72
73 let feed_pane = FeedPane::new(&events_tx, &store);
74 let bottom_bar = BottomBar::new(&store);
75
76 let mut this = Self {
77 stdout: stdout(),
78 mode: Mode::Log,
79 events: events_rx,
80 tasks: FuturesUnordered::new(),
81 store,
82 feed_pane: Component::new(feed_pane),
83 bottom_bar: Component::new(bottom_bar),
84 };
85
86 this.resize(cols, rows);
87 this
88 }
89
90 pub fn initialize(&mut self) {
91 self.feed_pane.component.do_load_page_of_tweets(true);
92 self.set_mode(Mode::Interactive).unwrap();
93 }
94
95 fn set_mode(&mut self, mode: Mode) -> Result<()> {
97 let prev_mode = self.mode;
98 self.mode = mode;
99
100 if prev_mode == Mode::Log && mode == Mode::Interactive {
101 execute!(self.stdout, EnterAlternateScreen)?;
102 terminal::enable_raw_mode()?;
103 } else if prev_mode == Mode::Interactive && mode == Mode::Log {
104 execute!(self.stdout, LeaveAlternateScreen)?;
105 terminal::enable_raw_mode()?;
106 }
109
110 Ok(())
111 }
112
113 pub fn resize(&mut self, cols: u16, rows: u16) {
114 self.feed_pane.bounding_box = BoundingBox::new(0, 0, cols, rows - 2);
115 self.bottom_bar.bounding_box = BoundingBox::new(0, rows - 1, cols, 1);
116 }
117
118 pub async fn render(&mut self) -> Result<()> {
119 self.feed_pane.render_if_necessary(&mut self.stdout)?;
120 self.bottom_bar.render_if_necessary(&mut self.stdout)?;
121
122 let focus = self.feed_pane.get_cursor();
123 queue!(&self.stdout, cursor::MoveTo(focus.0, focus.1))?;
124
125 self.stdout.flush()?;
126 Ok(())
127 }
128
129 pub fn log_message(&mut self, message: &str) -> Result<()> {
130 self.set_mode(Mode::Log)?;
131 println!("{message}\r");
132 Ok(())
133 }
134
135 async fn handle_internal_event(&mut self, event: InternalEvent) {
136 match event {
137 InternalEvent::RegisterTask(task) => {
138 self.tasks.push(task);
139 self.bottom_bar
140 .component
141 .set_num_tasks_in_flight(self.tasks.len());
142 }
143 InternalEvent::LogTweet(tweet_id) => {
144 {
145 let tweets = self.store.tweets.lock().unwrap();
146 let tweet = &tweets[&tweet_id];
147 fs::write("/tmp/tweet", format!("{:#?}", tweet)).unwrap();
149 }
150
151 let mut subshell = process::Command::new("less")
153 .args(["/tmp/tweet"])
154 .spawn()
155 .unwrap();
156 subshell.wait().unwrap();
157 }
158 InternalEvent::LogError(err) => {
159 self.log_message(err.to_string().as_str()).unwrap();
160 }
161 }
162 }
163
164 async fn handle_terminal_event(&mut self, event: &Event) {
165 match event {
166 Event::Key(key_event) => {
167 let handled = self.feed_pane.component.handle_key_event(key_event);
168 if !handled {
169 match key_event.code {
170 KeyCode::Esc => {
171 self.set_mode(Mode::Interactive).unwrap();
172 self.feed_pane.component.invalidate();
173 self.bottom_bar.component.invalidate();
174 }
175 KeyCode::Char('q') => {
176 reset();
177 process::exit(0);
178 }
179 _ => (),
180 }
181 }
182 }
183 Event::Resize(cols, rows) => self.resize(*cols, *rows),
184 _ => (),
185 }
186 }
187
188 pub async fn event_loop(&mut self) -> Result<()> {
189 let mut terminal_event_stream = EventStream::new();
190
191 loop {
192 let terminal_event = terminal_event_stream.next().fuse();
193 let internal_event = self.events.recv();
194 let there_are_tasks = !self.tasks.is_empty();
195 let task_event = self.tasks.next().fuse();
196
197 tokio::select! {
198 event = terminal_event => {
199 if let Some(Ok(event)) = event {
200 self.handle_terminal_event(&event).await;
201 }
202 },
203 event = internal_event => {
204 if let Some(event) = event {
205 self.handle_internal_event(event).await;
206 }
207 },
208 _ = task_event, if there_are_tasks => {
211 self.bottom_bar.component.set_num_tasks_in_flight(self.tasks.len());
212 }
213 }
214
215 self.render().await?
216 }
217 }
218}
219
220pub fn reset() {
221 execute!(stdout(), LeaveAlternateScreen).unwrap();
222 terminal::disable_raw_mode().unwrap()
223}