tuisky 0.2.2

TUI client for Bluesky
Documentation
use super::types::{Action, Transition, View};
use super::utils::profile_name_as_str;
use super::ViewComponent;
use crate::backend::types::{FeedSourceInfo, PinnedFeed};
use crate::backend::{Watch, Watcher};
use crate::components::views::types::Data;
use color_eyre::Result;
use ratatui::style::{Style, Stylize};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, List, ListState, Padding};
use ratatui::{layout::Rect, Frame};
use std::sync::Arc;
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::oneshot;

pub struct RootComponent {
    items: Vec<PinnedFeed>,
    state: ListState,
    action_tx: UnboundedSender<Action>,
    watcher: Box<dyn Watch<Output = Vec<PinnedFeed>>>,
    quit: Option<oneshot::Sender<()>>,
}

impl RootComponent {
    pub fn new(action_tx: UnboundedSender<Action>, watcher: Arc<Watcher>) -> Self {
        Self {
            items: Vec::new(),
            state: ListState::default(),
            action_tx,
            watcher: Box::new(watcher.pinned_feeds()),
            quit: None,
        }
    }
}

impl ViewComponent for RootComponent {
    fn view(&self) -> View {
        View::Root
    }
    fn activate(&mut self) -> Result<()> {
        let (tx, mut rx) = (self.action_tx.clone(), self.watcher.subscribe());
        let (quit_tx, mut quit_rx) = oneshot::channel();
        self.quit = Some(quit_tx);
        tokio::spawn(async move {
            loop {
                tokio::select! {
                    changed = rx.changed() => {
                        match changed {
                            Ok(()) => {
                                if let Err(e) = tx.send(Action::Update(Box::new(Data::SavedFeeds(
                                    rx.borrow_and_update().clone(),
                                )))) {
                                    log::error!("failed to send update action: {e}");
                                }
                            }
                            Err(e) => {
                                log::warn!("changed channel error: {e}");
                                break;
                            }
                        }
                    }
                    _ = &mut quit_rx => {
                        break;
                    }
                }
            }
            log::debug!("subscription finished");
        });
        Ok(())
    }
    fn deactivate(&mut self) -> Result<()> {
        if let Some(tx) = self.quit.take() {
            if tx.send(()).is_err() {
                log::error!("failed to send quit signal");
            }
        }
        self.watcher.unsubscribe();
        Ok(())
    }
    fn update(&mut self, action: Action) -> Result<Option<Action>> {
        match action {
            Action::NextItem if !self.items.is_empty() => {
                self.state.select(Some(
                    self.state
                        .selected()
                        .map(|s| (s + 1).min(self.items.len()))
                        .unwrap_or_default(),
                ));
                return Ok(Some(Action::Render));
            }
            Action::PrevItem if !self.items.is_empty() => {
                self.state.select(Some(
                    self.state
                        .selected()
                        .map(|s| s.max(1) - 1)
                        .unwrap_or_default(),
                ));
                return Ok(Some(Action::Render));
            }
            Action::Enter if !self.items.is_empty() => {
                if let Some(index) = self.state.selected() {
                    if index == self.items.len() {
                        self.deactivate()?;
                        return Ok(Some(Action::Logout));
                    }
                    if let Some(feed) = self.items.get(index) {
                        return Ok(Some(Action::Transition(Transition::Push(Box::new(
                            View::Feed(Box::new(feed.info.clone())),
                        )))));
                    }
                }
            }
            Action::Refresh => {
                self.watcher.refresh();
            }
            Action::Update(data) => {
                let Data::SavedFeeds(feeds) = data.as_ref() else {
                    return Ok(None);
                };
                self.items.clone_from(feeds);
                if self.state.selected().is_none() && !self.items.is_empty() {
                    self.state.select(Some(0));
                }
                return Ok(Some(Action::Render));
            }
            _ => {}
        }
        Ok(None)
    }
    fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> {
        let mut items = self
            .items
            .iter()
            .map(|feed| match &feed.info {
                FeedSourceInfo::Feed(generator_view) => Text::from(vec![
                    Line::from(vec![
                        Span::from("[feed]").blue(),
                        Span::from(" "),
                        Span::from(generator_view.display_name.clone()).bold(),
                        Span::from(" "),
                        Span::from(format!(
                            "by {}",
                            profile_name_as_str(&generator_view.creator)
                        ))
                        .gray(),
                    ]),
                    Line::from(format!(
                        "  {}",
                        generator_view.description.as_deref().unwrap_or_default()
                    ))
                    .dim(),
                ]),
                FeedSourceInfo::List(list_view) => Text::from(vec![
                    Line::from(vec![
                        Span::from("[list]").yellow(),
                        Span::from(" "),
                        Span::from(list_view.name.as_str()).bold(),
                        Span::from(" "),
                        Span::from(format!("by {}", profile_name_as_str(&list_view.creator)))
                            .gray(),
                    ]),
                    Line::from(format!(
                        "  {}",
                        list_view.description.as_deref().unwrap_or_default()
                    ))
                    .dim(),
                ]),
                FeedSourceInfo::Timeline(_) => Text::from(vec![
                    Line::from(vec![
                        Span::from("[timeline]").green(),
                        Span::from(" "),
                        Span::from("Following").bold(),
                    ]),
                    Line::from("  Your following feed").dim(),
                ]),
            })
            .collect::<Vec<_>>();
        if !items.is_empty() {
            items.push(Text::from("Sign out").red());
        }
        f.render_stateful_widget(
            List::new(items)
                .block(Block::default().padding(Padding::uniform(1)))
                .highlight_style(Style::default().reset().reversed()),
            area,
            &mut self.state,
        );
        Ok(())
    }
}