twitter_tool/ui/
mod.rs

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/// NB: not totally comfortable with this event bus architecture; the loose coupling is convenient
40/// but it introduces non-deterministic delay, and feels overly general (over time I guess there
41/// will end up being too many enum variants.
42///
43/// Try to keep the scope limited to actually global events; for component-to-component events,
44/// consider directly coupling those pieces together.
45#[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    // CR: just return unit and panic
96    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            // CR: disabling raw mode entirely also gets rid of the keypress events...
107            // disable_raw_mode()?;
108        }
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                    // CR: okay, maybe handle the error here
148                    fs::write("/tmp/tweet", format!("{:#?}", tweet)).unwrap();
149                }
150
151                // CR: also handle the errors here
152                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                // NB: removing the precondition will cause the UI to eventually break, even if the
209                // match arm handler is empty, why?
210                _ = 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}