use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState},
Frame,
};
use crate::models::{MessagePriority, MessageType, QueuedMessage};
use crate::utils::truncate_str;
#[derive(Debug, Clone, Default)]
pub struct MessageFilter {
pub message_type: Option<MessageType>,
pub priority: Option<MessagePriority>,
pub recipient_filter: Option<String>,
}
pub struct MessagingDisplay {
messages: Vec<QueuedMessage>,
filtered_indices: Vec<usize>,
state: ListState,
#[allow(dead_code)]
focused: bool,
filter: MessageFilter,
}
impl MessagingDisplay {
pub fn new() -> Self {
Self {
messages: Vec::new(),
filtered_indices: Vec::new(),
state: ListState::default(),
focused: false,
filter: MessageFilter::default(),
}
}
pub fn set_messages(&mut self, messages: Vec<QueuedMessage>) {
self.messages = messages;
self.apply_filter();
}
#[allow(dead_code)]
pub fn messages(&self) -> &[QueuedMessage] {
&self.messages
}
#[allow(dead_code)]
pub fn visible_count(&self) -> usize {
self.filtered_indices.len()
}
#[allow(dead_code)]
pub fn total_count(&self) -> usize {
self.messages.len()
}
#[allow(dead_code)]
pub fn set_filter(&mut self, filter: MessageFilter) {
self.filter = filter;
self.apply_filter();
}
#[allow(dead_code)]
pub fn clear_filter(&mut self) {
self.filter = MessageFilter::default();
self.apply_filter();
}
#[allow(dead_code)]
pub fn filter(&self) -> &MessageFilter {
&self.filter
}
fn apply_filter(&mut self) {
self.filtered_indices = self
.messages
.iter()
.enumerate()
.filter(|(_, msg)| {
if let Some(ref filter_type) = self.filter.message_type {
if &msg.message.message_type != filter_type {
return false;
}
}
if let Some(ref filter_priority) = self.filter.priority {
if &msg.message.priority != filter_priority {
return false;
}
}
if let Some(ref recipient_filter) = self.filter.recipient_filter {
let recipient_str = format!("{:?}", msg.message.to);
if !recipient_str
.to_lowercase()
.contains(&recipient_filter.to_lowercase())
{
return false;
}
}
true
})
.map(|(i, _)| i)
.collect();
if let Some(selected) = self.state.selected() {
if selected >= self.filtered_indices.len() {
if self.filtered_indices.is_empty() {
self.state.select(None);
} else {
self.state.select(Some(0));
}
}
}
}
#[allow(dead_code)]
pub fn set_focused(&mut self, focused: bool) {
self.focused = focused;
}
#[allow(dead_code)]
pub fn is_focused(&self) -> bool {
self.focused
}
#[allow(dead_code)]
pub fn next(&mut self) {
super::select_next(&mut self.state, self.filtered_indices.len());
}
#[allow(dead_code)]
pub fn prev(&mut self) {
super::select_prev(&mut self.state, self.filtered_indices.len());
}
#[allow(dead_code)]
pub fn selected_message(&self) -> Option<&QueuedMessage> {
self.state
.selected()
.and_then(|i| self.filtered_indices.get(i))
.and_then(|&idx| self.messages.get(idx))
}
#[allow(dead_code)]
fn type_symbol(message_type: &MessageType) -> (&'static str, Color) {
match message_type {
MessageType::Query => ("?", Color::Cyan),
MessageType::Response => ("R", Color::Green),
MessageType::Notify => ("!", Color::Yellow),
MessageType::Delegate => ("D", Color::Magenta),
}
}
#[allow(dead_code)]
fn priority_symbol(priority: &MessagePriority) -> (&'static str, Color) {
match priority {
MessagePriority::High => ("⬆", Color::Red),
MessagePriority::Normal => (" ", Color::White),
MessagePriority::Low => ("⬇", Color::Gray),
}
}
#[allow(dead_code)]
fn recipient_display(recipient: &crate::models::MessageRecipient) -> String {
match recipient {
crate::models::MessageRecipient::ExpertId { expert_id } => format!("→{expert_id}"),
crate::models::MessageRecipient::Role { role } => {
format!("→@{}", truncate_str(role, 7))
}
}
}
#[allow(dead_code)]
pub fn render(&mut self, frame: &mut Frame, area: Rect) {
let items: Vec<ListItem> = self
.filtered_indices
.iter()
.map(|&idx| {
let msg = &self.messages[idx];
let (type_symbol, type_color) = Self::type_symbol(&msg.message.message_type);
let (priority_symbol, priority_color) =
Self::priority_symbol(&msg.message.priority);
let recipient = Self::recipient_display(&msg.message.to);
let subject = truncate_str(&msg.message.content.subject, 25);
let time_ago = {
let duration = chrono::Utc::now().signed_duration_since(msg.message.created_at);
if duration.num_hours() > 0 {
format!("{}h", duration.num_hours())
} else if duration.num_minutes() > 0 {
format!("{}m", duration.num_minutes())
} else {
format!("{}s", duration.num_seconds().max(0))
}
};
let status_indicator = if msg.is_failed() {
("✗", Color::Red)
} else if msg.is_expired() {
("⌛", Color::DarkGray)
} else if msg.attempts > 0 {
("↻", Color::Yellow)
} else {
("○", Color::White)
};
let spans = vec![
Span::styled(
type_symbol,
Style::default().fg(type_color).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("[{}{}]", msg.message.from_expert_id, recipient),
Style::default().add_modifier(Modifier::DIM),
),
Span::raw(" "),
Span::styled(subject, Style::default()),
Span::raw(" "),
Span::styled(
format!("{time_ago:>4}"),
Style::default().fg(Color::DarkGray),
),
Span::raw(" "),
Span::styled(priority_symbol, Style::default().fg(priority_color)),
Span::styled(status_indicator.0, Style::default().fg(status_indicator.1)),
];
ListItem::new(Line::from(spans))
})
.collect();
let border_style = if self.focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::Gray)
};
let title = if self.filtered_indices.len() == self.messages.len() {
format!("Messages [{}]", self.messages.len())
} else {
format!(
"Messages [{}/{}]",
self.filtered_indices.len(),
self.messages.len()
)
};
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(title),
)
.highlight_style(
Style::default()
.add_modifier(Modifier::REVERSED)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
frame.render_stateful_widget(list, area, &mut self.state);
}
}
impl Default for MessagingDisplay {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{Message, MessageContent, MessageRecipient};
fn create_test_queued_message(
from: u32,
to: MessageRecipient,
msg_type: MessageType,
priority: MessagePriority,
subject: &str,
) -> QueuedMessage {
let content = MessageContent {
subject: subject.to_string(),
body: "Test body".to_string(),
};
let message = Message::new(from, to, msg_type, content).with_priority(priority);
QueuedMessage::new(message)
}
#[test]
fn messaging_display_new_creates_empty() {
let display = MessagingDisplay::new();
assert!(display.messages.is_empty());
assert!(display.filtered_indices.is_empty());
assert!(!display.focused);
}
#[test]
fn messaging_display_set_messages_updates_and_filters() {
let mut display = MessagingDisplay::new();
let messages = vec![
create_test_queued_message(
0,
MessageRecipient::expert_id(1),
MessageType::Query,
MessagePriority::High,
"Test 1",
),
create_test_queued_message(
1,
MessageRecipient::role("backend".to_string()),
MessageType::Delegate,
MessagePriority::Normal,
"Test 2",
),
];
display.set_messages(messages);
assert_eq!(display.total_count(), 2);
assert_eq!(display.visible_count(), 2);
}
#[test]
fn messaging_display_filter_by_type() {
let mut display = MessagingDisplay::new();
let messages = vec![
create_test_queued_message(
0,
MessageRecipient::expert_id(1),
MessageType::Query,
MessagePriority::Normal,
"Query message",
),
create_test_queued_message(
0,
MessageRecipient::expert_id(2),
MessageType::Delegate,
MessagePriority::Normal,
"Delegate message",
),
create_test_queued_message(
0,
MessageRecipient::expert_id(3),
MessageType::Query,
MessagePriority::Normal,
"Another query",
),
];
display.set_messages(messages);
assert_eq!(display.visible_count(), 3);
display.set_filter(MessageFilter {
message_type: Some(MessageType::Query),
..Default::default()
});
assert_eq!(display.visible_count(), 2);
display.clear_filter();
assert_eq!(display.visible_count(), 3);
}
#[test]
fn messaging_display_filter_by_priority() {
let mut display = MessagingDisplay::new();
let messages = vec![
create_test_queued_message(
0,
MessageRecipient::expert_id(1),
MessageType::Query,
MessagePriority::High,
"High priority",
),
create_test_queued_message(
0,
MessageRecipient::expert_id(2),
MessageType::Query,
MessagePriority::Normal,
"Normal priority",
),
];
display.set_messages(messages);
display.set_filter(MessageFilter {
priority: Some(MessagePriority::High),
..Default::default()
});
assert_eq!(display.visible_count(), 1);
}
#[test]
fn messaging_display_navigation() {
let mut display = MessagingDisplay::new();
let messages = vec![
create_test_queued_message(
0,
MessageRecipient::expert_id(1),
MessageType::Query,
MessagePriority::Normal,
"Message 1",
),
create_test_queued_message(
0,
MessageRecipient::expert_id(2),
MessageType::Query,
MessagePriority::Normal,
"Message 2",
),
create_test_queued_message(
0,
MessageRecipient::expert_id(3),
MessageType::Query,
MessagePriority::Normal,
"Message 3",
),
];
display.set_messages(messages);
assert!(display.selected_message().is_none());
display.next();
assert!(display.selected_message().is_some());
display.next();
display.next();
display.next(); assert!(display.selected_message().is_some());
}
#[test]
fn messaging_display_prev_navigation() {
let mut display = MessagingDisplay::new();
let messages = vec![
create_test_queued_message(
0,
MessageRecipient::expert_id(1),
MessageType::Query,
MessagePriority::Normal,
"Message 1",
),
create_test_queued_message(
0,
MessageRecipient::expert_id(2),
MessageType::Query,
MessagePriority::Normal,
"Message 2",
),
];
display.set_messages(messages);
display.prev();
assert!(display.selected_message().is_some());
display.prev(); assert!(display.selected_message().is_some());
}
#[test]
fn messaging_display_focus_state() {
let mut display = MessagingDisplay::new();
assert!(!display.is_focused());
display.set_focused(true);
assert!(display.is_focused());
display.set_focused(false);
assert!(!display.is_focused());
}
#[test]
fn messaging_display_type_symbol_returns_correct_values() {
assert_eq!(MessagingDisplay::type_symbol(&MessageType::Query).0, "?");
assert_eq!(MessagingDisplay::type_symbol(&MessageType::Response).0, "R");
assert_eq!(MessagingDisplay::type_symbol(&MessageType::Notify).0, "!");
assert_eq!(MessagingDisplay::type_symbol(&MessageType::Delegate).0, "D");
}
#[test]
fn messaging_display_priority_symbol_returns_correct_values() {
assert_eq!(
MessagingDisplay::priority_symbol(&MessagePriority::High).0,
"⬆"
);
assert_eq!(
MessagingDisplay::priority_symbol(&MessagePriority::Normal).0,
" "
);
assert_eq!(
MessagingDisplay::priority_symbol(&MessagePriority::Low).0,
"⬇"
);
}
#[test]
fn messaging_display_recipient_display_formats_correctly() {
assert!(MessagingDisplay::recipient_display(&MessageRecipient::expert_id(5)).contains("5"));
assert!(MessagingDisplay::recipient_display(&MessageRecipient::role(
"backend".to_string()
))
.contains("@"));
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use crate::models::{
Message, MessageContent, MessagePriority, MessageRecipient, MessageType, QueuedMessage,
};
use proptest::prelude::*;
fn arbitrary_message_type() -> impl Strategy<Value = MessageType> {
prop_oneof![
Just(MessageType::Query),
Just(MessageType::Response),
Just(MessageType::Notify),
Just(MessageType::Delegate),
]
}
fn arbitrary_message_priority() -> impl Strategy<Value = MessagePriority> {
prop_oneof![
Just(MessagePriority::High),
Just(MessagePriority::Normal),
Just(MessagePriority::Low),
]
}
fn arbitrary_message_recipient() -> impl Strategy<Value = MessageRecipient> {
prop_oneof![
(1u32..100).prop_map(MessageRecipient::expert_id),
"[a-zA-Z0-9]{1,20}".prop_map(MessageRecipient::role),
]
}
fn arbitrary_queued_message() -> impl Strategy<Value = QueuedMessage> {
(
0u32..10,
arbitrary_message_recipient(),
arbitrary_message_type(),
arbitrary_message_priority(),
"[a-zA-Z0-9 ]{1,50}",
"[a-zA-Z0-9 ]{1,200}",
)
.prop_map(|(from_id, to, msg_type, priority, subject, body)| {
let content = MessageContent { subject, body };
let message = Message::new(from_id, to, msg_type, content).with_priority(priority);
QueuedMessage::new(message)
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn ui_display_completeness(
messages in prop::collection::vec(arbitrary_queued_message(), 0..20)
) {
let mut display = MessagingDisplay::new();
display.set_messages(messages.clone());
assert_eq!(
display.total_count(),
messages.len(),
"Display should show all queued messages"
);
assert_eq!(
display.visible_count(),
messages.len(),
"Without filter, all messages should be visible"
);
let displayed_messages = display.messages();
assert_eq!(
displayed_messages.len(),
messages.len(),
"messages() should return all set messages"
);
if !messages.is_empty() {
display.next();
assert!(
display.selected_message().is_some(),
"Should be able to select a message"
);
}
}
#[test]
fn ui_display_filter_by_type(
messages in prop::collection::vec(arbitrary_queued_message(), 1..20),
filter_type in arbitrary_message_type()
) {
let mut display = MessagingDisplay::new();
display.set_messages(messages.clone());
display.set_filter(MessageFilter {
message_type: Some(filter_type),
..Default::default()
});
let expected_count = messages.iter()
.filter(|m| m.message.message_type == filter_type)
.count();
assert_eq!(
display.visible_count(),
expected_count,
"Filter should show only messages of type {:?}",
filter_type
);
display.clear_filter();
assert_eq!(
display.visible_count(),
messages.len(),
"Clear filter should restore all messages"
);
}
#[test]
fn ui_display_filter_by_priority(
messages in prop::collection::vec(arbitrary_queued_message(), 1..20),
filter_priority in arbitrary_message_priority()
) {
let mut display = MessagingDisplay::new();
display.set_messages(messages.clone());
display.set_filter(MessageFilter {
priority: Some(filter_priority),
..Default::default()
});
let expected_count = messages.iter()
.filter(|m| m.message.priority == filter_priority)
.count();
assert_eq!(
display.visible_count(),
expected_count,
"Filter should show only messages with priority {:?}",
filter_priority
);
}
#[test]
fn ui_display_navigation_consistency(
messages in prop::collection::vec(arbitrary_queued_message(), 2..10)
) {
let mut display = MessagingDisplay::new();
display.set_messages(messages.clone());
let num_messages = messages.len();
display.next();
let first_index = display.state.selected();
assert_eq!(first_index, Some(0), "First next() should select index 0");
for expected_index in 1..num_messages {
display.next();
let current_index = display.state.selected();
assert_eq!(
current_index,
Some(expected_index),
"Navigation should proceed through indices sequentially"
);
}
display.next();
let wrapped_index = display.state.selected();
assert_eq!(
wrapped_index,
Some(0),
"Navigation should wrap around to index 0"
);
display.next();
let second_index = display.state.selected();
assert_eq!(second_index, Some(1), "next() should go to index 1");
display.prev();
let back_to_first_index = display.state.selected();
assert_eq!(
back_to_first_index,
Some(0),
"prev() should reverse next() and go back to index 0"
);
}
#[test]
fn ui_display_message_fields_accessible(
messages in prop::collection::vec(arbitrary_queued_message(), 1..10)
) {
let mut display = MessagingDisplay::new();
display.set_messages(messages.clone());
display.next();
if let Some(selected) = display.selected_message() {
let _ = &selected.message.message_id;
let _ = &selected.message.from_expert_id;
let _ = &selected.message.to;
let _ = &selected.message.message_type;
let _ = &selected.message.priority;
let _ = &selected.message.created_at;
let _ = &selected.message.content.subject;
let _ = &selected.message.content.body;
let _ = &selected.attempts;
let _ = selected.is_failed();
let _ = selected.is_expired();
let msg_id = &selected.message.message_id;
assert!(
messages.iter().any(|m| &m.message.message_id == msg_id),
"Selected message should be from the input list"
);
}
}
#[test]
fn ui_display_type_and_priority_symbols(
msg_type in arbitrary_message_type(),
priority in arbitrary_message_priority()
) {
let (type_symbol, type_color) = MessagingDisplay::type_symbol(&msg_type);
assert!(
!type_symbol.is_empty(),
"Type symbol should not be empty for {:?}",
msg_type
);
let _ = type_color;
let (priority_symbol, priority_color) = MessagingDisplay::priority_symbol(&priority);
let _ = priority_symbol;
let _ = priority_color;
let all_types = [
MessageType::Query,
MessageType::Response,
MessageType::Notify,
MessageType::Delegate,
];
let symbols: Vec<_> = all_types.iter()
.map(|t| MessagingDisplay::type_symbol(t).0)
.collect();
for (i, s1) in symbols.iter().enumerate() {
for (j, s2) in symbols.iter().enumerate() {
if i != j {
assert_ne!(
s1, s2,
"Type symbols should be unique: {:?} vs {:?}",
all_types[i], all_types[j]
);
}
}
}
}
#[test]
fn ui_display_recipient_formatting(
recipient in arbitrary_message_recipient()
) {
let formatted = MessagingDisplay::recipient_display(&recipient);
assert!(
!formatted.is_empty(),
"Recipient display should not be empty"
);
assert!(
formatted.contains("→"),
"Recipient display should contain direction indicator: {}",
formatted
);
if let MessageRecipient::Role { .. } = recipient {
assert!(
formatted.contains("@"),
"Role recipient should have @ indicator: {}",
formatted
);
}
}
}
}