Overhaul breadarr-tui UX: filter/sort, color, help overlay, cross-nav

Library tab gets a cycling filter (all/series/movies/missing/unmonitored)
and sort (title/missing/kind), color-coded kind tags and missing-count
severity, and monitor-toggle without opening detail. Keybinding hints move
out of cramped block titles into a context-aware status bar plus a `?`
help overlay, both driven by one shared keybinding table so they can't
drift apart. Episode status icons and review-queue confidence get the same
severity coloring. Stuck tab gains real selection/focus and can jump
straight to a show's Library detail (needed adding media_item_id to
StalledGrab's query — the one small backend touch in this pass). Adds a
manual refresh-now key.
This commit is contained in:
Breadway 2026-07-21 20:55:04 +08:00
parent 99604a7e55
commit 6e7be67f0b
5 changed files with 534 additions and 74 deletions

View file

@ -74,6 +74,88 @@ impl AddKind {
}
}
/// Client-side filter over the already-fetched `media_items` — the server
/// has no filter/sort query params, and the library is small enough that
/// refiltering the in-memory `Vec` on every keypress is free.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LibraryFilter {
All,
Series,
Movies,
Missing,
Unmonitored,
}
impl LibraryFilter {
pub const ALL: [LibraryFilter; 5] = [
LibraryFilter::All,
LibraryFilter::Series,
LibraryFilter::Movies,
LibraryFilter::Missing,
LibraryFilter::Unmonitored,
];
pub fn next(self) -> Self {
let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
Self::ALL[(idx + 1) % Self::ALL.len()]
}
pub fn label(&self) -> &'static str {
match self {
LibraryFilter::All => "all",
LibraryFilter::Series => "series",
LibraryFilter::Movies => "movies",
LibraryFilter::Missing => "missing",
LibraryFilter::Unmonitored => "unmonitored",
}
}
fn matches(&self, m: &MediaItemSummary) -> bool {
match self {
LibraryFilter::All => true,
LibraryFilter::Series => m.kind == "series",
LibraryFilter::Movies => m.kind == "movie",
LibraryFilter::Missing => m.missing_count > 0,
LibraryFilter::Unmonitored => !m.monitored,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LibrarySort {
TitleAsc,
MissingDesc,
Kind,
}
impl LibrarySort {
pub const ALL: [LibrarySort; 3] = [
LibrarySort::TitleAsc,
LibrarySort::MissingDesc,
LibrarySort::Kind,
];
pub fn next(self) -> Self {
let idx = Self::ALL.iter().position(|s| *s == self).unwrap_or(0);
Self::ALL[(idx + 1) % Self::ALL.len()]
}
pub fn label(&self) -> &'static str {
match self {
LibrarySort::TitleAsc => "title",
LibrarySort::MissingDesc => "missing",
LibrarySort::Kind => "kind",
}
}
}
/// Which half of the Stuck tab has selection/arrow-key focus.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StuckSection {
Stalled,
Maxed,
}
/// A weight axis's display name plus a getter/setter pair, so
/// `WEIGHT_FIELDS` can enumerate `WeightsDto`'s fields by position instead
/// of every caller matching on an index.
@ -123,9 +205,24 @@ pub struct App {
pub focus: Focus,
pub status: String,
pub should_quit: bool,
/// Toggled by `?`; intercepted at the top of `main.rs`'s key handler
/// like `Focus::AddSearchInput`/`Focus::WeightInput`, but kept as its
/// own bool (not folded into `Focus`) since it needs to open from any
/// tab rather than being scoped to one.
pub help_visible: bool,
/// Set by the `R` key; consumed by `main.rs`'s refresh loop to bypass
/// the normal 3s throttle for one immediate refresh.
pub force_refresh: bool,
pub media_items: Vec<MediaItemSummary>,
pub media_state: ListState,
/// Filtered+sorted indices into `media_items`, recomputed by
/// `recompute_library_view` — the top-level Library list and
/// `media_state` always operate over this, never over `media_items`
/// directly, so filter/sort never has to touch the raw fetched data.
pub library_view: Vec<usize>,
pub library_filter: LibraryFilter,
pub library_sort: LibrarySort,
pub detail: Option<MediaItemDetail>,
/// Selection within `detail.episodes` — separate from `media_state`
/// since they're two different lists sharing the same tab.
@ -142,6 +239,9 @@ pub struct App {
pub add_results_state: ListState,
pub stuck: Option<StuckReport>,
pub stuck_focus: StuckSection,
pub stalled_state: ListState,
pub maxed_state: ListState,
pub calendar: Vec<CalendarEntry>,
pub library_health: Option<LibraryHealthReport>,
@ -185,8 +285,13 @@ impl App {
focus: Focus::List,
status: String::new(),
should_quit: false,
help_visible: false,
force_refresh: false,
media_items: Vec::new(),
media_state: ListState::default(),
library_view: Vec::new(),
library_filter: LibraryFilter::All,
library_sort: LibrarySort::TitleAsc,
detail: None,
episode_state: ListState::default(),
releases: Vec::new(),
@ -197,6 +302,9 @@ impl App {
add_results: Vec::new(),
add_results_state: ListState::default(),
stuck: None,
stuck_focus: StuckSection::Stalled,
stalled_state: ListState::default(),
maxed_state: ListState::default(),
calendar: Vec::new(),
library_health: None,
candidates: Vec::new(),
@ -236,9 +344,7 @@ impl App {
}
} else {
self.media_items = self.client.list_media().await?;
if self.media_state.selected().is_none() && !self.media_items.is_empty() {
self.media_state.select(Some(0));
}
self.recompute_library_view();
}
}
Tab::History => {
@ -255,7 +361,17 @@ impl App {
}
Tab::Add => {}
Tab::Stuck => {
self.stuck = Some(self.client.stuck().await?);
let report = self.client.stuck().await?;
if self.stalled_state.selected().is_none() && !report.stalled_grabs.is_empty()
{
self.stalled_state.select(Some(0));
}
if self.maxed_state.selected().is_none()
&& !report.maxed_out_search_targets.is_empty()
{
self.maxed_state.select(Some(0));
}
self.stuck = Some(report);
}
Tab::Calendar => {
self.calendar = self.client.calendar().await?;
@ -287,13 +403,68 @@ impl App {
self.detail.as_ref().map(|d| d.id)
}
/// Rebuilds `library_view` from `media_items` under the current
/// filter/sort, then re-selects whichever item was selected before (by
/// id, not raw index) if it's still in view — falls back to the first
/// item, or no selection if the view is now empty. Called after
/// `media_items` changes, and after `library_filter`/`library_sort`
/// change.
pub fn recompute_library_view(&mut self) {
let previously_selected_id = self.selected_media_item().map(|m| m.id);
let mut indices: Vec<usize> = self
.media_items
.iter()
.enumerate()
.filter(|(_, m)| self.library_filter.matches(m))
.map(|(i, _)| i)
.collect();
match self.library_sort {
LibrarySort::TitleAsc => indices.sort_by(|&a, &b| {
self.media_items[a]
.title
.to_lowercase()
.cmp(&self.media_items[b].title.to_lowercase())
}),
LibrarySort::MissingDesc => indices.sort_by(|&a, &b| {
self.media_items[b]
.missing_count
.cmp(&self.media_items[a].missing_count)
}),
LibrarySort::Kind => indices.sort_by(|&a, &b| {
self.media_items[a].kind.cmp(&self.media_items[b].kind).then_with(|| {
self.media_items[a]
.title
.to_lowercase()
.cmp(&self.media_items[b].title.to_lowercase())
})
}),
}
self.library_view = indices;
match previously_selected_id
.and_then(|id| self.library_view.iter().position(|&i| self.media_items[i].id == id))
{
Some(pos) => self.media_state.select(Some(pos)),
None if !self.library_view.is_empty() => self.media_state.select(Some(0)),
None => self.media_state.select(None),
}
}
pub fn selected_media_item(&self) -> Option<&MediaItemSummary> {
let idx = self.media_state.selected()?;
let real_idx = *self.library_view.get(idx)?;
self.media_items.get(real_idx)
}
pub fn move_selection(&mut self, delta: i32) {
let (state, len) = match self.tab {
Tab::Library if matches!(self.focus, Focus::Candidates) => {
(&mut self.candidates_state, self.candidates.len())
}
Tab::Library if self.detail.is_none() => {
(&mut self.media_state, self.media_items.len())
(&mut self.media_state, self.library_view.len())
}
Tab::Library => (
&mut self.episode_state,
@ -302,6 +473,15 @@ impl App {
Tab::History => (&mut self.releases_state, self.releases.len()),
Tab::Review => (&mut self.review_state, self.review_items.len()),
Tab::Add => (&mut self.add_results_state, self.add_results.len()),
Tab::Stuck => {
let report_len = self.stuck.as_ref().map_or((0, 0), |r| {
(r.stalled_grabs.len(), r.maxed_out_search_targets.len())
});
match self.stuck_focus {
StuckSection::Stalled => (&mut self.stalled_state, report_len.0),
StuckSection::Maxed => (&mut self.maxed_state, report_len.1),
}
}
Tab::Profiles if self.profile_detail.is_some() => {
(&mut self.profile_weight_state, WEIGHT_FIELDS.len())
}
@ -320,10 +500,7 @@ impl App {
if !matches!(self.tab, Tab::Library) || self.detail.is_some() {
return;
}
let Some(idx) = self.media_state.selected() else {
return;
};
let Some(item) = self.media_items.get(idx) else {
let Some(item) = self.selected_media_item() else {
return;
};
match self.client.media_detail(item.id).await {
@ -707,6 +884,28 @@ impl App {
}
}
/// Toggles monitored for whatever's selected in the top-level Library
/// list, without needing to open its detail view first.
pub async fn toggle_monitor_list_selected(&mut self) {
let Some(item) = self.selected_media_item() else {
return;
};
let (id, monitored) = (item.id, item.monitored);
let result = if monitored {
self.client.unmonitor(id).await
} else {
self.client.monitor(id).await
};
match result {
Ok(()) => {
self.status = "monitor state updated".to_string();
self.media_items = self.client.list_media().await.unwrap_or_default();
self.recompute_library_view();
}
Err(e) => self.status = format!("monitor toggle failed: {e}"),
}
}
pub async fn toggle_monitor_selected(&mut self) {
let Some(detail) = &self.detail else {
return;
@ -745,6 +944,7 @@ impl App {
self.status = "deleted".to_string();
self.detail = None;
self.media_items = self.client.list_media().await.unwrap_or_default();
self.recompute_library_view();
}
Err(e) => self.status = format!("delete failed: {e}"),
}
@ -789,4 +989,40 @@ impl App {
Err(e) => self.status = format!("file delete failed: {e}"),
}
}
/// Jumps from whichever Stuck-tab row is selected straight to that
/// show's Library detail view.
pub async fn jump_to_stuck_target(&mut self) {
let Some(report) = &self.stuck else {
return;
};
let media_item_id = match self.stuck_focus {
StuckSection::Maxed => self
.maxed_state
.selected()
.and_then(|i| report.maxed_out_search_targets.get(i))
.map(|t| t.media_item_id),
StuckSection::Stalled => self
.stalled_state
.selected()
.and_then(|i| report.stalled_grabs.get(i))
.map(|g| g.media_item_id),
};
let Some(id) = media_item_id else {
return;
};
match self.client.media_detail(id).await {
Ok(detail) => {
self.tab = Tab::Library;
self.episode_state.select(if detail.episodes.is_empty() {
None
} else {
Some(0)
});
self.detail = Some(detail);
self.focus = Focus::List;
}
Err(e) => self.status = format!("error loading detail: {e}"),
}
}
}

View file

@ -14,7 +14,7 @@ use crossterm::terminal::{
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use app::{App, Focus, Tab};
use app::{App, Focus, StuckSection, Tab};
#[tokio::main]
async fn main() -> Result<()> {
@ -47,8 +47,9 @@ async fn run(
let mut last_refresh = tokio::time::Instant::now() - Duration::from_secs(10);
loop {
if last_refresh.elapsed() >= Duration::from_secs(3) {
if last_refresh.elapsed() >= Duration::from_secs(3) || app.force_refresh {
app.refresh_active_tab().await;
app.force_refresh = false;
last_refresh = tokio::time::Instant::now();
}
@ -101,6 +102,16 @@ async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) {
return;
}
// Help overlay intercepts everything while open, same
// priority-over-global-keys idiom as the two input modes above.
if app.help_visible {
match code {
KeyCode::Char('?') | KeyCode::Esc => app.help_visible = false,
_ => {}
}
return;
}
// Any key other than a second `x`/`d` clears a pending delete
// confirmation — the confirmation must be the very next keypress, not
// just "any keypress before the user gets distracted."
@ -136,8 +147,28 @@ async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) {
app.start_editing_selected_weight();
}
Tab::Profiles => app.open_profile_detail(),
Tab::Stuck => app.jump_to_stuck_target().await,
_ => {}
},
KeyCode::Left | KeyCode::Right if matches!(app.tab, Tab::Stuck) => {
app.stuck_focus = match app.stuck_focus {
StuckSection::Stalled => StuckSection::Maxed,
StuckSection::Maxed => StuckSection::Stalled,
};
}
KeyCode::Char('?') => app.help_visible = true,
KeyCode::Char('R') => app.force_refresh = true,
KeyCode::Char('f') if matches!(app.tab, Tab::Library) && app.detail.is_none() => {
app.library_filter = app.library_filter.next();
app.recompute_library_view();
}
KeyCode::Char('o') if matches!(app.tab, Tab::Library) && app.detail.is_none() => {
app.library_sort = app.library_sort.next();
app.recompute_library_view();
}
KeyCode::Char('m') if matches!(app.tab, Tab::Library) && app.detail.is_none() => {
app.toggle_monitor_list_selected().await;
}
KeyCode::Char('a') if matches!(app.tab, Tab::Review) => {
app.approve_selected_review().await;
}

View file

@ -1,10 +1,20 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Tabs};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs};
use ratatui::Frame;
use crate::app::{App, Focus, Tab};
use crate::app::{App, Focus, StuckSection, Tab};
// Shared color palette — kept to these meanings so a color never has to be
// second-guessed at a glance:
// Green healthy / 0 missing / high confidence / not stuck
// Yellow warning / low missing / mid confidence / at backoff ceiling
// Red critical / high missing / low confidence / past ceiling
// DarkGray unmonitored / muted / not currently relevant
// Blue TV kind tag (categorical, not severity)
// Magenta Movie kind tag (categorical, not severity)
// Cyan focus/interactive accent (tab highlight, active input, focused section) — never severity
pub fn draw(frame: &mut Frame, app: &App) {
let chunks = Layout::default()
@ -30,6 +40,102 @@ pub fn draw(frame: &mut Frame, app: &App) {
}
draw_status(frame, chunks[2], app);
if app.help_visible {
draw_help_overlay(frame, frame.area(), app);
}
}
/// Keybindings relevant to the current tab/focus/detail state, in the order
/// they should be shown — the single source of truth shared by the status
/// bar (which shows a short prefix) and the help overlay (which shows all of
/// it plus `GLOBAL_KEYS`), so the two can't drift apart.
fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> {
match app.tab {
Tab::Library if matches!(app.focus, Focus::Candidates) => {
vec![("Enter", "grab"), ("Esc", "cancel")]
}
Tab::Library if app.detail.is_some() => vec![
("Esc", "back"),
("s", "search now"),
("m", "monitor show"),
("e", "monitor episode"),
("S", "monitor season"),
("x", "delete show"),
("d", "delete file"),
("c", "pick release"),
],
Tab::Library => vec![
("Enter", "open detail"),
("f", "cycle filter"),
("o", "cycle sort"),
("m", "monitor toggle"),
],
Tab::Review => vec![("a", "approve"), ("r", "reject")],
Tab::Add => match app.focus {
Focus::AddSearchInput => vec![("Enter", "search")],
_ => vec![("Enter", "add"), ("Esc", "back to search")],
},
Tab::Profiles if app.profile_detail.is_some() => {
vec![("Enter", "edit weight"), ("Esc", "back")]
}
Tab::Profiles => vec![("Enter", "open profile")],
Tab::Stuck => vec![("Left/Right", "switch section"), ("Enter", "jump to show")],
_ => vec![],
}
}
const GLOBAL_KEYS: &[(&str, &str)] = &[
("Tab", "switch tab"),
("j/k", "move"),
("?", "help"),
("R", "refresh now"),
("q", "quit"),
];
/// Centers a `width` x `height` rect inside `area` — standard ratatui idiom
/// for a popup/overlay.
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
}
}
fn draw_help_overlay(frame: &mut Frame, area: Rect, app: &App) {
let mut lines: Vec<Line> = context_keybindings(app)
.into_iter()
.map(|(key, desc)| {
Line::from(vec![
Span::styled(format!("{key:12}"), Style::default().fg(Color::Cyan)),
Span::raw(desc),
])
})
.collect();
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Global",
Style::default().add_modifier(Modifier::BOLD),
)));
lines.extend(GLOBAL_KEYS.iter().map(|(key, desc)| {
Line::from(vec![
Span::styled(format!("{key:12}"), Style::default().fg(Color::Cyan)),
Span::raw(*desc),
])
}));
let popup = centered_rect(50, lines.len() as u16 + 2, area);
frame.render_widget(Clear, popup);
let paragraph = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title("Help — ? or Esc to close"),
);
frame.render_widget(paragraph, popup);
}
fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
@ -88,18 +194,21 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
.episodes
.iter()
.map(|e| {
let status = if e.has_file {
""
let (status, color) = if e.has_file {
("", Color::Green)
} else if e.monitored {
""
("", Color::Yellow)
} else {
"-"
("-", Color::DarkGray)
};
let title = e.title.as_deref().unwrap_or("");
ListItem::new(format!(
"{status} S{:02}E{:02} {title}",
e.season_number, e.episode_number
))
ListItem::new(Line::from(vec![
Span::styled(status, Style::default().fg(color)),
Span::raw(format!(
" S{:02}E{:02} {title}",
e.season_number, e.episode_number
)),
]))
})
.collect();
let monitor_label = if detail.monitored {
@ -116,9 +225,7 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
};
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(format!(
"{} ({}) [{monitor_label}] — Esc: back s: search now m: monitor show \
e: monitor episode S: monitor season x: delete show d: delete file \
c: pick release{confirm}",
"{} ({}) [{monitor_label}]{confirm}",
detail.title,
detail.year.map(|y| y.to_string()).unwrap_or_default()
)))
@ -129,28 +236,64 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
}
let items: Vec<ListItem> = app
.media_items
.library_view
.iter()
.map(|m| {
let missing = if m.missing_count > 0 {
.map(|&i| {
let m = &app.media_items[i];
let kind_tag = if m.kind == "movie" { "[Movie]" } else { "[TV]" };
let kind_color = if m.kind == "movie" {
Color::Magenta
} else {
Color::Blue
};
let ratio = if m.episode_count > 0 {
m.missing_count as f64 / m.episode_count as f64
} else if m.missing_count > 0 {
1.0
} else {
0.0
};
// Mute severity color for unmonitored items — a missing count
// on something deliberately unmonitored isn't actionable.
let missing_color = if !m.monitored || m.missing_count == 0 {
Color::DarkGray
} else if ratio <= 0.25 {
Color::Yellow
} else {
Color::Red
};
let missing_text = if m.missing_count > 0 {
format!("{} missing", m.missing_count)
} else {
String::new()
};
ListItem::new(format!(
"{} ({}){}",
m.title,
m.year.map(|y| y.to_string()).unwrap_or_default(),
missing
))
let title_style = if m.monitored {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
};
ListItem::new(Line::from(vec![
Span::styled(format!("{kind_tag} "), Style::default().fg(kind_color)),
Span::styled(
format!(
"{} ({})",
m.title,
m.year.map(|y| y.to_string()).unwrap_or_default()
),
title_style,
),
Span::styled(missing_text, Style::default().fg(missing_color)),
]))
})
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("Monitored Shows — Enter for detail"),
)
.block(Block::default().borders(Borders::ALL).title(format!(
"Monitored Shows ({}/{}) — filter: {} sort: {}",
app.library_view.len(),
app.media_items.len(),
app.library_filter.label(),
app.library_sort.label()
)))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = app.media_state.clone();
frame.render_stateful_widget(list, area, &mut state);
@ -233,12 +376,21 @@ fn draw_review(frame: &mut Frame, area: Rect, app: &App) {
.review_items
.iter()
.map(|r| {
ListItem::new(format!(
"({:.0}%) {} -> {}",
r.confidence * 100.0,
r.raw_release_title,
r.candidate_media_title.as_deref().unwrap_or("?")
))
let color = if r.confidence < 0.70 {
Color::Red
} else if r.confidence < 0.85 {
Color::Yellow
} else {
Color::Green
};
ListItem::new(Line::from(vec![
Span::styled(format!("({:.0}%)", r.confidence * 100.0), Style::default().fg(color)),
Span::raw(format!(
" {} -> {}",
r.raw_release_title,
r.candidate_media_title.as_deref().unwrap_or("?")
)),
]))
})
.collect();
let list = List::new(items)
@ -256,6 +408,11 @@ fn draw_review(frame: &mut Frame, area: Rect, app: &App) {
/// than expected, how deep the review queue has backed up, and search
/// targets that have been failing every attempt long enough for their
/// backoff to hit its ceiling. Read-only report, no selection/navigation.
// Mirrors breadarrd/src/api/routes/stuck.rs::MAXED_SEARCH_COUNT. Duplicated
// because the daemon doesn't expose it via the API; if it drifts this is
// cosmetic only (wrong shade), not a behavior bug.
const MAXED_SEARCH_COUNT: i64 = 6;
fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) {
let Some(report) = &app.stuck else {
let placeholder = Paragraph::new("loading...").block(
@ -272,42 +429,71 @@ fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) {
.constraints([Constraint::Min(3), Constraint::Min(3)])
.split(area);
let stalled_border = if matches!(app.stuck_focus, StuckSection::Stalled) {
Color::Cyan
} else {
Color::Reset
};
let stalled_items: Vec<ListItem> = report
.stalled_grabs
.iter()
.map(|g| {
ListItem::new(format!(
"{} — {} (grabbed {})",
g.media_title, g.raw_title, g.grabbed_at
))
ListItem::new(Line::from(Span::styled(
format!("{}{} (grabbed {})", g.media_title, g.raw_title, g.grabbed_at),
Style::default().fg(Color::Yellow),
)))
})
.collect();
let stalled_list =
List::new(stalled_items).block(Block::default().borders(Borders::ALL).title(format!(
"Stalled grabs ({}) — review queue: {} pending",
report.stalled_grabs.len(),
report.review_queue_depth
)));
frame.render_widget(stalled_list, chunks[0]);
let stalled_list = List::new(stalled_items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(stalled_border))
.title(format!(
"Stalled grabs ({}) — review queue: {} pending",
report.stalled_grabs.len(),
report.review_queue_depth
)),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut stalled_state = app.stalled_state.clone();
frame.render_stateful_widget(stalled_list, chunks[0], &mut stalled_state);
let maxed_border = if matches!(app.stuck_focus, StuckSection::Maxed) {
Color::Cyan
} else {
Color::Reset
};
let maxed_items: Vec<ListItem> = report
.maxed_out_search_targets
.iter()
.map(|t| {
ListItem::new(format!(
"{} — {} attempts, last searched {}",
t.media_title,
t.search_count,
t.last_searched_at.as_deref().unwrap_or("never")
))
let color = if t.search_count > MAXED_SEARCH_COUNT {
Color::Red
} else {
Color::Yellow
};
ListItem::new(Line::from(Span::styled(
format!(
"{} — {} attempts, last searched {}",
t.media_title,
t.search_count,
t.last_searched_at.as_deref().unwrap_or("never")
),
Style::default().fg(color),
)))
})
.collect();
let maxed_list = List::new(maxed_items).block(
Block::default()
.borders(Borders::ALL)
.title("Search targets at max backoff"),
);
frame.render_widget(maxed_list, chunks[1]);
let maxed_list = List::new(maxed_items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(maxed_border))
.title("Search targets at max backoff — Enter: jump to show"),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut maxed_state = app.maxed_state.clone();
frame.render_stateful_widget(maxed_list, chunks[1], &mut maxed_state);
}
fn draw_add(frame: &mut Frame, area: Rect, app: &App) {
@ -549,10 +735,15 @@ fn draw_profiles(frame: &mut Frame, area: Rect, app: &App) {
}
fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
let text = if app.status.is_empty() {
"Tab: switch view | j/k: move | q: quit".to_string()
} else {
let text = if !app.status.is_empty() {
app.status.clone()
} else {
let hints: Vec<String> = context_keybindings(app)
.into_iter()
.take(4)
.map(|(key, desc)| format!("{key}: {desc}"))
.collect();
format!("{} | ?: help", hints.join(" | "))
};
let status = Paragraph::new(text).block(Block::default().borders(Borders::ALL));
frame.render_widget(status, area);