use crate::{AppDevice, BuildInfo, ReportSaveMode, UiLaunchOptions}; use bitdo_app_core::{ ConfigBackupId, DedicatedButtonMapping, FirmwareFinalReport, FirmwareUpdatePlan, U2CoreProfile, }; use bitdo_proto::{DiagCommandStatus, DiagProbeResult, DiagSeverity, SupportTier, VidPid}; use chrono::Utc; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use serde::{Deserialize, Serialize}; use std::collections::VecDeque; use std::path::PathBuf; use super::action::QuickAction; pub const EVENT_LOG_CAPACITY: usize = 200; #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Screen { Dashboard, Task, Diagnostics, MappingEditor, Recovery, Settings, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum DashboardLayoutMode { #[default] Wide, Compact, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum PanelFocus { #[default] Devices, QuickActions, EventLog, Status, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum EventLevel { Info, Warning, Error, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct EventEntry { pub timestamp_utc: String, pub level: EventLevel, pub message: String, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TaskMode { Diagnostics, Preflight, Updating, Final, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TaskState { pub mode: TaskMode, pub plan: Option, pub progress: u8, pub status: String, pub final_report: Option, pub downloaded_firmware_path: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum DiagnosticsFilter { #[default] All, Issues, Experimental, } impl DiagnosticsFilter { pub const ALL: [DiagnosticsFilter; 3] = [ DiagnosticsFilter::All, DiagnosticsFilter::Issues, DiagnosticsFilter::Experimental, ]; pub fn label(self) -> &'static str { match self { DiagnosticsFilter::All => "All", DiagnosticsFilter::Issues => "Issues", DiagnosticsFilter::Experimental => "Experimental", } } pub fn matches(self, check: &DiagCommandStatus) -> bool { match self { DiagnosticsFilter::All => true, DiagnosticsFilter::Issues => !check.ok || check.severity != DiagSeverity::Ok, DiagnosticsFilter::Experimental => check.is_experimental, } } pub fn shift(self, delta: i32) -> Self { let current = Self::ALL .iter() .position(|candidate| *candidate == self) .unwrap_or(0) as i32; let len = Self::ALL.len() as i32; let mut next = current + delta; while next < 0 { next += len; } Self::ALL[(next as usize) % Self::ALL.len()] } } #[derive(Clone, Debug)] pub struct DiagnosticsState { pub result: DiagProbeResult, pub summary: String, pub selected_check_index: usize, pub active_filter: DiagnosticsFilter, pub latest_report_path: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MappingEditorKind { Jp108, Ultimate2, } #[derive(Clone, Debug, Serialize, Deserialize)] pub enum MappingDraftState { Jp108 { loaded: Vec, current: Vec, undo_stack: Vec>, selected_row: usize, }, Ultimate2 { loaded: U2CoreProfile, current: U2CoreProfile, undo_stack: Vec, selected_row: usize, }, } #[derive(Clone, Debug)] pub struct QuickActionState { pub action: QuickAction, pub enabled: bool, pub reason: Option, } #[derive(Clone, Debug)] pub struct AppState { pub screen: Screen, pub build_info: BuildInfo, pub advanced_mode: bool, pub report_save_mode: ReportSaveMode, pub settings_path: Option, pub dashboard_layout_mode: DashboardLayoutMode, pub last_panel_focus: PanelFocus, pub devices: Vec, pub selected_device_id: Option, pub selected_filtered_index: usize, pub device_filter: String, pub quick_actions: Vec, pub selected_action_index: usize, pub event_log: VecDeque, pub task_state: Option, pub diagnostics_state: Option, pub mapping_draft_state: Option, pub latest_backup: Option, pub write_lock_until_restart: bool, pub latest_report_path: Option, pub status_line: String, pub firmware_path_override: Option, pub allow_unsafe: bool, pub brick_risk_ack: bool, pub experimental: bool, pub chunk_size: Option, pub quit_requested: bool, } impl AppState { pub fn new(opts: &UiLaunchOptions) -> Self { let mut state = Self { screen: Screen::Dashboard, build_info: opts.build_info.clone(), advanced_mode: opts.advanced_mode, report_save_mode: if !opts.advanced_mode && opts.report_save_mode == ReportSaveMode::Off { ReportSaveMode::FailureOnly } else { opts.report_save_mode }, settings_path: opts.settings_path.clone(), dashboard_layout_mode: DashboardLayoutMode::Wide, last_panel_focus: PanelFocus::Devices, devices: Vec::new(), selected_device_id: None, selected_filtered_index: 0, device_filter: String::new(), quick_actions: Vec::new(), selected_action_index: 0, event_log: VecDeque::with_capacity(EVENT_LOG_CAPACITY), task_state: None, diagnostics_state: None, mapping_draft_state: None, latest_backup: None, write_lock_until_restart: false, latest_report_path: None, status_line: "OpenBitdo ready".to_owned(), firmware_path_override: opts.firmware_path.clone(), allow_unsafe: opts.allow_unsafe, brick_risk_ack: opts.brick_risk_ack, experimental: opts.experimental, chunk_size: opts.chunk_size, quit_requested: false, }; state.recompute_quick_actions(); state } pub fn set_layout_from_size(&mut self, width: u16, height: u16) { self.dashboard_layout_mode = if width < 80 || height < 24 { DashboardLayoutMode::Compact } else { DashboardLayoutMode::Wide }; } pub fn filtered_device_indices(&self) -> Vec { if self.device_filter.trim().is_empty() { return (0..self.devices.len()).collect(); } let query = self.device_filter.to_lowercase(); let matcher = SkimMatcherV2::default(); let mut scored: Vec<(i64, usize)> = self .devices .iter() .enumerate() .filter_map(|(idx, dev)| { let text = format!( "{:04x}:{:04x} {}", dev.vid_pid.vid, dev.vid_pid.pid, dev.name.to_lowercase() ); matcher.fuzzy_match(&text, &query).map(|score| (score, idx)) }) .collect(); scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); scored.into_iter().map(|(_, idx)| idx).collect() } pub fn selected_device(&self) -> Option<&AppDevice> { self.selected_device_id .and_then(|id| self.devices.iter().find(|d| d.vid_pid == id)) .or_else(|| self.devices.first()) } pub fn select_filtered_index(&mut self, index: usize) { let filtered = self.filtered_device_indices(); if let Some(device_idx) = filtered.get(index).copied() { self.selected_filtered_index = index; self.selected_device_id = Some(self.devices[device_idx].vid_pid); self.recompute_quick_actions(); } } pub fn select_next_device(&mut self) { let filtered = self.filtered_device_indices(); if filtered.is_empty() { return; } self.selected_filtered_index = (self.selected_filtered_index + 1) % filtered.len(); self.select_filtered_index(self.selected_filtered_index); } pub fn select_prev_device(&mut self) { let filtered = self.filtered_device_indices(); if filtered.is_empty() { return; } if self.selected_filtered_index == 0 { self.selected_filtered_index = filtered.len().saturating_sub(1); } else { self.selected_filtered_index -= 1; } self.select_filtered_index(self.selected_filtered_index); } pub fn append_event(&mut self, level: EventLevel, message: impl Into) { if self.event_log.len() >= EVENT_LOG_CAPACITY { self.event_log.pop_front(); } self.event_log.push_back(EventEntry { timestamp_utc: Utc::now().format("%H:%M:%S").to_string(), level, message: message.into(), }); } pub fn set_status(&mut self, message: impl Into) { self.status_line = message.into(); } pub fn select_next_action(&mut self) { if self.quick_actions.is_empty() { return; } self.selected_action_index = (self.selected_action_index + 1) % self.quick_actions.len(); } pub fn select_prev_action(&mut self) { if self.quick_actions.is_empty() { return; } if self.selected_action_index == 0 { self.selected_action_index = self.quick_actions.len().saturating_sub(1); } else { self.selected_action_index -= 1; } } pub fn selected_action(&self) -> Option { self.quick_actions .get(self.selected_action_index) .filter(|a| a.enabled) .map(|a| a.action) } pub fn diagnostics_filtered_indices(&self) -> Vec { let Some(diagnostics) = self.diagnostics_state.as_ref() else { return Vec::new(); }; diagnostics .result .command_checks .iter() .enumerate() .filter_map(|(idx, check)| diagnostics.active_filter.matches(check).then_some(idx)) .collect() } pub fn selected_diagnostics_check(&self) -> Option<&DiagCommandStatus> { let diagnostics = self.diagnostics_state.as_ref()?; diagnostics .result .command_checks .get(diagnostics.selected_check_index) } pub fn select_diagnostics_filtered_index(&mut self, filtered_index: usize) { let filtered = self.diagnostics_filtered_indices(); if let Some(check_index) = filtered.get(filtered_index).copied() { if let Some(diagnostics) = self.diagnostics_state.as_mut() { diagnostics.selected_check_index = check_index; } } } pub fn select_next_diagnostics_check(&mut self) { let filtered = self.diagnostics_filtered_indices(); if filtered.is_empty() { return; } let current = self .diagnostics_state .as_ref() .and_then(|diagnostics| { filtered .iter() .position(|idx| *idx == diagnostics.selected_check_index) }) .unwrap_or(0); let next = (current + 1) % filtered.len(); self.select_diagnostics_filtered_index(next); } pub fn select_prev_diagnostics_check(&mut self) { let filtered = self.diagnostics_filtered_indices(); if filtered.is_empty() { return; } let current = self .diagnostics_state .as_ref() .and_then(|diagnostics| { filtered .iter() .position(|idx| *idx == diagnostics.selected_check_index) }) .unwrap_or(0); let next = if current == 0 { filtered.len().saturating_sub(1) } else { current - 1 }; self.select_diagnostics_filtered_index(next); } pub fn set_diagnostics_filter(&mut self, filter: DiagnosticsFilter) { if let Some(diagnostics) = self.diagnostics_state.as_mut() { diagnostics.active_filter = filter; } self.ensure_diagnostics_selection(); } pub fn shift_diagnostics_filter(&mut self, delta: i32) { if let Some(diagnostics) = self.diagnostics_state.as_mut() { diagnostics.active_filter = diagnostics.active_filter.shift(delta); } self.ensure_diagnostics_selection(); } pub fn ensure_diagnostics_selection(&mut self) { let filtered = self.diagnostics_filtered_indices(); let Some(diagnostics) = self.diagnostics_state.as_mut() else { return; }; if filtered.is_empty() { diagnostics.selected_check_index = 0; } else if !filtered.contains(&diagnostics.selected_check_index) { diagnostics.selected_check_index = filtered[0]; } } pub fn recompute_quick_actions(&mut self) { self.quick_actions = if matches!(self.screen, Screen::Dashboard) { let actions = vec![ QuickActionState { action: QuickAction::Refresh, enabled: true, reason: None, }, QuickActionState { action: QuickAction::Diagnose, enabled: self.selected_device().is_some(), reason: None, }, QuickActionState { action: QuickAction::RecommendedUpdate, enabled: self .selected_device() .map(|d| d.support_tier == SupportTier::Full) .unwrap_or(false) && !self.write_lock_until_restart, reason: self.selected_device().and_then(|d| { if d.support_tier != SupportTier::Full { Some("Read-only until hardware confirmation".to_owned()) } else if self.write_lock_until_restart { Some("Write locked until restart".to_owned()) } else { None } }), }, QuickActionState { action: QuickAction::EditMappings, enabled: self .selected_device() .map(|d| { (d.capability.supports_jp108_dedicated_map || (d.capability.supports_u2_button_map && d.capability.supports_u2_slot_config)) && d.support_tier == SupportTier::Full && !self.write_lock_until_restart }) .unwrap_or(false), reason: None, }, QuickActionState { action: QuickAction::Settings, enabled: true, reason: None, }, QuickActionState { action: QuickAction::Quit, enabled: true, reason: None, }, ]; if self.selected_action_index >= actions.len() { self.selected_action_index = 0; } actions } else if matches!(self.screen, Screen::Task) { vec![ QuickActionState { action: QuickAction::Confirm, enabled: self .task_state .as_ref() .map(|task| matches!(task.mode, TaskMode::Preflight)) .unwrap_or(false), reason: None, }, QuickActionState { action: QuickAction::Cancel, enabled: true, reason: None, }, QuickActionState { action: QuickAction::Back, enabled: true, reason: None, }, ] } else if matches!(self.screen, Screen::Diagnostics) { vec![ QuickActionState { action: QuickAction::RunAgain, enabled: self.selected_device().is_some(), reason: None, }, QuickActionState { action: QuickAction::SaveReport, enabled: self.diagnostics_state.is_some(), reason: None, }, QuickActionState { action: QuickAction::Back, enabled: true, reason: None, }, ] } else if matches!(self.screen, Screen::MappingEditor) { vec![ QuickActionState { action: QuickAction::ApplyDraft, enabled: !self.write_lock_until_restart, reason: None, }, QuickActionState { action: QuickAction::UndoDraft, enabled: self.mapping_can_undo(), reason: None, }, QuickActionState { action: QuickAction::ResetDraft, enabled: self.mapping_has_changes(), reason: None, }, QuickActionState { action: QuickAction::RestoreBackup, enabled: self.latest_backup.is_some(), reason: None, }, QuickActionState { action: QuickAction::Firmware, enabled: !self.write_lock_until_restart, reason: None, }, QuickActionState { action: QuickAction::Back, enabled: true, reason: None, }, ] } else if matches!(self.screen, Screen::Recovery) { vec![ QuickActionState { action: QuickAction::RestoreBackup, enabled: self.latest_backup.is_some(), reason: None, }, QuickActionState { action: QuickAction::Back, enabled: true, reason: None, }, QuickActionState { action: QuickAction::Quit, enabled: true, reason: None, }, ] } else { vec![ QuickActionState { action: QuickAction::Back, enabled: true, reason: None, }, QuickActionState { action: QuickAction::Quit, enabled: true, reason: None, }, ] }; if self.selected_action_index >= self.quick_actions.len() { self.selected_action_index = 0; } } pub fn mapping_can_undo(&self) -> bool { match self.mapping_draft_state.as_ref() { Some(MappingDraftState::Jp108 { undo_stack, .. }) => !undo_stack.is_empty(), Some(MappingDraftState::Ultimate2 { undo_stack, .. }) => !undo_stack.is_empty(), None => false, } } pub fn mapping_has_changes(&self) -> bool { match self.mapping_draft_state.as_ref() { Some(MappingDraftState::Jp108 { loaded, current, .. }) => loaded != current, Some(MappingDraftState::Ultimate2 { loaded, current, .. }) => loaded != current, None => false, } } }