mod bars;
mod browser;
mod capture;
mod chrome;
mod columns;
mod connection;
mod devices;
mod dialogs;
mod fit;
mod help;
mod monitor;
mod mstp;
mod network;
mod networks;
mod safety;
mod sc;
mod sc_credentials;
mod watch;
mod who_has;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Paragraph, Wrap};
use ratatui::Frame;
use super::app::names::Name;
use super::app::watch::Screen;
use super::app::{App, Link, Overlay};
pub(crate) const MIN_WIDTH: u16 = 80;
pub(crate) const MIN_HEIGHT: u16 = 24;
pub(crate) const FULL_WIDTH: u16 = 120;
pub(crate) const WIDE_WIDTH: u16 = 160;
pub(crate) const FULL_HEIGHT: u16 = 40;
pub(crate) fn draw(frame: &mut Frame, app: &App) {
let area = frame.area();
let theme = Theme { color: app.color };
app.devices.named.set(false);
if let Some(browser) = &app.browser {
browser.objects.named.set(false);
}
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
too_small(frame, area, &theme);
return;
}
let mut banners = app.duplicates.banner().to_vec();
banners.extend(app.network.registration_banner());
let banner: Vec<String> = banners
.iter()
.flat_map(|text| devices::wrap_banner(text, usize::from(area.width)))
.collect();
let banner_height = banner.len();
let log_height = match (app.show_log, area.height >= FULL_HEIGHT) {
(false, _) => 0,
(true, true) => 12,
(true, false) => 7,
};
let [status, banner_area, body, log, footer] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(banner_height as u16),
Constraint::Min(5),
Constraint::Length(log_height),
Constraint::Length(1),
])
.areas(area);
bars::status_bar(frame, status, app, &theme);
devices::banner(frame, banner_area, &banner, &theme);
match &app.browser {
_ if app.screen == Screen::Watch => watch::draw(frame, body, app, &theme),
_ if app.screen == Screen::Network => network::draw(frame, body, app, &theme),
_ if app.screen == Screen::Sc => sc::draw(frame, body, app, &theme),
_ if app.screen == Screen::Mstp => mstp::draw(frame, body, app, &theme),
_ if app.screen == Screen::Capture => capture::draw(frame, body, app, &theme),
Some(open) if app.screen == Screen::Browse => {
browser::draw(frame, body, open, Names::of(app), &theme)
}
_ => who_has::devices(frame, body, app, &theme),
}
if app.show_log {
chrome::log_pane(frame, log, app, &theme);
}
let tab_focus = app.screen == Screen::Network && network::wide(app, body);
bars::footer(frame, footer, app, &theme, tab_focus);
match &app.overlay {
Overlay::None | Overlay::Command(_) => {}
Overlay::Connection => {
if let Some(connection) = &app.connection {
let live = !matches!(app.link, Link::Down | Link::Failed(_));
connection::draw(frame, area, connection, live, &theme);
}
}
Overlay::Help => help::draw(frame, area, app, &theme),
Overlay::WhoIs(form) => dialogs::who_is(frame, area, app, form, &theme),
Overlay::Picker(picker) => dialogs::picker(frame, area, picker, &theme),
Overlay::Safety(modal) => safety::draw(frame, area, app, modal, &theme),
Overlay::Join(dialog) => mstp::join(frame, area, dialog, &app.mstp_thread, &theme),
}
}
fn too_small(frame: &mut Frame, area: Rect, theme: &Theme) {
let text = vec![
Line::styled(
format!("Terminal too small: {}x{}.", area.width, area.height),
theme.warn(),
),
Line::raw(format!(
"bacnet tui needs at least {MIN_WIDTH}x{MIN_HEIGHT} \
({FULL_WIDTH}x{FULL_HEIGHT} for the full layout)."
)),
Line::raw("Resize the window, or press Ctrl-C twice to quit."),
];
frame.render_widget(Paragraph::new(text).wrap(Wrap { trim: true }), area);
}
pub(crate) struct Theme {
color: bool,
}
impl Theme {
fn pick(&self, colored: Style, plain: Style) -> Style {
if self.color {
colored
} else {
plain
}
}
fn bar(&self) -> Style {
self.pick(
Style::new().fg(Color::Black).bg(Color::Cyan),
Style::new().add_modifier(Modifier::REVERSED),
)
}
fn safe(&self) -> Style {
self.pick(
Style::new()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD),
Style::new().add_modifier(Modifier::BOLD),
)
}
fn accent(&self) -> Style {
self.pick(
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
Style::new().add_modifier(Modifier::BOLD),
)
}
fn warn(&self) -> Style {
self.pick(
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD),
Style::new().add_modifier(Modifier::BOLD),
)
}
fn danger(&self) -> Style {
self.pick(
Style::new()
.fg(Color::White)
.bg(Color::Red)
.add_modifier(Modifier::BOLD),
Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED),
)
}
fn dim(&self) -> Style {
self.pick(
Style::new().fg(Color::DarkGray),
Style::new().add_modifier(Modifier::DIM),
)
}
fn selected(&self) -> Style {
Style::new().add_modifier(Modifier::REVERSED)
}
}
pub(super) fn sanitize(text: &str) -> String {
text.chars()
.map(|ch| if ch.is_control() { ' ' } else { ch })
.collect()
}
pub(super) const MIN_NAME: usize = 8;
#[derive(Clone, Copy)]
pub(super) struct Names<'a> {
app: &'a App,
live: bool,
}
impl<'a> Names<'a> {
pub(super) fn of(app: &'a App) -> Self {
Self {
app,
live: app.names_live(),
}
}
pub(super) fn text(
&self,
device: u32,
object: Option<bacnet_types::primitives::ObjectIdentifier>,
) -> &'a str {
match object.and_then(|object| self.app.name_of(device, object)) {
Some(Name::Known(name)) => name,
Some(Name::Unavailable) => "-",
Some(Name::Unreachable) => UNREAD,
None if self.live => PENDING,
None => "",
}
}
}
pub(super) const PENDING: &str = "…";
pub(super) const UNREAD: &str = "?";
pub(super) fn name_marker(text: &str) -> bool {
text == PENDING || text == UNREAD
}
pub(super) fn text_width(text: &str) -> usize {
ratatui::text::Span::raw(text).width()
}
pub(super) fn ellipsize(text: &str, width: usize) -> String {
use unicode_segmentation::UnicodeSegmentation;
if text_width(text) <= width {
return text.to_string();
}
let mut out = String::new();
let mut used = 0;
for grapheme in text.graphemes(true) {
let w = text_width(grapheme);
if used + w + 1 > width {
break;
}
used += w;
out.push_str(grapheme);
}
if width > 0 {
out.push('…');
}
out
}
pub(super) fn more(
mut lines: Vec<Line<'static>>,
height: u16,
theme: &Theme,
) -> Vec<Line<'static>> {
let height = usize::from(height).max(1);
if lines.len() > height {
let hidden = lines.len() - (height - 1);
lines.truncate(height - 1);
lines.push(Line::styled(format!("+{hidden} more"), theme.warn()));
}
lines
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
area.centered(
Constraint::Length(width.min(area.width)),
Constraint::Length(height.min(area.height)),
)
}