tuisky 0.2.2

TUI client for Bluesky
Documentation
use super::types::{Action, View};
use super::ViewComponent;
use bsky_sdk::agent::config::Config;
use bsky_sdk::BskyAgent;
use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Style, Stylize};
use ratatui::text::Line;
use ratatui::widgets::{Block, Padding, Paragraph, Wrap};
use ratatui::Frame;
use std::sync::{Arc, RwLock};
use tokio::sync::mpsc::UnboundedSender;
use tui_textarea::TextArea;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
    Service,
    Identifier,
    Password,
    Submit,
}

impl Focus {
    fn next(&self) -> Self {
        match self {
            Self::Service => Self::Identifier,
            Self::Identifier => Self::Password,
            Self::Password => Self::Submit,
            Self::Submit => Self::Service,
        }
    }
    fn prev(&self) -> Self {
        match self {
            Self::Service => Self::Submit,
            Self::Identifier => Self::Service,
            Self::Password => Self::Identifier,
            Self::Submit => Self::Password,
        }
    }
}

pub struct LoginComponent {
    service: TextArea<'static>,
    identifier: TextArea<'static>,
    password: TextArea<'static>,
    focus: Focus,
    error_message: Arc<RwLock<Option<String>>>,
    action_tx: UnboundedSender<Action>,
}

impl LoginComponent {
    pub fn new(action_tx: UnboundedSender<Action>) -> Self {
        let mut service = TextArea::new(vec![String::from("https://bsky.social")]);
        service.set_block(
            Block::bordered()
                .title("Service")
                .border_style(Style::default().dim()),
        );
        service.set_cursor_line_style(Style::default());
        service.set_cursor_style(Style::default());
        let mut identifier = TextArea::default();
        identifier.set_block(
            Block::bordered()
                .title("Identifier")
                .border_style(Style::default()),
        );
        identifier.set_cursor_line_style(Style::default());
        let mut password = TextArea::default();
        password.set_mask_char('*');
        password.set_block(
            Block::bordered()
                .title("Password")
                .border_style(Style::default().dim()),
        );
        password.set_cursor_line_style(Style::default());
        password.set_cursor_style(Style::default());
        Self {
            service,
            identifier,
            password,
            focus: Focus::Identifier,
            error_message: Arc::new(RwLock::new(None)),
            action_tx,
        }
    }
    fn current_textarea(&mut self) -> Option<&mut TextArea<'static>> {
        match self.focus {
            Focus::Service => Some(&mut self.service),
            Focus::Identifier => Some(&mut self.identifier),
            Focus::Password => Some(&mut self.password),
            Focus::Submit => None,
        }
    }
    fn update_focus(&mut self, focus: Focus) {
        if let Some(textarea) = self.current_textarea() {
            textarea.set_cursor_style(Style::default());
            if let Some(block) = textarea.block().cloned() {
                textarea.set_block(block.border_style(Style::default().dim()));
            }
        }
        self.focus = focus;
        if let Some(textarea) = self.current_textarea() {
            textarea.set_cursor_style(Style::default().reversed());
            if let Some(block) = textarea.block().cloned() {
                textarea.set_block(block.border_style(Style::default()));
            }
        }
    }
    fn login(&self) -> Result<()> {
        let service = self.service.lines().join("");
        let identifier = self.identifier.lines().join("");
        let password = self.password.lines().join("");
        let error_message = Arc::clone(&self.error_message);
        let action_tx = self.action_tx.clone();
        tokio::spawn(async move {
            let Ok(agent) = BskyAgent::builder()
                .config(Config {
                    endpoint: service,
                    ..Default::default()
                })
                .build()
                .await
            else {
                return log::error!("failed to build agent");
            };
            if agent
                .api
                .com
                .atproto
                .server
                .describe_server()
                .await
                .is_err()
            {
                log::warn!("describe server failed");
                if let Ok(mut message) = error_message.write() {
                    message.replace(String::from("failed to connect to server"));
                }
                if let Err(e) = action_tx.send(Action::Render) {
                    log::error!("failed to send render event: {e}");
                }
                return;
            }
            match agent.login(identifier, password).await {
                Ok(session) => {
                    log::info!("login succeeded: {session:?}");
                    if let Err(e) = action_tx.send(Action::Login(Box::new(agent))) {
                        log::error!("failed to send login event: {e}");
                    }
                }
                Err(e) => {
                    log::warn!("login failed: {e}");
                    if let Ok(mut message) = error_message.write() {
                        message.replace(e.to_string());
                    }
                }
            }
            if let Err(e) = action_tx.send(Action::Render) {
                log::error!("failed to send render event: {e}");
            }
        });
        Ok(())
    }
}

impl ViewComponent for LoginComponent {
    fn view(&self) -> View {
        View::Login
    }
    fn handle_key_events(&mut self, key: KeyEvent) -> Result<Option<Action>> {
        if let Some(textarea) = self.current_textarea() {
            Ok(match (key.code, key.modifiers) {
                (KeyCode::Enter, _) | (KeyCode::Char('m'), KeyModifiers::CONTROL) => {
                    Some(Action::Enter)
                }
                _ => {
                    let cursor = textarea.cursor();
                    if textarea.input(key) || textarea.cursor() != cursor {
                        Some(Action::Render)
                    } else {
                        None
                    }
                }
            })
        } else {
            Ok(None)
        }
    }
    fn update(&mut self, action: Action) -> Result<Option<Action>> {
        match action {
            Action::NextItem => {
                self.update_focus(self.focus.next());
                Ok(Some(Action::Render))
            }
            Action::PrevItem => {
                self.update_focus(self.focus.prev());
                Ok(Some(Action::Render))
            }
            Action::Enter => {
                match self.focus {
                    Focus::Submit => {
                        self.login()?;
                    }
                    _ => {
                        self.update_focus(self.focus.next());
                    }
                }
                Ok(Some(Action::Render))
            }
            _ => Ok(None),
        }
    }
    fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> {
        let block = Block::default().padding(Padding::proportional(2));
        let layout = Layout::vertical([
            Constraint::Length(3),
            Constraint::Length(3),
            Constraint::Length(3),
            Constraint::Length(1),
            Constraint::Length(1),
            Constraint::Length(2),
        ])
        .split(block.inner(area));

        let mut submit = Line::from("Submit").blue().centered();
        if self.focus == Focus::Submit {
            submit = submit.reversed();
        }
        f.render_widget(&self.service, layout[0]);
        f.render_widget(&self.identifier, layout[1]);
        f.render_widget(&self.password, layout[2]);
        f.render_widget(submit, layout[3]);
        if let Ok(message) = self.error_message.read() {
            if let Some(s) = message.as_ref() {
                f.render_widget(
                    Paragraph::new(s.as_str())
                        .style(Style::default().red())
                        .wrap(Wrap::default()),
                    layout[5],
                );
            }
        }
        Ok(())
    }
}