tuisky 0.2.2

TUI client for Bluesky
Documentation
use super::column::ColumnComponent;
use super::Component;
use crate::config::Config;
use crate::types::Action;
use crate::utils::get_data_dir;
use bsky_sdk::agent::config::Config as AgentConfig;
use color_eyre::Result;
use crossterm::event::KeyEvent;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect, Size};
use ratatui::style::{Style, Stylize};
use ratatui::widgets::{Block, BorderType};
use ratatui::Frame;
use serde::{Deserialize, Serialize};
use std::fs::{create_dir_all, File};
use std::path::PathBuf;
use tokio::sync::mpsc::UnboundedSender;

#[derive(Debug, Default, Serialize, Deserialize)]
struct AppData {
    views: Vec<ViewData>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
struct ViewData {
    agent: Option<AgentConfig>,
}

#[derive(Default)]
struct State {
    selected: Option<usize>,
}

pub struct MainComponent {
    config: Config,
    action_tx: UnboundedSender<Action>,
    columns: Vec<ColumnComponent>,
    state: State,
}

impl MainComponent {
    pub fn new(config: Config, action_tx: UnboundedSender<Action>) -> Self {
        Self {
            config,
            action_tx,
            columns: Vec::new(),
            state: State { selected: None },
        }
    }
    pub async fn save(&self) -> Result<()> {
        let mut appdata = AppData {
            views: Vec::with_capacity(self.columns.len()),
        };
        for view in &self.columns {
            let config = if let Some(w) = &view.watcher {
                Some(w.agent.to_config().await)
            } else {
                None
            };
            appdata.views.push(ViewData { agent: config });
        }
        let path = Self::appdata_path()?;
        serde_json::to_writer_pretty(File::create(&path)?, &appdata)?;
        log::info!("saved appdata to: {path:?}");
        Ok(())
    }
    fn load() -> Result<AppData> {
        let path = Self::appdata_path()?;
        let appdata = serde_json::from_reader::<_, AppData>(File::open(&path)?)?;
        log::info!("loaded appdata from {path:?}");
        Ok(appdata)
    }
    fn appdata_path() -> Result<PathBuf> {
        let data_dir = get_data_dir()?;
        create_dir_all(&data_dir)?;
        Ok(data_dir.join("appdata.json"))
    }
}

impl Component for MainComponent {
    fn init(&mut self, size: Size) -> Result<()> {
        let appdata = if let Ok(appdata) = Self::load() {
            appdata
        } else {
            log::warn!("failed to load appdata, using default");
            AppData::default()
        };

        let auto_num = usize::from(size.width) / 75;
        let num_columns = self
            .config
            .num_columns
            .map_or(auto_num, |n| n.min(auto_num));

        for i in 0..num_columns {
            let mut column = ColumnComponent::new(self.config.clone(), self.action_tx.clone());
            if let Some(config) = appdata.views.get(i).and_then(|view| view.agent.as_ref()) {
                column.init_with_config(config)?;
            } else {
                column.init(size)?;
            }
            self.columns.push(column);
        }
        if !self.columns.is_empty() {
            self.state.selected = Some(0);
        }
        Ok(())
    }
    fn handle_key_events(&mut self, key: KeyEvent) -> Result<Option<Action>> {
        if let Some(selected) = self.state.selected {
            self.columns[selected].handle_key_events(key)
        } else {
            Ok(None)
        }
    }
    fn update(&mut self, action: Action) -> Result<Option<Action>> {
        match action {
            Action::NextFocus => {
                if let Some(selected) = self.state.selected {
                    self.columns[selected].is_menu_active = false;
                }
                self.state.selected =
                    Some(self.state.selected.map_or(0, |s| s + 1) % self.columns.len());
                return Ok(Some(Action::Render));
            }
            Action::PrevFocus => {
                if let Some(selected) = self.state.selected {
                    self.columns[selected].is_menu_active = false;
                }
                self.state.selected = Some(
                    self.state
                        .selected
                        .map_or(0, |s| s + self.columns.len() - 1)
                        % self.columns.len(),
                );
                return Ok(Some(Action::Render));
            }
            _ => {
                for column in self.columns.iter_mut() {
                    if let Some(action) = column.update(action.clone())? {
                        return Ok(Some(action));
                    }
                }
            }
        }
        Ok(None)
    }
    fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> {
        let layout = Layout::default()
            .direction(Direction::Horizontal)
            .constraints(self.columns.iter().map(|_| Constraint::Fill(1)))
            .split(area);
        for (i, (area, view)) in layout.iter().zip(self.columns.iter_mut()).enumerate() {
            let mut block = Block::bordered()
                .title(view.title())
                .title_alignment(Alignment::Center);
            if self.state.selected == Some(i) {
                block = block
                    .border_type(BorderType::Double)
                    .border_style(Style::default().reset().bold());
            }
            view.draw(f, block.inner(*area))?;
            f.render_widget(block, *area);
        }
        Ok(())
    }
}