cleanroom: modernize tui diagnostics and align release packaging

This commit is contained in:
2026-03-07 13:30:12 -05:00
parent aaa321e9ff
commit 86875075fc
58 changed files with 6554 additions and 3758 deletions

View File

@@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum QuickAction {
Refresh,
Diagnose,
RunAgain,
SaveReport,
RecommendedUpdate,
EditMappings,
Settings,
Quit,
Confirm,
Cancel,
ApplyDraft,
UndoDraft,
ResetDraft,
RestoreBackup,
Firmware,
Back,
}
impl QuickAction {
pub fn label(self) -> &'static str {
match self {
QuickAction::Refresh => "Refresh",
QuickAction::Diagnose => "Diagnose",
QuickAction::RunAgain => "Run Again",
QuickAction::SaveReport => "Save Report",
QuickAction::RecommendedUpdate => "Recommended Update",
QuickAction::EditMappings => "Edit Mapping",
QuickAction::Settings => "Settings",
QuickAction::Quit => "Quit",
QuickAction::Confirm => "Confirm",
QuickAction::Cancel => "Cancel",
QuickAction::ApplyDraft => "Apply",
QuickAction::UndoDraft => "Undo",
QuickAction::ResetDraft => "Reset",
QuickAction::RestoreBackup => "Restore Backup",
QuickAction::Firmware => "Firmware",
QuickAction::Back => "Back",
}
}
}

View File

@@ -0,0 +1,69 @@
use crate::{DashboardLayoutMode, PanelFocus, ReportSaveMode};
use bitdo_app_core::{
ConfigBackupId, DedicatedButtonMapping, FirmwareFinalReport, FirmwareUpdateSessionId,
U2CoreProfile,
};
use bitdo_proto::DiagProbeResult;
use bitdo_proto::VidPid;
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub enum MappingApplyDraft {
Jp108(Vec<DedicatedButtonMapping>),
Ultimate2(U2CoreProfile),
}
#[derive(Clone, Debug)]
pub enum Effect {
RefreshDevices,
RunDiagnostics {
vid_pid: VidPid,
},
LoadMappings {
vid_pid: VidPid,
},
ApplyMappings {
vid_pid: VidPid,
draft: MappingApplyDraft,
},
RestoreBackup {
backup_id: ConfigBackupId,
},
PreparePreflight {
vid_pid: VidPid,
firmware_path_override: Option<PathBuf>,
allow_unsafe: bool,
brick_risk_ack: bool,
experimental: bool,
chunk_size: Option<usize>,
},
StartFirmware {
session_id: FirmwareUpdateSessionId,
acknowledged_risk: bool,
},
CancelFirmware {
session_id: FirmwareUpdateSessionId,
},
PollFirmwareReport {
session_id: FirmwareUpdateSessionId,
},
DeleteTempFile {
path: PathBuf,
},
PersistSettings {
path: PathBuf,
advanced_mode: bool,
report_save_mode: ReportSaveMode,
device_filter_text: String,
dashboard_layout_mode: DashboardLayoutMode,
last_panel_focus: PanelFocus,
},
PersistSupportReport {
operation: String,
vid_pid: Option<VidPid>,
status: String,
message: String,
diag: Option<DiagProbeResult>,
firmware: Option<FirmwareFinalReport>,
},
}

View File

@@ -0,0 +1,85 @@
use crate::AppDevice;
use bitdo_app_core::{
ConfigBackupId, DedicatedButtonMapping, FirmwareFinalReport, FirmwareProgressEvent,
FirmwareUpdatePlan, U2CoreProfile,
};
use bitdo_proto::{DiagProbeResult, VidPid};
use std::path::PathBuf;
use super::action::QuickAction;
use super::state::DiagnosticsFilter;
#[derive(Clone, Debug)]
pub enum AppEvent {
Init,
Tick,
DeviceFilterSet(String),
DeviceFilterInput(char),
DeviceFilterBackspace,
SelectFilteredDevice(usize),
SelectNextDevice,
SelectPrevDevice,
SelectNextAction,
SelectPrevAction,
DiagnosticsSelectCheck(usize),
DiagnosticsSelectNextCheck,
DiagnosticsSelectPrevCheck,
DiagnosticsShiftFilter(i32),
DiagnosticsSetFilter(DiagnosticsFilter),
TriggerAction(QuickAction),
ConfirmPrimary,
Back,
Quit,
ToggleAdvancedMode,
CycleReportSaveMode,
MappingAdjust(i32),
MappingMoveSelection(i32),
DevicesLoaded(Vec<AppDevice>),
DevicesLoadFailed(String),
DiagnosticsCompleted {
vid_pid: VidPid,
result: DiagProbeResult,
summary: String,
},
DiagnosticsFailed {
vid_pid: VidPid,
error: String,
},
MappingsLoadedJp108 {
vid_pid: VidPid,
mappings: Vec<DedicatedButtonMapping>,
},
MappingsLoadedUltimate2 {
vid_pid: VidPid,
profile: U2CoreProfile,
},
MappingLoadFailed(String),
MappingApplied {
backup_id: Option<ConfigBackupId>,
message: String,
recovery_lock: bool,
},
MappingApplyFailed(String),
BackupRestoreCompleted(String),
BackupRestoreFailed(String),
PreflightReady {
vid_pid: VidPid,
firmware_path: PathBuf,
source: String,
version: String,
plan: FirmwareUpdatePlan,
downloaded_firmware_path: Option<PathBuf>,
},
PreflightBlocked(String),
UpdateStarted {
session_id: String,
source: String,
version: String,
},
UpdateProgress(FirmwareProgressEvent),
UpdateFinished(FirmwareFinalReport),
UpdateFailed(String),
SettingsPersisted,
SupportReportSaved(PathBuf),
Error(String),
}

View File

@@ -0,0 +1,5 @@
pub mod action;
pub mod effect;
pub mod event;
pub mod reducer;
pub mod state;

View File

@@ -0,0 +1,882 @@
use super::action::QuickAction;
use super::effect::{Effect, MappingApplyDraft};
use super::event::AppEvent;
use super::state::{
AppState, DiagnosticsFilter, DiagnosticsState, EventLevel, MappingDraftState, PanelFocus,
Screen, TaskMode, TaskState,
};
pub fn reduce(state: &mut AppState, event: AppEvent) -> Vec<Effect> {
let mut effects = Vec::new();
match event {
AppEvent::Init => {
state.append_event(EventLevel::Info, "Initializing dashboard");
effects.push(Effect::RefreshDevices);
}
AppEvent::Tick => {
if let Some(task) = state.task_state.as_ref() {
if matches!(task.mode, TaskMode::Updating) {
if let Some(plan) = task.plan.as_ref() {
effects.push(Effect::PollFirmwareReport {
session_id: plan.session_id.clone(),
});
}
}
}
}
AppEvent::DeviceFilterSet(next) => {
state.device_filter = next;
state.selected_filtered_index = 0;
state.last_panel_focus = PanelFocus::Devices;
state.select_filtered_index(0);
if let Some(effect) = persist_settings_effect(state) {
effects.push(effect);
}
}
AppEvent::DeviceFilterInput(ch) => {
state.device_filter.push(ch);
state.selected_filtered_index = 0;
state.select_filtered_index(0);
if let Some(effect) = persist_settings_effect(state) {
effects.push(effect);
}
}
AppEvent::DeviceFilterBackspace => {
state.device_filter.pop();
state.selected_filtered_index = 0;
state.select_filtered_index(0);
if let Some(effect) = persist_settings_effect(state) {
effects.push(effect);
}
}
AppEvent::SelectFilteredDevice(index) => {
state.select_filtered_index(index);
state.last_panel_focus = PanelFocus::Devices;
state.recompute_quick_actions();
}
AppEvent::SelectNextDevice => {
state.select_next_device();
}
AppEvent::SelectPrevDevice => {
state.select_prev_device();
}
AppEvent::SelectNextAction => {
state.select_next_action();
state.last_panel_focus = PanelFocus::QuickActions;
}
AppEvent::SelectPrevAction => {
state.select_prev_action();
state.last_panel_focus = PanelFocus::QuickActions;
}
AppEvent::DiagnosticsSelectCheck(index) => {
state.select_diagnostics_filtered_index(index);
}
AppEvent::DiagnosticsSelectNextCheck => {
state.select_next_diagnostics_check();
}
AppEvent::DiagnosticsSelectPrevCheck => {
state.select_prev_diagnostics_check();
}
AppEvent::DiagnosticsShiftFilter(delta) => {
state.shift_diagnostics_filter(delta);
}
AppEvent::DiagnosticsSetFilter(filter) => {
state.set_diagnostics_filter(filter);
}
AppEvent::TriggerAction(action) => {
effects.extend(handle_action(state, action));
}
AppEvent::ConfirmPrimary => {
if let Some(action) = state.selected_action() {
effects.extend(handle_action(state, action));
}
}
AppEvent::Back => {
let keep_task_state = state
.task_state
.as_ref()
.map(|task| task.mode == TaskMode::Updating)
.unwrap_or(false);
if let Some(path) = take_cleanup_path_for_navigation(state) {
effects.push(Effect::DeleteTempFile { path });
}
state.screen = Screen::Dashboard;
if !keep_task_state {
state.task_state = None;
} else {
state.set_status("Firmware update continues in background");
}
state.diagnostics_state = None;
state.mapping_draft_state = None;
state.recompute_quick_actions();
}
AppEvent::Quit => {
state.quit_requested = true;
}
AppEvent::ToggleAdvancedMode => {
state.advanced_mode = !state.advanced_mode;
if !state.advanced_mode && state.report_save_mode == crate::ReportSaveMode::Off {
state.report_save_mode = crate::ReportSaveMode::FailureOnly;
}
state.append_event(
EventLevel::Info,
if state.advanced_mode {
"Advanced mode enabled"
} else {
"Advanced mode disabled"
},
);
if let Some(effect) = persist_settings_effect(state) {
effects.push(effect);
}
state.recompute_quick_actions();
}
AppEvent::CycleReportSaveMode => {
state.report_save_mode = state.report_save_mode.next(state.advanced_mode);
state.append_event(
EventLevel::Info,
format!(
"Report save mode changed to {}",
state.report_save_mode.as_str()
),
);
if let Some(effect) = persist_settings_effect(state) {
effects.push(effect);
}
}
AppEvent::MappingMoveSelection(delta) => {
if let Some(mapping) = state.mapping_draft_state.as_mut() {
match mapping {
MappingDraftState::Jp108 {
selected_row,
current,
..
} => {
if current.is_empty() {
return effects;
}
let len = current.len() as i32;
let mut idx = *selected_row as i32 + delta;
while idx < 0 {
idx += len;
}
*selected_row = (idx % len) as usize;
}
MappingDraftState::Ultimate2 {
selected_row,
current,
..
} => {
if current.mappings.is_empty() {
return effects;
}
let len = current.mappings.len() as i32;
let mut idx = *selected_row as i32 + delta;
while idx < 0 {
idx += len;
}
*selected_row = (idx % len) as usize;
}
}
}
state.recompute_quick_actions();
}
AppEvent::MappingAdjust(delta) => {
adjust_mapping(state, delta);
state.recompute_quick_actions();
}
AppEvent::DevicesLoaded(devices) => {
state.devices = devices;
let filtered = state.filtered_device_indices();
if filtered.is_empty() {
state.selected_device_id = None;
state.selected_filtered_index = 0;
state.set_status("No controller detected");
} else {
let selected = filtered
.get(state.selected_filtered_index)
.copied()
.unwrap_or(filtered[0]);
state.selected_filtered_index =
state.selected_filtered_index.min(filtered.len() - 1);
state.selected_device_id = Some(state.devices[selected].vid_pid);
state.set_status("Controllers refreshed");
}
state.append_event(
EventLevel::Info,
format!("Device refresh complete ({} found)", state.devices.len()),
);
state.recompute_quick_actions();
}
AppEvent::DevicesLoadFailed(err) => {
state.set_status(format!("Refresh failed: {err}"));
state.append_event(EventLevel::Error, format!("Refresh failed: {err}"));
}
AppEvent::DiagnosticsCompleted {
vid_pid,
result,
summary,
} => {
let check_count = result.command_checks.len();
state.screen = Screen::Diagnostics;
state.task_state = None;
state.diagnostics_state = Some(DiagnosticsState {
result: result.clone(),
summary: summary.clone(),
selected_check_index: 0,
active_filter: DiagnosticsFilter::All,
latest_report_path: None,
});
state.ensure_diagnostics_selection();
state.set_status("Diagnostics complete");
state.append_event(
EventLevel::Info,
format!("Diagnostics complete for {vid_pid} ({check_count} checks)"),
);
if crate::should_save_support_report(state.report_save_mode, false) {
effects.push(Effect::PersistSupportReport {
operation: "diag-probe".to_owned(),
vid_pid: Some(vid_pid),
status: "ok".to_owned(),
message: summary,
diag: Some(result),
firmware: None,
});
}
state.recompute_quick_actions();
}
AppEvent::DiagnosticsFailed { vid_pid, error } => {
state.screen = Screen::Task;
state.diagnostics_state = None;
state.task_state = Some(TaskState {
mode: TaskMode::Final,
plan: None,
progress: 100,
status: format!("Diagnostics failed for {vid_pid}: {error}"),
final_report: None,
downloaded_firmware_path: None,
});
state.set_status("Diagnostics failed");
state.append_event(
EventLevel::Error,
format!("Diagnostics failed for {vid_pid}: {error}"),
);
if crate::should_save_support_report(state.report_save_mode, true) {
effects.push(Effect::PersistSupportReport {
operation: "diag-probe".to_owned(),
vid_pid: Some(vid_pid),
status: "failed".to_owned(),
message: error,
diag: None,
firmware: None,
});
}
state.recompute_quick_actions();
}
AppEvent::MappingsLoadedJp108 { vid_pid, mappings } => {
state.screen = Screen::MappingEditor;
state.mapping_draft_state = Some(MappingDraftState::Jp108 {
loaded: mappings.clone(),
current: mappings,
undo_stack: Vec::new(),
selected_row: 0,
});
state.append_event(
EventLevel::Info,
format!("Loaded JP108 mappings for {vid_pid}"),
);
state.set_status("Mapping draft loaded");
state.recompute_quick_actions();
}
AppEvent::MappingsLoadedUltimate2 { vid_pid, profile } => {
state.screen = Screen::MappingEditor;
state.mapping_draft_state = Some(MappingDraftState::Ultimate2 {
loaded: profile.clone(),
current: profile,
undo_stack: Vec::new(),
selected_row: 0,
});
state.append_event(
EventLevel::Info,
format!("Loaded Ultimate2 profile mapping for {vid_pid}"),
);
state.set_status("Mapping draft loaded");
state.recompute_quick_actions();
}
AppEvent::MappingLoadFailed(err) => {
state.set_status(format!("Mapping load failed: {err}"));
state.append_event(EventLevel::Error, format!("Mapping load failed: {err}"));
}
AppEvent::MappingApplied {
backup_id,
message,
recovery_lock,
} => {
state.latest_backup = backup_id;
if recovery_lock {
state.write_lock_until_restart = true;
state.screen = Screen::Recovery;
state.set_status("Write lock enabled until restart");
state.append_event(EventLevel::Error, message);
} else {
state.set_status("Mapping applied");
state.append_event(EventLevel::Info, message);
}
state.recompute_quick_actions();
}
AppEvent::MappingApplyFailed(err) => {
state.set_status(format!("Apply failed: {err}"));
state.append_event(EventLevel::Error, format!("Apply failed: {err}"));
}
AppEvent::BackupRestoreCompleted(message) => {
state.set_status("Backup restored");
state.append_event(EventLevel::Info, message);
}
AppEvent::BackupRestoreFailed(message) => {
state.set_status("Backup restore failed");
state.append_event(EventLevel::Error, message);
}
AppEvent::PreflightReady {
vid_pid,
firmware_path,
source,
version,
plan,
downloaded_firmware_path,
} => {
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Preflight,
plan: Some(plan.clone()),
progress: 0,
status: format!(
"Ready to update {vid_pid} to {version} from {}",
firmware_path.display()
),
final_report: None,
downloaded_firmware_path,
});
state.append_event(
EventLevel::Info,
format!("Preflight passed ({source}, {version})"),
);
state.set_status("Preflight ready, confirm to acknowledge risk and start");
state.recompute_quick_actions();
}
AppEvent::PreflightBlocked(reason) => {
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Final,
plan: None,
progress: 100,
status: format!("Preflight blocked: {reason}"),
final_report: None,
downloaded_firmware_path: None,
});
state.set_status("Preflight blocked");
state.append_event(EventLevel::Warning, reason);
state.recompute_quick_actions();
}
AppEvent::UpdateStarted {
session_id,
source,
version,
} => {
if let Some(task) = state.task_state.as_mut() {
task.mode = TaskMode::Updating;
task.progress = 1;
task.status =
format!("Session {session_id}: updating from {source} (target {version})");
} else {
state.task_state = Some(TaskState {
mode: TaskMode::Updating,
plan: None,
progress: 1,
status: format!("Session {session_id}: update started"),
final_report: None,
downloaded_firmware_path: None,
});
}
state.screen = Screen::Task;
state.append_event(EventLevel::Info, "Firmware transfer started");
state.set_status("Firmware update in progress");
state.recompute_quick_actions();
}
AppEvent::UpdateProgress(progress_event) => {
if let Some(task) = state.task_state.as_mut() {
task.mode = TaskMode::Updating;
task.progress = progress_event.progress;
task.status = format!("{}: {}", progress_event.stage, progress_event.message);
}
state.append_event(
EventLevel::Info,
format!(
"{}% {}: {}",
progress_event.progress, progress_event.stage, progress_event.message
),
);
}
AppEvent::UpdateFinished(report) => {
let downloaded_firmware_path = take_any_downloaded_firmware_path(state);
let failed = report.status != bitdo_app_core::FirmwareOutcome::Completed;
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Final,
plan: None,
progress: 100,
status: format!("Update {:?}: {}", report.status, report.message),
final_report: Some(report.clone()),
downloaded_firmware_path: None,
});
state.set_status(format!("Update {:?}", report.status));
state.append_event(
if failed {
EventLevel::Error
} else {
EventLevel::Info
},
format!(
"Update {:?} (chunks {}/{})",
report.status, report.chunks_sent, report.chunks_total
),
);
if crate::should_save_support_report(state.report_save_mode, failed) {
effects.push(Effect::PersistSupportReport {
operation: "fw-write".to_owned(),
vid_pid: state.selected_device().map(|d| d.vid_pid),
status: if failed {
"failed".to_owned()
} else {
"completed".to_owned()
},
message: report.message.clone(),
diag: None,
firmware: Some(report),
});
}
if let Some(path) = downloaded_firmware_path {
effects.push(Effect::DeleteTempFile { path });
}
state.recompute_quick_actions();
}
AppEvent::UpdateFailed(err) => {
let downloaded_firmware_path = take_any_downloaded_firmware_path(state);
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Final,
plan: None,
progress: 100,
status: format!("Update failed: {err}"),
final_report: None,
downloaded_firmware_path: None,
});
state.set_status("Update failed");
state.append_event(EventLevel::Error, format!("Update failed: {err}"));
if let Some(path) = downloaded_firmware_path {
effects.push(Effect::DeleteTempFile { path });
}
state.recompute_quick_actions();
}
AppEvent::SettingsPersisted => {
state.append_event(EventLevel::Info, "Settings saved");
}
AppEvent::SupportReportSaved(path) => {
state.latest_report_path = Some(path.clone());
if let Some(diagnostics) = state.diagnostics_state.as_mut() {
diagnostics.latest_report_path = Some(path.clone());
}
state.append_event(
EventLevel::Info,
format!("Support report saved: {}", path.display()),
);
}
AppEvent::Error(message) => {
state.set_status(message.clone());
state.append_event(EventLevel::Error, message);
}
}
effects
}
fn handle_action(state: &mut AppState, action: QuickAction) -> Vec<Effect> {
let mut effects = Vec::new();
match state.screen {
Screen::Dashboard => match action {
QuickAction::Refresh => {
effects.push(Effect::RefreshDevices);
}
QuickAction::Diagnose => {
if let Some(vid_pid) = state.selected_device().map(|d| d.vid_pid) {
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Diagnostics,
plan: None,
progress: 5,
status: format!("Running diagnostics for {vid_pid}"),
final_report: None,
downloaded_firmware_path: None,
});
state.diagnostics_state = None;
effects.push(Effect::RunDiagnostics { vid_pid });
}
}
QuickAction::RecommendedUpdate => {
if let Some(vid_pid) = state.selected_device().map(|d| d.vid_pid) {
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Preflight,
plan: None,
progress: 0,
status: format!("Preparing preflight for {vid_pid}"),
final_report: None,
downloaded_firmware_path: None,
});
effects.push(Effect::PreparePreflight {
vid_pid,
firmware_path_override: state.firmware_path_override.clone(),
allow_unsafe: true,
brick_risk_ack: true,
experimental: state.experimental,
chunk_size: state.chunk_size,
});
}
}
QuickAction::EditMappings => {
if let Some(vid_pid) = state.selected_device().map(|d| d.vid_pid) {
effects.push(Effect::LoadMappings { vid_pid });
}
}
QuickAction::Settings => {
state.screen = Screen::Settings;
}
QuickAction::Quit => state.quit_requested = true,
_ => {}
},
Screen::Task => match action {
QuickAction::Confirm => {
if let Some(task) = state.task_state.as_ref() {
if let Some(plan) = task.plan.as_ref() {
effects.push(Effect::StartFirmware {
session_id: plan.session_id.clone(),
acknowledged_risk: true,
});
}
}
}
QuickAction::Cancel => {
if let Some(task) = state.task_state.as_ref() {
if task.mode == TaskMode::Updating {
if let Some(plan) = task.plan.as_ref() {
effects.push(Effect::CancelFirmware {
session_id: plan.session_id.clone(),
});
} else {
state.screen = Screen::Dashboard;
state.task_state = None;
}
} else {
if let Some(path) = take_cleanup_path_for_navigation(state) {
effects.push(Effect::DeleteTempFile { path });
}
state.screen = Screen::Dashboard;
state.task_state = None;
}
} else {
state.screen = Screen::Dashboard;
}
}
QuickAction::Back => {
state.screen = Screen::Dashboard;
if state
.task_state
.as_ref()
.map(|task| task.mode == TaskMode::Updating)
.unwrap_or(false)
{
state.set_status("Firmware update continues in background");
} else {
if let Some(path) = take_cleanup_path_for_navigation(state) {
effects.push(Effect::DeleteTempFile { path });
}
state.task_state = None;
}
}
QuickAction::Quit => state.quit_requested = true,
_ => {}
},
Screen::Diagnostics => match action {
QuickAction::RunAgain => {
let vid_pid = state
.diagnostics_state
.as_ref()
.map(|diagnostics| diagnostics.result.target)
.or_else(|| state.selected_device().map(|device| device.vid_pid));
if let Some(vid_pid) = vid_pid {
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Diagnostics,
plan: None,
progress: 5,
status: format!("Running diagnostics for {vid_pid}"),
final_report: None,
downloaded_firmware_path: None,
});
state.diagnostics_state = None;
effects.push(Effect::RunDiagnostics { vid_pid });
}
}
QuickAction::SaveReport => {
if let Some(diagnostics) = state.diagnostics_state.as_ref() {
let has_issues =
diagnostics.result.command_checks.iter().any(|check| {
!check.ok || check.severity != bitdo_proto::DiagSeverity::Ok
});
let target = diagnostics.result.target;
let result = diagnostics.result.clone();
let summary = diagnostics.summary.clone();
state.set_status("Saving diagnostics report");
effects.push(Effect::PersistSupportReport {
operation: "diag-probe".to_owned(),
vid_pid: Some(target),
status: if has_issues {
"attention".to_owned()
} else {
"ok".to_owned()
},
message: summary,
diag: Some(result),
firmware: None,
});
}
}
QuickAction::Back => {
state.screen = Screen::Dashboard;
state.task_state = None;
state.diagnostics_state = None;
}
QuickAction::Quit => state.quit_requested = true,
_ => {}
},
Screen::MappingEditor => match action {
QuickAction::ApplyDraft => {
if let Some(vid_pid) = state.selected_device().map(|d| d.vid_pid) {
if let Some(draft) = state.mapping_draft_state.as_ref() {
let payload = match draft {
MappingDraftState::Jp108 { current, .. } => {
MappingApplyDraft::Jp108(current.clone())
}
MappingDraftState::Ultimate2 { current, .. } => {
MappingApplyDraft::Ultimate2(current.clone())
}
};
effects.push(Effect::ApplyMappings {
vid_pid,
draft: payload,
});
}
}
}
QuickAction::UndoDraft => {
mapping_undo(state);
}
QuickAction::ResetDraft => {
mapping_reset(state);
}
QuickAction::RestoreBackup => {
if let Some(backup) = state.latest_backup.clone() {
effects.push(Effect::RestoreBackup { backup_id: backup });
}
}
QuickAction::Firmware => {
if let Some(vid_pid) = state.selected_device().map(|d| d.vid_pid) {
state.screen = Screen::Task;
state.task_state = Some(TaskState {
mode: TaskMode::Preflight,
plan: None,
progress: 0,
status: format!("Preparing preflight for {vid_pid}"),
final_report: None,
downloaded_firmware_path: None,
});
effects.push(Effect::PreparePreflight {
vid_pid,
firmware_path_override: state.firmware_path_override.clone(),
allow_unsafe: true,
brick_risk_ack: true,
experimental: state.experimental,
chunk_size: state.chunk_size,
});
}
}
QuickAction::Back => {
state.screen = Screen::Dashboard;
state.mapping_draft_state = None;
}
QuickAction::Quit => state.quit_requested = true,
_ => {}
},
Screen::Recovery => match action {
QuickAction::RestoreBackup => {
if let Some(backup) = state.latest_backup.clone() {
effects.push(Effect::RestoreBackup { backup_id: backup });
}
}
QuickAction::Back => {
state.screen = Screen::Dashboard;
}
QuickAction::Quit => state.quit_requested = true,
_ => {}
},
Screen::Settings => match action {
QuickAction::Back => state.screen = Screen::Dashboard,
QuickAction::Quit => state.quit_requested = true,
_ => {}
},
}
state.recompute_quick_actions();
effects
}
fn persist_settings_effect(state: &AppState) -> Option<Effect> {
state
.settings_path
.clone()
.map(|path| Effect::PersistSettings {
path,
advanced_mode: state.advanced_mode,
report_save_mode: state.report_save_mode,
device_filter_text: state.device_filter.clone(),
dashboard_layout_mode: state.dashboard_layout_mode,
last_panel_focus: state.last_panel_focus,
})
}
fn mapping_undo(state: &mut AppState) {
match state.mapping_draft_state.as_mut() {
Some(MappingDraftState::Jp108 {
current,
undo_stack,
..
}) => {
if let Some(previous) = undo_stack.pop() {
*current = previous;
}
}
Some(MappingDraftState::Ultimate2 {
current,
undo_stack,
..
}) => {
if let Some(previous) = undo_stack.pop() {
*current = previous;
}
}
None => {}
}
}
fn mapping_reset(state: &mut AppState) {
match state.mapping_draft_state.as_mut() {
Some(MappingDraftState::Jp108 {
loaded,
current,
undo_stack,
..
}) => {
undo_stack.push(current.clone());
*current = loaded.clone();
}
Some(MappingDraftState::Ultimate2 {
loaded,
current,
undo_stack,
..
}) => {
undo_stack.push(current.clone());
*current = loaded.clone();
}
None => {}
}
}
fn adjust_mapping(state: &mut AppState, delta: i32) {
match state.mapping_draft_state.as_mut() {
Some(MappingDraftState::Jp108 {
current,
undo_stack,
selected_row,
..
}) => {
if *selected_row < current.len() {
undo_stack.push(current.clone());
let entry = &mut current[*selected_row];
entry.target_hid_usage = cycle_jp108(entry.target_hid_usage, delta);
}
}
Some(MappingDraftState::Ultimate2 {
current,
undo_stack,
selected_row,
..
}) => {
if *selected_row < current.mappings.len() {
undo_stack.push(current.clone());
let entry = &mut current.mappings[*selected_row];
entry.target_hid_usage = cycle_u2(entry.target_hid_usage, delta);
}
}
None => {}
}
}
fn take_any_downloaded_firmware_path(state: &mut AppState) -> Option<std::path::PathBuf> {
state
.task_state
.as_mut()
.and_then(|task| task.downloaded_firmware_path.take())
}
fn take_cleanup_path_for_navigation(state: &mut AppState) -> Option<std::path::PathBuf> {
let should_cleanup = state
.task_state
.as_ref()
.map(|task| task.mode != TaskMode::Updating)
.unwrap_or(false);
if should_cleanup {
take_any_downloaded_firmware_path(state)
} else {
None
}
}
const JP108_PRESETS: [u16; 16] = [
0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000a, 0x000b, 0x0028, 0x0029, 0x002c, 0x003a,
0x003b, 0x003c, 0x00e0, 0x00e1,
];
const U2_PRESETS: [u16; 17] = [
0x0100, 0x0101, 0x0102, 0x0103, 0x0104, 0x0105, 0x0106, 0x0107, 0x0108, 0x0109, 0x010a, 0x010b,
0x010c, 0x010d, 0x010e, 0x010f, 0x0110,
];
fn cycle_jp108(current: u16, delta: i32) -> u16 {
cycle_from_table(&JP108_PRESETS, current, delta)
}
fn cycle_u2(current: u16, delta: i32) -> u16 {
cycle_from_table(&U2_PRESETS, current, delta)
}
fn cycle_from_table(table: &[u16], current: u16, delta: i32) -> u16 {
let pos = table.iter().position(|item| *item == current).unwrap_or(0) as i32;
let len = table.len() as i32;
let mut next = pos + delta;
while next < 0 {
next += len;
}
table[(next as usize) % table.len()]
}

View File

@@ -0,0 +1,634 @@
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<FirmwareUpdatePlan>,
pub progress: u8,
pub status: String,
pub final_report: Option<FirmwareFinalReport>,
pub downloaded_firmware_path: Option<PathBuf>,
}
#[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<PathBuf>,
}
#[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<DedicatedButtonMapping>,
current: Vec<DedicatedButtonMapping>,
undo_stack: Vec<Vec<DedicatedButtonMapping>>,
selected_row: usize,
},
Ultimate2 {
loaded: U2CoreProfile,
current: U2CoreProfile,
undo_stack: Vec<U2CoreProfile>,
selected_row: usize,
},
}
#[derive(Clone, Debug)]
pub struct QuickActionState {
pub action: QuickAction,
pub enabled: bool,
pub reason: Option<String>,
}
#[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<PathBuf>,
pub dashboard_layout_mode: DashboardLayoutMode,
pub last_panel_focus: PanelFocus,
pub devices: Vec<AppDevice>,
pub selected_device_id: Option<VidPid>,
pub selected_filtered_index: usize,
pub device_filter: String,
pub quick_actions: Vec<QuickActionState>,
pub selected_action_index: usize,
pub event_log: VecDeque<EventEntry>,
pub task_state: Option<TaskState>,
pub diagnostics_state: Option<DiagnosticsState>,
pub mapping_draft_state: Option<MappingDraftState>,
pub latest_backup: Option<ConfigBackupId>,
pub write_lock_until_restart: bool,
pub latest_report_path: Option<PathBuf>,
pub status_line: String,
pub firmware_path_override: Option<PathBuf>,
pub allow_unsafe: bool,
pub brick_risk_ack: bool,
pub experimental: bool,
pub chunk_size: Option<usize>,
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<usize> {
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<String>) {
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<String>) {
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<QuickAction> {
self.quick_actions
.get(self.selected_action_index)
.filter(|a| a.enabled)
.map(|a| a.action)
}
pub fn diagnostics_filtered_indices(&self) -> Vec<usize> {
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,
}
}
}

View File

@@ -0,0 +1,301 @@
use crate::support_report::persist_support_report;
use crate::{should_save_support_report, HeadlessOutputMode, RunLaunchOptions};
use anyhow::{anyhow, Result};
use bitdo_app_core::{
FirmwareCancelRequest, FirmwareConfirmRequest, FirmwareFinalReport, FirmwareOutcome,
FirmwarePreflightRequest, FirmwareStartRequest, OpenBitdoCore,
};
use serde::Serialize;
use std::path::Path;
use tokio::time::{sleep, Duration};
#[derive(Serialize)]
struct JsonProgress<'a> {
r#type: &'static str,
session_id: &'a str,
sequence: u64,
stage: &'a str,
progress: u8,
message: &'a str,
timestamp: String,
}
#[derive(Serialize)]
struct JsonFinal<'a> {
r#type: &'static str,
session_id: &'a str,
status: &'a str,
chunks_sent: usize,
chunks_total: usize,
message: &'a str,
error_code: Option<String>,
}
pub async fn run_headless(
core: OpenBitdoCore,
opts: RunLaunchOptions,
) -> Result<FirmwareFinalReport> {
let downloaded_firmware = opts.firmware_path.is_none() && opts.use_recommended;
let firmware_path = if let Some(path) = opts.firmware_path.clone() {
path
} else if opts.use_recommended {
core.download_recommended_firmware(opts.vid_pid)
.await
.map(|d| d.firmware_path)
.map_err(|err| anyhow!("recommended firmware unavailable: {err}"))?
} else {
return Err(anyhow!(
"firmware path is required when --recommended is not used"
));
};
let preflight = match core
.preflight_firmware(FirmwarePreflightRequest {
vid_pid: opts.vid_pid,
firmware_path: firmware_path.clone(),
allow_unsafe: opts.allow_unsafe,
brick_risk_ack: opts.brick_risk_ack,
experimental: opts.experimental,
chunk_size: opts.chunk_size,
})
.await
{
Ok(preflight) => preflight,
Err(err) => {
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Err(err.into());
}
};
if !preflight.gate.allowed {
let message = preflight
.gate
.message
.unwrap_or_else(|| "policy denied".to_owned());
emit_failed_final(&opts, "preflight", &message);
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Err(anyhow!("preflight denied: {message}"));
}
let plan = match preflight.plan {
Some(plan) => plan,
None => {
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Err(anyhow!("preflight allowed without transfer plan"));
}
};
if let Err(err) = core
.start_firmware(FirmwareStartRequest {
session_id: plan.session_id.clone(),
})
.await
{
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Err(err.into());
}
if let Err(err) = core
.confirm_firmware(FirmwareConfirmRequest {
session_id: plan.session_id.clone(),
acknowledged_risk: opts.acknowledged_risk,
})
.await
{
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Err(err.into());
}
let mut events = match core.subscribe_events(&plan.session_id.0).await {
Ok(events) => events,
Err(err) => {
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Err(err.into());
}
};
loop {
tokio::select! {
evt = events.recv() => {
if let Ok(evt) = evt {
if opts.emit_events {
emit_progress(&opts, &evt.session_id.0, evt.sequence, &evt.stage, evt.progress, &evt.message, evt.timestamp.to_rfc3339());
}
if evt.terminal {
break;
}
}
}
_ = sleep(Duration::from_millis(10)) => {
let report = match core.firmware_report(&plan.session_id.0).await {
Ok(report) => report,
Err(err) => {
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path)
.await;
return Err(err.into());
}
};
if let Some(report) = report {
emit_final(&opts, &report);
maybe_persist_report(&core, &opts, &report).await;
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Ok(report);
}
}
}
}
let report = match core.firmware_report(&plan.session_id.0).await {
Ok(report) => report.unwrap_or(FirmwareFinalReport {
session_id: plan.session_id,
status: FirmwareOutcome::Failed,
started_at: None,
completed_at: None,
bytes_total: 0,
chunks_total: 0,
chunks_sent: 0,
error_code: None,
message: "missing final report".to_owned(),
}),
Err(err) => {
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
return Err(err.into());
}
};
emit_final(&opts, &report);
maybe_persist_report(&core, &opts, &report).await;
maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await;
Ok(report)
}
pub async fn cancel_headless(
core: &OpenBitdoCore,
session_id: &str,
) -> Result<FirmwareFinalReport> {
core.cancel_firmware(FirmwareCancelRequest {
session_id: bitdo_app_core::FirmwareUpdateSessionId(session_id.to_owned()),
})
.await
.map_err(Into::into)
}
fn emit_progress(
opts: &RunLaunchOptions,
session_id: &str,
sequence: u64,
stage: &str,
progress: u8,
message: &str,
timestamp: String,
) {
match opts.output_mode {
HeadlessOutputMode::Human => {
println!("[{progress:>3}%] {stage}: {message}");
}
HeadlessOutputMode::Json => {
let payload = JsonProgress {
r#type: "progress",
session_id,
sequence,
stage,
progress,
message,
timestamp,
};
if let Ok(json) = serde_json::to_string(&payload) {
println!("{json}");
}
}
}
}
fn emit_final(opts: &RunLaunchOptions, report: &FirmwareFinalReport) {
match opts.output_mode {
HeadlessOutputMode::Human => {
println!(
"final: {:?} chunks={}/{} message={}",
report.status, report.chunks_sent, report.chunks_total, report.message
);
}
HeadlessOutputMode::Json => {
let payload = JsonFinal {
r#type: "final",
session_id: &report.session_id.0,
status: match report.status {
FirmwareOutcome::Completed => "Completed",
FirmwareOutcome::Cancelled => "Cancelled",
FirmwareOutcome::Failed => "Failed",
},
chunks_sent: report.chunks_sent,
chunks_total: report.chunks_total,
message: &report.message,
error_code: report.error_code.map(|err| format!("{err:?}")),
};
if let Ok(json) = serde_json::to_string(&payload) {
println!("{json}");
}
}
}
}
fn emit_failed_final(opts: &RunLaunchOptions, session_id: &str, message: &str) {
match opts.output_mode {
HeadlessOutputMode::Human => {
println!("final: Failed message={message}");
}
HeadlessOutputMode::Json => {
let payload = JsonFinal {
r#type: "final",
session_id,
status: "Failed",
chunks_sent: 0,
chunks_total: 0,
message,
error_code: None,
};
if let Ok(json) = serde_json::to_string(&payload) {
println!("{json}");
}
}
}
}
async fn maybe_persist_report(
core: &OpenBitdoCore,
opts: &RunLaunchOptions,
report: &FirmwareFinalReport,
) {
let is_failure = report.status != FirmwareOutcome::Completed;
if !should_save_support_report(opts.report_save_mode, is_failure) {
return;
}
let devices = core.list_devices().await.unwrap_or_default();
let selected = devices.iter().find(|d| d.vid_pid == opts.vid_pid);
let status = if is_failure { "failed" } else { "completed" };
let _ = persist_support_report(
"fw-write",
selected,
status,
report.message.clone(),
None,
Some(report),
)
.await;
}
async fn maybe_cleanup_downloaded_firmware(downloaded_firmware: bool, firmware_path: &Path) {
if downloaded_firmware {
let _ = cleanup_temp_file(firmware_path).await;
}
}
async fn cleanup_temp_file(path: &Path) -> std::io::Result<()> {
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
pub mod ui_state;

View File

@@ -0,0 +1,82 @@
use crate::{DashboardLayoutMode, PanelFocus, ReportSaveMode};
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PersistedUiState {
#[serde(default = "default_settings_schema_version")]
pub schema_version: u32,
#[serde(default)]
pub advanced_mode: bool,
#[serde(default)]
pub report_save_mode: ReportSaveMode,
#[serde(default)]
pub device_filter_text: String,
#[serde(default)]
pub dashboard_layout_mode: DashboardLayoutMode,
#[serde(default)]
pub last_panel_focus: PanelFocus,
}
impl Default for PersistedUiState {
fn default() -> Self {
Self {
schema_version: default_settings_schema_version(),
advanced_mode: false,
report_save_mode: ReportSaveMode::FailureOnly,
device_filter_text: String::new(),
dashboard_layout_mode: DashboardLayoutMode::Wide,
last_panel_focus: PanelFocus::Devices,
}
}
}
const fn default_settings_schema_version() -> u32 {
2
}
pub fn load_ui_state(path: &Path) -> Result<PersistedUiState> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(PersistedUiState::default())
}
Err(err) => return Err(err.into()),
};
let mut loaded: PersistedUiState = toml::from_str(&raw)
.map_err(|err| anyhow!("failed to parse ui state {}: {err}", path.display()))?;
loaded.schema_version = default_settings_schema_version();
if !loaded.advanced_mode && loaded.report_save_mode == ReportSaveMode::Off {
loaded.report_save_mode = ReportSaveMode::FailureOnly;
}
Ok(loaded)
}
pub fn persist_ui_state(
path: &Path,
advanced_mode: bool,
report_save_mode: ReportSaveMode,
device_filter_text: String,
dashboard_layout_mode: DashboardLayoutMode,
last_panel_focus: PanelFocus,
) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = toml::to_string_pretty(&PersistedUiState {
schema_version: default_settings_schema_version(),
advanced_mode,
report_save_mode,
device_filter_text,
dashboard_layout_mode,
last_panel_focus,
})
.map_err(|err| anyhow!("failed to serialize ui state: {err}"))?;
std::fs::write(path, body)?;
Ok(())
}

View File

@@ -0,0 +1,314 @@
use crate::app::effect::{Effect, MappingApplyDraft};
use crate::app::event::AppEvent;
use crate::app::state::AppState;
use crate::persistence::ui_state::persist_ui_state;
use crate::support_report::persist_support_report;
use bitdo_app_core::{
FirmwareCancelRequest, FirmwareConfirmRequest, FirmwarePreflightRequest, FirmwareStartRequest,
OpenBitdoCore, U2SlotId,
};
use std::path::Path;
pub async fn execute_effect(
core: &OpenBitdoCore,
state: &AppState,
effect: Effect,
) -> Vec<AppEvent> {
match effect {
Effect::RefreshDevices => match core.list_devices().await {
Ok(mut devices) => {
devices.sort_by_key(|d| (d.vid_pid.vid, d.vid_pid.pid));
vec![AppEvent::DevicesLoaded(devices)]
}
Err(err) => vec![AppEvent::DevicesLoadFailed(err.to_string())],
},
Effect::RunDiagnostics { vid_pid } => match core.diag_probe(vid_pid).await {
Ok(result) => {
let summary = state
.devices
.iter()
.find(|device| device.vid_pid == vid_pid)
.map(|device| core.beginner_diag_summary(device, &result))
.unwrap_or_else(|| "Diagnostics completed".to_owned());
vec![AppEvent::DiagnosticsCompleted {
vid_pid,
result,
summary,
}]
}
Err(err) => vec![AppEvent::DiagnosticsFailed {
vid_pid,
error: err.to_string(),
}],
},
Effect::LoadMappings { vid_pid } => {
let device = state.devices.iter().find(|d| d.vid_pid == vid_pid);
if let Some(device) = device {
if device.capability.supports_jp108_dedicated_map {
match core.jp108_read_dedicated_mapping(vid_pid).await {
Ok(mappings) => vec![AppEvent::MappingsLoadedJp108 { vid_pid, mappings }],
Err(err) => vec![AppEvent::MappingLoadFailed(err.to_string())],
}
} else if device.capability.supports_u2_button_map
&& device.capability.supports_u2_slot_config
{
match core.u2_read_core_profile(vid_pid, U2SlotId::Slot1).await {
Ok(profile) => vec![AppEvent::MappingsLoadedUltimate2 { vid_pid, profile }],
Err(err) => vec![AppEvent::MappingLoadFailed(err.to_string())],
}
} else {
vec![AppEvent::MappingLoadFailed(
"Device does not support mapping editor".to_owned(),
)]
}
} else {
vec![AppEvent::MappingLoadFailed("No device selected".to_owned())]
}
}
Effect::ApplyMappings { vid_pid, draft } => match draft {
MappingApplyDraft::Jp108(mappings) => match core
.jp108_apply_dedicated_mapping_with_recovery(vid_pid, mappings, true)
.await
{
Ok(report) => {
let recovery_lock = report.rollback_failed();
let message = if report.write_applied {
"JP108 mapping applied".to_owned()
} else if recovery_lock {
"Apply failed and rollback failed; writes locked until restart".to_owned()
} else {
"Apply failed but rollback restored prior mapping".to_owned()
};
vec![AppEvent::MappingApplied {
backup_id: report.backup_id,
message,
recovery_lock,
}]
}
Err(err) => vec![AppEvent::MappingApplyFailed(err.to_string())],
},
MappingApplyDraft::Ultimate2(profile) => match core
.u2_apply_core_profile_with_recovery(
vid_pid,
profile.slot,
profile.mode,
profile.mappings,
profile.l2_analog,
profile.r2_analog,
true,
)
.await
{
Ok(report) => {
let recovery_lock = report.rollback_failed();
let message = if report.write_applied {
"Ultimate2 profile applied".to_owned()
} else if recovery_lock {
"Apply failed and rollback failed; writes locked until restart".to_owned()
} else {
"Apply failed but rollback restored prior profile".to_owned()
};
vec![AppEvent::MappingApplied {
backup_id: report.backup_id,
message,
recovery_lock,
}]
}
Err(err) => vec![AppEvent::MappingApplyFailed(err.to_string())],
},
},
Effect::RestoreBackup { backup_id } => match core.restore_backup(backup_id).await {
Ok(_) => vec![AppEvent::BackupRestoreCompleted(
"Backup restore completed".to_owned(),
)],
Err(err) => vec![AppEvent::BackupRestoreFailed(format!(
"Backup restore failed: {err}"
))],
},
Effect::PreparePreflight {
vid_pid,
firmware_path_override,
allow_unsafe,
brick_risk_ack,
experimental,
chunk_size,
} => {
let device = state.devices.iter().find(|d| d.vid_pid == vid_pid);
let Some(device) = device else {
return vec![AppEvent::PreflightBlocked("No selected device".to_owned())];
};
let (firmware_path, source, version, downloaded_firmware_path) =
if let Some(path) = firmware_path_override {
(path, "local file".to_owned(), "manual".to_owned(), None)
} else {
match core.download_recommended_firmware(vid_pid).await {
Ok(download) => {
let path = download.firmware_path;
(
path.clone(),
"recommended verified download".to_owned(),
download.version,
Some(path),
)
}
Err(err) => {
return vec![AppEvent::PreflightBlocked(format!(
"Recommended firmware unavailable: {err}"
))]
}
}
};
match core
.preflight_firmware(FirmwarePreflightRequest {
vid_pid: device.vid_pid,
firmware_path: firmware_path.clone(),
allow_unsafe,
brick_risk_ack,
experimental,
chunk_size,
})
.await
{
Ok(preflight) => {
if !preflight.gate.allowed {
if let Some(path) = downloaded_firmware_path.as_ref() {
let _ = cleanup_temp_file(path).await;
}
vec![AppEvent::PreflightBlocked(
preflight
.gate
.message
.unwrap_or_else(|| "Preflight denied by policy".to_owned()),
)]
} else if let Some(plan) = preflight.plan {
vec![AppEvent::PreflightReady {
vid_pid,
firmware_path,
source,
version,
plan,
downloaded_firmware_path,
}]
} else {
if let Some(path) = downloaded_firmware_path.as_ref() {
let _ = cleanup_temp_file(path).await;
}
vec![AppEvent::PreflightBlocked(
"Preflight allowed but no transfer plan was returned".to_owned(),
)]
}
}
Err(err) => {
if let Some(path) = downloaded_firmware_path.as_ref() {
let _ = cleanup_temp_file(path).await;
}
vec![AppEvent::PreflightBlocked(format!(
"Preflight failed: {err}"
))]
}
}
}
Effect::StartFirmware {
session_id,
acknowledged_risk,
} => {
if let Err(err) = core
.start_firmware(FirmwareStartRequest {
session_id: session_id.clone(),
})
.await
{
return vec![AppEvent::UpdateFailed(err.to_string())];
}
if let Err(err) = core
.confirm_firmware(FirmwareConfirmRequest {
session_id: session_id.clone(),
acknowledged_risk,
})
.await
{
return vec![AppEvent::UpdateFailed(err.to_string())];
}
vec![AppEvent::UpdateStarted {
session_id: session_id.0,
source: "selected firmware".to_owned(),
version: "target".to_owned(),
}]
}
Effect::CancelFirmware { session_id } => match core
.cancel_firmware(FirmwareCancelRequest { session_id })
.await
{
Ok(report) => vec![AppEvent::UpdateFinished(report)],
Err(err) => vec![AppEvent::UpdateFailed(err.to_string())],
},
Effect::PollFirmwareReport { session_id } => {
match core.firmware_report(&session_id.0).await {
Ok(Some(report)) => vec![AppEvent::UpdateFinished(report)],
Ok(None) => Vec::new(),
Err(err) => vec![AppEvent::UpdateFailed(err.to_string())],
}
}
Effect::DeleteTempFile { path } => match cleanup_temp_file(&path).await {
Ok(_) => Vec::new(),
Err(err) => vec![AppEvent::Error(format!(
"Failed to delete temporary firmware {}: {err}",
path.display()
))],
},
Effect::PersistSettings {
path,
advanced_mode,
report_save_mode,
device_filter_text,
dashboard_layout_mode,
last_panel_focus,
} => match persist_ui_state(
&path,
advanced_mode,
report_save_mode,
device_filter_text,
dashboard_layout_mode,
last_panel_focus,
) {
Ok(_) => vec![AppEvent::SettingsPersisted],
Err(err) => vec![AppEvent::Error(format!("Settings save failed: {err}"))],
},
Effect::PersistSupportReport {
operation,
vid_pid,
status,
message,
diag,
firmware,
} => {
let device = vid_pid.and_then(|id| state.devices.iter().find(|d| d.vid_pid == id));
match persist_support_report(
&operation,
device,
&status,
message,
diag.as_ref(),
firmware.as_ref(),
)
.await
{
Ok(path) => vec![AppEvent::SupportReportSaved(path)],
Err(err) => vec![AppEvent::Error(format!(
"Support report save failed: {err}"
))],
}
}
}
}
async fn cleanup_temp_file(path: &Path) -> std::io::Result<()> {
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}

View File

@@ -0,0 +1,281 @@
use super::effect_executor::execute_effect;
use crate::app::event::AppEvent;
use crate::app::reducer::reduce;
use crate::app::state::{AppState, EventLevel, Screen, TaskMode};
use crate::persistence::ui_state::load_ui_state;
use crate::support_report::prune_reports_on_startup;
use crate::ui::layout::{self, HitTarget};
use crate::UiLaunchOptions;
use anyhow::Result;
use bitdo_app_core::{FirmwareProgressEvent, OpenBitdoCore};
use crossterm::event::{self, Event as CEvent, KeyCode, MouseButton, MouseEvent, MouseEventKind};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use std::collections::VecDeque;
use std::io::Stdout;
use tokio::sync::broadcast;
use tokio::time::Duration;
pub async fn run_ui_loop(core: OpenBitdoCore, opts: UiLaunchOptions) -> Result<()> {
let _ = prune_reports_on_startup().await;
let mut state = AppState::new(&opts);
if let Some(path) = state.settings_path.as_ref() {
match load_ui_state(path) {
Ok(persisted) => {
state.device_filter = persisted.device_filter_text;
state.dashboard_layout_mode = persisted.dashboard_layout_mode;
state.last_panel_focus = persisted.last_panel_focus;
state.advanced_mode = persisted.advanced_mode;
state.report_save_mode = persisted.report_save_mode;
}
Err(err) => {
state.set_status("Settings file invalid; using defaults");
state.append_event(EventLevel::Warning, format!("Settings load failed: {err}"));
}
}
}
let mut terminal = init_terminal()?;
let mut hit_map = layout::HitMap::default();
let mut firmware_events: Option<broadcast::Receiver<FirmwareProgressEvent>> = None;
process_event(&core, &mut state, AppEvent::Init).await;
loop {
if let Ok(size) = terminal.size() {
state.set_layout_from_size(size.width, size.height);
}
ensure_firmware_subscription(&core, &state, &mut firmware_events).await?;
poll_firmware_events(&mut state, &mut firmware_events).await;
process_event(&core, &mut state, AppEvent::Tick).await;
terminal.draw(|frame| {
hit_map = layout::render(frame, &state);
})?;
if state.quit_requested {
break;
}
if !event::poll(Duration::from_millis(80))? {
continue;
}
match event::read()? {
CEvent::Key(key) => {
if let Some(app_event) = key_to_event(&state, key.code) {
process_event(&core, &mut state, app_event).await;
}
}
CEvent::Mouse(mouse) => {
if let Some(app_event) = mouse_to_event(&state, &hit_map, mouse) {
process_event(&core, &mut state, app_event).await;
}
}
CEvent::Resize(width, height) => {
state.set_layout_from_size(width, height);
}
_ => {}
}
}
teardown_terminal(&mut terminal)?;
Ok(())
}
async fn process_event(core: &OpenBitdoCore, state: &mut AppState, initial: AppEvent) {
let mut queue = VecDeque::from([initial]);
while let Some(event) = queue.pop_front() {
let effects = reduce(state, event);
for effect in effects {
let emitted = execute_effect(core, state, effect).await;
for next in emitted {
queue.push_back(next);
}
}
}
}
async fn ensure_firmware_subscription(
core: &OpenBitdoCore,
state: &AppState,
receiver: &mut Option<broadcast::Receiver<FirmwareProgressEvent>>,
) -> Result<()> {
if receiver.is_some() {
return Ok(());
}
let Some(task) = state.task_state.as_ref() else {
return Ok(());
};
if !matches!(task.mode, TaskMode::Updating) {
return Ok(());
}
let Some(plan) = task.plan.as_ref() else {
return Ok(());
};
*receiver = Some(core.subscribe_events(&plan.session_id.0).await?);
Ok(())
}
async fn poll_firmware_events(
state: &mut AppState,
receiver: &mut Option<broadcast::Receiver<FirmwareProgressEvent>>,
) {
let Some(rx) = receiver.as_mut() else {
return;
};
let mut events = Vec::new();
loop {
match rx.try_recv() {
Ok(evt) => events.push(evt),
Err(broadcast::error::TryRecvError::Empty) => break,
Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
Err(broadcast::error::TryRecvError::Closed) => {
*receiver = None;
break;
}
}
}
for evt in events {
let _ = reduce(state, AppEvent::UpdateProgress(evt));
}
}
fn key_to_event(state: &AppState, key: KeyCode) -> Option<AppEvent> {
match key {
KeyCode::Char('q') => Some(AppEvent::Quit),
KeyCode::Esc => Some(AppEvent::Back),
KeyCode::Enter => Some(AppEvent::ConfirmPrimary),
KeyCode::Down => match state.screen {
Screen::Dashboard => Some(AppEvent::SelectNextDevice),
Screen::Diagnostics => Some(AppEvent::DiagnosticsSelectNextCheck),
Screen::MappingEditor => Some(AppEvent::MappingMoveSelection(1)),
_ => Some(AppEvent::SelectNextAction),
},
KeyCode::Up => match state.screen {
Screen::Dashboard => Some(AppEvent::SelectPrevDevice),
Screen::Diagnostics => Some(AppEvent::DiagnosticsSelectPrevCheck),
Screen::MappingEditor => Some(AppEvent::MappingMoveSelection(-1)),
_ => Some(AppEvent::SelectPrevAction),
},
KeyCode::Left => match state.screen {
Screen::MappingEditor => Some(AppEvent::MappingAdjust(-1)),
Screen::Diagnostics => Some(AppEvent::SelectPrevAction),
_ => Some(AppEvent::SelectPrevAction),
},
KeyCode::Right => match state.screen {
Screen::MappingEditor => Some(AppEvent::MappingAdjust(1)),
Screen::Diagnostics => Some(AppEvent::SelectNextAction),
_ => Some(AppEvent::SelectNextAction),
},
KeyCode::Tab => {
if state.screen == Screen::Diagnostics {
Some(AppEvent::DiagnosticsShiftFilter(1))
} else {
None
}
}
KeyCode::BackTab => {
if state.screen == Screen::Diagnostics {
Some(AppEvent::DiagnosticsShiftFilter(-1))
} else {
None
}
}
KeyCode::Backspace => {
if state.screen == Screen::Dashboard {
Some(AppEvent::DeviceFilterBackspace)
} else {
None
}
}
KeyCode::Char('t') => {
if state.screen == Screen::Settings {
Some(AppEvent::ToggleAdvancedMode)
} else {
None
}
}
KeyCode::Char('r') => {
if state.screen == Screen::Settings {
Some(AppEvent::CycleReportSaveMode)
} else {
None
}
}
KeyCode::Char(ch) => {
if state.screen == Screen::Dashboard && !ch.is_control() {
Some(AppEvent::DeviceFilterInput(ch))
} else {
None
}
}
_ => None,
}
}
fn mouse_to_event(
state: &AppState,
hit_map: &layout::HitMap,
mouse: MouseEvent,
) -> Option<AppEvent> {
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => match hit_map.hit(mouse.column, mouse.row) {
Some(HitTarget::DeviceRow(idx)) => Some(AppEvent::SelectFilteredDevice(idx)),
Some(HitTarget::QuickAction(action)) => Some(AppEvent::TriggerAction(action)),
Some(HitTarget::FilterInput) => Some(AppEvent::SelectFilteredDevice(0)),
Some(HitTarget::DiagnosticsCheck(idx)) => Some(AppEvent::DiagnosticsSelectCheck(idx)),
Some(HitTarget::DiagnosticsFilter(filter)) => {
Some(AppEvent::DiagnosticsSetFilter(filter))
}
Some(HitTarget::ToggleAdvancedMode) => Some(AppEvent::ToggleAdvancedMode),
Some(HitTarget::CycleReportMode) => Some(AppEvent::CycleReportSaveMode),
None => None,
},
MouseEventKind::ScrollDown => match state.screen {
Screen::Diagnostics => Some(AppEvent::DiagnosticsSelectNextCheck),
_ => Some(AppEvent::SelectNextDevice),
},
MouseEventKind::ScrollUp => match state.screen {
Screen::Diagnostics => Some(AppEvent::DiagnosticsSelectPrevCheck),
_ => Some(AppEvent::SelectPrevDevice),
},
_ => None,
}
}
fn init_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
use crossterm::event::EnableMouseCapture;
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
fn teardown_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
use crossterm::event::DisableMouseCapture;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}

View File

@@ -0,0 +1,2 @@
pub mod effect_executor;
pub mod r#loop;

View File

@@ -1,38 +0,0 @@
use crate::ReportSaveMode;
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Clone, Debug, Serialize, Deserialize)]
struct PersistedSettings {
#[serde(default = "default_settings_schema_version")]
schema_version: u32,
#[serde(default)]
advanced_mode: bool,
#[serde(default)]
report_save_mode: ReportSaveMode,
}
const fn default_settings_schema_version() -> u32 {
1
}
/// Persist beginner/advanced preferences in a small TOML config file.
pub(crate) fn persist_user_settings(
path: &Path,
advanced_mode: bool,
report_save_mode: ReportSaveMode,
) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = toml::to_string_pretty(&PersistedSettings {
schema_version: default_settings_schema_version(),
advanced_mode,
report_save_mode,
})
.map_err(|err| anyhow!("failed to serialize user settings: {err}"))?;
std::fs::write(path, body)?;
Ok(())
}

View File

@@ -0,0 +1,29 @@
---
source: crates/bitdo_tui/src/tests.rs
assertion_line: 196
expression: rendered
---
╭Session───────────────────────────────────────────────────────────────────────╮
│OpenBitDo Dashboard • 3 devices • reports fail-only • safe │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Search filter────────────────╮╭Device full──────────╮╭Actions Enter/click──╮
│Search active ││Ultimate2 2dc8:5209 ││› Refresh • scan │
╰──────────────────────────────╯│Support: supported ││ Diagnose • probe │
╭Controllers detected─────────╮│Protocol: Standard64 ││ Recommended Update │
│› 2dc8:5209 Ultimate2 ││Evidence: Confirmed ││ Edit Mapping • map│
│full • S64 • conf ││ ││ Settings • prefs │
│ 2dc8:6009 Ultimate ││Capabilities ││ Quit • exit │
│ro • S64 • infer ││• firmware ││ │
│ 2dc8:901a Candidate ││• profile rw │╰──────────────────────╯
│ro • unknown • untest ││• mode switch │╭Activity events──────╮
│ ││• JP108 mapping ││ │
│ ││• U2 slot + map ││ │
│ ││ ││ │
│ ││ ││ │
│ ││ ││ │
│ ││ ││ │
╰──────────────────────────────╯╰──────────────────────╯╰──────────────────────╯
╭Status────────────────────────────────────────────────────────────────────────╮
│Ready │
│Ultimate2 • click • arrows • Enter • Esc/q │
╰──────────────────────────────────────────────────────────────────────────────╯

View File

@@ -0,0 +1,35 @@
---
source: crates/bitdo_tui/src/tests.rs
assertion_line: 317
expression: rendered
---
╭Session───────────────────────────────────────────────────────────────────────────────────────────╮
│OpenBitDo Diagnostics • 3/5 passed • reports fail-only • safe │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Diagnostics summary──────────────────────────────────────────────────────────────────────────────╮
│3/5 passed • 2 issues • 2 experimental │
│Tier: full • Family: Standard64 • Transport: ready │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Filter All──╮╭Filter Issu╮╭Filter Exper╮╭Selected Check detail────────────────────────────────╮
│All ││Issues ││Experimental ││GetPid OK │
╰─────────────╯╰────────────╯╰─────────────╯│Severity: Ok │
╭Checks click a row───────────────────────╮│Confidence: Confirmed │
│› OK GetPid detected pid 0x5… ││Experimental: no │
│ OK GetMode mode 2 ││Error code: none │
│ OK GetSuperButton exp ok ││Response: Ok • attempts 1 │
│ WARN ReadProfile exp timeout while wa… ││IO: wrote 64B, read 64B │
│ ATTN Version invalid response… ││Validator: test:GetPid │
│ │╰──────────────────────────────────────────────────────╯
│ │╭Next Steps guidance──────────────────────────────────╮
│ ││Action: Return to the dashboard and choose Recomme… │
│ ││Summary: 3/5 checks passed. Experimental checks: 1… │
│ ││Saved report: not yet saved in this screen │
│ ││ │
│ ││ │
│ ││ │
╰──────────────────────────────────────────╯╰──────────────────────────────────────────────────────╯
╭Run Again──────────────────────╮╭Save Report────────────────────╮╭Back───────────────────────────╮
│Run Again ││Save Report ││Back │
│rerun safe-read probe ││write support report ││return to dashboard │
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯

View File

@@ -0,0 +1,29 @@
---
source: crates/bitdo_tui/src/tests.rs
assertion_line: 327
expression: rendered
---
╭Session───────────────────────────────────────────────────────────────────────╮
│OpenBitDo Diagnostics • 3/5 passed • reports fail-only • safe │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Diagnostics summary──────────────────────────────────────────────────────────╮
│3/5 passed • 2 issues • 2 experimental │
│Tier: full • Family: Standard64 • Transport: ready │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Checks tab cycles filter─────────────────────────────────────────────────────╮
│All 5 Issues 2 Exp 2 │
│› OK GetPid detected pid 0x5209 │
│ OK GetMode mode 2 │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Selected Check detail────────────────────────────────────────────────────────╮
│GetPid OK │
│Severity: Ok │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Next Steps guidance──────────────────────────────────────────────────────────╮
│Action: Return to the dashboard and choose Recommended Update or Edit Mapp… │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Run Again───────────────╮╭Save Report──────────────╮╭Back────────────────────╮
│Run Again ││Save Report ││Back │
│rerun safe-read probe ││write support report ││return to dashboard │
╰────────────────────────╯╰─────────────────────────╯╰────────────────────────╯

View File

@@ -0,0 +1,35 @@
---
source: crates/bitdo_tui/src/tests.rs
assertion_line: 343
expression: rendered
---
╭Session───────────────────────────────────────────────────────────────────────────────────────────╮
│OpenBitDo Diagnostics • 3/5 passed • reports fail-only • safe │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Diagnostics summary──────────────────────────────────────────────────────────────────────────────╮
│3/5 passed • 2 issues • 2 experimental │
│Tier: full • Family: Standard64 • Transport: ready │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Filter All──╮╭Filter Issu╮╭Filter Exper╮╭Selected Check detail────────────────────────────────╮
│All ││Issues ││Experimental ││Version ATTN │
╰─────────────╯╰────────────╯╰─────────────╯│Severity: NeedsAttention │
╭Checks click a row───────────────────────╮│Confidence: Confirmed │
│ WARN ReadProfile exp timeout while wa… ││Experimental: no │
│› ATTN Version invalid response… ││Error code: InvalidResponse │
│ ││Response: Invalid • attempts 1 │
│ ││IO: wrote 64B, read 8B │
│ ││Validator: test:Version │
│ │╰──────────────────────────────────────────────────────╯
│ │╭Next Steps guidance──────────────────────────────────╮
│ ││Action: Return to the dashboard and choose Recomme… │
│ ││Summary: 3/5 checks passed. Experimental checks: 1… │
│ ││Saved report: /tmp/openbitdo-diag-report.toml │
│ ││ │
│ ││ │
│ ││ │
╰──────────────────────────────────────────╯╰──────────────────────────────────────────────────────╯
╭Run Again──────────────────────╮╭Save Report────────────────────╮╭Back───────────────────────────╮
│Run Again ││Save Report ││Back │
│rerun safe-read probe ││write support report ││return to dashboard │
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯

View File

@@ -0,0 +1,35 @@
---
source: crates/bitdo_tui/src/tests.rs
assertion_line: 233
expression: rendered
---
╭Session───────────────────────────────────────────────────────────────────────────────────────────╮
│OpenBitDo Mappings • draft modified • reports fail-only • safe │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Mapping Studio modified──────────────────────────────────────────────────────────────────────────╮
│Apply is explicit. Arrow keys adjust only the highlighted mapping. │
│ │
│JP108 dedicated mapping │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Mappings up/down select • left/right adjust────────────────╮╭Inspector selected mapping─────────╮
│› A -> 0x0005 ││Button: A │
│ ││Target HID: 0x0005 │
│ ││ │
│ ││Left/right cycles preset targets. │
│ ││ │
│ ││Ready │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰────────────────────────────────────────────────────────────╯╰────────────────────────────────────╯
╭Apply──────────────────────────╮╭Undo───────────────────────────╮╭Reset──────────────────────────╮
│Apply ││Undo ││Reset │
│write current draft ││restore last edit ││discard draft changes │
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯
╭Restore Backup─────────────────╮╭Firmware───────────────────────╮╭Back───────────────────────────╮
│Restore Backup ││Firmware ││Back │
│recover saved backup ││switch to firmware flow ││return to dashboard │
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯

View File

@@ -0,0 +1,29 @@
---
source: crates/bitdo_tui/src/tests.rs
assertion_line: 243
expression: rendered
---
╭Session───────────────────────────────────────────────────────────────────────╮
│OpenBitDo Recovery • write lock active • reports fail-only • safe │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Recovery safe rollback path──────────────────────────────────────────────────╮
│Recovery lock is active │
│ │
│Write operations stay blocked until the app restarts. │
│Restore a backup if one exists, validate the device, then restart. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Guidance recommended sequence────────────────────────────────────────────────╮
│1. Restore backup if available. │
│2. Confirm the controller responds normally. │
│3. Restart OpenBitDo before any further writes. │
│ │
│No backup is registered for this session. │
│Ready │
│ │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
╭Restore Backup──────────╮╭Back─────────────────────╮╭Quit────────────────────╮
│Restore Backup ││Back ││Quit │
│attempt rollback ││return to dashboard ││exit openbitdo │
╰────────────────────────╯╰─────────────────────────╯╰────────────────────────╯

View File

@@ -0,0 +1,35 @@
---
source: crates/bitdo_tui/src/tests.rs
assertion_line: 212
expression: rendered
---
╭Session───────────────────────────────────────────────────────────────────────────────────────────╮
│OpenBitDo Workflow • Ready • reports fail-only • safe │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Preflight status and intent──────────────────────────────────────────────────────────────────────╮
│Preflight Workflow preflight safety check │
│ │
│Ready to confirm transfer │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭Details workflow context───────────────────────────────╮╭Progress transfer state────────────────╮
│Ready to confirm transfer ││█████ │
│ ││█████ 12% │
│ │╰────────────────────────────────────────╯
│ │╭Context current session────────────────╮
│ ││Stage: preflight safety check │
│ ││Progress: 12% │
│ ││Reports: failure_only │
│ ││Ready │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰────────────────────────────────────────────────────────╯╰────────────────────────────────────────╯
╭Confirm────────────────────────╮╭Cancel─────────────────────────╮╭Back───────────────────────────╮
│Confirm ││Cancel ││Back │
│acknowledge risk + start ││stop this workflow ││return to dashboard │
╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯

View File

@@ -1,370 +1,654 @@
use super::*;
use crate::support_report::report_subject_token;
use bitdo_app_core::{FirmwareOutcome, OpenBitdoCoreConfig};
use bitdo_proto::SupportLevel;
#[test]
fn about_state_roundtrip_returns_home() {
let mut app = TuiApp::default();
app.refresh_devices(vec![AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "Test".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: Some("SERIAL1".to_owned()),
connected: true,
}]);
app.open_about();
assert_eq!(app.state, TuiWorkflowState::About);
app.close_overlay();
assert_eq!(app.state, TuiWorkflowState::Home);
}
#[test]
fn refresh_devices_without_any_device_enters_wait_state() {
let mut app = TuiApp::default();
app.refresh_devices(Vec::new());
assert_eq!(app.state, TuiWorkflowState::WaitForDevice);
assert!(app.selected.is_none());
}
#[test]
fn refresh_devices_autoselects_single_device() {
let mut app = TuiApp::default();
app.refresh_devices(vec![AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "One".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: None,
connected: true,
}]);
assert_eq!(app.state, TuiWorkflowState::Home);
assert_eq!(app.selected_index, 0);
assert_eq!(app.selected, Some(VidPid::new(0x2dc8, 0x6009)));
}
#[test]
fn serial_token_prefers_serial_then_vidpid() {
let with_serial = AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "S".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: Some("ABC 123".to_owned()),
connected: true,
};
assert_eq!(report_subject_token(Some(&with_serial)), "ABC_123");
let without_serial = AppDevice {
serial: None,
..with_serial
};
assert_eq!(report_subject_token(Some(&without_serial)), "2dc86009");
}
#[test]
fn launch_options_default_to_failure_only_reports() {
let opts = TuiLaunchOptions::default();
assert_eq!(opts.report_save_mode, ReportSaveMode::FailureOnly);
}
#[test]
fn blocked_panel_text_matches_support_tier() {
let mut app = TuiApp::default();
app.refresh_devices(vec![AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x2100),
name: "Candidate".to_owned(),
support_level: SupportLevel::DetectOnly,
support_tier: SupportTier::CandidateReadOnly,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability {
supports_mode: true,
supports_profile_rw: true,
supports_boot: false,
supports_firmware: false,
supports_jp108_dedicated_map: false,
supports_u2_slot_config: false,
supports_u2_button_map: false,
},
evidence: bitdo_proto::SupportEvidence::Inferred,
serial: None,
connected: true,
}]);
let selected = app.selected_device().expect("selected");
let text = blocked_action_panel_text(selected);
assert!(text.contains("blocked"));
assert!(text.contains("Status shown as Blocked"));
assert_eq!(beginner_status_label(selected), "Blocked");
}
#[test]
fn non_advanced_report_mode_skips_off_setting() {
let mut app = TuiApp {
advanced_mode: false,
..Default::default()
};
assert_eq!(app.report_save_mode, ReportSaveMode::FailureOnly);
app.cycle_report_save_mode().expect("cycle");
assert_eq!(app.report_save_mode, ReportSaveMode::Always);
app.cycle_report_save_mode().expect("cycle");
assert_eq!(app.report_save_mode, ReportSaveMode::FailureOnly);
}
#[test]
fn unknown_device_label_is_beginner_friendly() {
let device = AppDevice {
vid_pid: VidPid::new(0x2dc8, 0xabcd),
name: "PID_UNKNOWN".to_owned(),
support_level: SupportLevel::DetectOnly,
support_tier: SupportTier::DetectOnly,
protocol_family: bitdo_proto::ProtocolFamily::Unknown,
capability: bitdo_proto::PidCapability::identify_only(),
evidence: bitdo_proto::SupportEvidence::Untested,
serial: None,
connected: true,
};
let label = super::display_device_name(&device);
assert!(label.contains("Unknown 8BitDo Device"));
assert!(label.contains("2dc8:abcd"));
}
use crate::app::action::QuickAction;
use crate::app::event::AppEvent;
use crate::app::reducer::reduce;
use crate::app::state::{
AppState, DiagnosticsFilter, DiagnosticsState, MappingDraftState, Screen, TaskMode,
};
use crate::persistence::ui_state::{load_ui_state, persist_ui_state};
use crate::runtime::effect_executor::execute_effect;
use bitdo_app_core::{DedicatedButtonId, DedicatedButtonMapping, OpenBitdoCoreConfig};
use bitdo_proto::{
BitdoErrorCode, CommandId, DiagCommandStatus, DiagProbeResult, DiagSeverity,
EvidenceConfidence, ResponseStatus, SupportTier, VidPid,
};
use insta::assert_snapshot;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use std::collections::BTreeMap;
use std::path::PathBuf;
#[tokio::test]
async fn home_refresh_loads_devices() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
async fn quick_action_matrix_blocks_update_for_read_only() {
let core = bitdo_app_core::OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
let mut state = AppState::new(&UiLaunchOptions::default());
let devices = core.list_devices().await.expect("devices");
let _ = reduce(&mut state, AppEvent::DevicesLoaded(devices));
assert!(!app.devices.is_empty());
assert!(app.selected_device().is_some());
let update = state
.quick_actions
.iter()
.find(|a| a.action == QuickAction::RecommendedUpdate)
.expect("update action");
assert!(update.enabled);
let readonly_idx = state
.devices
.iter()
.position(|d| d.support_tier != SupportTier::Full)
.expect("readonly device");
state.selected_device_id = Some(state.devices[readonly_idx].vid_pid);
state.recompute_quick_actions();
let update = state
.quick_actions
.iter()
.find(|a| a.action == QuickAction::RecommendedUpdate)
.expect("update action");
assert!(!update.enabled);
}
#[test]
fn mapping_draft_undo_and_reset() {
let mut state = AppState::new(&UiLaunchOptions::default());
state.screen = Screen::MappingEditor;
state.mapping_draft_state = Some(MappingDraftState::Jp108 {
loaded: vec![DedicatedButtonMapping {
button: DedicatedButtonId::A,
target_hid_usage: 0x0004,
}],
current: vec![DedicatedButtonMapping {
button: DedicatedButtonId::A,
target_hid_usage: 0x0004,
}],
undo_stack: Vec::new(),
selected_row: 0,
});
let _ = reduce(&mut state, AppEvent::MappingAdjust(1));
assert!(state.mapping_has_changes());
let _ = reduce(&mut state, AppEvent::TriggerAction(QuickAction::UndoDraft));
assert!(!state.mapping_has_changes());
let _ = reduce(&mut state, AppEvent::MappingAdjust(1));
assert!(state.mapping_has_changes());
let _ = reduce(&mut state, AppEvent::TriggerAction(QuickAction::ResetDraft));
assert!(!state.mapping_has_changes());
}
#[test]
fn settings_schema_v2_roundtrip() {
let path = std::env::temp_dir().join("bitdo-tui-ui-state-v2.toml");
persist_ui_state(
&path,
true,
ReportSaveMode::Always,
"ultimate".to_owned(),
DashboardLayoutMode::Compact,
PanelFocus::QuickActions,
)
.expect("persist");
let loaded = load_ui_state(&path).expect("load");
assert_eq!(loaded.schema_version, 2);
assert!(loaded.advanced_mode);
assert_eq!(loaded.report_save_mode, ReportSaveMode::Always);
assert_eq!(loaded.device_filter_text, "ultimate");
assert_eq!(loaded.dashboard_layout_mode, DashboardLayoutMode::Compact);
assert_eq!(loaded.last_panel_focus, PanelFocus::QuickActions);
let _ = std::fs::remove_file(path);
}
#[test]
fn invalid_ui_state_returns_error() {
let path = std::env::temp_dir().join("bitdo-tui-invalid-ui-state.toml");
std::fs::write(&path, "advanced_mode = [").expect("write invalid");
let err = load_ui_state(&path).expect_err("invalid ui state must error");
assert!(err.to_string().contains("failed to parse ui state"));
let _ = std::fs::remove_file(path);
}
#[test]
fn launch_defaults_are_safe() {
let ui = UiLaunchOptions::default();
assert!(!ui.allow_unsafe);
assert!(!ui.brick_risk_ack);
let headless = RunLaunchOptions::default();
assert!(!headless.allow_unsafe);
assert!(!headless.brick_risk_ack);
assert!(!headless.acknowledged_risk);
}
#[tokio::test]
async fn run_tui_app_no_ui_blocks_detect_only_pid() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
async fn integration_refresh_select_preflight_cancel_path() {
let core = bitdo_app_core::OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
let result = run_tui_app(
core,
TuiLaunchOptions {
no_ui: true,
selected_vid_pid: Some(VidPid::new(0x2dc8, 0x2100)),
..Default::default()
},
let mut state = AppState::new(&UiLaunchOptions::default());
drive(&core, &mut state, AppEvent::Init).await;
assert!(!state.devices.is_empty());
let full_support_index = state
.devices
.iter()
.position(|device| device.support_tier == SupportTier::Full)
.expect("full-support device");
drive(
&core,
&mut state,
AppEvent::SelectFilteredDevice(full_support_index),
)
.await;
drive(
&core,
&mut state,
AppEvent::TriggerAction(QuickAction::RecommendedUpdate),
)
.await;
assert!(result.is_err());
assert_eq!(state.screen, Screen::Task);
assert!(state.task_state.is_some());
let downloaded_path = state
.task_state
.as_ref()
.and_then(|task| task.downloaded_firmware_path.clone())
.expect("downloaded firmware path");
assert!(downloaded_path.exists());
drive(
&core,
&mut state,
AppEvent::TriggerAction(QuickAction::Cancel),
)
.await;
assert_eq!(state.screen, Screen::Dashboard);
assert!(!downloaded_path.exists());
}
#[tokio::test]
async fn run_tui_app_no_ui_full_support_completes() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
async fn integration_diagnostics_run_rerun_save_and_back() {
let core = bitdo_app_core::OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut state = AppState::new(&UiLaunchOptions::default());
drive(&core, &mut state, AppEvent::Init).await;
drive(&core, &mut state, AppEvent::SelectFilteredDevice(0)).await;
drive(
&core,
&mut state,
AppEvent::TriggerAction(QuickAction::Diagnose),
)
.await;
assert_eq!(state.screen, Screen::Diagnostics);
assert!(state.diagnostics_state.is_some());
assert!(state.task_state.is_none());
drive(
&core,
&mut state,
AppEvent::TriggerAction(QuickAction::RunAgain),
)
.await;
assert_eq!(state.screen, Screen::Diagnostics);
assert!(state.diagnostics_state.is_some());
drive(
&core,
&mut state,
AppEvent::TriggerAction(QuickAction::SaveReport),
)
.await;
let saved_path = state
.diagnostics_state
.as_ref()
.and_then(|diagnostics| diagnostics.latest_report_path.clone())
.expect("diagnostics report path");
assert!(saved_path.exists());
drive(
&core,
&mut state,
AppEvent::TriggerAction(QuickAction::Back),
)
.await;
assert_eq!(state.screen, Screen::Dashboard);
let _ = std::fs::remove_file(saved_path);
}
#[test]
fn diagnostics_filter_changes_visible_rows() {
let mut state = snapshot_state();
state.screen = Screen::Diagnostics;
state.diagnostics_state = Some(sample_diagnostics_state(None));
state.recompute_quick_actions();
assert_eq!(state.diagnostics_filtered_indices(), vec![0, 1, 2, 3, 4]);
let _ = reduce(
&mut state,
AppEvent::DiagnosticsSetFilter(DiagnosticsFilter::Issues),
);
assert_eq!(state.diagnostics_filtered_indices(), vec![3, 4]);
assert_eq!(
state
.selected_diagnostics_check()
.map(|check| check.command),
Some(CommandId::ReadProfile)
);
let _ = reduce(
&mut state,
AppEvent::DiagnosticsSetFilter(DiagnosticsFilter::Experimental),
);
assert_eq!(state.diagnostics_filtered_indices(), vec![2, 3]);
assert_eq!(
state
.selected_diagnostics_check()
.map(|check| check.command),
Some(CommandId::ReadProfile)
);
}
#[tokio::test]
async fn manual_save_report_updates_diagnostics_state() {
let core = bitdo_app_core::OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut state = snapshot_state();
state.screen = Screen::Diagnostics;
state.diagnostics_state = Some(sample_diagnostics_state(None));
state.recompute_quick_actions();
drive(
&core,
&mut state,
AppEvent::TriggerAction(QuickAction::SaveReport),
)
.await;
let saved_path = state
.diagnostics_state
.as_ref()
.and_then(|diagnostics| diagnostics.latest_report_path.clone())
.expect("saved diagnostics report path");
assert!(saved_path.exists());
let _ = std::fs::remove_file(saved_path);
}
#[test]
fn recovery_transition_is_preserved() {
let mut state = AppState::new(&UiLaunchOptions::default());
let _ = reduce(
&mut state,
AppEvent::MappingApplied {
backup_id: None,
message: "rollback failed".to_owned(),
recovery_lock: true,
},
);
assert_eq!(state.screen, Screen::Recovery);
assert!(state.write_lock_until_restart);
}
#[tokio::test]
async fn headless_human_and_json_modes_complete() {
let core = bitdo_app_core::OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
run_tui_app(
core,
TuiLaunchOptions {
no_ui: true,
selected_vid_pid: Some(VidPid::new(0x2dc8, 0x6009)),
let report_human = run_headless(
core.clone(),
RunLaunchOptions {
vid_pid: VidPid::new(0x2dc8, 0x6009),
use_recommended: true,
allow_unsafe: true,
brick_risk_ack: true,
acknowledged_risk: true,
output_mode: HeadlessOutputMode::Human,
emit_events: false,
..Default::default()
},
)
.await
.expect("run app");
}
.expect("human mode");
assert_eq!(
report_human.status,
bitdo_app_core::FirmwareOutcome::Completed
);
#[tokio::test]
async fn tui_flow_with_manual_path_completes() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
default_chunk_size: 16,
progress_interval_ms: 1,
..Default::default()
});
let path = std::env::temp_dir().join("openbitdo-tui-flow.bin");
tokio::fs::write(&path, vec![1u8; 128])
.await
.expect("write");
let report = run_tui_flow(
let report_json = run_headless(
core,
TuiRunRequest {
RunLaunchOptions {
vid_pid: VidPid::new(0x2dc8, 0x6009),
firmware_path: path.clone(),
use_recommended: true,
allow_unsafe: true,
brick_risk_ack: true,
experimental: true,
chunk_size: Some(32),
acknowledged_risk: true,
no_ui: true,
output_mode: HeadlessOutputMode::Json,
emit_events: true,
..Default::default()
},
)
.await
.expect("run tui flow");
assert_eq!(report.status, FirmwareOutcome::Completed);
let _ = tokio::fs::remove_file(path).await;
.expect("json mode");
assert_eq!(
report_json.status,
bitdo_app_core::FirmwareOutcome::Completed
);
}
#[tokio::test]
async fn support_report_is_toml_file() {
let device = AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "Test".to_owned(),
support_level: SupportLevel::Full,
support_tier: SupportTier::Full,
#[test]
fn snapshot_dashboard_80x24() {
let mut state = snapshot_state();
state.dashboard_layout_mode = DashboardLayoutMode::Wide;
let rendered = render_state(&mut state, 80, 24);
assert_snapshot!(rendered);
}
#[test]
fn snapshot_task_screen_100x30() {
let mut state = snapshot_state();
state.screen = Screen::Task;
state.task_state = Some(crate::app::state::TaskState {
mode: TaskMode::Preflight,
plan: None,
progress: 12,
status: "Ready to confirm transfer".to_owned(),
final_report: None,
downloaded_firmware_path: None,
});
state.recompute_quick_actions();
let rendered = render_state(&mut state, 100, 30);
assert_snapshot!(rendered);
}
#[test]
fn snapshot_diagnostics_screen_100x30() {
let mut state = snapshot_state();
state.screen = Screen::Diagnostics;
state.diagnostics_state = Some(sample_diagnostics_state(None));
state.recompute_quick_actions();
let rendered = render_state(&mut state, 100, 30);
assert_snapshot!(rendered);
}
#[test]
fn snapshot_diagnostics_screen_80x24() {
let mut state = snapshot_state();
state.screen = Screen::Diagnostics;
state.diagnostics_state = Some(sample_diagnostics_state(None));
state.recompute_quick_actions();
let rendered = render_state(&mut state, 80, 24);
assert_snapshot!(rendered);
}
#[test]
fn snapshot_diagnostics_screen_with_saved_report() {
let mut state = snapshot_state();
state.screen = Screen::Diagnostics;
state.diagnostics_state = Some(sample_diagnostics_state(Some(PathBuf::from(
"/tmp/openbitdo-diag-report.toml",
))));
if let Some(diagnostics) = state.diagnostics_state.as_mut() {
diagnostics.active_filter = DiagnosticsFilter::Issues;
diagnostics.selected_check_index = 4;
}
state.recompute_quick_actions();
let rendered = render_state(&mut state, 100, 30);
assert_snapshot!(rendered);
}
#[test]
fn snapshot_mapping_editor_screen() {
let mut state = snapshot_state();
state.screen = Screen::MappingEditor;
state.mapping_draft_state = Some(MappingDraftState::Jp108 {
loaded: vec![DedicatedButtonMapping {
button: DedicatedButtonId::A,
target_hid_usage: 0x0004,
}],
current: vec![DedicatedButtonMapping {
button: DedicatedButtonId::A,
target_hid_usage: 0x0005,
}],
undo_stack: Vec::new(),
selected_row: 0,
});
state.recompute_quick_actions();
let rendered = render_state(&mut state, 100, 30);
assert_snapshot!(rendered);
}
#[test]
fn snapshot_recovery_screen() {
let mut state = snapshot_state();
state.screen = Screen::Recovery;
state.write_lock_until_restart = true;
state.recompute_quick_actions();
let rendered = render_state(&mut state, 80, 24);
assert_snapshot!(rendered);
}
async fn drive(core: &bitdo_app_core::OpenBitdoCore, state: &mut AppState, initial: AppEvent) {
let mut queue = std::collections::VecDeque::from([initial]);
while let Some(event) = queue.pop_front() {
let effects = reduce(state, event);
for effect in effects {
let emitted = execute_effect(core, state, effect).await;
for next in emitted {
queue.push_back(next);
}
}
}
}
fn snapshot_state() -> AppState {
let mut state = AppState::new(&UiLaunchOptions::default());
let _ = reduce(
&mut state,
AppEvent::DevicesLoaded(vec![
bitdo_app_core::AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x5209),
name: "Ultimate2".to_owned(),
support_level: bitdo_proto::SupportLevel::Full,
support_tier: bitdo_proto::SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: None,
connected: true,
},
bitdo_app_core::AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x6009),
name: "Ultimate".to_owned(),
support_level: bitdo_proto::SupportLevel::DetectOnly,
support_tier: bitdo_proto::SupportTier::CandidateReadOnly,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::identify_only(),
evidence: bitdo_proto::SupportEvidence::Inferred,
serial: None,
connected: true,
},
bitdo_app_core::AppDevice {
vid_pid: VidPid::new(0x2dc8, 0x901a),
name: "Candidate".to_owned(),
support_level: bitdo_proto::SupportLevel::DetectOnly,
support_tier: bitdo_proto::SupportTier::CandidateReadOnly,
protocol_family: bitdo_proto::ProtocolFamily::Unknown,
capability: bitdo_proto::PidCapability::identify_only(),
evidence: bitdo_proto::SupportEvidence::Untested,
serial: None,
connected: true,
},
]),
);
state.event_log.clear();
state.status_line = "Ready".to_owned();
state
}
fn sample_diagnostics_state(report_path: Option<PathBuf>) -> DiagnosticsState {
DiagnosticsState {
result: sample_diagnostics_result(),
summary: "3/5 checks passed. Experimental checks: 1/2 passed. Issues: 2 total, 1 need attention. Transport ready: yes. Standard64 diagnostics are available. This device is full-support.".to_owned(),
selected_check_index: 0,
active_filter: DiagnosticsFilter::All,
latest_report_path: report_path,
}
}
fn sample_diagnostics_result() -> DiagProbeResult {
DiagProbeResult {
target: VidPid::new(0x2dc8, 0x5209),
profile_name: "Ultimate2".to_owned(),
support_level: bitdo_proto::SupportLevel::Full,
support_tier: bitdo_proto::SupportTier::Full,
protocol_family: bitdo_proto::ProtocolFamily::Standard64,
capability: bitdo_proto::PidCapability::full(),
evidence: bitdo_proto::SupportEvidence::Confirmed,
serial: Some("RPT-1".to_owned()),
connected: true,
};
let report_path = persist_support_report(
"diag-probe",
Some(&device),
"ok",
"all checks passed".to_owned(),
None,
None,
)
.await
.expect("report path");
assert_eq!(
report_path.extension().and_then(|s| s.to_str()),
Some("toml")
);
let _ = tokio::fs::remove_file(report_path).await;
transport_ready: true,
command_checks: vec![
diag_check(
CommandId::GetPid,
DiagCheckFixture {
ok: true,
confidence: EvidenceConfidence::Confirmed,
is_experimental: false,
severity: DiagSeverity::Ok,
error_code: None,
detail: "detected pid 0x5209",
parsed_facts: [("detected_pid", 0x5209)].into_iter().collect(),
},
),
diag_check(
CommandId::GetMode,
DiagCheckFixture {
ok: true,
confidence: EvidenceConfidence::Confirmed,
is_experimental: false,
severity: DiagSeverity::Ok,
error_code: None,
detail: "mode 2",
parsed_facts: [("mode", 2)].into_iter().collect(),
},
),
diag_check(
CommandId::GetSuperButton,
DiagCheckFixture {
ok: true,
confidence: EvidenceConfidence::Inferred,
is_experimental: true,
severity: DiagSeverity::Ok,
error_code: None,
detail: "ok",
parsed_facts: BTreeMap::new(),
},
),
diag_check(
CommandId::ReadProfile,
DiagCheckFixture {
ok: false,
confidence: EvidenceConfidence::Inferred,
is_experimental: true,
severity: DiagSeverity::Warning,
error_code: Some(BitdoErrorCode::Timeout),
detail: "timeout while waiting for device response",
parsed_facts: BTreeMap::new(),
},
),
diag_check(
CommandId::Version,
DiagCheckFixture {
ok: false,
confidence: EvidenceConfidence::Confirmed,
is_experimental: false,
severity: DiagSeverity::NeedsAttention,
error_code: Some(BitdoErrorCode::InvalidResponse),
detail: "invalid response for Version: response signature mismatch",
parsed_facts: [("version_x100", 4200), ("beta", 0)].into_iter().collect(),
},
),
],
}
}
#[tokio::test]
async fn update_action_enters_jp108_wizard_for_jp108_device() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
let jp108_idx = app
.devices
.iter()
.position(|d| d.vid_pid.pid == 0x5209)
.expect("jp108 fixture");
app.select_index(jp108_idx);
app.state = TuiWorkflowState::Home;
let mut terminal = None;
let mut events = None;
let opts = TuiLaunchOptions::default();
execute_home_action(
&core,
&mut terminal,
&mut app,
&opts,
&mut events,
HomeAction::Update,
)
.await
.expect("execute");
assert_eq!(app.state, TuiWorkflowState::Jp108Mapping);
assert!(!app.jp108_mappings.is_empty());
struct DiagCheckFixture<'a> {
ok: bool,
confidence: EvidenceConfidence,
is_experimental: bool,
severity: DiagSeverity,
error_code: Option<BitdoErrorCode>,
detail: &'a str,
parsed_facts: BTreeMap<&'a str, u32>,
}
#[tokio::test]
async fn update_action_enters_u2_wizard_for_ultimate2_device() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
let u2_idx = app
.devices
.iter()
.position(|d| d.vid_pid.pid == 0x6012)
.expect("u2 fixture");
app.select_index(u2_idx);
app.state = TuiWorkflowState::Home;
let mut terminal = None;
let mut events = None;
let opts = TuiLaunchOptions::default();
execute_home_action(
&core,
&mut terminal,
&mut app,
&opts,
&mut events,
HomeAction::Update,
)
.await
.expect("execute");
assert_eq!(app.state, TuiWorkflowState::U2CoreProfile);
assert!(app.u2_profile.is_some());
fn diag_check(command: CommandId, fixture: DiagCheckFixture<'_>) -> DiagCommandStatus {
DiagCommandStatus {
command,
ok: fixture.ok,
confidence: fixture.confidence,
is_experimental: fixture.is_experimental,
severity: fixture.severity,
attempts: 1,
validator: format!("test:{command:?}"),
response_status: if fixture.ok {
ResponseStatus::Ok
} else {
ResponseStatus::Invalid
},
bytes_written: 64,
bytes_read: if fixture.ok { 64 } else { 8 },
error_code: fixture.error_code,
detail: fixture.detail.to_owned(),
parsed_facts: fixture
.parsed_facts
.into_iter()
.map(|(key, value)| (key.to_owned(), value))
.collect(),
}
}
#[tokio::test]
async fn device_flow_backup_apply_sets_backup_id() {
let core = OpenBitdoCore::new(OpenBitdoCoreConfig {
mock_mode: true,
..Default::default()
});
let mut app = TuiApp::default();
app.refresh_devices(core.list_devices().await.expect("devices"));
let jp108_idx = app
.devices
.iter()
.position(|d| d.vid_pid.pid == 0x5209)
.expect("jp108 fixture");
app.select_index(jp108_idx);
app.begin_jp108_mapping(
core.jp108_read_dedicated_mapping(VidPid::new(0x2dc8, 0x5209))
.await
.expect("read"),
);
fn render_state(state: &mut AppState, width: u16, height: u16) -> String {
state.set_layout_from_size(width, height);
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| {
let _ = crate::ui::layout::render(frame, state);
})
.expect("draw");
let mut terminal = None;
let mut events = None;
let opts = TuiLaunchOptions::default();
execute_device_flow_action(
&core,
&mut terminal,
&mut app,
&opts,
&mut events,
DeviceFlowAction::BackupApply,
)
.await
.expect("apply");
let backend = terminal.backend();
let buffer = backend.buffer();
let mut lines = Vec::new();
for y in 0..height {
let mut line = String::new();
for x in 0..width {
line.push_str(buffer[(x, y)].symbol());
}
lines.push(line.trim_end().to_owned());
}
assert!(app.latest_backup.is_some());
lines.join("\n")
}

View File

@@ -0,0 +1,283 @@
use crate::app::action::QuickAction;
use crate::app::state::{AppState, DiagnosticsFilter, Screen};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use ratatui::Frame;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::screens::{dashboard, diagnostics, mapping_editor, recovery, settings, task};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HitTarget {
DeviceRow(usize),
QuickAction(QuickAction),
FilterInput,
DiagnosticsCheck(usize),
DiagnosticsFilter(DiagnosticsFilter),
ToggleAdvancedMode,
CycleReportMode,
}
#[derive(Clone, Copy, Debug)]
pub struct HitRegion {
pub rect: Rect,
pub target: HitTarget,
}
#[derive(Clone, Debug, Default)]
pub struct HitMap {
pub regions: Vec<HitRegion>,
}
#[derive(Clone, Debug)]
pub struct ActionDescriptor {
pub action: QuickAction,
pub label: String,
pub caption: String,
pub enabled: bool,
pub active: bool,
}
impl HitMap {
pub fn push(&mut self, rect: Rect, target: HitTarget) {
self.regions.push(HitRegion { rect, target });
}
pub fn extend(&mut self, regions: Vec<HitRegion>) {
self.regions.extend(regions);
}
pub fn hit(&self, x: u16, y: u16) -> Option<HitTarget> {
self.regions
.iter()
.find(|region| point_in_rect(x, y, region.rect))
.map(|region| region.target)
}
}
pub fn render(frame: &mut Frame<'_>, state: &AppState) -> HitMap {
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(frame.area());
render_header(frame, root[0], state);
match state.screen {
Screen::Dashboard => dashboard::render(frame, state, root[1]),
Screen::Task => task::render(frame, state, root[1]),
Screen::Diagnostics => diagnostics::render(frame, state, root[1]),
Screen::MappingEditor => mapping_editor::render(frame, state, root[1]),
Screen::Recovery => recovery::render(frame, state, root[1]),
Screen::Settings => settings::render(frame, state, root[1]),
}
}
pub fn render_action_strip(
frame: &mut Frame<'_>,
area: Rect,
actions: &[ActionDescriptor],
) -> Vec<HitRegion> {
if actions.is_empty() {
return Vec::new();
}
let columns = action_columns(area.width, actions.len());
let rows = actions.len().div_ceil(columns);
let row_constraints = vec![Constraint::Length(4); rows];
let row_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(row_constraints)
.split(area);
let mut regions = Vec::new();
for (row_idx, row_rect) in row_chunks.iter().copied().enumerate() {
let start = row_idx * columns;
let end = (start + columns).min(actions.len());
let row_actions = &actions[start..end];
let constraints = vec![
Constraint::Percentage((100 / row_actions.len()).max(1) as u16);
row_actions.len()
];
let col_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(row_rect);
for (descriptor, rect) in row_actions.iter().zip(col_chunks.iter().copied()) {
let label_width = rect.width.saturating_sub(4) as usize;
let style = crate::ui::theme::action_label_style(descriptor.active, descriptor.enabled);
let border = crate::ui::theme::border_style(descriptor.active, descriptor.enabled);
let label = truncate_to_width(&descriptor.label, label_width);
let caption = truncate_to_width(&descriptor.caption, label_width);
let body = Paragraph::new(vec![
Line::from(Span::styled(label, style)),
Line::from(Span::styled(
caption,
crate::ui::theme::action_caption_style(descriptor.enabled),
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(border)
.title(descriptor.action.label()),
);
frame.render_widget(body, rect);
regions.push(HitRegion {
rect,
target: HitTarget::QuickAction(descriptor.action),
});
}
}
regions
}
pub fn action_grid_height(width: u16, count: usize) -> u16 {
if count == 0 {
0
} else {
let columns = action_columns(width, count);
let rows = count.div_ceil(columns);
(rows as u16).saturating_mul(4)
}
}
pub fn panel_block<'a>(title: &'a str, subtitle: Option<&'a str>, active: bool) -> Block<'a> {
let mut title_spans = vec![Span::styled(title, crate::ui::theme::title_style())];
if let Some(subtitle) = subtitle.filter(|subtitle| !subtitle.is_empty()) {
title_spans.push(Span::styled(" ", crate::ui::theme::subtle_style()));
title_spans.push(Span::styled(subtitle, crate::ui::theme::subtle_style()));
}
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(crate::ui::theme::border_style(active, true))
.title(Line::from(title_spans))
}
pub fn inner_rect(rect: Rect, horizontal: u16, vertical: u16) -> Rect {
let x = rect.x.saturating_add(horizontal);
let y = rect.y.saturating_add(vertical);
let width = rect.width.saturating_sub(horizontal.saturating_mul(2));
let height = rect.height.saturating_sub(vertical.saturating_mul(2));
Rect::new(x, y, width, height)
}
fn render_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) {
let filtered = state.filtered_device_indices().len();
let mode = if state.advanced_mode { "adv" } else { "safe" };
let summary = match state.screen {
Screen::Dashboard => format!("{filtered} devices"),
Screen::Task => truncate_to_width(&state.status_line, 20),
Screen::Diagnostics => state
.diagnostics_state
.as_ref()
.map(|diagnostics| {
let passed = diagnostics
.result
.command_checks
.iter()
.filter(|check| check.ok)
.count();
format!(
"{passed}/{} passed",
diagnostics.result.command_checks.len()
)
})
.unwrap_or_else(|| "diagnostics".to_owned()),
Screen::MappingEditor => {
if state.mapping_has_changes() {
"draft modified".to_owned()
} else {
"draft clean".to_owned()
}
}
Screen::Recovery => "write lock active".to_owned(),
Screen::Settings => "preferences".to_owned(),
};
let line = Line::from(vec![
Span::styled("OpenBitDo", crate::ui::theme::app_title_style()),
Span::raw(" "),
Span::styled(
screen_label(state.screen),
crate::ui::theme::screen_title_style(),
),
Span::raw(""),
Span::styled(summary, crate::ui::theme::subtle_style()),
Span::raw(""),
Span::styled(
format!("reports {}", report_mode_short(state.report_save_mode)),
crate::ui::theme::subtle_style(),
),
Span::raw(""),
Span::styled(mode, crate::ui::theme::subtle_style()),
]);
let header = Paragraph::new(line).block(panel_block("Session", None, true));
frame.render_widget(header, area);
}
fn action_columns(width: u16, count: usize) -> usize {
let desired = if width >= 110 {
4
} else if width >= 76 {
3
} else if width >= 48 {
2
} else {
1
};
desired.min(count.max(1))
}
fn screen_label(screen: Screen) -> &'static str {
match screen {
Screen::Dashboard => "Dashboard",
Screen::Task => "Workflow",
Screen::Diagnostics => "Diagnostics",
Screen::MappingEditor => "Mappings",
Screen::Recovery => "Recovery",
Screen::Settings => "Settings",
}
}
fn report_mode_short(mode: crate::ReportSaveMode) -> &'static str {
match mode {
crate::ReportSaveMode::Off => "off",
crate::ReportSaveMode::Always => "always",
crate::ReportSaveMode::FailureOnly => "fail-only",
}
}
pub fn point_in_rect(x: u16, y: u16, rect: Rect) -> bool {
x >= rect.x
&& y >= rect.y
&& x < rect.x.saturating_add(rect.width)
&& y < rect.y.saturating_add(rect.height)
}
pub fn truncate_to_width(input: &str, max_width: usize) -> String {
if UnicodeWidthStr::width(input) <= max_width {
return input.to_owned();
}
let mut out = String::new();
let mut width = 0usize;
for ch in input.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if width + ch_width >= max_width.saturating_sub(1) {
break;
}
out.push(ch);
width += ch_width;
}
out.push('…');
out
}

View File

@@ -0,0 +1,3 @@
pub mod layout;
pub mod screens;
pub mod theme;

View File

@@ -0,0 +1,411 @@
use crate::app::state::{AppState, DashboardLayoutMode, PanelFocus};
use crate::ui::layout::{inner_rect, panel_block, truncate_to_width, HitMap, HitRegion, HitTarget};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph};
use ratatui::Frame;
pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap {
let mut map = HitMap::default();
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(10), Constraint::Length(4)])
.split(area);
let status_hint = match state.dashboard_layout_mode {
DashboardLayoutMode::Compact => "compact layout • resize for full three-panel view",
DashboardLayoutMode::Wide => "click • arrows • Enter • Esc/q",
};
let selected_summary = state
.selected_device()
.map(|device| device.name.clone())
.unwrap_or_else(|| "No controller selected".to_owned());
let status = Paragraph::new(vec![
Line::from(Span::raw(state.status_line.clone())),
Line::from(vec![
Span::styled(selected_summary, crate::ui::theme::subtle_style()),
Span::raw(""),
Span::styled(status_hint, crate::ui::theme::subtle_style()),
]),
])
.block(panel_block("Status", None, false));
frame.render_widget(status, root[1]);
match state.dashboard_layout_mode {
DashboardLayoutMode::Wide => render_wide(frame, state, root[0], &mut map),
DashboardLayoutMode::Compact => render_compact(frame, state, root[0], &mut map),
}
map
}
fn render_wide(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(40),
Constraint::Percentage(30),
Constraint::Percentage(30),
])
.split(area);
render_devices(frame, state, columns[0], map);
render_selected_device(frame, state, columns[1]);
render_sidebar(frame, state, columns[2], map);
}
fn render_compact(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(9), Constraint::Min(6)])
.split(area);
let top = if area.width < 60 {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(rows[0])
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(rows[0])
};
render_devices(frame, state, top[0], map);
render_selected_device(frame, state, top[1]);
render_sidebar(frame, state, rows[1], map);
}
fn render_devices(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(5)])
.split(area);
let filter_label = if state.last_panel_focus == PanelFocus::Devices {
format!("{} active", state.device_filter)
} else {
state.device_filter.clone()
};
let filter = Paragraph::new(Line::from(vec![
Span::styled("Search ", crate::ui::theme::title_style()),
Span::raw(if filter_label.is_empty() {
"type a model, VID, or PID".to_owned()
} else {
filter_label
}),
]))
.block(panel_block(
"Search",
Some("filter"),
state.last_panel_focus == PanelFocus::Devices,
));
frame.render_widget(filter, chunks[0]);
map.push(chunks[0], HitTarget::FilterInput);
let filtered = state.filtered_device_indices();
let mut rows = Vec::new();
if filtered.is_empty() {
rows.push(ListItem::new(vec![
Line::from("No matching devices"),
Line::from(Span::styled(
"Try a broader search or refresh the device list",
crate::ui::theme::subtle_style(),
)),
]));
} else {
for (display_idx, device_idx) in filtered.iter().copied().enumerate() {
let dev = &state.devices[device_idx];
let selected = state
.selected_device_id
.map(|id| id == dev.vid_pid)
.unwrap_or(false)
|| display_idx == state.selected_filtered_index;
let prefix = if selected { "" } else { " " };
let title = format!(
"{prefix} {:04x}:{:04x} {}",
dev.vid_pid.vid,
dev.vid_pid.pid,
truncate_to_width(&dev.name, chunks[1].width.saturating_sub(18) as usize)
);
let detail = format!(
"{}{}{}",
support_tier_short(dev.support_tier),
protocol_short(dev.protocol_family),
evidence_short(dev.evidence)
);
let style = if selected {
crate::ui::theme::selected_row_style()
} else {
Style::default()
};
rows.push(
ListItem::new(vec![
Line::from(Span::styled(title, style)),
Line::from(Span::styled(detail, crate::ui::theme::subtle_style())),
])
.style(style),
);
}
}
let list = List::new(rows).block(panel_block("Controllers", Some("detected"), true));
frame.render_widget(list, chunks[1]);
map.extend(device_regions(
chunks[1],
state.filtered_device_indices().len(),
));
}
fn render_selected_device(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let selected = state.selected_device();
let lines = if let Some(device) = selected {
let mut details = vec![
Line::from(vec![
Span::styled(device.name.clone(), crate::ui::theme::screen_title_style()),
Span::raw(" "),
Span::styled(
format!("{:04x}:{:04x}", device.vid_pid.vid, device.vid_pid.pid),
crate::ui::theme::subtle_style(),
),
]),
Line::from(format!(
"Support: {}",
support_tier_label(device.support_tier)
)),
Line::from(format!("Protocol: {:?}", device.protocol_family)),
Line::from(format!("Evidence: {:?}", device.evidence)),
Line::from(""),
Line::from("Capabilities"),
];
for capability in capability_lines(device) {
details.push(Line::from(Span::styled(
capability,
crate::ui::theme::subtle_style(),
)));
}
if device.support_tier != bitdo_proto::SupportTier::Full {
details.push(Line::from(""));
details.push(Line::from(Span::styled(
"Write actions stay blocked until hardware confirmation lands.",
crate::ui::theme::warning_style(),
)));
}
details
} else {
vec![
Line::from("No controller selected"),
Line::from(""),
Line::from(Span::styled(
"Refresh the dashboard or connect a device to continue.",
crate::ui::theme::subtle_style(),
)),
]
};
let subtitle = selected
.map(|device| support_tier_short(device.support_tier))
.unwrap_or("idle");
let panel = Paragraph::new(lines).block(panel_block("Device", Some(subtitle), true));
frame.render_widget(panel, area);
}
fn render_sidebar(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(9), Constraint::Min(5)])
.split(area);
render_actions(frame, state, rows[0], map);
render_events(frame, state, rows[1]);
}
fn render_actions(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
let lines = state
.quick_actions
.iter()
.enumerate()
.map(|(idx, quick)| {
let prefix = if idx == state.selected_action_index {
""
} else {
" "
};
let caption = quick
.reason
.as_deref()
.map(compact_reason)
.unwrap_or_else(|| action_caption(quick.action).to_owned());
let style = if idx == state.selected_action_index {
crate::ui::theme::selected_row_style()
} else if quick.enabled {
Style::default()
} else {
crate::ui::theme::muted_style()
};
ListItem::new(Line::from(vec![
Span::styled(format!("{prefix} {}", quick.action.label()), style),
Span::raw(""),
Span::styled(caption, crate::ui::theme::subtle_style()),
]))
})
.collect::<Vec<_>>();
let panel = List::new(lines).block(panel_block("Actions", Some("Enter/click"), true));
frame.render_widget(panel, area);
let inner = inner_rect(area, 1, 1);
let visible = inner.height as usize;
for idx in 0..state.quick_actions.len().min(visible) {
map.push(
Rect::new(inner.x, inner.y + idx as u16, inner.width, 1),
HitTarget::QuickAction(state.quick_actions[idx].action),
);
}
}
fn render_events(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let visible = area.height.saturating_sub(2) as usize;
let entries = state
.event_log
.iter()
.rev()
.take(visible)
.rev()
.map(|entry| {
let prefix = entry.timestamp_utc.to_string();
let color = crate::ui::theme::level_color(entry.level);
let level = match entry.level {
crate::app::state::EventLevel::Info => "info",
crate::app::state::EventLevel::Warning => "warn",
crate::app::state::EventLevel::Error => "error",
};
ListItem::new(Line::from(vec![
Span::styled(prefix, Style::default().fg(color)),
Span::raw(" "),
Span::styled(format!("[{level}]"), Style::default().fg(color)),
Span::raw(" "),
Span::raw(truncate_to_width(
&entry.message,
area.width.saturating_sub(20) as usize,
)),
]))
})
.collect::<Vec<_>>();
let widget = List::new(entries).block(panel_block("Activity", Some("events"), true));
frame.render_widget(widget, area);
}
fn device_regions(list_rect: Rect, total_rows: usize) -> Vec<HitRegion> {
let visible_rows = list_rect.height.saturating_sub(2) as usize / 2;
let max = total_rows.min(visible_rows);
let mut out = Vec::with_capacity(max);
for idx in 0..max {
let rect = Rect::new(
list_rect.x.saturating_add(1),
list_rect
.y
.saturating_add(1 + (idx as u16).saturating_mul(2)),
list_rect.width.saturating_sub(2),
2,
);
out.push(HitRegion {
rect,
target: HitTarget::DeviceRow(idx),
});
}
out
}
fn truncate_reason(reason: &str) -> String {
truncate_to_width(reason, 24)
}
fn action_caption(action: crate::app::action::QuickAction) -> &'static str {
match action {
crate::app::action::QuickAction::Refresh => "scan",
crate::app::action::QuickAction::Diagnose => "probe",
crate::app::action::QuickAction::RecommendedUpdate => "safe update",
crate::app::action::QuickAction::EditMappings => "mapping",
crate::app::action::QuickAction::Settings => "prefs",
crate::app::action::QuickAction::Quit => "exit",
_ => "available",
}
}
fn support_tier_label(tier: bitdo_proto::SupportTier) -> &'static str {
match tier {
bitdo_proto::SupportTier::Full => "supported",
bitdo_proto::SupportTier::CandidateReadOnly => "read-only",
bitdo_proto::SupportTier::DetectOnly => "detect-only",
}
}
fn support_tier_short(tier: bitdo_proto::SupportTier) -> &'static str {
match tier {
bitdo_proto::SupportTier::Full => "full",
bitdo_proto::SupportTier::CandidateReadOnly => "ro",
bitdo_proto::SupportTier::DetectOnly => "detect",
}
}
fn capability_lines(device: &bitdo_app_core::AppDevice) -> Vec<String> {
let mut lines = Vec::new();
if device.capability.supports_firmware {
lines.push("• firmware".to_owned());
}
if device.capability.supports_profile_rw {
lines.push("• profile rw".to_owned());
}
if device.capability.supports_mode {
lines.push("• mode switch".to_owned());
}
if device.capability.supports_jp108_dedicated_map {
lines.push("• JP108 mapping".to_owned());
}
if device.capability.supports_u2_button_map || device.capability.supports_u2_slot_config {
lines.push("• U2 slot + map".to_owned());
}
if lines.is_empty() {
lines.push("• detect only".to_owned());
}
lines
}
fn compact_reason(reason: &str) -> String {
if reason.contains("Read-only") {
"read-only".to_owned()
} else if reason.contains("restart") {
"restart required".to_owned()
} else {
truncate_reason(reason)
}
}
fn protocol_short(protocol: bitdo_proto::ProtocolFamily) -> &'static str {
match protocol {
bitdo_proto::ProtocolFamily::Standard64 => "S64",
bitdo_proto::ProtocolFamily::Unknown => "unknown",
_ => "other",
}
}
fn evidence_short(evidence: bitdo_proto::SupportEvidence) -> &'static str {
match evidence {
bitdo_proto::SupportEvidence::Confirmed => "conf",
bitdo_proto::SupportEvidence::Inferred => "infer",
bitdo_proto::SupportEvidence::Untested => "untest",
}
}

View File

@@ -0,0 +1,543 @@
use crate::app::action::QuickAction;
use crate::app::state::{AppState, DiagnosticsFilter};
use crate::ui::layout::{
action_grid_height, inner_rect, panel_block, render_action_strip, truncate_to_width,
ActionDescriptor, HitMap, HitTarget,
};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph};
use ratatui::Frame;
pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap {
let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Min(9),
Constraint::Length(action_height),
])
.split(area);
render_summary(frame, state, rows[0]);
let body = if rows[1].width >= 92 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(44), Constraint::Percentage(56)])
.split(rows[1])
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(rows[1])
};
let mut map = HitMap::default();
render_check_panel(frame, state, body[0], &mut map);
let detail_sections = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
.split(body[1]);
render_selected_check(frame, state, detail_sections[0]);
render_next_steps(frame, state, detail_sections[1]);
let actions = state
.quick_actions
.iter()
.enumerate()
.map(|(idx, action)| ActionDescriptor {
action: action.action,
label: action.action.label().to_owned(),
caption: diagnostics_action_caption(action.action).to_owned(),
enabled: action.enabled,
active: idx == state.selected_action_index,
})
.collect::<Vec<_>>();
map.extend(render_action_strip(frame, rows[2], &actions));
map
}
fn render_summary(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let Some(diagnostics) = state.diagnostics_state.as_ref() else {
let empty = Paragraph::new("No diagnostics result loaded.").block(panel_block(
"Diagnostics",
Some("summary"),
true,
));
frame.render_widget(empty, area);
return;
};
let total = diagnostics.result.command_checks.len();
let passed = diagnostics
.result
.command_checks
.iter()
.filter(|check| check.ok)
.count();
let issues = diagnostics
.result
.command_checks
.iter()
.filter(|check| !check.ok || check.severity != bitdo_proto::DiagSeverity::Ok)
.count();
let experimental = diagnostics
.result
.command_checks
.iter()
.filter(|check| check.is_experimental)
.count();
let transport = if diagnostics.result.transport_ready {
"ready"
} else {
"degraded"
};
let lines = vec![
Line::from(vec![
Span::styled(
format!("{passed}/{total} passed"),
crate::ui::theme::screen_title_style(),
),
Span::raw(""),
Span::styled(format!("{issues} issues"), severity_style(issues > 0)),
Span::raw(""),
Span::styled(
format!("{experimental} experimental"),
crate::ui::theme::subtle_style(),
),
]),
Line::from(vec![
Span::styled(
format!(
"Tier: {}",
support_tier_label(diagnostics.result.support_tier)
),
crate::ui::theme::subtle_style(),
),
Span::raw(""),
Span::styled(
format!("Family: {:?}", diagnostics.result.protocol_family),
crate::ui::theme::subtle_style(),
),
Span::raw(""),
Span::styled(
format!("Transport: {transport}"),
crate::ui::theme::subtle_style(),
),
]),
];
let panel = Paragraph::new(lines).block(panel_block("Diagnostics", Some("summary"), true));
frame.render_widget(panel, area);
}
fn render_check_panel(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
if area.height < 8 {
render_compact_check_panel(frame, state, area, map);
return;
}
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(5)])
.split(area);
render_filter_row(frame, state, sections[0], map);
let filtered = state.diagnostics_filtered_indices();
let items = filtered
.iter()
.map(|check_index| {
let check = &state
.diagnostics_state
.as_ref()
.expect("diagnostics state present")
.result
.command_checks[*check_index];
let selected = state
.diagnostics_state
.as_ref()
.map(|diagnostics| diagnostics.selected_check_index == *check_index)
.unwrap_or(false);
let marker = if selected { "" } else { " " };
let experimental = if check.is_experimental { " exp" } else { "" };
let line = format!(
"{marker} {} {:?}{experimental} {}",
severity_badge(check.severity),
check.command,
truncate_to_width(&check.detail, sections[1].width.saturating_sub(26) as usize)
);
let style = if selected {
crate::ui::theme::selected_row_style()
} else {
severity_row_style(check.severity)
};
ListItem::new(line).style(style)
})
.collect::<Vec<_>>();
let list = if items.is_empty() {
List::new(vec![ListItem::new("No checks in this filter")])
} else {
List::new(items)
}
.block(panel_block("Checks", Some("click a row"), true));
frame.render_widget(list, sections[1]);
let list_inner = inner_rect(sections[1], 1, 1);
let visible_rows = list_inner.height as usize;
for filtered_index in 0..filtered.len().min(visible_rows) {
map.push(
Rect::new(
list_inner.x,
list_inner.y + filtered_index as u16,
list_inner.width,
1,
),
HitTarget::DiagnosticsCheck(filtered_index),
);
}
}
fn render_compact_check_panel(
frame: &mut Frame<'_>,
state: &AppState,
area: Rect,
map: &mut HitMap,
) {
let diagnostics = state
.diagnostics_state
.as_ref()
.expect("diagnostics state present");
let filtered = state.diagnostics_filtered_indices();
let inner = inner_rect(area, 1, 1);
let filter_segments = compact_filter_segments(diagnostics);
let filter_line = Line::from(
filter_segments
.iter()
.enumerate()
.map(|(idx, (filter, label))| {
let prefix = if idx == 0 { "" } else { " " };
Span::styled(
format!("{prefix}{label}"),
if diagnostics.active_filter == *filter {
crate::ui::theme::screen_title_style()
} else {
crate::ui::theme::subtle_style()
},
)
})
.collect::<Vec<_>>(),
);
let mut lines = vec![filter_line];
let visible_rows = inner.height.saturating_sub(1) as usize;
if filtered.is_empty() {
lines.push(Line::from("No checks in this filter"));
} else {
for check_index in filtered.iter().take(visible_rows) {
let check = &diagnostics.result.command_checks[*check_index];
let marker = if diagnostics.selected_check_index == *check_index {
""
} else {
" "
};
let experimental = if check.is_experimental { " exp" } else { "" };
lines.push(Line::from(Span::styled(
format!(
"{marker} {} {:?}{experimental} {}",
severity_badge(check.severity),
check.command,
truncate_to_width(&check.detail, inner.width.saturating_sub(24) as usize,)
),
if diagnostics.selected_check_index == *check_index {
crate::ui::theme::selected_row_style()
} else {
severity_row_style(check.severity)
},
)));
}
}
let panel = Paragraph::new(lines).block(panel_block("Checks", Some("tab cycles filter"), true));
frame.render_widget(panel, area);
let mut x = inner.x;
for (idx, (filter, label)) in filter_segments.iter().enumerate() {
let text = if idx == 0 {
label.clone()
} else {
format!(" {label}")
};
let width = (text.len() as u16).min(inner.width.saturating_sub(x.saturating_sub(inner.x)));
if width == 0 {
break;
}
map.push(
Rect::new(x, inner.y, width, 1),
HitTarget::DiagnosticsFilter(*filter),
);
x = x.saturating_add(text.len() as u16);
}
for (row, _) in filtered.iter().take(visible_rows).enumerate() {
map.push(
Rect::new(
inner.x,
inner.y.saturating_add(row as u16).saturating_add(1),
inner.width,
1,
),
HitTarget::DiagnosticsCheck(row),
);
}
}
fn render_filter_row(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) {
let diagnostics = state
.diagnostics_state
.as_ref()
.expect("diagnostics state present");
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(34),
Constraint::Percentage(33),
Constraint::Percentage(33),
])
.split(area);
for (filter, rect) in DiagnosticsFilter::ALL
.into_iter()
.zip(chunks.iter().copied())
{
let active = diagnostics.active_filter == filter;
let count = diagnostics
.result
.command_checks
.iter()
.filter(|check| filter.matches(check))
.count();
let chip = Paragraph::new(vec![
Line::from(Span::styled(
filter.label(),
if active {
crate::ui::theme::screen_title_style()
} else {
Style::default()
},
)),
Line::from(Span::styled(
format!("{count} checks"),
crate::ui::theme::subtle_style(),
)),
])
.block(panel_block("Filter", Some(filter.label()), active));
frame.render_widget(chip, rect);
map.push(inner_rect(rect, 1, 1), HitTarget::DiagnosticsFilter(filter));
}
}
fn render_selected_check(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let lines = if let Some(check) = state.selected_diagnostics_check() {
let mut lines = vec![
Line::from(vec![
Span::styled(
format!("{:?}", check.command),
crate::ui::theme::screen_title_style(),
),
Span::raw(" "),
Span::styled(
severity_badge(check.severity),
severity_row_style(check.severity),
),
]),
Line::from(format!("Severity: {:?}", check.severity)),
Line::from(format!("Confidence: {:?}", check.confidence)),
Line::from(format!(
"Experimental: {}",
if check.is_experimental { "yes" } else { "no" }
)),
Line::from(format!(
"Error code: {}",
check
.error_code
.map(|code| format!("{code:?}"))
.unwrap_or_else(|| "none".to_owned())
)),
Line::from(format!(
"Response: {:?} • attempts {}",
check.response_status, check.attempts
)),
Line::from(format!(
"IO: wrote {}B, read {}B",
check.bytes_written, check.bytes_read
)),
Line::from(format!(
"Validator: {}",
truncate_to_width(&check.validator, area.width.saturating_sub(13) as usize)
)),
Line::from(""),
Line::from(Span::styled("Detail", crate::ui::theme::title_style())),
Line::from(check.detail.clone()),
];
if !check.parsed_facts.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Parsed facts",
crate::ui::theme::title_style(),
)));
for (key, value) in &check.parsed_facts {
lines.push(Line::from(format!("{key}: {value}")));
}
}
lines
} else {
vec![
Line::from("No diagnostics check selected."),
Line::from(""),
Line::from("Change filters or run diagnostics again."),
]
};
let detail = Paragraph::new(lines).block(panel_block("Selected Check", Some("detail"), true));
frame.render_widget(detail, area);
}
fn render_next_steps(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let Some(diagnostics) = state.diagnostics_state.as_ref() else {
let empty = Paragraph::new("No diagnostics guidance available.").block(panel_block(
"Next Steps",
Some("guidance"),
true,
));
frame.render_widget(empty, area);
return;
};
let report_line = diagnostics
.latest_report_path
.as_ref()
.map(|path| format!("Saved report: {}", path.display()))
.unwrap_or_else(|| "Saved report: not yet saved in this screen".to_owned());
let content_width = area.width.saturating_sub(4) as usize;
let action_line = format!(
"Action: {}",
truncate_to_width(
recommended_next_action(diagnostics),
content_width.saturating_sub(8)
)
);
let summary_line = format!(
"Summary: {}",
truncate_to_width(&diagnostics.summary, content_width.saturating_sub(9))
);
let report_line = truncate_to_width(&report_line, content_width);
let inner_height = area.height.saturating_sub(2);
let lines = match inner_height {
0 => Vec::new(),
1 => vec![Line::from(if diagnostics.latest_report_path.is_some() {
report_line.clone()
} else {
action_line.clone()
})],
2 => vec![
Line::from(action_line),
Line::from(Span::styled(report_line, crate::ui::theme::subtle_style())),
],
_ => vec![
Line::from(action_line),
Line::from(summary_line),
Line::from(Span::styled(report_line, crate::ui::theme::subtle_style())),
],
};
let panel = Paragraph::new(lines).block(panel_block("Next Steps", Some("guidance"), true));
frame.render_widget(panel, area);
}
fn diagnostics_action_caption(action: QuickAction) -> &'static str {
match action {
QuickAction::RunAgain => "rerun safe-read probe",
QuickAction::SaveReport => "write support report",
QuickAction::Back => "return to dashboard",
_ => "available",
}
}
fn recommended_next_action(diagnostics: &crate::app::state::DiagnosticsState) -> &'static str {
match diagnostics.result.support_tier {
bitdo_proto::SupportTier::Full => {
"Return to the dashboard and choose Recommended Update or Edit Mapping if needed."
}
bitdo_proto::SupportTier::CandidateReadOnly => {
"Save or share the report. Update and mapping remain blocked until confirmation lands."
}
bitdo_proto::SupportTier::DetectOnly => {
"Diagnostics only. Do not attempt update or mapping for this device."
}
}
}
fn severity_badge(severity: bitdo_proto::DiagSeverity) -> &'static str {
match severity {
bitdo_proto::DiagSeverity::Ok => "OK",
bitdo_proto::DiagSeverity::Warning => "WARN",
bitdo_proto::DiagSeverity::NeedsAttention => "ATTN",
}
}
fn severity_row_style(severity: bitdo_proto::DiagSeverity) -> Style {
match severity {
bitdo_proto::DiagSeverity::Ok => Style::default().fg(Color::White),
bitdo_proto::DiagSeverity::Warning => crate::ui::theme::warning_style(),
bitdo_proto::DiagSeverity::NeedsAttention => crate::ui::theme::danger_style(),
}
}
fn severity_style(has_issues: bool) -> Style {
if has_issues {
crate::ui::theme::warning_style()
} else {
crate::ui::theme::positive_style()
}
}
fn support_tier_label(tier: bitdo_proto::SupportTier) -> &'static str {
match tier {
bitdo_proto::SupportTier::Full => "full",
bitdo_proto::SupportTier::CandidateReadOnly => "candidate-readonly",
bitdo_proto::SupportTier::DetectOnly => "detect-only",
}
}
fn compact_filter_segments(
diagnostics: &crate::app::state::DiagnosticsState,
) -> Vec<(DiagnosticsFilter, String)> {
DiagnosticsFilter::ALL
.into_iter()
.map(|filter| {
let count = diagnostics
.result
.command_checks
.iter()
.filter(|check| filter.matches(check))
.count();
let label = match filter {
DiagnosticsFilter::All => format!("All {count}"),
DiagnosticsFilter::Issues => format!("Issues {count}"),
DiagnosticsFilter::Experimental => format!("Exp {count}"),
};
(filter, label)
})
.collect()
}

View File

@@ -0,0 +1,213 @@
use crate::app::state::{AppState, MappingDraftState};
use crate::ui::layout::{
action_grid_height, panel_block, render_action_strip, ActionDescriptor, HitMap,
};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph};
use ratatui::Frame;
pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap {
let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Min(8),
Constraint::Length(action_height),
])
.split(area);
let mut lines = vec![
Line::from(Span::styled(
"Apply is explicit. Arrow keys adjust only the highlighted mapping.",
crate::ui::theme::subtle_style(),
)),
Line::from(""),
];
let mut mapping_rows = Vec::new();
let mut inspector_lines = Vec::new();
match state.mapping_draft_state.as_ref() {
Some(MappingDraftState::Jp108 {
current,
selected_row,
..
}) => {
lines.push(Line::from(Span::styled(
"JP108 dedicated mapping",
crate::ui::theme::screen_title_style(),
)));
for (idx, entry) in current.iter().enumerate() {
let style = if idx == *selected_row {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let marker = if idx == *selected_row { "" } else { " " };
mapping_rows.push(
ListItem::new(format!(
"{marker} {:?} -> 0x{:04x}",
entry.button, entry.target_hid_usage
))
.style(style),
);
}
if let Some(selected) = current.get(*selected_row) {
inspector_lines.push(Line::from(format!("Button: {:?}", selected.button)));
inspector_lines.push(Line::from(format!(
"Target HID: 0x{:04x}",
selected.target_hid_usage
)));
inspector_lines.push(Line::from(""));
inspector_lines.push(Line::from("Left/right cycles preset targets."));
}
}
Some(MappingDraftState::Ultimate2 {
current,
selected_row,
..
}) => {
lines.push(Line::from(Span::styled(
format!(
"Ultimate2 profile slot {:?} mode {}",
current.slot, current.mode
),
crate::ui::theme::screen_title_style(),
)));
lines.push(Line::from(Span::styled(
format!("L2 {:.2} | R2 {:.2}", current.l2_analog, current.r2_analog),
crate::ui::theme::subtle_style(),
)));
for (idx, entry) in current.mappings.iter().enumerate() {
let style = if idx == *selected_row {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let marker = if idx == *selected_row { "" } else { " " };
mapping_rows.push(
ListItem::new(format!(
"{marker} {:?} -> {} (0x{:04x})",
entry.button,
u2_target_label(entry.target_hid_usage),
entry.target_hid_usage
))
.style(style),
);
}
if let Some(selected) = current.mappings.get(*selected_row) {
inspector_lines.push(Line::from(format!("Button: {:?}", selected.button)));
inspector_lines.push(Line::from(format!(
"Target: {} (0x{:04x})",
u2_target_label(selected.target_hid_usage),
selected.target_hid_usage
)));
inspector_lines.push(Line::from(""));
inspector_lines.push(Line::from("Left/right cycles preset targets."));
}
}
None => {
lines.push(Line::from("No mapping draft loaded."));
inspector_lines.push(Line::from(
"Select Edit Mapping from the dashboard to begin.",
));
}
}
let intro = Paragraph::new(lines).block(panel_block(
"Mapping Studio",
Some(if state.mapping_has_changes() {
"modified"
} else {
"clean"
}),
true,
));
frame.render_widget(intro, rows[0]);
let body = if rows[1].width >= 80 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(62), Constraint::Percentage(38)])
.split(rows[1])
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(62), Constraint::Percentage(38)])
.split(rows[1])
};
let table = List::new(mapping_rows).block(panel_block(
"Mappings",
Some("up/down select • left/right adjust"),
true,
));
frame.render_widget(table, body[0]);
let mut status_lines = inspector_lines;
status_lines.push(Line::from(""));
status_lines.push(Line::from(Span::styled(
state.status_line.clone(),
crate::ui::theme::subtle_style(),
)));
let status = Paragraph::new(status_lines).block(panel_block(
"Inspector",
Some("selected mapping"),
true,
));
frame.render_widget(status, body[1]);
let actions = state
.quick_actions
.iter()
.enumerate()
.map(|(idx, action)| ActionDescriptor {
action: action.action,
label: action.action.label().to_owned(),
caption: mapping_action_caption(action.action).to_owned(),
enabled: action.enabled,
active: idx == state.selected_action_index,
})
.collect::<Vec<_>>();
let mut map = HitMap::default();
map.extend(render_action_strip(frame, rows[2], &actions));
map
}
fn mapping_action_caption(action: crate::app::action::QuickAction) -> &'static str {
match action {
crate::app::action::QuickAction::ApplyDraft => "write current draft",
crate::app::action::QuickAction::UndoDraft => "restore last edit",
crate::app::action::QuickAction::ResetDraft => "discard draft changes",
crate::app::action::QuickAction::RestoreBackup => "recover saved backup",
crate::app::action::QuickAction::Firmware => "switch to firmware flow",
crate::app::action::QuickAction::Back => "return to dashboard",
_ => "available",
}
}
fn u2_target_label(target: u16) -> &'static str {
match target {
0x0100 => "A",
0x0101 => "B",
0x0102 => "X",
0x0103 => "Y",
0x0104 => "L1",
0x0105 => "R1",
0x0106 => "L2",
0x0107 => "R2",
0x0108 => "L3",
0x0109 => "R3",
0x010a => "Select",
0x010b => "Start",
0x010c => "Home",
0x010d => "DPadUp",
0x010e => "DPadDown",
0x010f => "DPadLeft",
0x0110 => "DPadRight",
_ => "Unknown",
}
}

View File

@@ -0,0 +1,6 @@
pub mod dashboard;
pub mod diagnostics;
pub mod mapping_editor;
pub mod recovery;
pub mod settings;
pub mod task;

View File

@@ -0,0 +1,76 @@
use crate::app::state::AppState;
use crate::ui::layout::{
action_grid_height, panel_block, render_action_strip, ActionDescriptor, HitMap,
};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap {
let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6),
Constraint::Min(6),
Constraint::Length(action_height),
])
.split(area);
let body = Paragraph::new(vec![
Line::from(Span::styled(
"Recovery lock is active",
crate::ui::theme::danger_style(),
)),
Line::from(""),
Line::from("Write operations stay blocked until the app restarts."),
Line::from("Restore a backup if one exists, validate the device, then restart."),
])
.block(panel_block("Recovery", Some("safe rollback path"), true));
frame.render_widget(body, rows[0]);
let backup_line = if state.latest_backup.is_some() {
"Backup detected. Restore Backup is available."
} else {
"No backup is registered for this session."
};
let detail = Paragraph::new(vec![
Line::from("1. Restore backup if available."),
Line::from("2. Confirm the controller responds normally."),
Line::from("3. Restart OpenBitDo before any further writes."),
Line::from(""),
Line::from(Span::styled(backup_line, crate::ui::theme::subtle_style())),
Line::from(Span::styled(
state.status_line.clone(),
crate::ui::theme::subtle_style(),
)),
])
.block(panel_block("Guidance", Some("recommended sequence"), true));
frame.render_widget(detail, rows[1]);
let actions = state
.quick_actions
.iter()
.enumerate()
.map(|(idx, action)| ActionDescriptor {
action: action.action,
label: action.action.label().to_owned(),
caption: recovery_action_caption(action.action).to_owned(),
enabled: action.enabled,
active: idx == state.selected_action_index,
})
.collect::<Vec<_>>();
let mut map = HitMap::default();
map.extend(render_action_strip(frame, rows[2], &actions));
map
}
fn recovery_action_caption(action: crate::app::action::QuickAction) -> &'static str {
match action {
crate::app::action::QuickAction::RestoreBackup => "attempt rollback",
crate::app::action::QuickAction::Back => "return to dashboard",
crate::app::action::QuickAction::Quit => "exit openbitdo",
_ => "available",
}
}

View File

@@ -0,0 +1,101 @@
use crate::app::state::AppState;
use crate::ui::layout::{
action_grid_height, inner_rect, panel_block, render_action_strip, ActionDescriptor, HitMap,
HitTarget,
};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap {
let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Length(5),
Constraint::Min(5),
Constraint::Length(action_height),
Constraint::Min(1),
])
.split(area);
let adv = Paragraph::new(vec![
Line::from(Span::styled(
if state.advanced_mode {
"Advanced mode is on"
} else {
"Advanced mode is off"
},
crate::ui::theme::screen_title_style(),
)),
Line::from(Span::styled(
"Toggle to expose expert-only report and workflow options.",
crate::ui::theme::subtle_style(),
)),
])
.block(panel_block("Advanced", Some("press t or click"), true));
frame.render_widget(adv, rows[0]);
let report = Paragraph::new(vec![
Line::from(Span::styled(
format!("Support reports: {}", state.report_save_mode.as_str()),
crate::ui::theme::screen_title_style(),
)),
Line::from(Span::styled(
"Cycle report persistence policy with r or mouse.",
crate::ui::theme::subtle_style(),
)),
])
.block(panel_block("Reports", Some("press r or click"), true));
frame.render_widget(report, rows[1]);
let settings_path = state
.settings_path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "not configured".to_owned());
let status = Paragraph::new(vec![
Line::from(state.status_line.as_str()),
Line::from(""),
Line::from(format!("Config path: {settings_path}")),
Line::from(Span::styled(
"Dashboard layout and filter state persist when a settings path is configured.",
crate::ui::theme::subtle_style(),
)),
])
.block(panel_block("Status", Some("saved preferences"), true));
frame.render_widget(status, rows[2]);
let actions = state
.quick_actions
.iter()
.enumerate()
.map(|(idx, action)| ActionDescriptor {
action: action.action,
label: action.action.label().to_owned(),
caption: settings_action_caption(action.action).to_owned(),
enabled: action.enabled,
active: idx == state.selected_action_index,
})
.collect::<Vec<_>>();
let mut map = HitMap::default();
map.push(inner_click_rect(rows[0]), HitTarget::ToggleAdvancedMode);
map.push(inner_click_rect(rows[1]), HitTarget::CycleReportMode);
map.extend(render_action_strip(frame, rows[3], &actions));
map
}
fn inner_click_rect(rect: Rect) -> Rect {
inner_rect(rect, 1, 1)
}
fn settings_action_caption(action: crate::app::action::QuickAction) -> &'static str {
match action {
crate::app::action::QuickAction::Back => "return to dashboard",
crate::app::action::QuickAction::Quit => "exit openbitdo",
_ => "available",
}
}

View File

@@ -0,0 +1,180 @@
use crate::app::state::{AppState, TaskMode};
use crate::ui::layout::{
action_grid_height, panel_block, render_action_strip, ActionDescriptor, HitMap,
};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Gauge, Paragraph};
use ratatui::Frame;
pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap {
let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6),
Constraint::Min(8),
Constraint::Length(action_height),
])
.split(area);
let task = state.task_state.as_ref();
let title = match task.map(|t| t.mode) {
Some(TaskMode::Diagnostics) => "Diagnostics",
Some(TaskMode::Preflight) => "Preflight",
Some(TaskMode::Updating) => "Updating",
Some(TaskMode::Final) => "Result",
None => "Task",
};
let summary_lines = if let Some(task) = task {
vec![
Line::from(vec![
Span::styled(
format!("{title} Workflow"),
crate::ui::theme::screen_title_style(),
),
Span::raw(" "),
Span::styled(
task_mode_caption(task.mode),
crate::ui::theme::subtle_style(),
),
]),
Line::from(""),
Line::from(task.status.clone()),
]
} else {
vec![
Line::from(Span::styled(
"No active workflow",
crate::ui::theme::screen_title_style(),
)),
Line::from(""),
Line::from("Select a device action from the dashboard to begin."),
]
};
let summary =
Paragraph::new(summary_lines).block(panel_block(title, Some("status and intent"), true));
frame.render_widget(summary, rows[0]);
render_task_details(frame, state, rows[1]);
let mut map = HitMap::default();
let action_rows = state
.quick_actions
.iter()
.enumerate()
.map(|(idx, action)| ActionDescriptor {
action: action.action,
label: action.action.label().to_owned(),
caption: task_action_caption(action.action).to_owned(),
enabled: action.enabled,
active: idx == state.selected_action_index,
})
.collect::<Vec<_>>();
map.extend(render_action_strip(frame, rows[2], &action_rows));
map
}
fn render_task_details(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
let task = state.task_state.as_ref();
let columns = if area.width >= 76 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
.split(area)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(area)
};
let detail_lines = if let Some(task) = task {
let mut lines = vec![Line::from(task.status.clone())];
if let Some(plan) = task.plan.as_ref() {
lines.push(Line::from(""));
lines.push(Line::from(format!("Session: {:?}", plan.session_id)));
lines.push(Line::from(format!("Chunk size: {} bytes", plan.chunk_size)));
lines.push(Line::from(format!("Chunks: {}", plan.chunks_total)));
lines.push(Line::from(format!("Estimated: {}s", plan.expected_seconds)));
if !plan.warnings.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Warnings",
crate::ui::theme::warning_style(),
)));
for warning in &plan.warnings {
lines.push(Line::from(format!("{warning}")));
}
}
}
if let Some(final_report) = task.final_report.as_ref() {
lines.push(Line::from(""));
lines.push(Line::from(format!(
"Final status: {:?}",
final_report.status
)));
lines.push(Line::from(format!(
"Transfer: {}/{} chunks",
final_report.chunks_sent, final_report.chunks_total
)));
lines.push(Line::from(format!("Message: {}", final_report.message)));
}
lines
} else {
vec![Line::from("No details available")]
};
let detail =
Paragraph::new(detail_lines).block(panel_block("Details", Some("workflow context"), true));
frame.render_widget(detail, columns[0]);
let progress = task.map(|task| task.progress).unwrap_or_default();
let progress_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(4), Constraint::Min(4)])
.split(columns[1]);
let gauge = Gauge::default()
.block(panel_block("Progress", Some("transfer state"), true))
.gauge_style(Style::default().fg(ratatui::style::Color::Green))
.percent(progress as u16)
.label(format!("{progress}%"));
frame.render_widget(gauge, progress_rows[0]);
let summary_lines = if let Some(task) = task {
vec![
Line::from(format!("Stage: {}", task_mode_caption(task.mode))),
Line::from(format!("Progress: {progress}%")),
Line::from(format!("Reports: {}", state.report_save_mode.as_str())),
Line::from(Span::styled(
state.status_line.clone(),
crate::ui::theme::subtle_style(),
)),
]
} else {
vec![Line::from("Select an action to see task details.")]
};
let summary =
Paragraph::new(summary_lines).block(panel_block("Context", Some("current session"), true));
frame.render_widget(summary, progress_rows[1]);
}
fn task_mode_caption(mode: TaskMode) -> &'static str {
match mode {
TaskMode::Diagnostics => "diagnostic probe",
TaskMode::Preflight => "preflight safety check",
TaskMode::Updating => "firmware transfer",
TaskMode::Final => "final outcome",
}
}
fn task_action_caption(action: crate::app::action::QuickAction) -> &'static str {
match action {
crate::app::action::QuickAction::Confirm => "acknowledge risk + start",
crate::app::action::QuickAction::Cancel => "stop this workflow",
crate::app::action::QuickAction::Back => "return to dashboard",
_ => "available",
}
}

View File

@@ -0,0 +1,83 @@
use ratatui::style::{Color, Modifier, Style};
pub fn app_title_style() -> Style {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
}
pub fn screen_title_style() -> Style {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
}
pub fn title_style() -> Style {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
}
pub fn subtle_style() -> Style {
Style::default().fg(Color::Gray)
}
pub fn muted_style() -> Style {
Style::default().fg(Color::DarkGray)
}
pub fn positive_style() -> Style {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
}
pub fn warning_style() -> Style {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
}
pub fn danger_style() -> Style {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
}
pub fn selected_row_style() -> Style {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
}
pub fn action_label_style(active: bool, enabled: bool) -> Style {
match (active, enabled) {
(true, true) => Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
(false, true) => Style::default().fg(Color::White),
(_, false) => muted_style(),
}
}
pub fn action_caption_style(enabled: bool) -> Style {
if enabled {
subtle_style()
} else {
muted_style()
}
}
pub fn border_style(active: bool, enabled: bool) -> Style {
match (active, enabled) {
(true, true) => Style::default().fg(Color::Cyan),
(false, true) => Style::default().fg(Color::Gray),
(_, false) => Style::default().fg(Color::DarkGray),
}
}
pub fn level_color(level: crate::app::state::EventLevel) -> Color {
match level {
crate::app::state::EventLevel::Info => Color::White,
crate::app::state::EventLevel::Warning => Color::Yellow,
crate::app::state::EventLevel::Error => Color::Red,
}
}