user: share candidate-browser helpers between F6/F7

F6 (learn) and F7 (compare) were duplicating the candidate-screen
skeleton: outer magenta-bordered block with screen legend + title,
settings row / content / help vertical split, 40/60 list/detail
horizontal split, j/k/↑/↓ nav with bounds clamping.

Factor out three helpers in user/widgets.rs:

  candidate_frame(frame, area, title) -> (settings, content, help)
  list_detail_split(content) -> (list, detail)
  handle_list_nav(events, list_state, count, on_other)

Callers provide screen-specific content — settings line, empty state,
per-candidate list item, detail pane, help line, extra key bindings —
and the helpers absorb the common framing.

Net change is small in lines (-13 src) but removes the
copy-paste-and-tweak trap: F8/F9/whatever-next-screen now starts from
these three calls instead of a copy of learn.rs.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-17 16:22:30 -04:00
parent 2b03dbb200
commit d4331e80f5
3 changed files with 120 additions and 133 deletions

View file

@ -109,6 +109,73 @@ pub fn tree_legend() -> Line<'static> {
)
}
// ---------------------------------------------------------------------------
// Candidate-browser screen skeleton (F6 learn, F7 compare, future screens)
// ---------------------------------------------------------------------------
use ratatui::{
layout::{Constraint, Layout, Rect},
widgets::ListState,
crossterm::event::{Event, KeyEvent},
Frame,
};
/// Frame a candidate-browser screen: outer magenta-bordered block with
/// the screen legend on the left and `title` on the right, split into
/// (settings_row, content_area, help_row). Caller renders into the
/// three sub-areas.
pub fn candidate_frame(frame: &mut Frame, area: Rect, title: &str) -> (Rect, Rect, Rect) {
let block = Block::default()
.title_top(Line::from(super::screen_legend()).left_aligned())
.title_top(Line::from(format!(" {} ", title)).right_aligned())
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta));
let inner = block.inner(area);
frame.render_widget(block, area);
let [settings, content] = Layout::vertical([
Constraint::Length(1), Constraint::Min(0),
]).areas(inner);
let help = Rect { y: area.y + area.height - 1, height: 1, ..area };
(settings, content, help)
}
/// 40/60 horizontal split for list + detail panes within the content area.
pub fn list_detail_split(content: Rect) -> (Rect, Rect) {
let [list, detail] = Layout::horizontal([
Constraint::Percentage(40), Constraint::Percentage(60),
]).areas(content);
(list, detail)
}
/// Handle j/k/↑/↓ list navigation and keep the selection in bounds.
/// Any other key is passed to `on_other` for screen-specific handling.
pub fn handle_list_nav(
events: &[Event],
list_state: &mut ListState,
count: usize,
mut on_other: impl FnMut(KeyCode),
) {
for event in events {
if let Event::Key(KeyEvent { code, .. }) = event {
match code {
KeyCode::Up | KeyCode::Char('k') => {
let i = list_state.selected().unwrap_or(0);
list_state.select(Some(i.saturating_sub(1)));
}
KeyCode::Down | KeyCode::Char('j') => {
let i = list_state.selected().unwrap_or(0);
list_state.select(Some((i + 1).min(count.saturating_sub(1))));
}
_ => on_other(*code),
}
}
}
if count > 0 {
let sel = list_state.selected().unwrap_or(0).min(count - 1);
list_state.select(Some(sel));
}
}
// ---------------------------------------------------------------------------
// SectionTree — expand/collapse tree renderer for ContextSection