use std::{cell::RefCell, collections::VecDeque, rc::Rc, time::Duration};
use color_eyre::{Result, eyre};
use ratatui::{
DefaultTerminal,
layout::{Constraint, Flex, Layout, Margin, Rect},
style::{Color, Style},
widgets::{Block, Clear, Gauge, Paragraph},
};
use crate::{
core::{
library::feedlibrary::FeedLibrary,
ui::{
appscreen::{AppScreen, AppScreenEvent},
dialog::Dialog,
notification::{AppNotification, NotificationPriority},
},
},
ui::screens::{mainscreen::MainScreen, welcomedialog::WelcomeDialog},
};
pub enum AppWorkStatus {
None,
Working(f32, String),
}
impl AppWorkStatus {
pub fn is_none(&self) -> bool {
matches!(self, AppWorkStatus::None)
}
}
fn interpolate_color(fg: u32, bg: u32, t: f32) -> Color {
let t = t.clamp(0.0, 1.0);
let fg_r = ((fg >> 16) & 0xFF) as f32;
let fg_g = ((fg >> 8) & 0xFF) as f32;
let fg_b = (fg & 0xFF) as f32;
let bg_r = ((bg >> 16) & 0xFF) as f32;
let bg_g = ((bg >> 8) & 0xFF) as f32;
let bg_b = (bg & 0xFF) as f32;
let r = (bg_r + (fg_r - bg_r) * t).round() as u8;
let g = (bg_g + (fg_g - bg_g) * t).round() as u8;
let b = (bg_b + (fg_b - bg_b) * t).round() as u8;
Color::Rgb(r, g, b)
}
pub struct App {
running: bool,
library: Rc<RefCell<FeedLibrary>>,
current_state: Option<Box<dyn AppScreen>>,
states_queue: VecDeque<Box<dyn AppScreen>>,
dialog_queue: VecDeque<Box<dyn Dialog>>,
active_notification: Option<AppNotification>,
event_poll_timeout: Duration,
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
impl App {
pub fn new() -> Self {
Self {
library: Rc::new(RefCell::new(FeedLibrary::new())),
running: true,
current_state: None,
states_queue: VecDeque::<Box<dyn AppScreen>>::new(),
dialog_queue: VecDeque::<Box<dyn Dialog>>::new(),
active_notification: None,
event_poll_timeout: Duration::from_millis(100),
}
}
pub fn init(&mut self, mut state: Box<dyn AppScreen>) {
state.start();
self.current_state = Some(state);
}
pub fn initmain(&mut self) {
self.init(Box::new(MainScreen::new(self.library.clone())));
if self.library.borrow().is_empty() {
self.open_dialog(Box::new(WelcomeDialog::new(self.library.clone())));
}
}
pub fn run(&mut self, mut terminal: DefaultTerminal) -> Result<()> {
while self.running {
let theme = {
let library = self.library.borrow();
library.settings.get_theme().unwrap().clone()
};
let work_status = self.get_work_status();
if let Some(ref notif) = self.active_notification
&& notif.is_expired()
{
self.active_notification = None;
}
if let Some(state) = self.current_state.as_mut() {
terminal.draw(|frame| {
let mainlayout =
Layout::vertical([Constraint::Percentage(99), Constraint::Min(3)])
.horizontal_margin(2)
.vertical_margin(1)
.split(frame.area());
state.render(frame, mainlayout[0]);
let background =
Block::default().style(Style::default().bg(Color::from_u32(theme.base[0])));
frame.render_widget(background, mainlayout[1]);
let title = if let Some(dialog) = self.dialog_queue.front() {
dialog.as_screen().get_title()
} else {
state.get_title()
};
let instructions = if let Some(dialog) = self.dialog_queue.front() {
dialog.as_screen().get_instructions()
} else {
state.get_instructions()
};
let instruction_width = (instructions.chars().count() as u16).min(85) + 5;
let statusline = Layout::horizontal([
Constraint::Max(25),
Constraint::Fill(1),
Constraint::Length(instruction_width),
])
.margin(1)
.split(mainlayout[1]);
let status_text = Paragraph::new(format!("\u{f0fb1} bulletty | {title}"))
.style(Style::default().fg(Color::from_u32(theme.base[0x6])));
frame.render_widget(status_text, statusline[0]);
let instructions_text = Paragraph::new(instructions.to_string())
.style(Style::default().fg(Color::from_u32(theme.base[3])))
.alignment(ratatui::layout::Alignment::Right);
frame.render_widget(instructions_text, statusline[2]);
let is_working = matches!(work_status, AppWorkStatus::Working(_, _));
let show_notification = match &self.active_notification {
Some(notif) => match notif.priority {
NotificationPriority::High => true,
NotificationPriority::Low => !is_working,
},
None => false,
};
if show_notification {
if let Some(ref notif) = self.active_notification {
let (icon, fg_color) = match notif.priority {
NotificationPriority::High => {
("\u{f06a}", theme.base[0x8]) }
NotificationPriority::Low => {
("\u{f0f3}", theme.base[0x9]) }
};
let fade = notif.fade_ratio();
let color = interpolate_color(fg_color, theme.base[0x0], fade);
let notification_text =
Paragraph::new(format!("{icon} {}", notif.message))
.style(Style::default().fg(color))
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(notification_text, statusline[1]);
}
} else if let AppWorkStatus::Working(percentage, description) = work_status {
let gauge = Gauge::default()
.gauge_style(
Style::default()
.fg(Color::from_u32(theme.base[0x9]))
.bg(Color::from_u32(theme.base[0x2])),
)
.percent((percentage * 100.0).round() as u16)
.label(&description);
frame.render_widget(gauge, statusline[1]);
}
if let Some(dialog) = self.dialog_queue.get_mut(0) {
let overlay = Block::default().style(
Style::default()
.bg(Color::from_u32(theme.base[2]))
.fg(Color::from_u32(theme.base[4])),
);
frame.render_widget(overlay, mainlayout[0]);
let border =
Block::new().style(Style::new().bg(Color::from_u32(theme.base[1])));
let block =
Block::new().style(Style::new().bg(Color::from_u32(theme.base[0])));
let area = popup_area(mainlayout[0], dialog.get_size());
let inner_area = area.inner(Margin::new(2, 1));
frame.render_widget(Clear, area);
frame.render_widget(border, area);
frame.render_widget(block, inner_area);
dialog.as_screen_mut().render(frame, inner_area);
}
})?;
let event_available =
crossterm::event::poll(self.event_poll_timeout).unwrap_or(true);
if event_available {
let terminal_event = crossterm::event::read()?;
let screen = if let Some(dialog) = self.dialog_queue.get_mut(0) {
dialog.as_screen_mut()
} else {
state.as_mut()
};
match screen.handle_event(terminal_event)? {
AppScreenEvent::None => {}
AppScreenEvent::ChangeState(app_state) => {
self.change_state(app_state);
}
AppScreenEvent::ExitState => {
self.exit_state();
}
AppScreenEvent::OpenDialog(app_state) => {
self.open_dialog(app_state);
}
AppScreenEvent::CloseDialog => {
self.close_current_dialog();
}
AppScreenEvent::Notify(notification) => {
self.active_notification = Some(notification);
}
AppScreenEvent::ExitApp => {
self.running = false;
}
}
};
} else {
self.running = false;
return Err(eyre::eyre!("No current AppState"));
}
}
if let Some(state) = self.current_state.as_mut() {
state.quit();
}
Ok(())
}
fn change_state(&mut self, mut new_state: Box<dyn AppScreen>) {
if let Some(mut state) = self.current_state.take() {
state.pause();
self.states_queue.push_back(state);
}
new_state.start();
self.current_state = Some(new_state);
}
fn exit_state(&mut self) {
if let Some(mut state) = self.current_state.take() {
state.quit();
}
if !self.states_queue.is_empty() {
if let Some(mut state) = self.states_queue.pop_back() {
state.unpause();
self.current_state = Some(state);
} else {
self.running = false;
}
} else {
self.running = false;
}
}
fn get_work_status(&self) -> AppWorkStatus {
if let Some(state) = self.current_state.as_ref() {
let status = state.get_work_status();
if !status.is_none() {
return status;
}
}
self.states_queue
.iter()
.map(|state| state.get_work_status())
.find(|state| !state.is_none())
.unwrap_or(AppWorkStatus::None)
}
fn open_dialog(&mut self, mut dialog_state: Box<dyn Dialog>) {
if let Some(state) = self.current_state.as_mut() {
state.pause();
}
dialog_state.as_screen_mut().start();
self.dialog_queue.push_back(dialog_state);
}
fn close_current_dialog(&mut self) {
if let Some(mut state) = self.dialog_queue.pop_back() {
state.as_screen_mut().quit();
}
}
}
fn popup_area(area: Rect, size: Rect) -> Rect {
let mut vertical = Layout::vertical([Constraint::Percentage(size.height)]).flex(Flex::Center);
let mut horizontal =
Layout::horizontal([Constraint::Percentage(size.width)]).flex(Flex::Center);
if size.x + size.y > 0 {
vertical = Layout::vertical([Constraint::Length(size.y)]).flex(Flex::Center);
horizontal = Layout::horizontal([Constraint::Length(size.x)]).flex(Flex::Center);
}
let [area] = vertical.areas(area);
let [area] = horizontal.areas(area);
area
}