use super::Protocol;
use std::time::{Duration, Instant};
pub const DISMISS_DURATION: Duration = Duration::from_secs(4);
pub const HELP_OVERLAY_MAX_HEIGHT: u16 = 38;
#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
pub enum FocusedPanel {
#[default]
Sidebar,
ConnectionDetails,
Chart,
Security,
Logs,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum AuthField {
Username,
Password,
SaveCheckbox,
}
#[derive(Clone, Debug, PartialEq, Default)]
pub enum InputMode {
#[default]
Normal,
Import {
path: String,
cursor: usize,
},
DependencyError {
protocol: Protocol,
missing: Vec<String>,
},
PermissionDenied {
action: String,
},
ConfirmDelete {
index: usize,
name: String,
confirm_selected: bool,
},
Help {
scroll: u16,
},
Rename {
index: usize,
new_name: String,
cursor: usize,
},
Search {
query: String,
cursor: usize,
},
ConfirmSwitch {
from: String,
to_idx: usize,
to_name: String,
confirm_selected: bool,
},
AuthPrompt {
profile_idx: usize,
profile_name: String,
username: String,
username_cursor: usize,
password: String,
password_cursor: usize,
focused_field: AuthField,
save_credentials: bool,
connect_after: bool,
},
}
#[must_use]
pub fn help_max_scroll_for_terminal_height(terminal_height: u16, total_lines: u16) -> u16 {
if terminal_height == 0 {
return 0;
}
let overlay_height = terminal_height
.saturating_sub(2)
.min(HELP_OVERLAY_MAX_HEIGHT);
let inner_height = overlay_height.saturating_sub(2);
total_lines.saturating_sub(inner_height)
}
pub struct FlipAnimation {
pub panel: FocusedPanel,
pub started: Instant,
pub to_back: bool,
}
impl FlipAnimation {
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn progress(&self) -> f64 {
let elapsed_us = self.started.elapsed().as_micros().min(u128::from(u64::MAX)) as f64;
let duration_us = (crate::constants::FLIP_ANIMATION_DURATION_MS * 1000) as f64;
(elapsed_us / duration_us).min(1.0)
}
#[must_use]
pub fn is_complete(&self) -> bool {
self.started.elapsed()
>= Duration::from_millis(crate::constants::FLIP_ANIMATION_DURATION_MS)
}
#[must_use]
pub fn width_ratio(&self) -> f64 {
let p = self.progress();
if p < 0.5 {
1.0 - (p * 2.0)
} else {
(p - 0.5) * 2.0
}
}
#[must_use]
pub fn past_midpoint(&self) -> bool {
self.progress() >= 0.5
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ProfileSortOrder {
#[default]
NameAsc,
NameDesc,
LastUsed,
Protocol,
}
impl ProfileSortOrder {
#[must_use]
pub fn next(self) -> Self {
match self {
Self::NameAsc => Self::NameDesc,
Self::NameDesc => Self::LastUsed,
Self::LastUsed => Self::Protocol,
Self::Protocol => Self::NameAsc,
}
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::NameAsc => "A→Z",
Self::NameDesc => "Z→A",
Self::LastUsed => "Recent",
Self::Protocol => "Proto",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ToastType {
#[default]
Info,
Success,
Warning,
Error,
}
#[derive(Clone)]
pub struct Toast {
pub message: String,
#[allow(clippy::struct_field_names)]
pub toast_type: ToastType,
pub expires: Instant,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum QualityLevel {
#[default]
Unknown,
Excellent,
Fair,
Poor,
}
impl QualityLevel {
#[must_use]
pub fn from_metrics(latency_ms: u64, packet_loss: f32, jitter_ms: u64) -> Self {
if latency_ms == 0 && packet_loss == 0.0 && jitter_ms == 0 {
return Self::Unknown;
}
if packet_loss >= 5.0 || jitter_ms >= 15 || latency_ms >= 300 {
Self::Poor
} else if packet_loss >= 1.0 || jitter_ms >= 5 || latency_ms >= 100 {
Self::Fair
} else {
Self::Excellent
}
}
}
impl Toast {
#[must_use]
pub fn is_expired(&self) -> bool {
Instant::now() > self.expires
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quality_unknown_when_all_zero() {
assert_eq!(QualityLevel::from_metrics(0, 0.0, 0), QualityLevel::Unknown);
}
#[test]
fn quality_excellent_low_metrics() {
assert_eq!(
QualityLevel::from_metrics(30, 0.0, 2),
QualityLevel::Excellent
);
}
#[test]
fn quality_fair_moderate_latency() {
assert_eq!(QualityLevel::from_metrics(150, 0.0, 0), QualityLevel::Fair);
}
#[test]
fn quality_poor_high_latency() {
assert_eq!(QualityLevel::from_metrics(400, 0.0, 0), QualityLevel::Poor);
}
#[test]
fn quality_poor_high_packet_loss() {
assert_eq!(QualityLevel::from_metrics(20, 6.0, 1), QualityLevel::Poor);
}
#[test]
fn quality_fair_moderate_jitter() {
assert_eq!(QualityLevel::from_metrics(20, 0.0, 8), QualityLevel::Fair);
}
#[test]
fn help_scroll_is_zero_when_terminal_height_unknown() {
assert_eq!(help_max_scroll_for_terminal_height(0, 44), 0);
}
fn make_animation(to_back: bool) -> FlipAnimation {
FlipAnimation {
panel: FocusedPanel::Chart,
started: Instant::now(),
to_back,
}
}
#[test]
fn animation_starts_not_complete() {
let anim = make_animation(true);
assert!(!anim.is_complete());
assert!(anim.progress() < 0.1);
}
#[test]
fn animation_width_ratio_starts_near_one() {
let anim = make_animation(true);
assert!(anim.width_ratio() > 0.8);
}
#[test]
fn animation_not_past_midpoint_at_start() {
let anim = make_animation(true);
assert!(!anim.past_midpoint());
}
#[test]
fn animation_complete_after_duration() {
let anim = FlipAnimation {
panel: FocusedPanel::Security,
started: Instant::now()
.checked_sub(Duration::from_millis(
crate::constants::FLIP_ANIMATION_DURATION_MS + 10,
))
.unwrap(),
to_back: false,
};
assert!(anim.is_complete());
assert!((anim.progress() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn animation_past_midpoint_after_duration() {
let anim = FlipAnimation {
panel: FocusedPanel::Chart,
started: Instant::now()
.checked_sub(Duration::from_millis(
crate::constants::FLIP_ANIMATION_DURATION_MS,
))
.unwrap(),
to_back: true,
};
assert!(anim.past_midpoint());
}
#[test]
fn animation_width_ratio_one_when_complete() {
let anim = FlipAnimation {
panel: FocusedPanel::ConnectionDetails,
started: Instant::now()
.checked_sub(Duration::from_millis(
crate::constants::FLIP_ANIMATION_DURATION_MS + 50,
))
.unwrap(),
to_back: true,
};
assert!((anim.width_ratio() - 1.0).abs() < f64::EPSILON);
}
}