feat: karapace-tui — interactive terminal UI for environment management

- ratatui + crossterm based TUI
- List/Detail/Help views with vim-style keybindings (j/k, g/G, Enter, q)
- Search/filter (/), sort cycling (s/S)
- Freeze, archive, rename actions from UI
- Destroy with confirmation dialog
- Color-coded environment states
This commit is contained in:
Marco Allegretti 2026-02-22 18:37:39 +01:00
parent 23ac53ba4d
commit 4a90300807
5 changed files with 4698 additions and 0 deletions

View file

@ -0,0 +1,23 @@
[package]
name = "karapace-tui"
description = "Terminal UI for Karapace environment manager"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[lints]
workspace = true
[lib]
name = "karapace_tui"
path = "src/lib.rs"
[dependencies]
ratatui.workspace = true
crossterm.workspace = true
karapace-core = { path = "../karapace-core" }
karapace-store = { path = "../karapace-store" }
[dev-dependencies]
tempfile.workspace = true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,448 @@
use crossterm::event::KeyCode;
use karapace_core::Engine;
use karapace_store::EnvMetadata;
use std::path::{Path, PathBuf};
#[derive(Debug, PartialEq, Eq)]
pub enum AppAction {
None,
Quit,
Refresh,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum View {
List,
Detail,
Help,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
Normal,
Search,
Rename,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortColumn {
ShortId,
Name,
State,
}
pub struct App {
pub store_root: PathBuf,
pub environments: Vec<EnvMetadata>,
pub filtered: Vec<usize>,
pub selected: usize,
pub view: View,
pub input_mode: InputMode,
pub text_input: String,
pub input_cursor: usize,
pub filter: String,
pub sort_column: SortColumn,
pub sort_ascending: bool,
pub status_message: String,
pub show_confirm: Option<String>,
}
impl App {
pub fn new(store_root: &Path) -> Self {
Self {
store_root: store_root.to_path_buf(),
environments: Vec::new(),
filtered: Vec::new(),
selected: 0,
view: View::List,
input_mode: InputMode::Normal,
text_input: String::new(),
input_cursor: 0,
filter: String::new(),
sort_column: SortColumn::Name,
sort_ascending: true,
status_message: String::new(),
show_confirm: None,
}
}
pub fn engine(&self) -> Engine {
Engine::new(&self.store_root)
}
pub fn refresh(&mut self) -> Result<(), String> {
match self.engine().list() {
Ok(envs) => {
self.environments = envs;
self.apply_sort();
self.apply_filter();
self.status_message = format!("{} environment(s)", self.environments.len());
Ok(())
}
Err(e) => {
self.status_message = format!("error: {e}");
Err(e.to_string())
}
}
}
pub fn apply_filter(&mut self) {
if self.filter.is_empty() {
self.filtered = (0..self.environments.len()).collect();
} else {
let needle = self.filter.to_lowercase();
self.filtered = self
.environments
.iter()
.enumerate()
.filter(|(_, e)| {
e.short_id.to_lowercase().contains(&needle)
|| e.env_id.to_lowercase().contains(&needle)
|| e.name
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&needle)
|| e.state.to_string().to_lowercase().contains(&needle)
})
.map(|(i, _)| i)
.collect();
}
if self.selected >= self.filtered.len() && !self.filtered.is_empty() {
self.selected = self.filtered.len() - 1;
} else if self.filtered.is_empty() {
self.selected = 0;
}
}
pub fn apply_sort(&mut self) {
let asc = self.sort_ascending;
match self.sort_column {
SortColumn::ShortId => self.environments.sort_by(|a, b| {
let ord = a.short_id.cmp(&b.short_id);
if asc {
ord
} else {
ord.reverse()
}
}),
SortColumn::Name => self.environments.sort_by(|a, b| {
let ord = a
.name
.as_deref()
.unwrap_or("")
.cmp(b.name.as_deref().unwrap_or(""));
if asc {
ord
} else {
ord.reverse()
}
}),
SortColumn::State => self.environments.sort_by(|a, b| {
let ord = a.state.to_string().cmp(&b.state.to_string());
if asc {
ord
} else {
ord.reverse()
}
}),
}
}
pub fn selected_env(&self) -> Option<&EnvMetadata> {
self.filtered
.get(self.selected)
.and_then(|&i| self.environments.get(i))
}
pub fn visible_count(&self) -> usize {
self.filtered.len()
}
pub fn handle_key(&mut self, key: KeyCode) -> AppAction {
// Search input mode
if self.input_mode == InputMode::Search {
return self.handle_search_key(key);
}
// Rename input mode
if self.input_mode == InputMode::Rename {
return self.handle_rename_key(key);
}
// Confirmation dialog active
if let Some(ref action) = self.show_confirm.clone() {
if let KeyCode::Char('y' | 'Y') = key {
self.execute_confirmed_action(action);
self.show_confirm = None;
return AppAction::Refresh;
}
self.show_confirm = None;
"cancelled".clone_into(&mut self.status_message);
return AppAction::None;
}
match self.view {
View::Help => match key {
KeyCode::Char('q') | KeyCode::Esc => {
self.view = View::List;
AppAction::None
}
_ => AppAction::None,
},
View::Detail => self.handle_detail_key(key),
View::List => self.handle_list_key(key),
}
}
fn handle_detail_key(&mut self, key: KeyCode) -> AppAction {
match key {
KeyCode::Char('q') | KeyCode::Esc => {
self.view = View::List;
AppAction::None
}
KeyCode::Char('d') => {
self.prompt_destroy();
AppAction::None
}
KeyCode::Char('f') => {
self.action_freeze();
AppAction::Refresh
}
KeyCode::Char('a') => {
self.action_archive();
AppAction::Refresh
}
KeyCode::Char('n') => {
self.start_rename();
AppAction::None
}
_ => AppAction::None,
}
}
fn handle_list_key(&mut self, key: KeyCode) -> AppAction {
match key {
KeyCode::Char('q') => AppAction::Quit,
KeyCode::Char('j') | KeyCode::Down => {
if !self.filtered.is_empty() {
self.selected = (self.selected + 1).min(self.filtered.len() - 1);
}
AppAction::None
}
KeyCode::Char('k') | KeyCode::Up => {
self.selected = self.selected.saturating_sub(1);
AppAction::None
}
KeyCode::Char('g') | KeyCode::Home => {
self.selected = 0;
AppAction::None
}
KeyCode::Char('G') | KeyCode::End => {
if !self.filtered.is_empty() {
self.selected = self.filtered.len() - 1;
}
AppAction::None
}
KeyCode::Enter => {
if self.selected_env().is_some() {
self.view = View::Detail;
}
AppAction::None
}
KeyCode::Char('r') => AppAction::Refresh,
KeyCode::Char('d') => {
self.prompt_destroy();
AppAction::None
}
KeyCode::Char('f') => {
self.action_freeze();
AppAction::Refresh
}
KeyCode::Char('a') => {
self.action_archive();
AppAction::Refresh
}
KeyCode::Char('n') => {
self.start_rename();
AppAction::None
}
KeyCode::Char('/') => {
self.input_mode = InputMode::Search;
self.text_input.clear();
self.input_cursor = 0;
"search: ".clone_into(&mut self.status_message);
AppAction::None
}
KeyCode::Char('s') => {
self.cycle_sort();
AppAction::None
}
KeyCode::Char('S') => {
self.sort_ascending = !self.sort_ascending;
self.apply_sort();
self.apply_filter();
AppAction::None
}
KeyCode::Char('?') => {
self.view = View::Help;
AppAction::None
}
_ => AppAction::None,
}
}
fn handle_search_key(&mut self, key: KeyCode) -> AppAction {
match key {
KeyCode::Esc => {
self.input_mode = InputMode::Normal;
self.filter.clear();
self.apply_filter();
self.status_message = format!("{} environment(s)", self.environments.len());
AppAction::None
}
KeyCode::Enter => {
self.input_mode = InputMode::Normal;
self.filter = self.text_input.clone();
self.apply_filter();
self.status_message = if self.filter.is_empty() {
format!("{} environment(s)", self.environments.len())
} else {
format!(
"filter '{}': {} match(es)",
self.filter,
self.filtered.len()
)
};
AppAction::None
}
KeyCode::Char(c) => {
self.text_input.insert(self.input_cursor, c);
self.input_cursor += 1;
self.filter = self.text_input.clone();
self.apply_filter();
self.status_message = format!("search: {}", self.text_input);
AppAction::None
}
KeyCode::Backspace => {
if self.input_cursor > 0 {
self.input_cursor -= 1;
self.text_input.remove(self.input_cursor);
self.filter = self.text_input.clone();
self.apply_filter();
}
self.status_message = format!("search: {}", self.text_input);
AppAction::None
}
_ => AppAction::None,
}
}
fn handle_rename_key(&mut self, key: KeyCode) -> AppAction {
match key {
KeyCode::Esc => {
self.input_mode = InputMode::Normal;
"rename cancelled".clone_into(&mut self.status_message);
AppAction::None
}
KeyCode::Enter => {
self.input_mode = InputMode::Normal;
let new_name = self.text_input.clone();
if let Some(env) = self.selected_env() {
let env_id = env.env_id.clone();
match self.engine().rename(&env_id, &new_name) {
Ok(()) => {
self.status_message = format!("renamed to '{new_name}'");
}
Err(e) => {
self.status_message = format!("rename failed: {e}");
}
}
}
AppAction::Refresh
}
KeyCode::Char(c) => {
self.text_input.insert(self.input_cursor, c);
self.input_cursor += 1;
self.status_message = format!("rename: {}", self.text_input);
AppAction::None
}
KeyCode::Backspace => {
if self.input_cursor > 0 {
self.input_cursor -= 1;
self.text_input.remove(self.input_cursor);
}
self.status_message = format!("rename: {}", self.text_input);
AppAction::None
}
_ => AppAction::None,
}
}
fn prompt_destroy(&mut self) {
if let Some(env) = self.selected_env() {
let label = env.name.clone().unwrap_or_else(|| env.short_id.to_string());
self.show_confirm = Some(format!("destroy:{}", env.env_id));
self.status_message = format!("destroy '{label}'? (y/n)");
}
}
fn action_freeze(&mut self) {
if let Some(env) = self.selected_env() {
let env_id = env.env_id.to_string();
let label = env.name.clone().unwrap_or_else(|| env.short_id.to_string());
match self.engine().freeze(&env_id) {
Ok(()) => self.status_message = format!("frozen '{label}'"),
Err(e) => self.status_message = format!("freeze failed: {e}"),
}
}
}
fn action_archive(&mut self) {
if let Some(env) = self.selected_env() {
let env_id = env.env_id.to_string();
let label = env.name.clone().unwrap_or_else(|| env.short_id.to_string());
match self.engine().archive(&env_id) {
Ok(()) => self.status_message = format!("archived '{label}'"),
Err(e) => self.status_message = format!("archive failed: {e}"),
}
}
}
fn start_rename(&mut self) {
if self.selected_env().is_some() {
self.input_mode = InputMode::Rename;
self.text_input.clear();
self.input_cursor = 0;
"rename: ".clone_into(&mut self.status_message);
}
}
fn cycle_sort(&mut self) {
self.sort_column = match self.sort_column {
SortColumn::ShortId => SortColumn::Name,
SortColumn::Name => SortColumn::State,
SortColumn::State => SortColumn::ShortId,
};
self.apply_sort();
self.apply_filter();
self.status_message = format!(
"sort: {:?} {}",
self.sort_column,
if self.sort_ascending { "" } else { "" }
);
}
fn execute_confirmed_action(&mut self, action: &str) {
if let Some(env_id) = action.strip_prefix("destroy:") {
match self.engine().destroy(env_id) {
Ok(()) => {
self.status_message = format!("destroyed {}", &env_id[..12.min(env_id.len())]);
}
Err(e) => {
self.status_message = format!("destroy failed: {e}");
}
}
}
}
}

View file

@ -0,0 +1,219 @@
//! Terminal UI for interactive Karapace environment management.
//!
//! This crate provides a ratatui-based TUI with environment listing, detail views,
//! search/filter, sorting, and keyboard-driven lifecycle actions (destroy, freeze,
//! archive, rename).
mod app;
mod ui;
pub use app::{App, AppAction, InputMode, SortColumn, View};
use crossterm::{
event::{self, Event, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::prelude::*;
use std::io;
use std::path::Path;
pub fn run(store_root: &Path) -> Result<(), String> {
enable_raw_mode().map_err(|e| format!("failed to enable raw mode: {e}"))?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).map_err(|e| format!("alternate screen: {e}"))?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).map_err(|e| format!("terminal init: {e}"))?;
let mut app = App::new(store_root);
app.refresh().ok();
let result = run_loop(&mut terminal, &mut app);
disable_raw_mode().map_err(|e| format!("failed to disable raw mode: {e}"))?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)
.map_err(|e| format!("leave alternate screen: {e}"))?;
terminal
.show_cursor()
.map_err(|e| format!("show cursor: {e}"))?;
result
}
fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
) -> Result<(), String> {
loop {
terminal
.draw(|f| ui::draw(f, app))
.map_err(|e| format!("draw: {e}"))?;
if event::poll(std::time::Duration::from_millis(250)).map_err(|e| format!("poll: {e}"))? {
if let Event::Key(key) = event::read().map_err(|e| format!("read: {e}"))? {
if key.kind != KeyEventKind::Press {
continue;
}
match app.handle_key(key.code) {
AppAction::None => {}
AppAction::Quit => return Ok(()),
AppAction::Refresh => {
app.refresh().ok();
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyCode;
fn make_app() -> (tempfile::TempDir, App) {
let dir = tempfile::tempdir().unwrap();
let app = App::new(dir.path());
(dir, app)
}
#[test]
fn app_creates_and_refreshes() {
let (_dir, mut app) = make_app();
assert!(app.refresh().is_ok() || app.refresh().is_err());
}
#[test]
fn app_quit_key() {
let (_dir, mut app) = make_app();
assert_eq!(app.handle_key(KeyCode::Char('q')), AppAction::Quit);
}
#[test]
fn app_refresh_key() {
let (_dir, mut app) = make_app();
assert_eq!(app.handle_key(KeyCode::Char('r')), AppAction::Refresh);
}
#[test]
fn app_navigation_j_k() {
let (_dir, mut app) = make_app();
assert_eq!(app.handle_key(KeyCode::Char('j')), AppAction::None);
assert_eq!(app.handle_key(KeyCode::Char('k')), AppAction::None);
assert_eq!(app.selected, 0);
}
#[test]
fn app_help_view() {
let (_dir, mut app) = make_app();
app.handle_key(KeyCode::Char('?'));
assert_eq!(app.view, View::Help);
app.handle_key(KeyCode::Esc);
assert_eq!(app.view, View::List);
}
#[test]
fn app_search_mode_enter_exit() {
let (_dir, mut app) = make_app();
app.handle_key(KeyCode::Char('/'));
assert_eq!(app.input_mode, InputMode::Search);
app.handle_key(KeyCode::Esc);
assert_eq!(app.input_mode, InputMode::Normal);
assert!(app.filter.is_empty());
}
#[test]
fn app_search_typing() {
let (_dir, mut app) = make_app();
app.handle_key(KeyCode::Char('/'));
app.handle_key(KeyCode::Char('t'));
app.handle_key(KeyCode::Char('e'));
app.handle_key(KeyCode::Char('s'));
app.handle_key(KeyCode::Char('t'));
assert_eq!(app.text_input, "test");
app.handle_key(KeyCode::Enter);
assert_eq!(app.filter, "test");
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn app_search_backspace() {
let (_dir, mut app) = make_app();
app.handle_key(KeyCode::Char('/'));
app.handle_key(KeyCode::Char('a'));
app.handle_key(KeyCode::Char('b'));
app.handle_key(KeyCode::Backspace);
assert_eq!(app.text_input, "a");
}
#[test]
fn app_sort_cycle() {
let (_dir, mut app) = make_app();
assert_eq!(app.sort_column, SortColumn::Name);
app.handle_key(KeyCode::Char('s'));
assert_eq!(app.sort_column, SortColumn::State);
app.handle_key(KeyCode::Char('s'));
assert_eq!(app.sort_column, SortColumn::ShortId);
app.handle_key(KeyCode::Char('s'));
assert_eq!(app.sort_column, SortColumn::Name);
}
#[test]
fn app_sort_direction_toggle() {
let (_dir, mut app) = make_app();
assert!(app.sort_ascending);
app.handle_key(KeyCode::Char('S'));
assert!(!app.sort_ascending);
app.handle_key(KeyCode::Char('S'));
assert!(app.sort_ascending);
}
#[test]
fn app_detail_view_enter_back() {
let (_dir, mut app) = make_app();
// No envs, Enter should not switch view
app.handle_key(KeyCode::Enter);
assert_eq!(app.view, View::List);
}
#[test]
fn app_confirm_cancel() {
let (_dir, mut app) = make_app();
app.show_confirm = Some("destroy:abc123".to_owned());
app.handle_key(KeyCode::Char('n'));
assert!(app.show_confirm.is_none());
assert_eq!(app.status_message, "cancelled");
}
#[test]
fn app_home_end_keys() {
let (_dir, mut app) = make_app();
app.handle_key(KeyCode::Home);
assert_eq!(app.selected, 0);
app.handle_key(KeyCode::End);
assert_eq!(app.selected, 0); // No envs
}
#[test]
fn app_rename_mode_enter_exit() {
let (_dir, mut app) = make_app();
// No envs, so rename shouldn't activate
app.handle_key(KeyCode::Char('n'));
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn app_visible_count_empty() {
let (_dir, app) = make_app();
assert_eq!(app.visible_count(), 0);
}
#[test]
fn app_filter_with_no_envs() {
let (_dir, mut app) = make_app();
app.filter = "test".to_owned();
app.apply_filter();
assert!(app.filtered.is_empty());
}
}

View file

@ -0,0 +1,251 @@
use crate::app::{App, InputMode, View};
use ratatui::{
prelude::*,
widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap},
};
pub fn draw(f: &mut Frame<'_>, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(5),
Constraint::Length(1),
])
.split(f.area());
draw_header(f, chunks[0]);
match app.view {
View::List => draw_list(f, app, chunks[1]),
View::Detail => draw_detail(f, app, chunks[1]),
View::Help => draw_help(f, chunks[1]),
}
draw_status_bar(f, app, chunks[2]);
}
fn draw_header(f: &mut Frame<'_>, area: Rect) {
let title = Paragraph::new(format!(
" Karapace Environment Manager v{}",
env!("CARGO_PKG_VERSION")
))
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(title, area);
}
fn draw_list(f: &mut Frame<'_>, app: &App, area: Rect) {
if app.environments.is_empty() {
let msg = Paragraph::new(" No environments found. Press 'q' to quit.").block(
Block::default()
.borders(Borders::ALL)
.title(" Environments "),
);
f.render_widget(msg, area);
return;
}
let header = Row::new(vec![
Cell::from("SHORT_ID").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("NAME").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("STATE").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("ENV_ID").style(Style::default().add_modifier(Modifier::BOLD)),
])
.height(1);
let rows: Vec<Row<'_>> = app
.filtered
.iter()
.enumerate()
.map(|(vi, &ei)| {
let env = &app.environments[ei];
let style = if vi == app.selected {
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let state_style = state_color(&env.state.to_string());
Row::new(vec![
Cell::from(env.short_id.to_string()),
Cell::from(env.name.as_deref().unwrap_or("").to_owned()),
Cell::from(env.state.to_string()).style(state_style),
Cell::from(env.env_id.to_string()),
])
.style(style)
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(14),
Constraint::Length(16),
Constraint::Length(10),
Constraint::Min(20),
],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(format!(
" Environments ({}/{}) ",
app.visible_count(),
app.environments.len()
)));
f.render_widget(table, area);
}
fn draw_detail(f: &mut Frame<'_>, app: &App, area: Rect) {
let Some(env) = app.selected_env() else {
let msg = Paragraph::new(" No environment selected.")
.block(Block::default().borders(Borders::ALL).title(" Detail "));
f.render_widget(msg, area);
return;
};
let text = vec![
Line::from(vec![
Span::styled(
"env_id: ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(env.env_id.to_string()),
]),
Line::from(vec![
Span::styled(
"short_id: ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(env.short_id.to_string()),
]),
Line::from(vec![
Span::styled(
"name: ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(env.name.as_deref().unwrap_or("(none)")),
]),
Line::from(vec![
Span::styled(
"state: ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(env.state.to_string(), state_color(&env.state.to_string())),
]),
Line::from(vec![
Span::styled(
"base_layer: ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(env.base_layer.to_string()),
]),
Line::from(vec![
Span::styled(
"deps: ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(env.dependency_layers.len().to_string()),
]),
Line::from(vec![
Span::styled(
"ref_count: ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(env.ref_count.to_string()),
]),
Line::from(vec![
Span::styled(
"created_at: ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(&env.created_at),
]),
Line::from(vec![
Span::styled(
"updated_at: ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(&env.updated_at),
]),
Line::from(""),
Line::from(Span::styled(
" [Esc] back [d] destroy [f] freeze [a] archive [n] rename",
Style::default().fg(Color::DarkGray),
)),
];
let detail = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL).title(format!(
" {} ",
env.name.as_deref().unwrap_or(&env.short_id)
)))
.wrap(Wrap { trim: false });
f.render_widget(detail, area);
}
fn draw_help(f: &mut Frame<'_>, area: Rect) {
let text = vec![
Line::from(Span::styled(
"Keybindings",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(" j / ↓ Move down"),
Line::from(" k / ↑ Move up"),
Line::from(" g / Home Go to top"),
Line::from(" G / End Go to bottom"),
Line::from(" Enter View details"),
Line::from(" d Destroy (with confirm)"),
Line::from(" f Freeze environment"),
Line::from(" a Archive environment"),
Line::from(" n Rename environment"),
Line::from(" / Search / filter"),
Line::from(" s Cycle sort column"),
Line::from(" S Toggle sort direction"),
Line::from(" r Refresh list"),
Line::from(" ? Show this help"),
Line::from(" q / Esc Quit / Back"),
];
let help = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL).title(" Help "))
.wrap(Wrap { trim: false });
f.render_widget(help, area);
}
fn draw_status_bar(f: &mut Frame<'_>, app: &App, area: Rect) {
let status = if app.show_confirm.is_some() || app.input_mode != InputMode::Normal {
Paragraph::new(format!(" {} ", app.status_message)).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else {
Paragraph::new(format!(
" {} │ [j/k] nav [Enter] detail [d] destroy [f] freeze [/] search [?] help [q] quit",
app.status_message
))
.style(Style::default().fg(Color::DarkGray))
};
f.render_widget(status, area);
}
fn state_color(state: &str) -> Style {
match state {
"built" => Style::default().fg(Color::Green),
"running" => Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
"defined" => Style::default().fg(Color::Yellow),
"frozen" => Style::default().fg(Color::Blue),
"archived" => Style::default().fg(Color::DarkGray),
_ => Style::default(),
}
}