use std::collections::BTreeSet;
use std::io::{self, BufRead};
use std::ops::Range;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use scrin::{
Rect, Terminal, TerminalOptions,
core::buffer::Buffer,
layout::{Constraint, Direction, Layout},
style::{Modifier, Style},
widgets::{Block, Line, List, ListItem, ListState, Paragraph, Text, Widget, WrapMode},
};
use crate::error::{Result, ThesaError};
use crate::{
detail_pair_line, draw_thesa_bottom_bar, draw_thesa_header, header_height, knott_block,
normalize_bg, paint_surface, section_line, selected_style, theme_blue, theme_dim, theme_green,
theme_surface, theme_white, truncate_to_width,
};
pub(crate) struct TerminalSession {
pub(crate) terminal: Terminal,
}
impl TerminalSession {
pub(crate) fn new() -> Result<Self> {
let mut terminal = Terminal::init_with(TerminalOptions {
mouse_capture: true,
..TerminalOptions::default()
})?;
terminal.clear();
Ok(Self { terminal })
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
let _ = self.terminal.restore();
}
}
pub(crate) struct RenderFrame<'a> {
area: Rect,
buffer: &'a mut Buffer,
cursor_position: Option<(u16, u16)>,
}
impl RenderFrame<'_> {
pub(crate) fn area(&self) -> Rect {
self.area
}
pub(crate) fn buffer(&mut self) -> &mut Buffer {
self.buffer
}
pub(crate) fn set_cursor_position(&mut self, x: u16, y: u16) {
self.cursor_position = Some((x, y));
}
pub(crate) fn render_widget<W>(&mut self, widget: W, area: Rect)
where
W: Widget,
{
widget.render(&mut *self.buffer, area);
}
}
pub(crate) fn draw_full_frame<F>(terminal: &mut Terminal, f: F) -> io::Result<()>
where
F: FnOnce(&mut RenderFrame<'_>),
{
terminal.size_changed()?;
terminal.clear();
let area = terminal.size();
let cursor_position = {
let mut frame = RenderFrame {
area,
buffer: terminal.back_buffer(),
cursor_position: None,
};
f(&mut frame);
frame.cursor_position
};
terminal.present_direct_timed()?;
if let Some((x, y)) = cursor_position {
terminal.set_cursor(x, y)?;
}
Ok(())
}
pub(crate) fn read_input_line(reader: &mut impl BufRead) -> Result<String> {
let mut value = String::new();
if reader.read_line(&mut value)? == 0 {
return Err(ThesaError::Message(
"standard input reached EOF while waiting for input".to_string(),
));
}
Ok(value)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RankedMultiSelectState {
cursor: usize,
selected: BTreeSet<usize>,
item_count: usize,
}
impl RankedMultiSelectState {
pub(crate) fn all_selected(item_count: usize) -> Self {
Self {
cursor: 0,
selected: (0..item_count).collect(),
item_count,
}
}
fn move_up(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
fn move_down(&mut self) {
if self.cursor + 1 < self.item_count {
self.cursor += 1;
}
}
fn move_page_up(&mut self, rows: usize) {
self.cursor = self.cursor.saturating_sub(rows.max(1));
}
fn move_page_down(&mut self, rows: usize) {
if self.item_count > 0 {
self.cursor = (self.cursor + rows.max(1)).min(self.item_count - 1);
}
}
fn toggle_active(&mut self) {
if self.cursor >= self.item_count || !self.selected.insert(self.cursor) {
self.selected.remove(&self.cursor);
}
}
fn select_all(&mut self) {
self.selected = (0..self.item_count).collect();
}
fn clear(&mut self) {
self.selected.clear();
}
pub(crate) fn selected_indices(&self) -> Vec<usize> {
self.selected.iter().copied().collect()
}
fn visible_range(&self, rows: usize) -> Range<usize> {
ranked_picker_window(self.item_count, self.cursor, rows)
}
}
pub(crate) struct RankedPickerEntry {
pub(crate) provider: String,
pub(crate) title: String,
pub(crate) license: String,
pub(crate) metrics: String,
pub(crate) details: Vec<(String, String)>,
}
pub(crate) fn pick_ranked_results_tui(
terminal: &mut Terminal,
mode_label: &str,
item_label: &str,
entries: &[RankedPickerEntry],
) -> Result<Vec<usize>> {
if entries.is_empty() {
return Ok(Vec::new());
}
let mut state = RankedMultiSelectState::all_selected(entries.len());
let mut tick = 0usize;
let mut status = format!("all {} ranked {item_label} selected", entries.len());
loop {
draw_full_frame(terminal, |frame| {
draw_ranked_picker(
frame, mode_label, item_label, entries, &state, &status, tick,
)
})?;
tick = tick.wrapping_add(1);
if !event::poll(Duration::from_millis(50))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
let page_rows = terminal.size().height.saturating_sub(10).max(1) as usize;
match key.code {
KeyCode::Esc => return Err(ThesaError::Message("aborted by user".to_string())),
KeyCode::Enter => return Ok(state.selected_indices()),
KeyCode::Up => state.move_up(),
KeyCode::Down => state.move_down(),
KeyCode::PageUp => state.move_page_up(page_rows),
KeyCode::PageDown => state.move_page_down(page_rows),
KeyCode::Home => state.cursor = 0,
KeyCode::End => state.cursor = entries.len() - 1,
KeyCode::Char(' ') => {
state.toggle_active();
status = ranked_picker_status(state.selected.len(), entries.len(), item_label);
}
KeyCode::Char('a' | 'A') => {
state.select_all();
status = format!("all {} ranked {item_label} selected", entries.len());
}
KeyCode::Char('n' | 'N') => {
state.clear();
status = format!("no {item_label} selected; Enter confirms no-op");
}
_ => {}
}
}
}
fn ranked_picker_status(selected: usize, total: usize, item_label: &str) -> String {
if selected == 0 {
format!("no {item_label} selected; Enter confirms no-op")
} else {
format!("{selected} of {total} ranked {item_label} selected")
}
}
fn ranked_picker_window(item_count: usize, cursor: usize, visible_rows: usize) -> Range<usize> {
if item_count == 0 || visible_rows == 0 {
return 0..0;
}
let cursor = cursor.min(item_count - 1);
let start = cursor.saturating_add(1).saturating_sub(visible_rows);
start..(start + visible_rows).min(item_count)
}
fn draw_ranked_picker(
frame: &mut RenderFrame<'_>,
mode_label: &str,
item_label: &str,
entries: &[RankedPickerEntry],
state: &RankedMultiSelectState,
status: &str,
tick: usize,
) {
let area = frame.area();
paint_surface(frame, area);
let selected_count = state.selected.len();
let active = &entries[state.cursor];
if area.width < 28 || area.height < 8 {
let marker = if state.selected.contains(&state.cursor) {
"[x]"
} else {
"[ ]"
};
let width = area.width.saturating_sub(1) as usize;
let lines = vec![
Line::styled(
&truncate_to_width(mode_label, width),
Style::default()
.fg(theme_blue())
.add_modifier(Modifier::BOLD),
),
Line::styled(
&format!("selected {selected_count}/{}", entries.len()),
Style::default().fg(theme_green()),
),
Line::styled(
&truncate_to_width(
&format!("{marker} #{} {}", state.cursor + 1, active.title),
width,
),
Style::default().fg(theme_white()),
),
Line::styled(
&truncate_to_width(&format!("{} | {}", active.license, active.metrics), width),
Style::default().fg(theme_dim()),
),
Line::styled(
&truncate_to_width("Up/Down Space a n Enter Esc", width),
Style::default().fg(theme_dim()),
),
];
frame.render_widget(Paragraph::from_text(Text::new(lines)), area);
return;
}
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(header_height(area)),
Constraint::Min(3),
Constraint::Length(2),
])
.split(area);
draw_thesa_header(frame, root[0], tick, status, mode_label);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints(if root[1].width >= 96 {
[Constraint::Percentage(70), Constraint::Percentage(30)]
} else {
[Constraint::Percentage(100), Constraint::Percentage(0)]
})
.split(root[1]);
let visible_rows = body[0].height.saturating_sub(2).max(1) as usize;
let range = state.visible_range(visible_rows);
let title_width = if body[0].width >= 76 {
(body[0].width as usize).saturating_sub(50).max(12)
} else {
(body[0].width as usize).saturating_sub(26).max(8)
};
let compact_rows = body[0].width < 70;
let items = range
.clone()
.map(|index| {
let entry = &entries[index];
let marker = if state.selected.contains(&index) {
"[x]"
} else {
"[ ]"
};
let title = truncate_to_width(&entry.title, title_width);
let rank = format!("#{:02}", index + 1);
let line = if compact_rows {
format!(
"{marker} {rank} {} | {} | {}",
title, entry.license, entry.metrics
)
} else {
format!(
"{marker} {rank} [{}] {title:<title_width$} | {} | {}",
entry.provider, entry.license, entry.metrics
)
};
ListItem::with_line(Line::styled(
&truncate_to_width(&line, body[0].width.saturating_sub(4) as usize),
Style::default().fg(theme_white()),
))
})
.collect::<Vec<_>>();
let mut list_state = ListState::default();
list_state.select(Some(state.cursor - range.start));
let list = List::new(&items)
.with_highlight_style(selected_style(tick))
.with_highlight_symbol("> ");
let list_title = format!(
" Ranked {item_label} | selected {selected_count}/{} | rows {}-{} ",
entries.len(),
range.start + 1,
range.end,
);
frame.render_block_stateful_list(
body[0],
&list,
&mut list_state,
knott_block(&list_title, theme_blue()),
);
if body[1].width > 0 {
let mut lines = vec![
section_line("Controls"),
detail_pair_line("Up/Down", "move"),
detail_pair_line("Space", "toggle"),
detail_pair_line("a", "select all"),
detail_pair_line("n", "clear all"),
detail_pair_line("Enter", "confirm"),
detail_pair_line("Esc", "cancel"),
Line::raw(""),
section_line("Active Result"),
detail_pair_line("rank", format!("{} of {}", state.cursor + 1, entries.len())),
detail_pair_line("provider", &active.provider),
detail_pair_line("title", &active.title),
detail_pair_line("license", &active.license),
detail_pair_line("facts", &active.metrics),
];
for (label, value) in &active.details {
lines.push(detail_pair_line(label, value));
}
let sidebar =
Paragraph::from_text(Text::new(lines)).wrap(WrapMode::Character { trim: false });
frame.render_block_widget(
body[1],
sidebar,
knott_block(" controls + metadata ", theme_dim()),
);
}
draw_thesa_bottom_bar(
frame,
root[2],
status,
&[
"Up/Down move",
"Space toggle",
"a all",
"n none",
"Enter confirm",
"Esc cancel",
],
);
}
pub(crate) trait FrameRenderExt {
fn render_block_widget<'item, W: Widget>(&mut self, area: Rect, widget: W, block: Block<'item>);
fn render_block_stateful_list<'item>(
&mut self,
area: Rect,
list: &List<'item>,
state: &mut ListState,
block: Block<'item>,
);
}
impl FrameRenderExt for RenderFrame<'_> {
fn render_block_widget<'item, W: Widget>(
&mut self,
area: Rect,
widget: W,
block: Block<'item>,
) {
block.render(self.buffer(), area);
let inner = block.inner(area);
if inner.width > 0 && inner.height > 0 {
self.buffer()
.fill(inner, ' ', theme_white(), Some(theme_surface()));
widget.render(self.buffer(), inner);
normalize_bg(self.buffer(), inner, theme_surface());
}
}
fn render_block_stateful_list<'b>(
&mut self,
area: Rect,
list: &List<'b>,
state: &mut ListState,
block: Block<'b>,
) {
block.render(self.buffer(), area);
let inner = block.inner(area);
if inner.width > 0 && inner.height > 0 {
self.buffer()
.fill(inner, ' ', theme_white(), Some(theme_surface()));
list.render_stateful(self.buffer(), inner, state);
normalize_bg(self.buffer(), inner, theme_surface());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_line_reports_eof_instead_of_returning_blank_input() {
let mut input = io::Cursor::new(Vec::<u8>::new());
let error = read_input_line(&mut input).expect_err("EOF must be an input error");
assert!(error.to_string().contains("EOF"));
}
#[test]
fn input_line_preserves_a_real_blank_line() {
let mut input = io::Cursor::new(b"\n".to_vec());
assert_eq!(read_input_line(&mut input).unwrap(), "\n");
}
#[test]
fn ranked_multi_select_preserves_rank_and_allows_confirmed_empty_selection() {
let mut state = RankedMultiSelectState::all_selected(5);
state.move_down();
state.toggle_active();
state.move_down();
state.toggle_active();
assert_eq!(state.selected_indices(), vec![0, 3, 4]);
state.clear();
assert!(state.selected_indices().is_empty());
state.select_all();
assert_eq!(state.selected_indices(), vec![0, 1, 2, 3, 4]);
}
#[test]
fn ranked_picker_window_scrolls_only_after_cursor_leaves_viewport() {
assert_eq!(ranked_picker_window(0, 4, 3), 0..0);
assert_eq!(ranked_picker_window(10, 0, 4), 0..4);
assert_eq!(ranked_picker_window(10, 3, 4), 0..4);
assert_eq!(ranked_picker_window(10, 4, 4), 1..5);
assert_eq!(ranked_picker_window(10, 9, 4), 6..10);
}
}