use crate::screens;
use crate::theme::Theme;
use crate::{Screen, TuiOptions};
use acorn::analyzer::fix::Plan;
use acorn::analyzer::host::discovery::{Records, RemoteMatch, RemoteSearchResponse};
use acorn::analyzer::Check;
use acorn::io::api::huggingface;
use acorn_schema::discovery::RemoteOrganizationRole;
use color_eyre::eyre::Result;
use crossterm::event;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use std::collections::BTreeSet;
use std::io::Stdout;
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant};
pub type DoctorData = Records;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum GatherMode {
#[default]
Local,
OstiProjects,
OstiPeople,
OstiOrganizations,
}
pub struct App {
pub should_quit: bool,
pub current_screen: Screen,
pub dashboard: State<DashboardData>,
pub doctor: DoctorState,
#[allow(dead_code)]
pub check: CheckState,
pub gguf_picker: Option<State<GgufPickerData>>,
pub gather: GatherState,
pub(crate) gather_remote: Option<Receiver<Result<RemoteSearchResponse, String>>>,
pub theme_picker: State<ThemePickerData>,
pub theme: Theme,
pub options: TuiOptions,
theme_last_changed: Instant,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Candidate {
pub id: String,
pub downloads: u64,
pub likes: Option<u64>,
pub quantizations: Vec<String>,
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub struct CheckState {
pub items: Vec<String>,
pub selected_index: usize,
pub loaded: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DashboardData {
pub input: String,
pub working_directory: String,
pub started_at: Instant,
}
#[derive(Clone, Debug)]
pub struct DoctorState {
pub data: Option<DoctorData>,
pub selected_record: usize,
pub selected: BTreeSet<usize>,
pub plan: Option<Plan>,
pub confirming: bool,
pub loaded: bool,
pub export_message: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GatherDiscovery {
pub identifier: String,
pub identifier_type: String,
pub source: String,
pub source_format: String,
}
#[derive(Clone, Debug)]
pub struct GatherState {
pub checks: Vec<Check>,
pub discoveries: Vec<GatherDiscovery>,
pub input: String,
pub input_count: usize,
pub selected_index: usize,
pub mode: GatherMode,
pub remote_matches: Vec<RemoteMatch>,
pub remote_total: usize,
pub remote_has_more: bool,
pub remote_offset: usize,
pub remote_loading: bool,
pub remote_error: Option<String>,
pub organization_filter: bool,
pub organization_role: RemoteOrganizationRole,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GgufPickerData {
pub candidates: Vec<Candidate>,
pub base_model: String,
pub result: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct State<T> {
pub data: T,
pub selected: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ThemePickerData {
pub names: Vec<&'static str>,
}
impl App {
#[cfg(test)]
pub fn new(initial_screen: Screen) -> Self {
Self::with_options(initial_screen, TuiOptions::default())
}
pub fn with_options(initial_screen: Screen, options: TuiOptions) -> Self {
Self {
should_quit: false,
current_screen: initial_screen,
dashboard: State::new(DashboardData::new()),
theme: Theme::from_env(),
doctor: DoctorState::new(),
check: CheckState::new(),
gguf_picker: None,
gather: GatherState::new(),
gather_remote: None,
theme_picker: State::new(ThemePickerData {
names: Theme::NAMES.to_vec(),
}),
options,
theme_last_changed: Instant::now(),
}
}
pub fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
loop {
screens::gather::poll_remote(self);
terminal.draw(|f| self.render(f))?;
if self.should_quit {
break;
}
if event::poll(Duration::from_millis(100))? {
let evt = event::read()?;
self.handle_event(evt);
}
}
Ok(())
}
fn render(&mut self, f: &mut ratatui::Frame) {
match self.current_screen {
| Screen::Dashboard => screens::dashboard::render(f, self),
| Screen::Doctor => screens::doctor::render(f, self),
| Screen::Check => screens::check::render(f, self),
| Screen::GgufPicker => screens::gguf_picker::render(f, self),
| Screen::Gather => screens::gather::render(f, self),
| Screen::ThemePicker => screens::theme_picker::render(f, self),
}
}
fn handle_event(&mut self, evt: event::Event) {
if self.current_screen != Screen::Dashboard && self.current_screen != Screen::Gather {
if let event::Event::Key(key) = &evt {
if key.kind == event::KeyEventKind::Press && (key.code == event::KeyCode::Char('t') || key.code == event::KeyCode::Char('T')) {
self.cycle_theme();
return;
}
}
}
match self.current_screen {
| Screen::Dashboard => screens::dashboard::handle_event(self, evt),
| Screen::Doctor => screens::doctor::handle_event(self, evt),
| Screen::Check => screens::check::handle_event(self, evt),
| Screen::GgufPicker => screens::gguf_picker::handle_event(self, evt),
| Screen::Gather => screens::gather::handle_event(self, evt),
| Screen::ThemePicker => screens::theme_picker::handle_event(self, evt),
}
}
pub fn set_gguf_picker(&mut self, state: State<GgufPickerData>) {
self.gguf_picker = Some(state);
}
pub fn take_gguf_picker_result(&mut self) -> Option<String> {
self.gguf_picker.as_mut().and_then(|state| state.data.result.take())
}
pub fn cycle_theme(&mut self) {
const DEBOUNCE_MS: u64 = 200;
if self.theme_last_changed.elapsed().as_millis() < DEBOUNCE_MS as u128 {
return;
}
self.theme_last_changed = Instant::now();
let current_index = Theme::NAMES.iter().position(|name| *name == self.theme.name).unwrap_or(0);
let next_index = (current_index + 1) % Theme::NAMES.len();
if let Some(theme) = Theme::named(Theme::NAMES[next_index]) {
self.theme = theme;
}
}
pub fn set_theme(&mut self, name: &str) {
if let Some(theme) = Theme::named(name) {
self.theme = theme;
}
}
pub fn navigate_to(&mut self, screen: Screen) {
self.current_screen = screen;
}
pub fn load_doctor_data(&mut self) {
if self.doctor.loaded {
return;
}
self.doctor.data = Some(Records::diagnose_host());
self.doctor.loaded = true;
}
pub fn refresh_doctor(&mut self) {
self.doctor.loaded = false;
self.doctor.data = None;
self.doctor.plan = None;
self.doctor.confirming = false;
self.doctor.selected.clear();
self.load_doctor_data();
}
}
impl From<huggingface::Candidate> for Candidate {
fn from(candidate: huggingface::Candidate) -> Self {
Self {
id: candidate.id,
downloads: candidate.downloads,
likes: candidate.likes,
quantizations: candidate.quantizations,
}
}
}
impl CheckState {
fn new() -> Self {
Self {
items: Vec::new(),
selected_index: 0,
loaded: false,
}
}
}
impl DashboardData {
fn new() -> Self {
Self {
input: String::new(),
working_directory: std::env::current_dir()
.map(|path| path.display().to_string())
.unwrap_or_else(|_| String::from("unknown")),
started_at: Instant::now(),
}
}
}
impl DoctorState {
fn new() -> Self {
Self {
data: None,
selected_record: 0,
selected: BTreeSet::new(),
plan: None,
confirming: false,
loaded: false,
export_message: None,
}
}
}
impl GatherState {
pub(crate) fn new() -> Self {
Self {
checks: Vec::new(),
discoveries: Vec::new(),
input: String::new(),
input_count: 0,
selected_index: 0,
mode: GatherMode::Local,
remote_matches: Vec::new(),
remote_total: 0,
remote_has_more: false,
remote_offset: 0,
remote_loading: false,
remote_error: None,
organization_filter: false,
organization_role: RemoteOrganizationRole::Any,
}
}
}
impl<T> State<T> {
pub fn new(data: T) -> Self {
Self { data, selected: 0 }
}
}