use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::app::App;
use crate::mentions::Mention;
pub const NOTICE_KEYS: &str = "j/k: select enter: jump i: ignore r: read esc: ignore all";
pub const VIEW_KEYS: &str = "j/k: select enter: open i: ignore r: read esc: close";
#[derive(Debug, Clone, Default, PartialEq)]
pub struct MentionsView {
pub rows: Vec<Mention>,
pub selected: usize,
pub for_issue: Option<u32>,
}
impl MentionsView {
#[must_use]
pub fn new(rows: Vec<Mention>, for_issue: Option<u32>) -> Self {
let mut view = MentionsView {
rows,
selected: 0,
for_issue,
};
view.clamp();
view
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
#[must_use]
pub fn current(&self) -> Option<&Mention> {
self.rows.get(self.selected)
}
pub fn clamp(&mut self) {
self.selected = self.selected.min(self.rows.len().saturating_sub(1));
}
pub fn move_by(&mut self, delta: isize) {
if self.rows.is_empty() {
self.selected = 0;
return;
}
let last = (self.rows.len() - 1) as isize;
let next = (self.selected as isize + delta).clamp(0, last);
self.selected = next as usize;
}
pub fn drop_current(&mut self) {
if self.selected < self.rows.len() {
self.rows.remove(self.selected);
}
self.clamp();
}
}
pub fn render_indicator(app: &App, f: &mut Frame, area: Rect) {
let chord = app
.bindings
.chords(
crate::keybindings::InputContext::Board,
crate::keybindings::TuiAction::OpenMentions,
)
.first()
.map_or_else(|| "i".to_string(), crate::keybindings::KeyChord::display);
let count = app.unread_mentions;
let text = format!(
"@ {count} unread mention{} — press {chord} to open",
if count == 1 { "" } else { "s" }
);
let line = Line::from(Span::styled(
text,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
f.render_widget(Paragraph::new(line), area);
}
pub fn render_notice(app: &App, f: &mut Frame, area: Rect) {
let Some(view) = app.mentions.as_ref() else {
return;
};
render_rows(f, area, view, "Mentions for you", NOTICE_KEYS);
}
pub fn render_view(app: &App, f: &mut Frame, area: Rect) {
let Some(view) = app.mentions_view.as_ref() else {
return;
};
render_rows(f, area, view, "Pending mentions", VIEW_KEYS);
}
fn render_rows(f: &mut Frame, area: Rect, view: &MentionsView, title: &str, keys: &str) {
if area.width == 0 || area.height == 0 {
return;
}
let rect = crate::ui::modal::modal_rect(area);
f.render_widget(Clear, rect);
let header = format!("{title} ({})", view.rows.len());
let block = Block::default().borders(Borders::ALL).title(header);
let inner = block.inner(rect);
f.render_widget(block, rect);
let footer_h: u16 = if inner.height > 2 { 2 } else { 0 };
let body_h = inner.height.saturating_sub(footer_h);
if body_h == 0 {
return;
}
if view.is_empty() {
f.render_widget(
Paragraph::new("Nothing is pending. Press Esc to close."),
Rect { height: 1, ..inner },
);
render_footer(f, inner, keys);
return;
}
let mut lines: Vec<Line> = Vec::new();
for (index, mention) in view.rows.iter().enumerate() {
let selected = index == view.selected;
let marker = if selected { ">" } else { " " };
let head = format!("{marker} #{:<4} {}", mention.issue_number, mention.body);
let head_style = if selected {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
lines.push(Line::from(Span::styled(head, head_style)));
let context_style = if selected {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
lines.push(Line::from(Span::styled(
format!(" {}", mention.context),
context_style,
)));
}
f.render_widget(
Paragraph::new(lines),
Rect {
height: body_h,
..inner
},
);
render_footer(f, inner, keys);
}
fn render_footer(f: &mut Frame, inner: Rect, keys: &str) {
if inner.height < 2 {
return;
}
let row = Rect {
y: inner.y + inner.height - 1,
height: 1,
..inner
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(
keys,
Style::default().fg(Color::DarkGray),
))),
row,
);
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::mentions::{Mention, MentionState};
use std::collections::BTreeMap;
fn mention(number: u32, body: &str, context: &str) -> Mention {
Mention {
id: format!("M{number}"),
key: format!("k{number}"),
issue_number: number,
issue_id: format!("ID{number}"),
issue_file: format!("a/b/{number}.md"),
body: body.to_string(),
context: context.to_string(),
created: "2026-10-10T00:00:00Z".to_string(),
state: MentionState::Unread,
extra: BTreeMap::new(),
}
}
#[test]
fn cursor_moves_within_the_rows_and_clamps() {
let mut view = MentionsView::new(
vec![mention(1, "alice", "a"), mention(2, "bob", "b")],
Some(1),
);
assert_eq!(view.current().map(|m| m.issue_number), Some(1));
view.move_by(1);
assert_eq!(view.current().map(|m| m.issue_number), Some(2));
view.move_by(5);
assert_eq!(view.current().map(|m| m.issue_number), Some(2), "clamped");
view.move_by(-9);
assert_eq!(view.current().map(|m| m.issue_number), Some(1), "clamped");
}
#[test]
fn dropping_the_current_row_keeps_the_cursor_in_range() {
let mut view =
MentionsView::new(vec![mention(1, "alice", "a"), mention(2, "bob", "b")], None);
view.move_by(1);
view.drop_current();
assert_eq!(view.rows.len(), 1);
assert_eq!(view.current().map(|m| m.issue_number), Some(1));
view.drop_current();
assert!(view.is_empty());
assert_eq!(view.current(), None);
view.move_by(1);
}
#[test]
fn a_new_view_clamps_a_stale_selection() {
let view = MentionsView {
rows: vec![mention(1, "alice", "a")],
selected: 9,
for_issue: None,
};
let mut view = view;
view.clamp();
assert_eq!(view.selected, 0);
}
}