use crate::screens;
use crate::theme::Theme;
use crate::{Screen, TuiOptions};
use acorn::analyzer::host::discovery::{RemoteMatch, RemoteSearchResponse};
use acorn::analyzer::Check;
use acorn::doctor::{MemoryInformation, NetworkInformation, SystemInformation, SystemSoftwareInformation, TableFormatPrint};
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::io::Stdout;
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant};
#[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 DoctorData {
pub system: Option<SystemData>,
pub memory: Option<MemoryData>,
pub network: Option<NetworkData>,
pub software: Option<SoftwareData>,
}
#[derive(Clone, Debug)]
pub struct DoctorState {
pub data: Option<DoctorData>,
pub selected_category: usize,
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)]
pub struct InterfaceData {
pub ip_addresses: Vec<String>,
pub mac_address: String,
pub mtu: String,
}
#[derive(Clone, Debug)]
pub struct MemoryData {
pub total: String,
pub available: String,
pub used: String,
pub swap: String,
}
#[derive(Clone, Debug)]
pub struct NetworkData {
pub interfaces: Vec<InterfaceData>,
}
#[derive(Clone, Debug)]
pub struct SoftwareData {
pub items: Vec<SoftwareItem>,
}
#[derive(Clone, Debug)]
pub struct SoftwareItem {
pub name: String,
pub installed: bool,
pub version: String,
pub path: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct State<T> {
pub data: T,
pub selected: usize,
}
#[derive(Clone, Debug)]
pub struct SystemData {
pub name: String,
pub kernel: String,
pub os_version: String,
pub host_name: String,
pub cpu_arch: String,
pub cpu_count: String,
}
#[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;
}
let sys = SystemInformation::init();
let mem = MemoryInformation::init();
let net = NetworkInformation::init();
let sw = SystemSoftwareInformation::init();
let software_items = vec![
SoftwareItem {
name: "Acorn".into(),
installed: sw.acorn.version.is_some(),
version: sw.acorn.version.unwrap_or_else(|| "---".into()),
path: sw.acorn.path.unwrap_or_else(|| "---".into()),
},
SoftwareItem {
name: "Git".into(),
installed: sw.git.version.is_some(),
version: sw.git.version.unwrap_or_else(|| "---".into()),
path: sw.git.path.unwrap_or_else(|| "---".into()),
},
SoftwareItem {
name: "Node.js".into(),
installed: sw.node.version.is_some(),
version: sw.node.version.unwrap_or_else(|| "---".into()),
path: sw.node.path.unwrap_or_else(|| "---".into()),
},
SoftwareItem {
name: "npm".into(),
installed: sw.npm.version.is_some(),
version: sw.npm.version.unwrap_or_else(|| "---".into()),
path: sw.npm.path.unwrap_or_else(|| "---".into()),
},
SoftwareItem {
name: "npx".into(),
installed: sw.npx.version.is_some(),
version: sw.npx.version.unwrap_or_else(|| "---".into()),
path: sw.npx.path.unwrap_or_else(|| "---".into()),
},
SoftwareItem {
name: "Pandoc".into(),
installed: sw.pandoc.version.is_some(),
version: sw.pandoc.version.unwrap_or_else(|| "---".into()),
path: sw.pandoc.path.unwrap_or_else(|| "---".into()),
},
SoftwareItem {
name: "Vale".into(),
installed: sw.vale.version.is_some(),
version: sw.vale.version.unwrap_or_else(|| "---".into()),
path: sw.vale.path.unwrap_or_else(|| "---".into()),
},
];
self.doctor.data = Some(DoctorData {
system: Some(SystemData {
name: sys.name,
kernel: sys.kernel_version,
os_version: sys.os_version,
host_name: sys.host_name,
cpu_arch: sys.cpu_arch,
cpu_count: sys.cpu_count,
}),
memory: Some(MemoryData {
total: mem.total,
available: mem.available,
used: mem.used,
swap: mem.swap,
}),
network: Some(NetworkData {
interfaces: net
.networks
.iter()
.filter(|n| !n.ip_address.is_empty())
.map(|n| InterfaceData {
ip_addresses: n.ip_address.clone(),
mac_address: n.mac_address.clone(),
mtu: n.mtu.clone(),
})
.collect(),
}),
software: Some(SoftwareData { items: software_items }),
});
self.doctor.loaded = true;
}
pub fn refresh_doctor(&mut self) {
self.doctor.loaded = false;
self.doctor.data = None;
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_category: 0,
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 }
}
}