pub const COMPACT_MAX: f32 = 760.0;
pub const MEDIUM_MAX: f32 = 1100.0;
pub const NAV_RAIL_PAD: f32 = 16.0;
pub const NAV_ITEM_PAD: f32 = 16.0;
pub const NAV_ICON: f32 = 24.0;
pub const NAV_GAP: f32 = 8.0;
pub const NAV_BADGE: f32 = 36.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Compact,
Medium,
Wide,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Layout {
pub width: f32,
pub height: f32,
pub mode: Mode,
}
impl Layout {
pub fn new(width: f32, height: f32) -> Self {
let mode = if width < COMPACT_MAX {
Mode::Compact
} else if width < MEDIUM_MAX {
Mode::Medium
} else {
Mode::Wide
};
Self {
width,
height,
mode,
}
}
pub fn nav_width(&self) -> f32 {
match self.mode {
Mode::Wide => 220.0,
Mode::Medium => 176.0,
Mode::Compact => 56.0,
}
}
pub fn nav_labels(&self) -> bool {
self.mode != Mode::Compact
}
pub fn nav_text_px(&self, badge: bool) -> f32 {
if !self.nav_labels() {
return 0.0;
}
let w = self.nav_width() - NAV_RAIL_PAD - NAV_ITEM_PAD - NAV_ICON - NAV_GAP;
(w - if badge { NAV_BADGE } else { 0.0 }).max(0.0)
}
pub fn nav_name_chars(&self) -> usize {
(self.nav_text_px(true) / 7.0).floor() as usize
}
pub fn nav_serial_chars(&self) -> usize {
(self.nav_text_px(true) / 6.2).floor() as usize
}
pub fn nav_long_settings(&self) -> bool {
self.nav_text_px(false) >= 15.0 * 7.0
}
pub fn content_width(&self) -> f32 {
(self.width - self.nav_width()).max(0.0)
}
pub fn card_columns(&self) -> usize {
const GAP: f32 = 12.0;
const MIN_CARD: f32 = 300.0;
let avail = (self.content_width() - 32.0).max(0.0);
(((avail + GAP) / (MIN_CARD + GAP)).floor() as usize).max(1)
}
pub fn card_detail(&self) -> u8 {
let cols = self.card_columns() as f32;
let w = (self.content_width() - 32.0 - 12.0 * (cols - 1.0)) / cols;
if w < 260.0 {
0
} else if self.height < 640.0 || w < 330.0 {
1
} else {
2
}
}
pub fn overview_columns(&self) -> usize {
let w = self.content_width();
if w >= 900.0 {
3
} else if w >= 640.0 {
2
} else {
1
}
}
pub fn show_charts(&self) -> bool {
self.content_width() >= 300.0 }
pub fn header_chips(&self) -> usize {
match self.mode {
Mode::Wide => usize::MAX,
Mode::Medium => 3,
Mode::Compact => 1,
}
}
pub fn stack_panes(&self) -> bool {
self.content_width() < 760.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabStyle {
Full,
Short,
Initial,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RateStyle {
Full,
Compact,
Hidden,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TabBar {
pub tabs: TabStyle,
pub rate: RateStyle,
}
fn text_px(chars: usize) -> f32 {
chars as f32 * 7.6
}
impl TabBar {
pub fn tabs_width(labels: &[&str], style: TabStyle) -> f32 {
let pad = if style == TabStyle::Full { 24.0 } else { 16.0 };
labels
.iter()
.map(|l| text_px(l.chars().count()) + pad)
.sum::<f32>()
+ 4.0 * labels.len().saturating_sub(1) as f32
}
pub fn rate_width(style: RateStyle) -> f32 {
match style {
RateStyle::Full => 170.0,
RateStyle::Compact => 112.0,
RateStyle::Hidden => 0.0,
}
}
}
impl Layout {
pub fn tab_bar(&self, full: &[&str], short: &[&str], initial: &[&str]) -> TabBar {
let avail = (self.content_width() - 32.0).max(0.0);
let sets = |s: TabStyle| match s {
TabStyle::Full => full,
TabStyle::Short => short,
TabStyle::Initial => initial,
};
const ORDER: [(TabStyle, RateStyle); 8] = [
(TabStyle::Full, RateStyle::Full),
(TabStyle::Full, RateStyle::Compact),
(TabStyle::Short, RateStyle::Full),
(TabStyle::Short, RateStyle::Compact),
(TabStyle::Short, RateStyle::Hidden),
(TabStyle::Initial, RateStyle::Compact),
(TabStyle::Initial, RateStyle::Hidden),
(TabStyle::Initial, RateStyle::Hidden),
];
for (tabs, rate) in ORDER {
let need = TabBar::tabs_width(sets(tabs), tabs)
+ TabBar::rate_width(rate)
+ if rate == RateStyle::Hidden { 0.0 } else { 12.0 };
if need <= avail {
return TabBar { tabs, rate };
}
}
TabBar {
tabs: TabStyle::Initial,
rate: RateStyle::Hidden,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modes() {
assert_eq!(Layout::new(640., 420.).mode, Mode::Compact);
assert_eq!(Layout::new(900., 600.).mode, Mode::Medium);
assert_eq!(Layout::new(1400., 900.).mode, Mode::Wide);
}
#[test]
fn columns_reflow() {
assert_eq!(Layout::new(640., 420.).card_columns(), 1);
assert!(Layout::new(1100., 700.).card_columns() >= 2);
assert!(Layout::new(2000., 900.).card_columns() >= 5);
assert_eq!(Layout::new(0., 0.).card_columns(), 1);
}
#[test]
fn overview_and_detail() {
assert_eq!(Layout::new(640., 420.).overview_columns(), 1);
assert_eq!(Layout::new(900., 700.).overview_columns(), 2);
assert_eq!(Layout::new(1600., 900.).overview_columns(), 3);
assert!(Layout::new(640., 420.).show_charts());
assert!(!Layout::new(300., 420.).show_charts());
assert!(Layout::new(1400., 900.).show_charts());
assert_eq!(Layout::new(640., 420.).card_detail(), 1);
assert_eq!(Layout::new(300., 420.).card_detail(), 0);
assert_eq!(Layout::new(1400., 900.).card_detail(), 2);
}
#[test]
fn nav_and_panes() {
assert!(!Layout::new(640., 420.).nav_labels());
assert!(Layout::new(1400., 900.).nav_labels());
assert!(Layout::new(800., 600.).stack_panes());
assert!(!Layout::new(1400., 900.).stack_panes());
assert_eq!(Layout::new(1400., 900.).header_chips(), usize::MAX);
}
const FULL: [&str; 5] = ["Overview", "Logs", "Commands", "Flash & Root", "Screen"];
const SHORT: [&str; 5] = ["Stats", "Logs", "Cmds", "Flash", "Screen"];
const INIT: [&str; 5] = ["O", "L", "C", "F", "S"];
#[test]
fn tab_bar_leanest_when_tiny() {
let b = Layout::new(300., 600.).tab_bar(&FULL, &SHORT, &INIT);
assert_eq!((b.tabs, b.rate), (TabStyle::Initial, RateStyle::Hidden));
}
#[test]
fn tab_bar_fits_at_every_width() {
for w in (300..=2400).step_by(10) {
let lay = Layout::new(w as f32, 700.);
let b = lay.tab_bar(&FULL, &SHORT, &INIT);
let set: &[&str] = match b.tabs {
TabStyle::Full => &FULL,
TabStyle::Short => &SHORT,
TabStyle::Initial => &INIT,
};
let need = TabBar::tabs_width(set, b.tabs) + TabBar::rate_width(b.rate);
if !(b.tabs == TabStyle::Initial && b.rate == RateStyle::Hidden) {
assert!(
need <= lay.content_width() - 32.0,
"w={w} {b:?} need={need}"
);
}
}
}
#[test]
fn tab_bar_choices() {
let wide = Layout::new(1600., 900.).tab_bar(&FULL, &SHORT, &INIT);
assert_eq!((wide.tabs, wide.rate), (TabStyle::Full, RateStyle::Full));
let min = Layout::new(640., 420.).tab_bar(&FULL, &SHORT, &INIT);
assert_ne!(min.rate, RateStyle::Full, "640px window must save space");
}
#[test]
fn layout_never_degenerate() {
for w in (0..=3000).step_by(40) {
for h in (0..=2000).step_by(100) {
let l = Layout::new(w as f32, h as f32);
assert!(l.card_columns() >= 1);
assert!(l.content_width() >= 0.0);
assert!((1..=3).contains(&l.overview_columns()));
assert!(l.card_detail() <= 2);
}
}
}
#[test]
fn nav_text_always_fits() {
for w in (0..=3000).step_by(10) {
let l = Layout::new(w as f32, 700.);
if l.nav_labels() {
assert!(l.nav_name_chars() as f32 * 7.0 <= l.nav_text_px(true));
assert!(l.nav_serial_chars() as f32 * 6.2 <= l.nav_text_px(true) + 0.1);
assert!(l.nav_name_chars() >= 6, "w={w}");
} else {
assert_eq!(l.nav_name_chars(), 0);
assert!(l.nav_width() >= NAV_ICON + 16.0 + 8.0);
}
}
}
#[test]
fn long_settings_label_only_when_it_fits() {
assert!(Layout::new(1600., 900.).nav_long_settings());
assert!(!Layout::new(640., 700.).nav_long_settings());
}
}