use crate::{
commands::auto_complete::fuzzy_match,
config::Keybindings,
mode::app_mode::{Mode, ModeRenderState, status_entry},
mode::normal_mode::NormalMode,
theme::Theme,
ui::{KeyResult, TabState},
};
use async_trait::async_trait;
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorsDialogKind {
ValueColors,
LevelColors,
}
#[derive(Debug, Clone)]
pub struct ValueColorEntry {
pub key: String,
pub label: String,
pub color: Color,
pub enabled: bool,
}
#[derive(Debug, Clone)]
pub struct ValueColorGroup {
pub label: String,
pub children: Vec<ValueColorEntry>,
}
#[derive(Debug, Clone)]
pub enum ValueColorRow {
Group(usize),
Entry(usize, usize),
}
#[derive(Debug)]
pub struct ValueColorsMode {
pub groups: Vec<ValueColorGroup>,
pub search: String,
pub selected: usize,
original_disabled: HashSet<String>,
pub kind: ColorsDialogKind,
}
impl ValueColorsMode {
pub fn new(groups: Vec<ValueColorGroup>, original_disabled: HashSet<String>) -> Self {
ValueColorsMode {
groups,
search: String::new(),
selected: 0,
original_disabled,
kind: ColorsDialogKind::ValueColors,
}
}
pub fn new_level_colors(
groups: Vec<ValueColorGroup>,
original_disabled: HashSet<String>,
) -> Self {
ValueColorsMode {
groups,
search: String::new(),
selected: 0,
original_disabled,
kind: ColorsDialogKind::LevelColors,
}
}
pub fn visible_rows(&self) -> Vec<ValueColorRow> {
let mut rows = Vec::new();
for (gi, group) in self.groups.iter().enumerate() {
if self.search.is_empty() {
rows.push(ValueColorRow::Group(gi));
for (ei, _) in group.children.iter().enumerate() {
rows.push(ValueColorRow::Entry(gi, ei));
}
} else {
let matching: Vec<usize> = group
.children
.iter()
.enumerate()
.filter(|(_, e)| {
let haystack = format!("{} {}", group.label, e.label);
fuzzy_match(&self.search, &haystack)
})
.map(|(i, _)| i)
.collect();
if !matching.is_empty() {
rows.push(ValueColorRow::Group(gi));
for ei in matching {
rows.push(ValueColorRow::Entry(gi, ei));
}
}
}
}
rows
}
pub fn group_enabled(&self, gi: usize) -> Option<bool> {
let group = &self.groups[gi];
let all = group.children.iter().all(|e| e.enabled);
let none = group.children.iter().all(|e| !e.enabled);
if all {
Some(true)
} else if none {
Some(false)
} else {
None
}
}
fn make_result(&self, disabled: HashSet<String>) -> KeyResult {
match self.kind {
ColorsDialogKind::ValueColors => KeyResult::ApplyValueColors(disabled),
ColorsDialogKind::LevelColors => KeyResult::ApplyLevelColors(disabled),
}
}
fn clamp_selected(&mut self) {
let count = self.visible_rows().len();
if count == 0 {
self.selected = 0;
} else if self.selected >= count {
self.selected = count - 1;
}
}
}
#[async_trait]
impl Mode for ValueColorsMode {
async fn handle_key(
mut self: Box<Self>,
tab: &mut TabState,
key: KeyCode,
modifiers: KeyModifiers,
) -> (Box<dyn Mode>, KeyResult) {
let kb = &tab.interaction.keybindings;
if kb.value_colors.cancel.matches(key, modifiers) {
if !self.search.is_empty() {
self.search.clear();
self.selected = 0;
return (self, KeyResult::Handled);
}
let result = self.make_result(self.original_disabled.clone());
return (Box::new(NormalMode::default()), result);
}
if kb.value_colors.apply.matches(key, modifiers) {
let disabled: HashSet<String> = self
.groups
.iter()
.flat_map(|g| g.children.iter())
.filter(|e| !e.enabled)
.map(|e| e.key.clone())
.collect();
let result = self.make_result(disabled);
return (Box::new(NormalMode::default()), result);
}
if kb.navigation.scroll_down.matches(key, modifiers) {
let count = self.visible_rows().len();
if count > 0 {
self.selected = (self.selected + 1).min(count - 1);
}
} else if kb.navigation.scroll_up.matches(key, modifiers) {
self.selected = self.selected.saturating_sub(1);
} else if kb.value_colors.toggle.matches(key, modifiers) {
let rows = self.visible_rows();
if let Some(row) = rows.get(self.selected) {
match row {
ValueColorRow::Group(gi) => {
let gi = *gi;
let target = !self.group_enabled(gi).unwrap_or(false);
for child in &mut self.groups[gi].children {
child.enabled = target;
}
}
ValueColorRow::Entry(gi, ei) => {
let (gi, ei) = (*gi, *ei);
self.groups[gi].children[ei].enabled =
!self.groups[gi].children[ei].enabled;
}
}
}
} else if kb.value_colors.all.matches(key, modifiers) && self.search.is_empty() {
for group in &mut self.groups {
for child in &mut group.children {
child.enabled = true;
}
}
} else if kb.value_colors.none.matches(key, modifiers) && self.search.is_empty() {
for group in &mut self.groups {
for child in &mut group.children {
child.enabled = false;
}
}
} else {
match key {
KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => {
self.search.push(c);
self.selected = 0;
self.clamp_selected();
}
KeyCode::Backspace => {
self.search.pop();
self.selected = 0;
self.clamp_selected();
}
_ => {}
}
}
(self, KeyResult::Ignored)
}
fn mode_bar_content(&self, kb: &Keybindings, theme: &Theme) -> Line<'static> {
let title = match self.kind {
ColorsDialogKind::ValueColors => "[VALUE COLORS] ",
ColorsDialogKind::LevelColors => "[LEVEL COLORS] ",
};
let mut spans: Vec<Span<'static>> = vec![Span::styled(
title,
Style::default()
.fg(theme.text_highlight_fg)
.add_modifier(Modifier::BOLD),
)];
status_entry(
&mut spans,
kb.value_colors.toggle.display(),
"toggle",
theme,
);
status_entry(&mut spans, kb.value_colors.all.display(), "all", theme);
status_entry(&mut spans, kb.value_colors.none.display(), "none", theme);
status_entry(&mut spans, kb.value_colors.apply.display(), "apply", theme);
status_entry(
&mut spans,
kb.value_colors.cancel.display(),
"cancel",
theme,
);
Line::from(spans)
}
fn render_state(&self) -> ModeRenderState {
match self.kind {
ColorsDialogKind::ValueColors => ModeRenderState::ValueColors {
groups: self.groups.clone(),
search: self.search.clone(),
selected: self.selected,
},
ColorsDialogKind::LevelColors => ModeRenderState::LevelColors {
groups: self.groups.clone(),
search: self.search.clone(),
selected: self.selected,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::db::LogManager;
use crate::ingestion::FileReader;
use crate::mode::app_mode::ModeRenderState;
use crate::ui::TabState;
use std::sync::Arc;
async fn make_tab() -> TabState {
let file_reader = FileReader::from_bytes(b"test line\n".to_vec());
let db = Arc::new(Database::in_memory().await.unwrap());
let log_manager = LogManager::new(db, None).await;
TabState::new(file_reader, log_manager, "test".to_string())
}
fn sample_groups() -> Vec<ValueColorGroup> {
vec![
ValueColorGroup {
label: "HTTP methods".to_string(),
children: vec![
ValueColorEntry {
key: "http_get".to_string(),
label: "GET".to_string(),
color: Color::Green,
enabled: true,
},
ValueColorEntry {
key: "http_post".to_string(),
label: "POST".to_string(),
color: Color::Cyan,
enabled: true,
},
],
},
ValueColorGroup {
label: "Status codes".to_string(),
children: vec![
ValueColorEntry {
key: "status_2xx".to_string(),
label: "2xx".to_string(),
color: Color::Green,
enabled: true,
},
ValueColorEntry {
key: "status_4xx".to_string(),
label: "4xx".to_string(),
color: Color::Yellow,
enabled: false,
},
],
},
ValueColorGroup {
label: "Identifiers".to_string(),
children: vec![ValueColorEntry {
key: "uuid".to_string(),
label: "UUIDs".to_string(),
color: Color::Magenta,
enabled: true,
}],
},
]
}
async fn press(
mode: ValueColorsMode,
tab: &mut TabState,
key: KeyCode,
) -> (Box<dyn Mode>, KeyResult) {
Box::new(mode)
.handle_key(tab, key, KeyModifiers::NONE)
.await
}
async fn press_dyn(
mode: Box<dyn Mode>,
tab: &mut TabState,
key: KeyCode,
) -> (Box<dyn Mode>, KeyResult) {
mode.handle_key(tab, key, KeyModifiers::NONE).await
}
#[tokio::test]
async fn test_visible_rows_no_search() {
let mode = ValueColorsMode::new(sample_groups(), HashSet::new());
let rows = mode.visible_rows();
assert_eq!(rows.len(), 8);
assert!(matches!(rows[0], ValueColorRow::Group(0)));
assert!(matches!(rows[1], ValueColorRow::Entry(0, 0)));
assert!(matches!(rows[3], ValueColorRow::Group(1)));
}
#[tokio::test]
async fn test_visible_rows_with_search() {
let mut mode = ValueColorsMode::new(sample_groups(), HashSet::new());
mode.search = "get".to_string();
let rows = mode.visible_rows();
assert_eq!(rows.len(), 2);
assert!(matches!(rows[0], ValueColorRow::Group(0)));
assert!(matches!(rows[1], ValueColorRow::Entry(0, 0)));
}
#[tokio::test]
async fn test_navigate_down() {
let mut tab = make_tab().await;
let mode = ValueColorsMode::new(sample_groups(), HashSet::new());
let (mode, _) = press(mode, &mut tab, KeyCode::Char('j')).await;
match mode.render_state() {
ModeRenderState::ValueColors { selected, .. } => assert_eq!(selected, 1),
other => panic!("expected ValueColors, got {:?}", other),
}
}
#[tokio::test]
async fn test_navigate_up_at_top() {
let mut tab = make_tab().await;
let mode = ValueColorsMode::new(sample_groups(), HashSet::new());
let (mode, _) = press(mode, &mut tab, KeyCode::Char('k')).await;
match mode.render_state() {
ModeRenderState::ValueColors { selected, .. } => assert_eq!(selected, 0),
other => panic!("expected ValueColors, got {:?}", other),
}
}
#[tokio::test]
async fn test_toggle_group_disables_all_children() {
let mut tab = make_tab().await;
let mode = ValueColorsMode::new(sample_groups(), HashSet::new());
let (mode, _) = press(mode, &mut tab, KeyCode::Char(' ')).await;
match mode.render_state() {
ModeRenderState::ValueColors { groups, .. } => {
assert!(groups[0].children.iter().all(|c| !c.enabled));
}
other => panic!("expected ValueColors, got {:?}", other),
}
}
#[tokio::test]
async fn test_toggle_group_enables_when_mixed() {
let mut tab = make_tab().await;
let groups = sample_groups(); let mode = ValueColorsMode::new(groups, HashSet::new());
let (mode, _) = press(mode, &mut tab, KeyCode::Char('j')).await;
let (mode, _) = press_dyn(mode, &mut tab, KeyCode::Char('j')).await;
let (mode, _) = press_dyn(mode, &mut tab, KeyCode::Char('j')).await;
let (mode, _) = press_dyn(mode, &mut tab, KeyCode::Char(' ')).await;
match mode.render_state() {
ModeRenderState::ValueColors { groups, .. } => {
assert!(groups[1].children.iter().all(|c| c.enabled));
}
other => panic!("expected ValueColors, got {:?}", other),
}
}
#[tokio::test]
async fn test_toggle_entry() {
let mut tab = make_tab().await;
let mode = ValueColorsMode::new(sample_groups(), HashSet::new());
let (mode, _) = press(mode, &mut tab, KeyCode::Char('j')).await;
let (mode, _) = press_dyn(mode, &mut tab, KeyCode::Char(' ')).await;
match mode.render_state() {
ModeRenderState::ValueColors { groups, .. } => {
assert!(!groups[0].children[0].enabled);
}
other => panic!("expected ValueColors, got {:?}", other),
}
}
#[tokio::test]
async fn test_enable_all() {
let mut tab = make_tab().await;
let mode = ValueColorsMode::new(sample_groups(), HashSet::new());
let (mode, _) = press(mode, &mut tab, KeyCode::Char('a')).await;
match mode.render_state() {
ModeRenderState::ValueColors { groups, .. } => {
assert!(groups.iter().all(|g| g.children.iter().all(|c| c.enabled)));
}
other => panic!("expected ValueColors, got {:?}", other),
}
}
#[tokio::test]
async fn test_disable_all() {
let mut tab = make_tab().await;
let mode = ValueColorsMode::new(sample_groups(), HashSet::new());
let (mode, _) = press(mode, &mut tab, KeyCode::Char('n')).await;
match mode.render_state() {
ModeRenderState::ValueColors { groups, .. } => {
assert!(groups.iter().all(|g| g.children.iter().all(|c| !c.enabled)));
}
other => panic!("expected ValueColors, got {:?}", other),
}
}
#[tokio::test]
async fn test_enter_collects_disabled() {
let mut tab = make_tab().await;
let mode = ValueColorsMode::new(sample_groups(), HashSet::new());
let (_, result) = press(mode, &mut tab, KeyCode::Enter).await;
match result {
KeyResult::ApplyValueColors(disabled) => {
assert!(disabled.contains("status_4xx"));
assert!(!disabled.contains("http_get"));
assert!(!disabled.contains("uuid"));
}
_ => panic!("expected ApplyValueColors"),
}
}
#[tokio::test]
async fn test_esc_with_search_clears_search() {
let mut tab = make_tab().await;
let mut mode = ValueColorsMode::new(sample_groups(), HashSet::new());
mode.search = "http".to_string();
let (mode, result) = press(mode, &mut tab, KeyCode::Esc).await;
assert!(matches!(result, KeyResult::Handled));
match mode.render_state() {
ModeRenderState::ValueColors { search, .. } => assert!(search.is_empty()),
other => panic!("expected ValueColors, got {:?}", other),
}
}
#[tokio::test]
async fn test_esc_without_search_restores_original() {
let mut tab = make_tab().await;
let mut original = HashSet::new();
original.insert("status_5xx".to_string());
let mode = ValueColorsMode::new(sample_groups(), original.clone());
let (_, result) = press(mode, &mut tab, KeyCode::Esc).await;
match result {
KeyResult::ApplyValueColors(disabled) => {
assert_eq!(disabled, original);
}
_ => panic!("expected ApplyValueColors"),
}
}
#[tokio::test]
async fn test_typing_activates_search() {
let mut tab = make_tab().await;
let mode = ValueColorsMode::new(sample_groups(), HashSet::new());
let (mode, _) = press(mode, &mut tab, KeyCode::Char('g')).await;
match mode.render_state() {
ModeRenderState::ValueColors { search, .. } => assert_eq!(search, "g"),
other => panic!("expected ValueColors, got {:?}", other),
}
}
#[tokio::test]
async fn test_backspace_removes_search_char() {
let mut tab = make_tab().await;
let mut mode = ValueColorsMode::new(sample_groups(), HashSet::new());
mode.search = "ge".to_string();
let (mode, _) = press(mode, &mut tab, KeyCode::Backspace).await;
match mode.render_state() {
ModeRenderState::ValueColors { search, .. } => assert_eq!(search, "g"),
other => panic!("expected ValueColors, got {:?}", other),
}
}
#[tokio::test]
async fn test_group_enabled_all() {
let mode = ValueColorsMode::new(sample_groups(), HashSet::new());
assert_eq!(mode.group_enabled(0), Some(true)); }
#[tokio::test]
async fn test_group_enabled_mixed() {
let mode = ValueColorsMode::new(sample_groups(), HashSet::new());
assert_eq!(mode.group_enabled(1), None); }
#[tokio::test]
async fn test_group_enabled_none() {
let mut groups = sample_groups();
for child in &mut groups[0].children {
child.enabled = false;
}
let mode = ValueColorsMode::new(groups, HashSet::new());
assert_eq!(mode.group_enabled(0), Some(false));
}
#[tokio::test]
async fn test_mode_bar_content() {
let mode = ValueColorsMode::new(sample_groups(), HashSet::new());
assert!(matches!(
mode.render_state(),
ModeRenderState::ValueColors { .. }
));
}
#[tokio::test]
async fn test_search_filters_to_matching_groups() {
let mut mode = ValueColorsMode::new(sample_groups(), HashSet::new());
mode.search = "uuid".to_string();
let rows = mode.visible_rows();
assert_eq!(rows.len(), 2);
assert!(matches!(rows[0], ValueColorRow::Group(2)));
assert!(matches!(rows[1], ValueColorRow::Entry(2, 0)));
}
#[tokio::test]
async fn test_unrecognized_key_returns_ignored() {
let mut tab = make_tab().await;
let mode = ValueColorsMode::new(sample_groups(), HashSet::new());
let (_, result) = press(mode, &mut tab, KeyCode::F(2)).await;
assert!(matches!(result, KeyResult::Ignored));
}
}