use std::{pin::Pin, time::Duration};
use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind};
use futures_util::{FutureExt, Stream, StreamExt};
use ratatui::{style::palette::tailwind, widgets::Widget};
use synd_auth::device_flow::{
self, DeviceAccessTokenResponse, DeviceAuthorizationResponse, DeviceFlow,
};
use tokio::time::{Instant, Sleep};
use crate::{
application::input_parser::ParseFeedUrlError,
auth::{AuthenticationProvider, Credential},
client::Client,
command::Command,
config,
interact::Interactor,
job::Jobs,
terminal::Terminal,
ui::{
self,
components::{authentication::AuthenticateState, root::Root, tabs::Tab, Components},
theme::Theme,
},
};
mod direction;
pub use direction::{Direction, IndexOutOfRange};
mod in_flight;
pub use in_flight::{InFlight, RequestId, RequestSequence};
mod input_parser;
use input_parser::InputParser;
mod authenticator;
pub use authenticator::{Authenticator, DeviceFlows, JwtService};
enum Screen {
Login,
Browse,
}
#[derive(PartialEq, Eq)]
pub enum EventLoopControlFlow {
Quit,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ListAction {
Append,
Replace,
}
pub struct Config {
pub idle_timer_interval: Duration,
pub throbber_timer_interval: Duration,
}
impl Default for Config {
fn default() -> Self {
Self {
idle_timer_interval: Duration::from_secs(250),
throbber_timer_interval: Duration::from_millis(250),
}
}
}
pub struct Application {
terminal: Terminal,
client: Client,
jobs: Jobs,
components: Components,
interactor: Interactor,
authenticator: Authenticator,
in_flight: InFlight,
theme: Theme,
idle_timer: Pin<Box<Sleep>>,
config: Config,
prev_key_seq: Vec<KeyEvent>,
screen: Screen,
should_render: bool,
should_quit: bool,
}
impl Application {
pub fn new(terminal: Terminal, client: Client) -> Self {
Application::with(terminal, client, Config::default())
}
pub fn with(terminal: Terminal, client: Client, config: Config) -> Self {
Self {
terminal,
client,
jobs: Jobs::new(),
components: Components::new(),
interactor: Interactor::new(),
authenticator: Authenticator::new(),
in_flight: InFlight::new().with_throbber_timer_interval(config.throbber_timer_interval),
theme: Theme::with_palette(&tailwind::BLUE),
idle_timer: Box::pin(tokio::time::sleep(config.idle_timer_interval)),
prev_key_seq: Vec::with_capacity(2),
screen: Screen::Login,
config,
should_quit: false,
should_render: false,
}
}
#[must_use]
pub fn with_theme(self, theme: Theme) -> Self {
Self { theme, ..self }
}
#[must_use]
pub fn with_authenticator(self, authenticator: Authenticator) -> Self {
Self {
authenticator,
..self
}
}
pub fn jwt_decoders(&self) -> &JwtService {
&self.authenticator.jwt_decoders
}
pub fn set_credential(&mut self, cred: Credential) {
self.client.set_credential(cred);
self.components.auth.authenticated();
self.initial_fetch();
self.screen = Screen::Browse;
self.should_render = true;
self.reset_idle_timer();
}
fn initial_fetch(&mut self) {
tracing::info!("Initial fetch");
let fut = async {
Ok(Command::FetchEntries {
after: None,
first: config::client::INITIAL_ENTRIES_TO_FETCH,
})
}
.boxed();
self.jobs.futures.push(fut);
}
pub async fn run<S>(mut self, input: &mut S) -> anyhow::Result<()>
where
S: Stream<Item = std::io::Result<CrosstermEvent>> + Unpin,
{
self.terminal.init()?;
self.event_loop(input).await;
self.terminal.restore()?;
Ok(())
}
async fn event_loop<S>(&mut self, input: &mut S)
where
S: Stream<Item = std::io::Result<CrosstermEvent>> + Unpin,
{
self.render();
loop {
if self.event_loop_until_idle(input).await == EventLoopControlFlow::Quit {
break;
}
}
}
pub async fn event_loop_until_idle<S>(&mut self, input: &mut S) -> EventLoopControlFlow
where
S: Stream<Item = std::io::Result<CrosstermEvent>> + Unpin,
{
loop {
let command = tokio::select! {
biased;
Some(event) = input.next() => {
self.handle_terminal_event(event)
}
Some(command) = self.jobs.futures.next() => {
Some(command.unwrap())
}
() = self.in_flight.throbber_timer() => {
Some(Command::RenderThrobber)
}
() = &mut self.idle_timer => {
Some(Command::Idle)
}
};
if let Some(command) = command {
self.apply(command);
}
if self.should_render {
self.render();
self.should_render = false;
self.components.prompt.clear_error_message();
}
if self.should_quit {
self.should_quit = false; break EventLoopControlFlow::Quit;
}
}
}
#[tracing::instrument(skip_all,fields(%command))]
fn apply(&mut self, command: Command) {
let mut next = Some(command);
while let Some(command) = next.take() {
match command {
Command::Quit => self.should_quit = true,
Command::ResizeTerminal { .. } => {
self.should_render = true;
}
Command::RenderThrobber => {
self.in_flight.reset_throbber_timer();
self.in_flight.inc_throbber_step();
self.should_render = true;
}
Command::Idle => {
self.handle_idle();
}
Command::Authenticate(provider) => match provider {
AuthenticationProvider::Github => {
self.authenticate(provider, self.authenticator.device_flows.github.clone());
}
AuthenticationProvider::Google => {
self.authenticate(provider, self.authenticator.device_flows.google.clone());
}
},
Command::MoveAuthenticationProvider(direction) => {
self.components.auth.move_selection(&direction);
self.should_render = true;
}
Command::DeviceAuthorizationFlow {
provider,
device_authorization,
} => match provider {
AuthenticationProvider::Github => {
self.device_authorize_flow(
provider,
self.authenticator.device_flows.github.clone(),
device_authorization,
);
}
AuthenticationProvider::Google => {
self.device_authorize_flow(
provider,
self.authenticator.device_flows.google.clone(),
device_authorization,
);
}
},
Command::CompleteDevieAuthorizationFlow {
provider,
device_access_token,
} => {
self.complete_device_authroize_flow(provider, device_access_token);
}
Command::MoveTabSelection(direction) => {
match self.components.tabs.move_selection(&direction) {
Tab::Feeds if !self.components.subscription.has_subscription() => {
next = Some(Command::FetchSubscription {
after: None,
first: config::client::INITIAL_FEEDS_TO_FETCH,
});
}
_ => {}
}
self.should_render = true;
}
Command::MoveSubscribedFeed(direction) => {
self.components.subscription.move_selection(&direction);
self.should_render = true;
}
Command::MoveSubscribedFeedFirst => {
self.components.subscription.move_first();
self.should_render = true;
}
Command::MoveSubscribedFeedLast => {
self.components.subscription.move_last();
self.should_render = true;
}
Command::PromptFeedSubscription => {
self.prompt_feed_subscription();
self.should_render = true;
}
Command::PromptFeedUnsubscription => {
self.prompt_feed_unsubscription();
self.should_render = true;
}
Command::SubscribeFeed { url } => {
self.subscribe_feed(url);
self.should_render = true;
}
Command::UnsubscribeFeed { url } => {
self.unsubscribe_feed(url);
self.should_render = true;
}
Command::FetchSubscription { after, first } => {
self.fetch_subscription(ListAction::Append, after, first);
}
Command::UpdateSubscription {
action,
subscription,
request_seq,
} => {
self.in_flight.remove(request_seq);
self.components
.subscription
.update_subscription(action, subscription);
self.should_render = true;
}
Command::ReloadSubscription => {
self.fetch_subscription(
ListAction::Replace,
None,
config::client::INITIAL_FEEDS_TO_FETCH,
);
self.should_render = true;
}
Command::CompleteSubscribeFeed { feed, request_seq } => {
self.in_flight.remove(request_seq);
self.components.subscription.add_subscribed_feed(feed);
self.fetch_entries(
ListAction::Replace,
None,
config::client::INITIAL_ENTRIES_TO_FETCH,
);
self.should_render = true;
}
Command::CompleteUnsubscribeFeed { url, request_seq } => {
self.in_flight.remove(request_seq);
self.components.subscription.remove_unsubscribed_feed(&url);
self.components.entries.remove_unsubscribed_entries(&url);
self.should_render = true;
}
Command::OpenFeed => {
self.open_feed();
}
Command::FetchEntries { after, first } => {
self.fetch_entries(ListAction::Append, after, first);
}
Command::UpdateEntries {
action,
payload,
request_seq,
} => {
self.in_flight.remove(request_seq);
self.components.entries.update_entries(action, payload);
self.should_render = true;
}
Command::ReloadEntries => {
self.fetch_entries(
ListAction::Replace,
None,
config::client::INITIAL_ENTRIES_TO_FETCH,
);
self.should_render = true;
}
Command::MoveEntry(direction) => {
self.components.entries.move_selection(&direction);
self.should_render = true;
}
Command::MoveEntryFirst => {
self.components.entries.move_first();
self.should_render = true;
}
Command::MoveEntryLast => {
self.components.entries.move_last();
self.should_render = true;
}
Command::OpenEntry => {
self.open_entry();
}
Command::HandleError {
message,
request_seq,
} => {
tracing::error!("{message}");
if let Some(request_seq) = request_seq {
self.in_flight.remove(request_seq);
}
self.components.prompt.set_error_message(message);
self.should_render = true;
}
}
}
}
fn render(&mut self) {
let cx = ui::Context {
theme: &self.theme,
in_flight: &self.in_flight,
};
let root = Root::new(&self.components, cx);
self.terminal
.render(|frame| Widget::render(root, frame.size(), frame.buffer_mut()))
.expect("Failed to render");
}
#[allow(clippy::single_match, clippy::too_many_lines)]
fn handle_terminal_event(&mut self, event: std::io::Result<CrosstermEvent>) -> Option<Command> {
match event.unwrap() {
CrosstermEvent::Resize(columns, rows) => {
Some(Command::ResizeTerminal { columns, rows })
}
CrosstermEvent::Key(KeyEvent {
kind: KeyEventKind::Release,
..
}) => None,
CrosstermEvent::Key(key) => {
self.reset_idle_timer();
tracing::debug!("Handle key event: {key:?}");
match self.screen {
Screen::Login => match key.code {
KeyCode::Enter => {
if self.components.auth.state() == &AuthenticateState::NotAuthenticated
{
return Some(Command::Authenticate(
self.components.auth.selected_provider(),
));
};
}
KeyCode::Char('j') => {
return Some(Command::MoveAuthenticationProvider(Direction::Down))
}
KeyCode::Char('k') => {
return Some(Command::MoveAuthenticationProvider(Direction::Up))
}
_ => {}
},
Screen::Browse => match key.code {
KeyCode::Tab => return Some(Command::MoveTabSelection(Direction::Right)),
KeyCode::BackTab => {
return Some(Command::MoveTabSelection(Direction::Left))
}
_ => match self.components.tabs.current() {
Tab::Entries => match key.code {
KeyCode::Char('j') => {
self.prev_key_seq.clear();
return Some(Command::MoveEntry(Direction::Down));
}
KeyCode::Char('k') => {
self.prev_key_seq.clear();
return Some(Command::MoveEntry(Direction::Up));
}
KeyCode::Char('r') => {
self.prev_key_seq.clear();
return Some(Command::ReloadEntries);
}
KeyCode::Enter => {
self.prev_key_seq.clear();
return Some(Command::OpenEntry);
}
KeyCode::Char('g') => match self.prev_key_seq.as_slice() {
&[KeyEvent {
code: KeyCode::Char('g'),
..
}] => {
self.prev_key_seq.clear();
return Some(Command::MoveEntryFirst);
}
_ => {
self.prev_key_seq.push(key);
}
},
KeyCode::Char('e') => match self.prev_key_seq.as_slice() {
&[KeyEvent {
code: KeyCode::Char('g'),
..
}] => {
self.prev_key_seq.clear();
return Some(Command::MoveEntryLast);
}
_ => {
self.prev_key_seq.clear();
}
},
_ => {
self.prev_key_seq.clear();
}
},
Tab::Feeds => match key.code {
KeyCode::Char('a') => {
self.prev_key_seq.clear();
return Some(Command::PromptFeedSubscription);
}
KeyCode::Char('d') => {
self.prev_key_seq.clear();
return Some(Command::PromptFeedUnsubscription);
}
KeyCode::Char('j') => {
self.prev_key_seq.clear();
return Some(Command::MoveSubscribedFeed(Direction::Down));
}
KeyCode::Char('k') => {
self.prev_key_seq.clear();
return Some(Command::MoveSubscribedFeed(Direction::Up));
}
KeyCode::Char('r') => {
self.prev_key_seq.clear();
return Some(Command::ReloadSubscription);
}
KeyCode::Enter => {
self.prev_key_seq.clear();
return Some(Command::OpenFeed);
}
KeyCode::Char('g') => match self.prev_key_seq.as_slice() {
&[KeyEvent {
code: KeyCode::Char('g'),
..
}] => {
self.prev_key_seq.clear();
return Some(Command::MoveSubscribedFeedFirst);
}
_ => {
self.prev_key_seq.push(key);
}
},
KeyCode::Char('e') => match self.prev_key_seq.as_slice() {
&[KeyEvent {
code: KeyCode::Char('g'),
..
}] => {
self.prev_key_seq.clear();
return Some(Command::MoveSubscribedFeedLast);
}
_ => {
self.prev_key_seq.clear();
}
},
_ => {
self.prev_key_seq.clear();
}
},
},
},
};
match key.code {
KeyCode::Char('q') => Some(Command::Quit),
_ => None,
}
}
_ => None,
}
}
}
impl Application {
fn fetch_subscription(&mut self, action: ListAction, after: Option<String>, first: i64) {
let client = self.client.clone();
let request_seq = self.in_flight.add(RequestId::FetchSubscription);
let fut = async move {
match client.fetch_subscription(after, Some(first)).await {
Ok(subscription) => Ok(Command::UpdateSubscription {
action,
subscription,
request_seq,
}),
Err(err) => Ok(Command::HandleError {
message: format!("{err}"),
request_seq: Some(request_seq),
}),
}
}
.boxed();
self.jobs.futures.push(fut);
}
}
impl Application {
fn prompt_feed_subscription(&mut self) {
let input = self
.interactor
.open_editor(InputParser::SUSBSCRIBE_FEED_PROMPT);
tracing::debug!("Got user modified feed subscription: {input}");
self.terminal.force_redraw();
let fut = match InputParser::new(input.as_str()).parse_feed_url() {
Ok(url) => {
let url = url.to_owned();
async move { Ok(Command::SubscribeFeed { url }) }.boxed()
}
Err(err) => async move {
let message = match err {
ParseFeedUrlError::NoInput => {
"Abort subscription. please enter feed URL to subscribe".to_owned()
}
ParseFeedUrlError::InvalidUrl { .. } => format!("{err}"),
};
Ok(Command::HandleError {
message,
request_seq: None,
})
}
.boxed(),
};
self.jobs.futures.push(fut);
}
fn prompt_feed_unsubscription(&mut self) {
let Some(url) = self
.components
.subscription
.selected_feed_url()
.map(ToOwned::to_owned)
else {
return;
};
let fut = async move { Ok(Command::UnsubscribeFeed { url }) }.boxed();
self.jobs.futures.push(fut);
}
fn subscribe_feed(&mut self, url: String) {
if self.components.subscription.is_already_subscribed(&url) {
let message = format!("{url} already subscribed");
let fut = std::future::ready(Ok(Command::HandleError {
message,
request_seq: None,
}))
.boxed();
self.jobs.futures.push(fut);
} else {
let client = self.client.clone();
let request_seq = self.in_flight.add(RequestId::SubscribeFeed);
let fut = async move {
match client.subscribe_feed(url).await {
Ok(feed) => Ok(Command::CompleteSubscribeFeed { feed, request_seq }),
Err(err) => Ok(Command::HandleError {
message: format!("{err}"),
request_seq: Some(request_seq),
}),
}
}
.boxed();
self.jobs.futures.push(fut);
}
}
fn unsubscribe_feed(&mut self, url: String) {
let client = self.client.clone();
let request_seq = self.in_flight.add(RequestId::UnsubscribeFeed);
let fut = async move {
match client.unsubscribe_feed(url.clone()).await {
Ok(()) => Ok(Command::CompleteUnsubscribeFeed { url, request_seq }),
Err(err) => Ok(Command::HandleError {
message: format!("{err}"),
request_seq: Some(request_seq),
}),
}
}
.boxed();
self.jobs.futures.push(fut);
}
}
impl Application {
fn open_feed(&mut self) {
let Some(feed_website_url) = self.components.subscription.selected_feed_website_url()
else {
return;
};
self.interactor.open_browser(feed_website_url);
}
fn open_entry(&mut self) {
let Some(entry_website_url) = self.components.entries.selected_entry_website_url() else {
return;
};
self.interactor.open_browser(entry_website_url);
}
}
impl Application {
#[tracing::instrument(skip(self))]
fn fetch_entries(&mut self, action: ListAction, after: Option<String>, first: i64) {
let client = self.client.clone();
let request_seq = self.in_flight.add(RequestId::FetchEntries);
let fut = async move {
match client.fetch_entries(after, first).await {
Ok(payload) => Ok(Command::UpdateEntries {
action,
payload,
request_seq,
}),
Err(err) => Ok(Command::HandleError {
message: format!("{err}"),
request_seq: Some(request_seq),
}),
}
}
.boxed();
self.jobs.futures.push(fut);
}
}
impl Application {
#[tracing::instrument(skip(self, device_flow))]
fn authenticate<P>(&mut self, provider: AuthenticationProvider, device_flow: DeviceFlow<P>)
where
P: device_flow::Provider + Sync + Send + 'static,
{
tracing::info!("Start authenticate");
let fut = async move {
match device_flow.device_authorize_request().await {
Ok(device_authorization) => Ok(Command::DeviceAuthorizationFlow {
provider,
device_authorization,
}),
Err(err) => Ok(Command::HandleError {
message: format!("{err}"),
request_seq: None,
}),
}
}
.boxed();
self.jobs.futures.push(fut);
}
fn device_authorize_flow<P>(
&mut self,
provider: AuthenticationProvider,
device_flow: DeviceFlow<P>,
device_authorization: DeviceAuthorizationResponse,
) where
P: device_flow::Provider + Sync + Send + 'static,
{
self.components
.auth
.set_device_authorization_response(device_authorization.clone());
self.should_render = true;
self.interactor
.open_browser(device_authorization.verification_uri().to_string());
let fut = async move {
match device_flow
.poll_device_access_token(
device_authorization.device_code,
device_authorization.interval,
)
.await
{
Ok(device_access_token) => Ok(Command::CompleteDevieAuthorizationFlow {
provider,
device_access_token,
}),
Err(err) => Ok(Command::HandleError {
message: format!("{err}"),
request_seq: None,
}),
}
}
.boxed();
self.jobs.futures.push(fut);
}
fn complete_device_authroize_flow(
&mut self,
provider: AuthenticationProvider,
device_access_token: DeviceAccessTokenResponse,
) {
tracing::info!("{device_access_token:?}");
let auth = match provider {
AuthenticationProvider::Github => Credential::Github {
access_token: device_access_token.access_token,
},
AuthenticationProvider::Google => Credential::Google {
id_token: device_access_token.id_token.expect("id token not found"),
refresh_token: device_access_token
.refresh_token
.expect("refresh token not found"),
},
};
#[cfg(not(feature = "integration"))]
{
if let Err(err) = crate::auth::persist_credential(&auth) {
tracing::warn!("Failed to save credential cache: {err}");
}
}
self.set_credential(auth);
}
}
impl Application {
fn handle_idle(&mut self) {
self.clear_idle_timer();
#[cfg(feature = "integration")]
{
tracing::debug!("Quit for idle");
self.should_render = true;
self.should_quit = true;
}
}
pub fn clear_idle_timer(&mut self) {
self.idle_timer
.as_mut()
.reset(Instant::now() + Duration::from_secs(86400 * 365 * 30));
}
pub fn reset_idle_timer(&mut self) {
self.idle_timer
.as_mut()
.reset(Instant::now() + self.config.idle_timer_interval);
}
}
#[cfg(feature = "integration")]
impl Application {
pub fn assert_buffer(&self, expected: &ratatui::buffer::Buffer) {
self.terminal.assert_buffer(expected);
}
}