use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use super::fit::{self, hint, span, Part, ALWAYS};
use super::{ellipsize, text_width, Theme, WIDE_WIDTH};
use crate::tui::app::command_bar;
use crate::tui::app::watch::Screen;
use crate::tui::app::{App, Link, Overlay};
use crate::tui::safety::Level as Safety;
const HELP_WIDTH: u16 = 9;
const RESERVED: u8 = 8;
pub(super) fn status_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let level = app.level();
let bar = match level {
Safety::Armed { .. } => theme.danger(),
_ => theme.bar(),
};
let [left_area, right_area] =
Layout::horizontal([Constraint::Min(0), Constraint::Length(HELP_WIDTH)]).areas(area);
let room = usize::from(left_area.width).saturating_sub(1);
let mut spans = vec![Span::styled(" ", bar)];
spans.extend(fit::fit(
status_parts(app, theme, bar),
&Span::styled(" | ", bar),
room,
));
frame.render_widget(Paragraph::new(Line::from(spans)).style(bar), left_area);
frame.render_widget(
Paragraph::new("? help ")
.style(bar)
.alignment(Alignment::Right),
right_area,
);
}
fn status_parts(app: &App, theme: &Theme, bar: Style) -> Vec<Part> {
let transport = if app.transport.is_empty() {
"no profile"
} else {
app.transport
};
let state = match &app.link {
Link::Down => "disconnected",
Link::Disconnecting => "disconnecting",
Link::Picking if app.transport == "MSTP" => "confirm joining",
Link::Picking => "choose an interface",
Link::Connecting => "connecting",
Link::Up { .. } => "up",
Link::Failed(_) => "failed",
Link::Passive if app.monitor.is_some() => "listen only",
Link::Passive => "no client",
};
let level = match app.level() {
Safety::Passive => Span::styled("PASSIVE", theme.safe()),
Safety::ReadOnly | Safety::Expired => Span::styled("READ-ONLY", theme.safe()),
Safety::Armed { dangerous } => {
let left = app.safety.armed.as_ref().map_or(0, |armed| {
armed.until.saturating_duration_since(app.now).as_secs()
});
let name = if dangerous { "WRITE+DANGER" } else { "WRITE" };
Span::styled(format!("{name} {}:{:02}", left / 60, left % 60), bar)
}
};
let mut parts = vec![
Part::new(1, vec![span("bacnet", bar)]),
strip(app, theme, bar),
];
let address = if app.transport == "SC" { 3 } else { 4 };
parts.push(match &app.link {
Link::Up { local } => Part::new(address, vec![span(format!("{transport} {local}"), bar)])
.or(8, vec![span(transport, bar)]),
_ => Part::new(8, vec![span(transport, bar)]),
});
parts.push(Part::new(ALWAYS, vec![span(state, bar)]));
if let Some(hub) = hub_state(app) {
parts.push(Part::new(4, vec![span(hub, bar)]));
}
parts.push(Part::new(ALWAYS, vec![level]));
let subscriptions = app.watch.stats.subscriptions;
parts.push(Part::new(
if subscriptions > 0 { 5 } else { 2 },
vec![span(format!("COV {subscriptions}"), bar)],
));
if let Some(registration) = app.network.registration_state() {
parts.push(Part::new(
5,
vec![span(format!("FD {}", registration.label()), bar)],
));
}
if !app.safety.ledger.is_empty() {
parts.push(Part::new(
7,
vec![span(format!("ledger {}", app.safety.ledger.len()), bar)],
));
}
parts.push(Part::new(
6,
vec![span(format!("drop {}", app.dropped), bar)],
));
parts
}
fn strip(app: &App, theme: &Theme, bar: Style) -> Part {
let tabs = crate::tui::help::screens(app);
let form = |labels: bool| {
let mut spans = Vec::new();
for (index, tab) in tabs.iter().enumerate() {
if index > 0 {
spans.push(span(" ", bar));
}
if tab.current {
spans.push(span(
format!("[{} {}]", tab.key, tab.label),
theme.selected(),
));
} else if labels {
spans.push(span(format!("{} {}", tab.key, tab.label), bar));
} else {
spans.push(span(tab.key.to_string(), bar));
}
}
spans
};
Part::new(2, form(true)).or(7, form(false))
}
fn hub_state(app: &App) -> Option<String> {
use bacnet_transport::sc::ScConnectionState;
let (link, _) = app.sc.link.as_ref()?;
Some(match (link.state, link.hub) {
(ScConnectionState::Connected, Some(hub)) => {
format!("hub {}", crate::tui::sc::hub_name(hub))
}
(ScConnectionState::Connecting, _) => "hub connecting".into(),
_ => "hub down".into(),
})
}
pub(super) fn footer(frame: &mut Frame, area: Rect, app: &App, theme: &Theme, tab_focus: bool) {
let width = usize::from(area.width);
if let Overlay::Command(text) = &app.overlay {
frame.render_widget(Paragraph::new(command_line(text, width, theme)), area);
return;
}
if app.filter_editing {
let line = Line::from(vec![
Span::styled(" /", theme.accent()),
Span::styled(format!("{}_", app.filter_text()), theme.selected()),
Span::styled(" Enter keep Esc clear", theme.dim()),
]);
frame.render_widget(Paragraph::new(line), area);
return;
}
let flash = app.flash.as_ref().map(|f| (f.text.as_str(), theme.warn()));
if app.overlay != Overlay::None {
if let Some((text, style)) = flash {
let line = Line::styled(
format!(" {}", ellipsize(text, width.saturating_sub(1))),
style,
);
frame.render_widget(Paragraph::new(line), area);
}
return;
}
let failed;
let notice = match (&app.link, flash) {
(_, Some(flash)) => Some(flash),
(Link::Failed(failure), None) => {
failed = format!("Connection failed: {}", failure.summary);
Some((failed.as_str(), theme.danger()))
}
_ => None,
};
let parts = screen_hints(app, theme, tab_focus);
let Some((text, style)) = notice else {
let line = fit::hints(parts, width.saturating_sub(1));
let mut spans = vec![Span::raw(" ")];
spans.extend(line.spans);
frame.render_widget(Paragraph::new(Line::from(spans)), area);
return;
};
let room = |keep| width.saturating_sub(1 + fit::reserve(&parts, keep) + 2);
let failed = matches!(app.link, Link::Failed(_));
let whole = text_width(text) <= room(ALWAYS);
let reserved = if area.width >= WIDE_WIDTH && whole && !failed {
ALWAYS
} else {
RESERVED
};
let notice_room = room(reserved);
let notice = ellipsize(text, notice_room);
let used = 1 + text_width(¬ice);
let hints = fit::hints(parts, width.saturating_sub(used + 2));
let [left, right] =
Layout::horizontal([Constraint::Length(used as u16), Constraint::Min(0)]).areas(area);
frame.render_widget(
Paragraph::new(Line::styled(format!(" {notice}"), style)),
left,
);
frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), right);
}
fn command_line(text: &str, width: usize, theme: &Theme) -> Line<'static> {
const KEYS: &str = " Enter run Esc close";
let typed = format!("{text}_");
let matches = command_bar::matching(text);
let syntax = if matches.is_empty() {
"no such command".to_string()
} else {
let forms: Vec<&str> = matches
.iter()
.flat_map(|s| s.usage.iter().copied())
.collect();
forms.join(" | ")
};
let room = width.saturating_sub(2 + text_width(&typed) + 2 + text_width(KEYS));
let mut spans = vec![
Span::styled(" :", theme.accent()),
Span::styled(typed, theme.selected()),
];
if room > 0 {
spans.push(Span::raw(" "));
spans.push(Span::styled(ellipsize(&syntax, room), theme.warn()));
}
spans.push(Span::styled(KEYS, theme.dim()));
Line::from(spans)
}
fn screen_hints(app: &App, theme: &Theme, tab_focus: bool) -> Vec<Part> {
let profiles = app.connection.is_some();
let h = |keep, key, what| hint(theme, keep, key, what);
let mut parts = Vec::new();
match app.screen {
Screen::Network if app.networks_tab() => {
parts.push(h(6, "r", "ask routers"));
parts.push(h(5, "Tab", "tables"));
parts.push(h(3, ":find", "NAME"));
parts.push(h(8, "Esc", "back"));
}
Screen::Network => {
parts.push(h(6, "r", "read tables"));
parts.push(h(4, "m", "survey BBMDs"));
parts.push(if tab_focus {
h(4, "Tab", "focus pane/networks").or_hint(theme, 6, "Tab", "focus pane")
} else {
h(4, "Tab", "BDT/FDT/matrix/networks").or_hint(theme, 6, "Tab", "tables")
});
parts.push(match app.away_from_own_bbmd() {
Some(_) => h(7, ":bbmd", "own BBMD"),
None => h(3, ":bbmd", "ADDRESS"),
});
parts.push(h(8, "Esc", "back"));
}
Screen::Sc if app.transport == "SC" => {
parts.push(
h(4, "Tab", "certificates/doctor/credentials").or_hint(theme, 6, "Tab", "view"),
);
parts.push(h(4, "p", "profiles"));
parts.push(h(5, "x", "disconnect"));
parts.push(h(8, "Esc", "back"));
}
Screen::Sc => {
if profiles {
parts.push(h(6, "p", "profiles"));
}
parts.push(h(8, "Esc", "back"));
}
Screen::Mstp if app.mstp.station.is_some() => {
parts.push(h(4, "z", "clear counters"));
parts.push(h(5, "Space", "pause/follow"));
parts.push(h(6, "x", "disconnect"));
parts.push(h(8, "Esc", "back"));
}
Screen::Mstp if app.monitor.is_some() => {
parts.push(h(6, "Tab", "view"));
parts.push(h(7, "J", "safe join"));
parts.push(h(3, "z", "clear"));
parts.push(h(4, "Space", "pause"));
parts.push(h(5, "x", "stop"));
parts.push(h(8, "Esc", "back"));
}
Screen::Mstp => {
if profiles {
parts.push(h(6, "p", "profiles"));
} else if app.transport == "MSTP" && matches!(app.link, Link::Down | Link::Failed(_)) {
parts.push(h(6, "c", "join"));
}
parts.push(h(8, "Esc", "back"));
}
Screen::Capture => {
parts.push(h(5, "Space", "pause"));
parts.push(h(4, "Tab", "pane"));
parts.push(h(6, "/", "filter"));
parts.push(h(3, "e", "export"));
parts.push(h(2, "z", "clear"));
parts.push(h(8, "Esc", "back"));
}
Screen::Watch => {
parts.push(h(6, "x", "remove"));
parts.push(h(5, ":watch", "DEV OBJ"));
parts.push(h(8, "Esc", "back"));
}
Screen::Main | Screen::Browse => main_hints(app, theme, &mut parts),
}
parts.push(h(ALWAYS, "?", "help"));
parts.push(h(ALWAYS, "q", "quit"));
parts
}
fn main_hints(app: &App, theme: &Theme, parts: &mut Vec<Part>) {
let profiles = app.connection.is_some();
let h = |keep, key, what| hint(theme, keep, key, what);
match &app.link {
Link::Up { .. } if app.screen == Screen::Browse => {
parts.push(h(7, "Enter", "read"));
parts.push(h(6, "a", "watch"));
parts.push(h(5, "Tab", "pane"));
parts.push(h(4, "/", "filter"));
parts.push(h(3, "r", "refresh"));
parts.push(h(8, "Esc", "devices"));
}
Link::Up { .. } => {
parts.push(h(7, "Enter", "browse"));
parts.push(h(7, "d", "Who-Is"));
parts.push(h(5, "/", "filter"));
parts.push(h(4, "s", "sort"));
parts.push(h(3, "x", "disconnect"));
}
Link::Failed(_) => {
parts.push(match profiles {
true => h(8, "p", "settings"),
false => h(8, "c", "retry"),
});
parts.push(h(4, "L", "log"));
}
link => {
if profiles {
parts.push(h(6, "p", "profiles"));
} else if matches!(link, Link::Down) {
parts.push(h(7, "c", "connect"));
}
if matches!(link, Link::Connecting | Link::Picking) {
parts.push(h(5, "x", "cancel"));
}
parts.push(h(4, "L", "log"));
}
}
}