use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use futures::Future;
use indexmap::IndexMap;
use itertools::Itertools;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use tokio::{
io::AsyncBufReadExt,
sync::mpsc::{self, UnboundedSender},
task::JoinHandle,
};
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use tui_input::{backend::crossterm::EventHandler, Input};
use std::{
process::{Command, Stdio},
time::Duration,
};
use super::{logger::Logger, Component, Frame};
use crate::{
action::Action,
systemd::{self, Scope, UnitId, UnitScope, UnitWithStatus},
};
#[derive(Debug, Default, Copy, Clone, PartialEq)]
pub enum Mode {
#[default]
Search,
ServiceList,
Help,
ActionMenu,
Processing,
Error,
}
#[derive(Default)]
pub struct Home {
pub scope: Scope,
pub limit_units: Vec<String>,
pub logger: Logger,
pub show_logger: bool,
pub all_units: IndexMap<UnitId, UnitWithStatus>,
pub filtered_units: StatefulList<UnitWithStatus>,
pub logs: Vec<String>,
pub logs_scroll_offset: u16,
pub mode: Mode,
pub previous_mode: Option<Mode>,
pub input: Input,
pub menu_items: StatefulList<MenuItem>,
pub cancel_token: Option<CancellationToken>,
pub spinner_tick: u8,
pub error_message: String,
pub action_tx: Option<mpsc::UnboundedSender<Action>>,
pub journalctl_tx: Option<std::sync::mpsc::Sender<UnitId>>,
}
pub struct MenuItem {
pub name: String,
pub action: Action,
}
impl MenuItem {
pub fn new(name: &str, action: Action) -> Self {
Self { name: name.to_owned(), action }
}
}
pub struct StatefulList<T> {
state: ListState,
items: Vec<T>,
}
impl<T> Default for StatefulList<T> {
fn default() -> Self {
Self::with_items(vec![])
}
}
impl<T> StatefulList<T> {
pub fn with_items(items: Vec<T>) -> StatefulList<T> {
StatefulList { state: ListState::default(), items }
}
#[allow(dead_code)]
fn selected_mut(&mut self) -> Option<&mut T> {
if self.items.is_empty() {
return None;
}
match self.state.selected() {
Some(i) => Some(&mut self.items[i]),
None => None,
}
}
fn selected(&self) -> Option<&T> {
if self.items.is_empty() {
return None;
}
match self.state.selected() {
Some(i) => Some(&self.items[i]),
None => None,
}
}
fn next(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i >= self.items.len().saturating_sub(1) {
0
} else {
i + 1
}
},
None => 0,
};
self.state.select(Some(i));
}
fn previous(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i == 0 {
self.items.len() - 1
} else {
i - 1
}
},
None => 0,
};
self.state.select(Some(i));
}
fn select(&mut self, index: Option<usize>) {
self.state.select(index);
}
fn unselect(&mut self) {
self.state.select(None);
}
}
impl Home {
pub fn new(scope: Scope, limit_units: &[String]) -> Self {
let limit_units = limit_units.to_vec();
Self { scope, limit_units, ..Default::default() }
}
pub fn set_units(&mut self, units: Vec<UnitWithStatus>) {
self.all_units.clear();
for unit_status in units.into_iter() {
self.all_units.insert(unit_status.id(), unit_status);
}
self.refresh_filtered_units();
}
pub fn update_units(&mut self, units: Vec<UnitWithStatus>) {
let now = std::time::Instant::now();
for unit in units {
if let Some(existing) = self.all_units.get_mut(&unit.id()) {
existing.update(unit);
} else {
self.all_units.insert(unit.id(), unit);
}
}
info!("Updated units in {:?}", now.elapsed());
let now = std::time::Instant::now();
self.refresh_filtered_units();
info!("Filtered units in {:?}", now.elapsed());
}
pub fn next(&mut self) {
self.logs = vec![];
self.filtered_units.next();
self.get_logs();
self.logs_scroll_offset = 0;
}
pub fn previous(&mut self) {
self.logs = vec![];
self.filtered_units.previous();
self.get_logs();
self.logs_scroll_offset = 0;
}
pub fn select(&mut self, index: Option<usize>, refresh_logs: bool) {
if refresh_logs {
self.logs = vec![];
}
self.filtered_units.select(index);
if refresh_logs {
self.get_logs();
self.logs_scroll_offset = 0;
}
}
pub fn unselect(&mut self) {
self.logs = vec![];
self.filtered_units.unselect();
}
pub fn selected_service(&self) -> Option<UnitId> {
self.filtered_units.selected().map(|u| u.id())
}
pub fn get_logs(&mut self) {
if let Some(selected) = self.filtered_units.selected() {
let unit_id = selected.id();
if let Err(e) = self.journalctl_tx.as_ref().unwrap().send(unit_id) {
warn!("Error sending unit name to journalctl thread: {}", e);
}
} else {
self.logs = vec![];
}
}
fn refresh_filtered_units(&mut self) {
let previously_selected = self.selected_service();
let search_value_lower = self.input.value().to_lowercase();
let matching = self
.all_units
.values()
.filter(|u| u.short_name().to_lowercase().contains(&search_value_lower))
.cloned()
.collect_vec();
self.filtered_units.items = matching;
if let Some(previously_selected) = previously_selected {
if let Some(index) = self
.filtered_units
.items
.iter()
.position(|u| u.name == previously_selected.name && u.scope == previously_selected.scope)
{
self.select(Some(index), false);
} else {
self.select(Some(0), true);
}
} else {
if !self.filtered_units.items.is_empty() {
self.select(Some(0), true);
} else {
self.unselect();
}
}
}
fn start_service(&mut self, service: UnitId) {
let cancel_token = CancellationToken::new();
let future = systemd::start_service(service.clone(), cancel_token.clone());
self.service_action(service, "Start".into(), cancel_token, future);
}
fn stop_service(&mut self, service: UnitId) {
let cancel_token = CancellationToken::new();
let future = systemd::stop_service(service.clone(), cancel_token.clone());
self.service_action(service, "Stop".into(), cancel_token, future);
}
fn reload_service(&mut self, service: UnitId) {
let cancel_token = CancellationToken::new();
let future = systemd::reload(service.scope, cancel_token.clone());
self.service_action(service, "Reload".into(), cancel_token, future);
}
fn restart_service(&mut self, service: UnitId) {
let cancel_token = CancellationToken::new();
let future = systemd::restart_service(service.clone(), cancel_token.clone());
self.service_action(service, "Restart".into(), cancel_token, future);
}
fn service_action<Fut>(&mut self, service: UnitId, action_name: String, cancel_token: CancellationToken, action: Fut)
where
Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
{
let tx = self.action_tx.clone().unwrap();
self.cancel_token = Some(cancel_token.clone());
let tx_clone = tx.clone();
let spinner_task = tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(200));
loop {
interval.tick().await;
tx_clone.send(Action::SpinnerTick).unwrap();
}
});
tokio::spawn(async move {
tx.send(Action::EnterMode(Mode::Processing)).unwrap();
match action.await {
Ok(_) => {
info!("{} of {:?} service {} succeeded", action_name, service.scope, service.name);
tx.send(Action::EnterMode(Mode::ServiceList)).unwrap();
},
Err(_) if cancel_token.is_cancelled() => {
warn!("{} of {:?} service {} was cancelled", action_name, service.scope, service.name)
},
Err(e) => {
error!("{} of {:?} service {} failed: {}", action_name, service.scope, service.name, e);
let mut error_string = e.to_string();
if error_string.contains("AccessDenied") {
error_string.push('\n');
error_string.push('\n');
error_string.push_str("Try running this tool with sudo.");
}
tx.send(Action::EnterError(error_string)).unwrap();
},
}
spinner_task.abort();
tx.send(Action::RefreshServices).unwrap();
for _ in 0..3 {
tokio::time::sleep(Duration::from_secs(1)).await;
tx.send(Action::RefreshServices).unwrap();
}
});
}
}
impl Component for Home {
fn init(&mut self, tx: UnboundedSender<Action>) -> anyhow::Result<()> {
self.action_tx = Some(tx.clone());
let (journalctl_tx, journalctl_rx) = std::sync::mpsc::channel::<UnitId>();
self.journalctl_tx = Some(journalctl_tx);
tokio::task::spawn_blocking(move || {
let mut last_follow_handle: Option<JoinHandle<()>> = None;
loop {
let mut unit: UnitId = match journalctl_rx.recv() {
Ok(unit) => unit,
Err(_) => return,
};
while let Ok(service) = journalctl_rx.try_recv() {
info!("Skipping logs for {}...", unit.name);
unit = service;
}
if let Some(handle) = last_follow_handle.take() {
info!("Cancelling previous journalctl task");
handle.abort();
}
std::thread::sleep(Duration::from_millis(100));
match systemd::get_unit_file_location(&unit) {
Ok(path) => {
let _ = tx.send(Action::SetUnitFilePath { unit: unit.clone(), path: Ok(path) });
let _ = tx.send(Action::Render);
},
Err(e) => {
let _ =
tx.send(Action::SetUnitFilePath { unit: unit.clone(), path: Err("could not be determined".into()) });
let _ = tx.send(Action::Render);
error!("Error getting unit file path for {}: {}", unit.name, e);
},
}
info!("Getting logs for {}", unit.name);
let start = std::time::Instant::now();
let mut args = vec!["--quiet", "--output=short-iso", "--lines=500", "-u"];
args.push(&unit.name);
if unit.scope == UnitScope::User {
args.push("--user");
}
match Command::new("journalctl").args(&args).output() {
Ok(output) => {
if output.status.success() {
info!("Got logs for {} in {:?}", unit.name, start.elapsed());
if let Ok(stdout) = std::str::from_utf8(&output.stdout) {
let mut logs = stdout.trim().split('\n').map(String::from).collect_vec();
if logs.is_empty() || logs[0].is_empty() {
logs.push(String::from("No logs found/available. Maybe try relaunching with `sudo systemctl-tui`"));
}
let _ = tx.send(Action::SetLogs { unit: unit.clone(), logs });
let _ = tx.send(Action::Render);
} else {
warn!("Error parsing stdout for {}", unit.name);
}
} else {
warn!("Error getting logs for {}: {}", unit.name, String::from_utf8_lossy(&output.stderr));
}
},
Err(e) => warn!("Error getting logs for {}: {}", unit.name, e),
}
let tx = tx.clone();
last_follow_handle = Some(tokio::spawn(async move {
let mut command = tokio::process::Command::new("journalctl");
command.arg("-u");
command.arg(unit.name.clone());
command.arg("--output=short-iso");
command.arg("--follow");
command.arg("--lines=0");
command.arg("--quiet");
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
if unit.scope == UnitScope::User {
command.arg("--user");
}
let mut child = command.spawn().expect("failed to execute process");
let stdout = child.stdout.take().unwrap();
let reader = tokio::io::BufReader::new(stdout);
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await.unwrap() {
let _ = tx.send(Action::AppendLogLine { unit: unit.clone(), line });
let _ = tx.send(Action::Render);
}
}));
}
});
Ok(())
}
fn handle_key_events(&mut self, key: KeyEvent) -> Vec<Action> {
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') => return vec![Action::Quit],
KeyCode::Char('q') => return vec![Action::Quit],
KeyCode::Char('z') => return vec![Action::Suspend],
KeyCode::Char('f') => return vec![Action::EnterMode(Mode::Search)],
KeyCode::Char('l') => return vec![Action::ToggleShowLogger],
KeyCode::Char('d') => return vec![Action::ScrollDown(1), Action::Render],
KeyCode::Char('u') => return vec![Action::ScrollUp(1), Action::Render],
_ => (),
}
}
if matches!(key.code, KeyCode::Char('?')) || matches!(key.code, KeyCode::F(1)) {
return vec![Action::ToggleHelp, Action::Render];
}
match key.code {
KeyCode::PageDown => return vec![Action::ScrollDown(1), Action::Render],
KeyCode::PageUp => return vec![Action::ScrollUp(1), Action::Render],
KeyCode::Home => return vec![Action::ScrollToTop, Action::Render],
KeyCode::End => return vec![Action::ScrollToBottom, Action::Render],
_ => (),
}
match self.mode {
Mode::ServiceList => {
match key.code {
KeyCode::Char('q') => vec![Action::Quit],
KeyCode::Up | KeyCode::Char('k') => {
if self.filtered_units.state.selected() == Some(0) {
return vec![Action::EnterMode(Mode::Search)];
}
self.previous();
vec![Action::Render]
},
KeyCode::Down | KeyCode::Char('j') => {
self.next();
vec![Action::Render]
},
KeyCode::Char('/') => vec![Action::EnterMode(Mode::Search)],
KeyCode::Enter | KeyCode::Char(' ') => vec![Action::EnterMode(Mode::ActionMenu)],
_ => vec![],
}
},
Mode::Help => match key.code {
KeyCode::Esc | KeyCode::Enter => vec![Action::ToggleHelp],
_ => vec![],
},
Mode::Error => match key.code {
KeyCode::Esc | KeyCode::Enter => vec![Action::EnterMode(Mode::ServiceList)],
_ => vec![],
},
Mode::Search => match key.code {
KeyCode::Esc => vec![Action::EnterMode(Mode::ServiceList)],
KeyCode::Enter => vec![Action::EnterMode(Mode::ActionMenu)],
KeyCode::Down | KeyCode::Tab => {
self.next();
vec![Action::EnterMode(Mode::ServiceList)]
},
KeyCode::Up => {
self.previous();
vec![Action::EnterMode(Mode::ServiceList)]
},
_ => {
let prev_search_value = self.input.value().to_owned();
self.input.handle_event(&crossterm::event::Event::Key(key));
if prev_search_value != self.input.value() {
self.refresh_filtered_units();
}
vec![Action::Render]
},
},
Mode::ActionMenu => match key.code {
KeyCode::Esc => vec![Action::EnterMode(Mode::ServiceList)],
KeyCode::Down | KeyCode::Char('j') => {
self.menu_items.next();
vec![Action::Render]
},
KeyCode::Up | KeyCode::Char('k') => {
self.menu_items.previous();
vec![Action::Render]
},
KeyCode::Enter | KeyCode::Char(' ') => match self.menu_items.selected() {
Some(i) => vec![i.action.clone()],
None => vec![Action::EnterMode(Mode::ServiceList)],
},
_ => vec![],
},
Mode::Processing => match key.code {
KeyCode::Esc => vec![Action::CancelTask],
_ => vec![],
},
}
}
fn dispatch(&mut self, action: Action) -> Option<Action> {
match action {
Action::ToggleShowLogger => {
self.show_logger = !self.show_logger;
return Some(Action::Render);
},
Action::EnterMode(mode) => {
if mode == Mode::ActionMenu {
if let Some(selected) = self.filtered_units.selected() {
let mut menu_items = vec![
MenuItem::new("Start", Action::StartService(selected.id())),
MenuItem::new("Stop", Action::StopService(selected.id())),
MenuItem::new("Restart", Action::RestartService(selected.id())),
MenuItem::new("Reload", Action::ReloadService(selected.id())),
];
if let Some(Ok(file_path)) = &selected.file_path {
menu_items.push(MenuItem::new("Copy unit file path to clipboard", Action::CopyUnitFilePath));
menu_items.push(MenuItem::new(
"Edit unit file",
Action::EditUnitFile { unit: selected.id(), path: file_path.clone() },
));
}
self.menu_items = StatefulList::with_items(menu_items);
self.menu_items.state.select(Some(0));
} else {
return None;
}
}
self.mode = mode;
return Some(Action::Render);
},
Action::EnterError(err) => {
tracing::error!(err);
self.error_message = err;
return Some(Action::EnterMode(Mode::Error));
},
Action::ToggleHelp => {
if self.mode != Mode::Help {
self.previous_mode = Some(self.mode);
self.mode = Mode::Help;
} else {
self.mode = self.previous_mode.unwrap_or(Mode::Search);
}
return Some(Action::Render);
},
Action::CopyUnitFilePath => {
if let Some(selected) = self.filtered_units.selected() {
if let Some(Ok(file_path)) = &selected.file_path {
match clipboard_anywhere::set_clipboard(file_path) {
Ok(_) => return Some(Action::EnterMode(Mode::ServiceList)),
Err(e) => return Some(Action::EnterError(format!("Error copying to clipboard: {}", e))),
}
} else {
return Some(Action::EnterError("No unit file path available".into()));
}
}
},
Action::SetUnitFilePath { unit, path } => {
if let Some(unit) = self.all_units.get_mut(&unit) {
unit.file_path = Some(path.clone());
}
self.refresh_filtered_units(); },
Action::SetLogs { unit, logs } => {
if let Some(selected) = self.filtered_units.selected() {
if selected.id() == unit {
self.logs = logs;
}
}
},
Action::AppendLogLine { unit, line } => {
if let Some(selected) = self.filtered_units.selected() {
if selected.id() == unit {
self.logs.push(line);
}
}
},
Action::ScrollUp(offset) => {
self.logs_scroll_offset = self.logs_scroll_offset.saturating_sub(offset);
info!("scroll offset: {}", self.logs_scroll_offset);
},
Action::ScrollDown(offset) => {
self.logs_scroll_offset = self.logs_scroll_offset.saturating_add(offset);
info!("scroll offset: {}", self.logs_scroll_offset);
},
Action::ScrollToTop => {
self.logs_scroll_offset = 0;
},
Action::ScrollToBottom => {
self.logs_scroll_offset = self.logs.len() as u16;
},
Action::StartService(service_name) => self.start_service(service_name),
Action::StopService(service_name) => self.stop_service(service_name),
Action::ReloadService(service_name) => self.reload_service(service_name),
Action::RestartService(service_name) => self.restart_service(service_name),
Action::RefreshServices => {
let tx = self.action_tx.clone().unwrap();
let scope = self.scope;
let limit_units = self.limit_units.to_vec();
tokio::spawn(async move {
let units = systemd::get_all_services(scope, &limit_units)
.await
.expect("Failed to get services. Check that systemd is running and try running this tool with sudo.");
tx.send(Action::SetServices(units)).unwrap();
});
},
Action::SetServices(units) => {
self.update_units(units);
return Some(Action::Render);
},
Action::SpinnerTick => {
self.spinner_tick = self.spinner_tick.wrapping_add(1);
return Some(Action::Render);
},
Action::CancelTask => {
if let Some(cancel_token) = self.cancel_token.take() {
cancel_token.cancel();
}
self.mode = Mode::ServiceList;
return Some(Action::Render);
},
_ => (),
}
None
}
fn render(&mut self, f: &mut Frame<'_>, rect: Rect) {
let rect = if self.show_logger {
let chunks = Layout::new(Direction::Vertical, Constraint::from_percentages([50, 50])).split(rect);
self.logger.render(f, chunks[1]);
chunks[0]
} else {
rect
};
let rects = Layout::new(Direction::Vertical, [Constraint::Min(3), Constraint::Percentage(100)]).split(rect);
let search_panel = rects[0];
let main_panel = rects[1];
fn colored_line(value: &str, color: Color) -> Line {
Line::from(vec![Span::styled(value, Style::default().fg(color))])
}
fn unit_color(unit: &UnitWithStatus) -> Color {
if unit.is_active() {
Color::Green
} else if unit.is_failed() {
Color::Red
} else if unit.is_not_found() {
Color::Yellow
} else {
Color::Reset
}
}
let items: Vec<ListItem> = self
.filtered_units
.items
.iter()
.map(|i| {
let color = unit_color(i);
let line = colored_line(i.short_name(), color);
ListItem::new(line)
})
.collect();
let items = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(if self.mode == Mode::ServiceList {
Style::default().fg(Color::LightGreen)
} else {
Style::default()
})
.title("─Services"),
)
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD));
let chunks =
Layout::new(Direction::Horizontal, [Constraint::Min(30), Constraint::Percentage(100)]).split(main_panel);
let right_panel = chunks[1];
f.render_stateful_widget(items, chunks[0], &mut self.filtered_units.state);
let selected_item = self.filtered_units.selected();
let right_panel =
Layout::new(Direction::Vertical, [Constraint::Min(7), Constraint::Percentage(100)]).split(right_panel);
let details_panel = right_panel[0];
let logs_panel = right_panel[1];
let details_block = Block::default().title("─Details").borders(Borders::ALL).border_type(BorderType::Rounded);
let details_panel_panes = Layout::new(Direction::Horizontal, [Constraint::Min(14), Constraint::Percentage(100)])
.split(details_block.inner(details_panel));
let props_pane = details_panel_panes[0];
let values_pane = details_panel_panes[1];
let props_lines = vec![
Line::from("Description: "),
Line::from("Scope: "),
Line::from("Loaded: "),
Line::from("Active: "),
Line::from("Unit file: "),
];
let details_text = if let Some(i) = selected_item {
fn line_color_string<'a>(value: String, color: Color) -> Line<'a> {
Line::from(vec![Span::styled(value, Style::default().fg(color))])
}
let load_color = match i.load_state.as_str() {
"loaded" => Color::Green,
"not-found" => Color::Yellow,
"error" => Color::Red,
_ => Color::Reset,
};
let active_color = match i.activation_state.as_str() {
"active" => Color::Green,
"inactive" => Color::Gray,
"failed" => Color::Red,
_ => Color::Reset,
};
let active_state_value = format!("{} ({})", i.activation_state, i.sub_state);
let scope = match i.scope {
UnitScope::Global => "Global",
UnitScope::User => "User",
};
let lines = vec![
colored_line(&i.description, Color::Reset),
colored_line(scope, Color::Reset),
colored_line(&i.load_state, load_color),
line_color_string(active_state_value, active_color),
match &i.file_path {
Some(Ok(file_path)) => Line::from(file_path.as_str()),
Some(Err(e)) => colored_line(e, Color::Red),
None => Line::from(""),
},
];
lines
} else {
vec![]
};
let paragraph = Paragraph::new(details_text).style(Style::default());
let props_widget = Paragraph::new(props_lines).alignment(ratatui::layout::Alignment::Right);
f.render_widget(props_widget, props_pane);
f.render_widget(paragraph, values_pane);
f.render_widget(details_block, details_panel);
let log_lines = self
.logs
.iter()
.rev()
.map(|l| {
if let Some((date, rest)) = l.splitn(2, ' ').collect_tuple() {
if date.len() != 24 {
return Line::from(l.as_str());
}
Line::from(vec![Span::styled(date, Style::default().fg(Color::DarkGray)), Span::raw(" "), Span::raw(rest)])
} else {
Line::from(l.as_str())
}
})
.collect_vec();
let paragraph = Paragraph::new(log_lines)
.block(Block::default().title("─Service Logs").borders(Borders::ALL).border_type(BorderType::Rounded))
.style(Style::default())
.wrap(Wrap { trim: true })
.scroll((self.logs_scroll_offset, 0));
f.render_widget(paragraph, logs_panel);
let width = search_panel.width.max(3) - 3; let scroll = self.input.visual_scroll(width as usize);
let input = Paragraph::new(self.input.value())
.style(match self.mode {
Mode::Search => Style::default().fg(Color::LightGreen),
_ => Style::default(),
})
.scroll((0, scroll as u16))
.block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).title(Line::from(vec![
Span::raw("─Search "),
Span::styled("(", Style::default().fg(Color::DarkGray)),
Span::styled("ctrl+f", Style::default().add_modifier(Modifier::BOLD).fg(Color::Gray)),
Span::styled(" or ", Style::default().fg(Color::DarkGray)),
Span::styled("/", Style::default().add_modifier(Modifier::BOLD).fg(Color::Gray)),
Span::styled(")", Style::default().fg(Color::DarkGray)),
])));
f.render_widget(input, search_panel);
let help_width = 24;
let help_area = Rect::new(search_panel.x + search_panel.width - help_width - 2, search_panel.y, help_width, 1);
f.render_widget(Clear, help_area);
let help_text = Paragraph::new(Line::from(vec![
Span::raw(" Press "),
Span::styled("?", Style::default().add_modifier(Modifier::BOLD).fg(Color::Gray)),
Span::raw(" or "),
Span::styled("F1", Style::default().add_modifier(Modifier::BOLD).fg(Color::Gray)),
Span::raw(" for help "),
]))
.style(Style::default().fg(Color::DarkGray));
f.render_widget(help_text, help_area);
if self.mode == Mode::Search {
f.set_cursor_position((
(search_panel.x + 1 + self.input.cursor() as u16).min(search_panel.x + search_panel.width - 2),
search_panel.y + 1,
));
}
if self.mode == Mode::Help {
let popup = centered_rect_abs(50, 18, f.area());
fn primary(s: &str) -> Span {
Span::styled(s, Style::default().fg(Color::Cyan))
}
let help_lines = vec![
Line::from(""),
Line::from(Span::styled("Shortcuts", Style::default().add_modifier(Modifier::UNDERLINED))),
Line::from(""),
Line::from(vec![primary("ctrl+C"), Span::raw(" or "), primary("ctrl+Q"), Span::raw(" to quit")]),
Line::from(vec![primary("ctrl+L"), Span::raw(" toggles the logger pane")]),
Line::from(vec![primary("PageUp"), Span::raw(" / "), primary("PageDown"), Span::raw(" scroll the logs")]),
Line::from(vec![primary("Home"), Span::raw(" / "), primary("End"), Span::raw(" scroll to top/bottom")]),
Line::from(vec![primary("Enter"), Span::raw(" or "), primary("Space"), Span::raw(" open the action menu")]),
Line::from(vec![primary("?"), Span::raw(" / "), primary("F1"), Span::raw(" open this help pane")]),
Line::from(""),
Line::from(Span::styled("Vim Style Shortcuts", Style::default().add_modifier(Modifier::UNDERLINED))),
Line::from(""),
Line::from(vec![primary("j"), Span::raw(" navigate down")]),
Line::from(vec![primary("k"), Span::raw(" navigate up")]),
Line::from(vec![primary("ctrl+U"), Span::raw(" / "), primary("ctrl+D"), Span::raw(" scroll the logs")]),
];
let name = env!("CARGO_PKG_NAME");
let version = env!("CARGO_PKG_VERSION");
let title = format!("─Help for {} v{}", name, version);
let paragraph = Paragraph::new(help_lines)
.block(Block::default().title(title).borders(Borders::ALL).border_type(BorderType::Rounded))
.style(Style::default())
.wrap(Wrap { trim: true });
f.render_widget(Clear, popup);
f.render_widget(paragraph, popup);
}
if self.mode == Mode::Error {
let popup = centered_rect_abs(50, 12, f.area());
let error_lines = self.error_message.split('\n').map(Line::from).collect_vec();
let paragraph = Paragraph::new(error_lines)
.block(
Block::default()
.title("─Error")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Red)),
)
.wrap(Wrap { trim: true });
f.render_widget(Clear, popup);
f.render_widget(paragraph, popup);
}
let selected_item = match self.filtered_units.selected() {
Some(s) => s,
None => return,
};
let min_width = selected_item.name.len() as u16 + 14;
let desired_width = min_width + 4; let popup_width = desired_width.min(f.area().width);
if self.mode == Mode::ActionMenu {
let height = self.menu_items.items.len() as u16 + 2;
let popup = centered_rect_abs(popup_width, height, f.area());
let items: Vec<ListItem> = self.menu_items.items.iter().map(|i| ListItem::new(i.name.as_str())).collect();
let items = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::LightGreen))
.title(format!("Actions for {}", self.filtered_units.selected().unwrap().name)),
)
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD));
f.render_widget(Clear, popup);
f.render_stateful_widget(items, popup, &mut self.menu_items.state);
}
if self.mode == Mode::Processing {
let height = self.menu_items.items.len() as u16 + 2;
let popup = centered_rect_abs(popup_width, height, f.area());
static SPINNER_CHARS: &[char] = &['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', '⣾'];
let spinner_char = SPINNER_CHARS[self.spinner_tick as usize % SPINNER_CHARS.len()];
let paragraph = Paragraph::new(vec![Line::from(format!("{}", spinner_char))])
.block(
Block::default()
.title("Processing")
.border_type(BorderType::Rounded)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::LightGreen)),
)
.style(Style::default())
.wrap(Wrap { trim: true });
f.render_widget(Clear, popup);
f.render_widget(paragraph, popup);
}
}
}
fn _centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::new(
Direction::Vertical,
[
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
],
)
.split(r);
Layout::new(
Direction::Horizontal,
[
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
],
)
.split(popup_layout[1])[1]
}
fn centered_rect_abs(width: u16, height: u16, r: Rect) -> Rect {
let offset_x = (r.width.saturating_sub(width)) / 2;
let offset_y = (r.height.saturating_sub(height)) / 2;
let width = width.min(r.width);
let height = height.min(r.height);
Rect::new(offset_x, offset_y, width, height)
}