forgecode / crates /forge_select /src /preview.rs
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
e5034c3 verified
Raw
History Blame Contribute Delete
48.2 kB
use std::collections::BTreeSet;
use std::io::{self, Write};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::time::Duration;
use std::{cmp, fmt};
use bstr::ByteSlice;
use crossterm::cursor::{Hide, MoveTo, MoveToColumn, MoveUp, Show};
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers, MouseEventKind,
};
use crossterm::style::{
Attribute, Color, Print, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor,
};
use crossterm::terminal::{self, Clear, ClearType, disable_raw_mode, enable_raw_mode};
use crossterm::{execute, queue};
use derive_setters::Setters;
use nucleo::pattern::{CaseMatching, Normalization};
use nucleo::{Config as NucleoConfig, Nucleo, Utf32String};
/// Row rendered by the shared selector UI.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SelectRow {
/// Machine-readable value returned when the row is selected.
pub raw: String,
/// User-facing text rendered in the selector list.
pub display: String,
/// Text indexed by the fuzzy matcher.
pub search: String,
/// Additional machine-readable fields used for preview placeholder
/// expansion.
pub fields: Vec<String>,
}
impl SelectRow {
/// Creates a selectable row with a raw value and a display value.
pub fn new(raw: impl Into<String>, display: impl Into<String>) -> Self {
let raw = raw.into();
Self {
fields: vec![raw.clone()],
search: raw.clone(),
raw,
display: display.into(),
}
}
/// Sets the text indexed by the fuzzy matcher.
pub fn search(mut self, search: impl Into<String>) -> Self {
self.search = search.into();
self
}
/// Creates a non-selectable header row.
pub fn header(display: impl Into<String>) -> Self {
Self {
raw: String::new(),
display: display.into(),
search: String::new(),
fields: Vec::new(),
}
}
}
impl fmt::Display for SelectRow {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.display)
}
}
/// Placement of the selector preview pane.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PreviewPlacement {
/// Render preview to the right of the list.
Right,
/// Render preview below the list.
Bottom,
}
/// Preview pane layout configuration.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PreviewLayout {
/// Preview pane placement.
pub placement: PreviewPlacement,
/// Percentage of available space allocated to preview.
pub percent: u16,
}
impl Default for PreviewLayout {
fn default() -> Self {
Self { placement: PreviewPlacement::Right, percent: 50 }
}
}
const SELECT_VIEWPORT_PERCENT: u16 = 95;
fn max_select_viewport_height(full_height: u16) -> u16 {
let full_height = full_height.max(1);
((full_height as u32 * SELECT_VIEWPORT_PERCENT as u32) / 100)
.max(1)
.min(full_height as u32) as u16
}
fn select_viewport_height(full_height: u16, desired_height: u16) -> u16 {
let full_height = full_height.max(1);
let desired_height = desired_height.max(1);
if desired_height <= full_height {
desired_height
} else {
max_select_viewport_height(full_height)
}
}
fn preview_select_viewport_height(full_height: u16) -> u16 {
let full_height = full_height.max(1);
full_height.saturating_sub(1).max(1)
}
fn reserve_inline_viewport_space(
stderr: &mut impl Write,
desired_height: u16,
) -> io::Result<(u16, u16)> {
let (_, full_height) = terminal::size()?;
let reserved_height = if desired_height == u16::MAX {
preview_select_viewport_height(full_height)
} else {
max_select_viewport_height(full_height)
.max(select_viewport_height(full_height, desired_height))
};
// Reserve space by scrolling the terminal, but leave the cursor on the
// original prompt row. The shell completion widget expects control to
// return on that same row so it can rewrite the current ZLE buffer.
for _ in 0..reserved_height {
queue!(stderr, Print("\r\n"))?;
}
queue!(stderr, MoveUp(reserved_height), MoveToColumn(0))?;
stderr.flush()?;
let cursor_top_row = full_height.saturating_sub(reserved_height.max(1));
Ok((reserved_height, cursor_top_row))
}
fn desired_select_viewport_height(
header_rows: usize,
matched_rows: usize,
preview_lines: usize,
layout: PreviewLayout,
) -> u16 {
let header_height = 2u16.saturating_add(header_rows as u16);
let list_height = (matched_rows as u16).max(1);
let preview_lines = preview_lines as u16;
match layout.placement {
PreviewPlacement::Right => header_height.saturating_add(list_height),
PreviewPlacement::Bottom if preview_lines > 0 => header_height
.saturating_add(list_height)
.saturating_add(preview_lines.saturating_add(2)),
PreviewPlacement::Bottom => header_height.saturating_add(list_height),
}
.max(1)
}
fn viewport_move_to(x: u16, y: u16, top_row: u16) -> MoveTo {
MoveTo(x, top_row.saturating_add(y))
}
fn restore_select_viewport(
stderr: &mut impl Write,
reserved_height: u16,
viewport_top_row: u16,
) -> io::Result<()> {
let (_, full_height) = terminal::size()?;
let max_top_row = full_height.saturating_sub(reserved_height.max(1));
let viewport_top_row = viewport_top_row.min(max_top_row);
for row_index in 0..reserved_height {
queue!(
stderr,
viewport_move_to(0, row_index, viewport_top_row),
Clear(ClearType::CurrentLine)
)?;
}
queue!(stderr, MoveTo(0, viewport_top_row.saturating_sub(1)))?;
stderr.flush()
}
struct TerminalGuard {
raw_mode_was_enabled: bool,
}
impl TerminalGuard {
fn enter() -> anyhow::Result<Self> {
let raw_mode_was_enabled = terminal::is_raw_mode_enabled()?;
enable_raw_mode()?;
execute!(io::stderr(), EnableMouseCapture, Hide)?;
Ok(Self { raw_mode_was_enabled })
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = execute!(io::stderr(), Show, DisableMouseCapture);
if !self.raw_mode_was_enabled {
let _ = disable_raw_mode();
}
}
}
/// Options for running the shared selector UI.
#[derive(Debug, Setters)]
#[setters(into)]
pub struct SelectUiOptions {
/// Optional prompt text displayed before the query.
#[setters(skip)]
pub prompt: Option<String>,
/// Optional initial search query.
pub query: Option<String>,
/// Rows rendered by the selector.
pub rows: Vec<SelectRow>,
/// Number of leading rows treated as non-selectable headers.
pub header_lines: usize,
/// Selection mode.
pub mode: SelectMode,
/// Optional shell command used to render the selected row preview.
pub preview: Option<String>,
/// Preview pane layout.
pub preview_layout: PreviewLayout,
/// Optional raw value to focus initially.
pub initial_raw: Option<String>,
}
impl SelectUiOptions {
/// Creates selector options for the provided prompt and rows.
pub fn new(prompt: impl Into<String>, rows: Vec<SelectRow>) -> Self {
Self {
prompt: Some(prompt.into()),
query: None,
rows,
header_lines: 0,
mode: SelectMode::Single,
preview: None,
preview_layout: PreviewLayout::default(),
initial_raw: None,
}
}
/// Runs the selector and returns the selected row.
///
/// # Errors
///
/// Returns an error if terminal setup, event handling, rendering, or
/// preview command execution setup fails.
pub fn prompt(self) -> anyhow::Result<Option<SelectRow>> {
let rows = self.rows.clone();
let selected_raw = run_select_ui(self)?;
Ok(selected_raw.and_then(|raw| rows.into_iter().find(|row| row.raw == raw)))
}
/// Runs the selector and returns all selected rows.
///
/// # Errors
///
/// Returns an error if terminal setup, event handling, rendering, or
/// preview command execution setup fails.
pub fn prompt_multi(self) -> anyhow::Result<Option<Vec<SelectRow>>> {
let rows = self.rows.clone();
let selected_raws = run_select_ui_values(self)?;
Ok(selected_raws.map(|raws| {
raws.into_iter()
.filter_map(|raw| rows.iter().find(|row| row.raw == raw).cloned())
.collect()
}))
}
}
/// Runs the shared nucleo-backed selector UI and returns the selected raw
/// value.
///
/// # Errors
///
/// Returns an error if terminal setup, event handling, rendering, or preview
/// command execution setup fails.
pub fn run_select_ui(options: SelectUiOptions) -> anyhow::Result<Option<String>> {
Ok(run_select_ui_values(options)?.and_then(|values| values.into_iter().next()))
}
fn run_select_ui_values(options: SelectUiOptions) -> anyhow::Result<Option<Vec<String>>> {
let SelectUiOptions {
prompt,
query,
rows,
header_lines,
mode,
preview,
preview_layout,
initial_raw,
} = options;
let header_count = header_lines.min(rows.len());
let header_rows = rows.iter().take(header_count).collect::<Vec<_>>();
let data_rows = rows.iter().skip(header_count).cloned().collect::<Vec<_>>();
if data_rows.is_empty() {
return Ok(None);
}
let mut matcher = Nucleo::new(NucleoConfig::DEFAULT, Arc::new(|| {}), None, 1);
let injector = matcher.injector();
for row in data_rows.iter().cloned() {
injector.push(row, |item, columns| {
if let Some(column) = columns.get_mut(0) {
*column = Utf32String::from(item.search.as_str());
}
});
}
drop(injector);
let mut query = query.unwrap_or_default();
matcher
.pattern
.reparse(0, &query, CaseMatching::Smart, Normalization::Smart, false);
let _ = matcher.tick(50);
let guard = TerminalGuard::enter()?;
let mut stderr = io::BufWriter::new(io::stderr());
let prompt = prompt.unwrap_or_else(|| "❯ ".to_string());
let preview_command = preview.unwrap_or_default();
let initial_matched_rows = matched_rows(&matcher);
// When a preview command is present, reserve the maximum available viewport
// height upfront. Without this, the initial reservation (calculated with
// zero preview lines) is too small: once a preview renders it consumes the
// configured percentage of the reserved space and leaves only 1–2 rows for
// the list, even when many items match.
let initial_desired_height = if !preview_command.is_empty() {
u16::MAX
} else {
desired_select_viewport_height(
header_rows.len(),
initial_matched_rows.len(),
0,
preview_layout,
)
};
let (reserved_height, viewport_top_row) =
reserve_inline_viewport_space(&mut stderr, initial_desired_height)?;
let mut selected_index = 0usize;
let mut initial_raw = initial_raw;
let mut initial_selection_applied = false;
let mut scroll_offset = 0usize;
let mut preview_scroll_offset = 0usize;
let mut queued_indices = BTreeSet::new();
let mut preview_cache = String::new();
let mut last_preview_key = String::new();
let mut last_query = query.clone();
let mut needs_render = true;
loop {
if query != last_query {
matcher.pattern.reparse(
0,
&query,
CaseMatching::Smart,
Normalization::Smart,
query.starts_with(&last_query),
);
last_query = query.clone();
let _ = matcher.tick(50);
selected_index = 0;
scroll_offset = 0;
preview_scroll_offset = 0;
needs_render = true;
}
let matched_rows = matched_rows(&matcher);
if !initial_selection_applied {
if let Some(initial_raw) = initial_raw.take()
&& let Some(index) = matched_rows.iter().position(|row| row.raw == initial_raw)
{
selected_index = index;
needs_render = true;
}
initial_selection_applied = true;
}
if matched_rows.is_empty() {
if selected_index != 0 || scroll_offset != 0 {
needs_render = true;
}
selected_index = 0;
scroll_offset = 0;
} else if selected_index >= matched_rows.len() {
selected_index = matched_rows.len().saturating_sub(1);
needs_render = true;
}
let selected_row = matched_rows.get(selected_index).copied();
let preview_key = selected_row
.map(|row| format!("{}\0{}", row.raw, query))
.unwrap_or_default();
if preview_key != last_preview_key {
preview_cache = selected_row
.map(|row| render_preview(&preview_command, row))
.unwrap_or_else(|| "No matches".to_string());
preview_scroll_offset = 0;
last_preview_key = preview_key;
needs_render = true;
}
let rendered_preview = if preview_command.is_empty() {
""
} else {
&preview_cache
};
if needs_render {
draw_preview_ui(
&mut stderr,
PreviewUi {
prompt: &prompt,
query: &query,
total_rows: data_rows.len(),
matched_rows: &matched_rows,
header_rows: &header_rows,
selected_index,
scroll_offset: &mut scroll_offset,
preview: rendered_preview,
preview_scroll_offset,
layout: preview_layout,
reserved_height,
viewport_top_row,
},
)?;
needs_render = false;
}
if event::poll(Duration::from_millis(250))? {
match event::read()? {
// On Windows, crossterm reports key Release events in addition
// to Press/Repeat (Unix reports only Press). Ignore Release so a
// stray Release — notably the Release of the Enter key that
// opened this picker — isn't read as a fresh keystroke that
// instantly accepts the default selection and closes the picker.
Event::Key(key) if key.kind == KeyEventKind::Release => {}
Event::Key(key) => {
match handle_key_event(
key,
&mut query,
matched_rows.len(),
&mut selected_index,
!preview_command.is_empty(),
) {
PickerAction::Continue => {
needs_render = true;
}
PickerAction::PreviewScrollUp => {
preview_scroll_offset = preview_scroll_offset.saturating_sub(1);
needs_render = true;
}
PickerAction::PreviewScrollDown => {
preview_scroll_offset = preview_scroll_offset.saturating_add(1);
needs_render = true;
}
PickerAction::PreviewPageUp => {
let page_size = preview_content_height(
header_rows.len(),
matched_rows.len(),
&preview_cache,
preview_layout,
reserved_height,
)
.saturating_sub(1)
.max(1);
preview_scroll_offset = preview_scroll_offset.saturating_sub(page_size);
needs_render = true;
}
PickerAction::PreviewPageDown => {
let page_size = preview_content_height(
header_rows.len(),
matched_rows.len(),
&preview_cache,
preview_layout,
reserved_height,
)
.saturating_sub(1)
.max(1);
preview_scroll_offset = preview_scroll_offset.saturating_add(page_size);
needs_render = true;
}
PickerAction::Toggle => {
if mode == SelectMode::Multi && selected_row.is_some() {
if !queued_indices.remove(&selected_index) {
queued_indices.insert(selected_index);
}
selected_index = cmp::min(
selected_index + 1,
matched_rows.len().saturating_sub(1),
);
needs_render = true;
}
}
PickerAction::Accept => {
if mode == SelectMode::Multi && !queued_indices.is_empty() {
restore_select_viewport(
&mut stderr,
reserved_height,
viewport_top_row,
)?;
drop(guard);
let selected = queued_indices
.iter()
.filter_map(|index| matched_rows.get(*index))
.map(|row| row.raw.clone())
.collect::<Vec<_>>();
return Ok(Some(selected));
}
if let Some(row) = selected_row {
restore_select_viewport(
&mut stderr,
reserved_height,
viewport_top_row,
)?;
drop(guard);
return Ok(Some(vec![row.raw.clone()]));
}
}
PickerAction::Exit => {
restore_select_viewport(
&mut stderr,
reserved_height,
viewport_top_row,
)?;
drop(guard);
return Ok(None);
}
}
}
Event::Mouse(mouse) => {
if !preview_command.is_empty()
&& mouse_over_preview(
mouse.column,
mouse.row,
header_rows.len(),
matched_rows.len(),
&preview_cache,
preview_layout,
reserved_height,
)
{
match mouse.kind {
MouseEventKind::ScrollUp => {
preview_scroll_offset = preview_scroll_offset.saturating_sub(3);
needs_render = true;
}
MouseEventKind::ScrollDown => {
preview_scroll_offset = preview_scroll_offset.saturating_add(3);
needs_render = true;
}
_ => {}
}
} else {
match mouse.kind {
MouseEventKind::ScrollUp => {
selected_index = selected_index.saturating_sub(1);
needs_render = true;
}
MouseEventKind::ScrollDown => {
selected_index = cmp::min(
selected_index.saturating_add(1),
matched_rows.len().saturating_sub(1),
);
needs_render = true;
}
_ => {}
}
}
}
Event::Resize(_, _) => {
needs_render = true;
}
_ => {}
}
}
if !preview_command.is_empty() {
let clamped_offset = preview_scroll_offset.min(max_preview_scroll_offset(
&preview_cache,
header_rows.len(),
matched_rows.len(),
preview_layout,
reserved_height,
));
if clamped_offset != preview_scroll_offset {
preview_scroll_offset = clamped_offset;
needs_render = true;
}
}
}
}
/// Selector behavior for accepting one or more rows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SelectMode {
/// Accept a single row.
Single,
/// Accept multiple rows queued with tab.
Multi,
}
#[derive(Debug, PartialEq, Eq)]
enum PickerAction {
Continue,
Accept,
Toggle,
Exit,
PreviewScrollUp,
PreviewScrollDown,
PreviewPageUp,
PreviewPageDown,
}
fn handle_key_event(
key: KeyEvent,
query: &mut String,
matched_len: usize,
selected_index: &mut usize,
has_preview: bool,
) -> PickerAction {
match key {
KeyEvent {
code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL, ..
}
| KeyEvent { code: KeyCode::Esc, .. } => PickerAction::Exit,
KeyEvent { code: KeyCode::Char('U'), .. } if has_preview => PickerAction::PreviewPageUp,
KeyEvent { code: KeyCode::Char('u'), modifiers, .. }
if has_preview && modifiers.contains(KeyModifiers::SHIFT) =>
{
PickerAction::PreviewPageUp
}
KeyEvent { code: KeyCode::PageUp, modifiers, .. }
if has_preview && modifiers.contains(KeyModifiers::SHIFT) =>
{
PickerAction::PreviewPageUp
}
KeyEvent { code: KeyCode::Char('D'), .. } if has_preview => PickerAction::PreviewPageDown,
KeyEvent { code: KeyCode::Char('d'), modifiers, .. }
if has_preview && modifiers.contains(KeyModifiers::SHIFT) =>
{
PickerAction::PreviewPageDown
}
KeyEvent { code: KeyCode::PageDown, modifiers, .. }
if has_preview && modifiers.contains(KeyModifiers::SHIFT) =>
{
PickerAction::PreviewPageDown
}
KeyEvent { code: KeyCode::Char('K'), .. } if has_preview => PickerAction::PreviewScrollUp,
KeyEvent { code: KeyCode::Char('k'), modifiers, .. }
if has_preview && modifiers.contains(KeyModifiers::SHIFT) =>
{
PickerAction::PreviewScrollUp
}
KeyEvent { code: KeyCode::Up, modifiers, .. }
if has_preview && modifiers.contains(KeyModifiers::SHIFT) =>
{
PickerAction::PreviewScrollUp
}
KeyEvent { code: KeyCode::Char('J'), .. } if has_preview => PickerAction::PreviewScrollDown,
KeyEvent { code: KeyCode::Char('j'), modifiers, .. }
if has_preview && modifiers.contains(KeyModifiers::SHIFT) =>
{
PickerAction::PreviewScrollDown
}
KeyEvent { code: KeyCode::Down, modifiers, .. }
if has_preview && modifiers.contains(KeyModifiers::SHIFT) =>
{
PickerAction::PreviewScrollDown
}
KeyEvent { code: KeyCode::Enter, .. } => PickerAction::Accept,
KeyEvent { code: KeyCode::BackTab, .. } | KeyEvent { code: KeyCode::Tab, .. } => {
PickerAction::Toggle
}
KeyEvent { code: KeyCode::Up, .. } => {
if matched_len > 0 {
*selected_index = selected_index.saturating_sub(1);
}
PickerAction::Continue
}
KeyEvent { code: KeyCode::Down, .. } => {
if matched_len > 0 {
*selected_index = cmp::min(*selected_index + 1, matched_len.saturating_sub(1));
}
PickerAction::Continue
}
KeyEvent { code: KeyCode::PageUp, .. } => {
if matched_len > 0 {
*selected_index = selected_index.saturating_sub(10);
}
PickerAction::Continue
}
KeyEvent { code: KeyCode::PageDown, .. } => {
if matched_len > 0 {
*selected_index = cmp::min(*selected_index + 10, matched_len.saturating_sub(1));
}
PickerAction::Continue
}
KeyEvent { code: KeyCode::Backspace, .. } => {
query.pop();
PickerAction::Continue
}
KeyEvent { code: KeyCode::Char(ch), modifiers, .. }
if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT =>
{
query.push(ch);
PickerAction::Continue
}
_ => PickerAction::Continue,
}
}
fn max_preview_scroll_offset(
preview: &str,
header_rows: usize,
matched_rows: usize,
layout: PreviewLayout,
reserved_height: u16,
) -> usize {
preview.lines().count().saturating_sub(
preview_content_height(header_rows, matched_rows, preview, layout, reserved_height).max(1),
)
}
fn bottom_preview_height(height: u16, body_height: u16, percent: u16) -> u16 {
let requested = ((height as u32 * percent as u32) / 100) as u16;
let minimum_preview_height = 3;
let minimum_list_height = (body_height / 3).clamp(3, 8);
let maximum_preview_height = body_height.saturating_sub(minimum_list_height);
let preview_height = requested.max(maximum_preview_height);
preview_height.clamp(
minimum_preview_height.min(body_height),
maximum_preview_height
.max(minimum_preview_height)
.min(body_height),
)
}
fn preview_content_height(
header_rows: usize,
matched_rows: usize,
preview: &str,
layout: PreviewLayout,
reserved_height: u16,
) -> usize {
let Ok((_, height)) = terminal::size() else {
return 1;
};
let desired_height =
desired_select_viewport_height(header_rows, matched_rows, preview.lines().count(), layout);
let height = select_viewport_height(height, desired_height).min(reserved_height);
let header_height = 2u16.saturating_add(header_rows as u16);
let body_height = height.saturating_sub(header_height).max(1);
(match layout.placement {
PreviewPlacement::Right => body_height,
PreviewPlacement::Bottom => {
bottom_preview_height(height, body_height, layout.percent).saturating_sub(2)
}
}) as usize
}
fn mouse_over_preview(
column: u16,
row: u16,
header_rows: usize,
matched_rows: usize,
preview: &str,
layout: PreviewLayout,
reserved_height: u16,
) -> bool {
let Ok((width, height)) = terminal::size() else {
return false;
};
let width = width.max(20);
let desired_height =
desired_select_viewport_height(header_rows, matched_rows, preview.lines().count(), layout);
let height = select_viewport_height(height, desired_height).min(reserved_height);
let header_height = 2u16.saturating_add(header_rows as u16);
let body_height = height.saturating_sub(header_height).max(1);
match layout.placement {
PreviewPlacement::Right => {
let preview_width = ((width as u32 * layout.percent as u32) / 100) as u16;
let preview_width = preview_width.clamp(10, width.saturating_sub(10));
let list_width = width.saturating_sub(preview_width + 3).max(10);
let preview_x = list_width + 3;
column >= preview_x && column < width && row >= header_height && row < height
}
PreviewPlacement::Bottom => {
let preview_height = bottom_preview_height(height, body_height, layout.percent);
let list_height = body_height.saturating_sub(preview_height).max(1);
let preview_y = header_height + list_height;
preview_height > 0
&& column < width
&& row >= preview_y
&& row < preview_y.saturating_add(preview_height)
}
}
}
fn matched_rows(matcher: &Nucleo<SelectRow>) -> Vec<&SelectRow> {
matcher
.snapshot()
.matched_items(..)
.map(|item| item.data)
.collect()
}
fn render_preview(command: &str, row: &SelectRow) -> String {
if command.trim().is_empty() {
return String::new();
}
let substituted = substitute_preview_command(command, row);
let output = Command::new("/bin/sh")
.arg("-c")
.arg(&substituted)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output();
match output {
Ok(output) => {
let mut rendered = output.stdout.to_str_lossy().into_owned();
let stderr = output.stderr.to_str_lossy();
if !stderr.is_empty() {
if !rendered.is_empty() && !rendered.ends_with('\n') {
rendered.push('\n');
}
rendered.push_str(&stderr);
}
rendered
}
Err(error) => format!("Preview command failed: {error}"),
}
}
fn substitute_preview_command(command: &str, row: &SelectRow) -> String {
let mut rendered = command.replace("{}", &shell_escape(&row.raw));
for (index, field) in row.fields.iter().enumerate() {
let token = format!("{{{}}}", index + 1);
rendered = rendered.replace(&token, &shell_escape(field));
}
rendered
}
fn shell_escape(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
struct PreviewUi<'a> {
prompt: &'a str,
query: &'a str,
total_rows: usize,
matched_rows: &'a [&'a SelectRow],
header_rows: &'a [&'a SelectRow],
selected_index: usize,
scroll_offset: &'a mut usize,
preview: &'a str,
preview_scroll_offset: usize,
layout: PreviewLayout,
reserved_height: u16,
viewport_top_row: u16,
}
fn draw_preview_ui(stderr: &mut impl Write, ui: PreviewUi<'_>) -> anyhow::Result<()> {
let PreviewUi {
prompt,
query,
total_rows,
matched_rows,
header_rows,
selected_index,
scroll_offset,
preview,
preview_scroll_offset,
layout,
reserved_height,
viewport_top_row,
} = ui;
let (width, full_height) = terminal::size()?;
let width = width.max(20);
let has_preview = !preview.is_empty();
// Always render into the full reserved region. Computing a smaller
// desired_height from current content and capping height to it would leave
// the already-reserved terminal rows blank, wasting visible space.
// Keep one safety row at the bottom of the reserved region to avoid
// terminal-specific implicit wrap/scroll behavior when writing at the
// final visible row. The reservation already accounts for that safety row
// when preview is enabled, so use all reserved rows here.
let height = reserved_height.max(1);
let max_top_row = full_height.saturating_sub(height.max(1));
let top_offset = viewport_top_row.min(max_top_row);
let header_height = 2u16.saturating_add(header_rows.len() as u16);
let body_height = height.saturating_sub(header_height).max(1);
let (
list_x,
list_y,
list_width,
list_height,
preview_x,
preview_y,
preview_width,
preview_height,
) = if has_preview {
match layout.placement {
PreviewPlacement::Right => {
let preview_width = ((width as u32 * layout.percent as u32) / 100) as u16;
let preview_width = preview_width.clamp(10, width.saturating_sub(10));
let list_width = width.saturating_sub(preview_width + 3).max(10);
(
0,
header_height,
list_width,
body_height,
list_width + 3,
header_height,
preview_width,
body_height,
)
}
PreviewPlacement::Bottom => {
let preview_height = bottom_preview_height(height, body_height, layout.percent);
let list_height = body_height.saturating_sub(preview_height).max(1);
(
0,
header_height,
width,
list_height,
0,
header_height + list_height,
width,
preview_height,
)
}
}
} else {
(0, header_height, width, body_height, 0, height, 0, 0)
};
let visible_rows = list_height as usize;
if visible_rows > 0 {
if selected_index < *scroll_offset {
*scroll_offset = selected_index;
} else if selected_index >= scroll_offset.saturating_add(visible_rows) {
*scroll_offset = selected_index.saturating_sub(visible_rows.saturating_sub(1));
}
}
for row_index in 0..reserved_height {
queue!(
stderr,
viewport_move_to(0, row_index, top_offset),
Clear(ClearType::CurrentLine)
)?;
}
queue!(
stderr,
viewport_move_to(0, 0, top_offset),
SetAttribute(Attribute::Bold),
SetForegroundColor(Color::AnsiValue(110)),
Print(truncate_line(
&format_prompt_query(prompt, query),
width as usize
)),
ResetColor,
SetAttribute(Attribute::Reset)
)?;
queue!(
stderr,
viewport_move_to(2, 1, top_offset),
SetForegroundColor(Color::AnsiValue(144)),
Print(format!("{}/{}", matched_rows.len(), total_rows)),
SetForegroundColor(Color::AnsiValue(59)),
Print(" "),
Print(truncate_line(
&"─".repeat(width as usize),
width.saturating_sub(3 + match_count_width(matched_rows.len(), total_rows)) as usize,
)),
ResetColor
)?;
for (index, row) in header_rows.iter().enumerate() {
let row_y = 2u16.saturating_add(index as u16);
if row_y < header_height {
queue!(
stderr,
viewport_move_to(2, row_y, top_offset),
SetAttribute(Attribute::Bold),
SetForegroundColor(Color::AnsiValue(109))
)?;
queue!(
stderr,
Print(truncate_line(
&row.display,
width.saturating_sub(2) as usize
))
)?;
queue!(stderr, ResetColor, SetAttribute(Attribute::Reset))?;
}
}
for row_index in 0..list_height {
queue!(
stderr,
viewport_move_to(list_x, list_y + row_index, top_offset),
Clear(ClearType::CurrentLine)
)?;
let item_index = *scroll_offset + row_index as usize;
if let Some(row) = matched_rows.get(item_index) {
let is_selected = item_index == selected_index;
let marker = "▌";
let content_width = list_width.saturating_sub(2) as usize;
if is_selected {
queue!(
stderr,
viewport_move_to(list_x, list_y + row_index, top_offset),
SetAttribute(Attribute::Bold),
SetForegroundColor(Color::AnsiValue(161)),
SetBackgroundColor(Color::AnsiValue(236)),
Print(marker),
SetForegroundColor(Color::AnsiValue(254)),
Print(" "),
Print(truncate_line_with_ellipsis(&row.display, content_width)),
ResetColor,
SetAttribute(Attribute::Reset)
)?;
} else {
queue!(
stderr,
viewport_move_to(list_x, list_y + row_index, top_offset),
SetForegroundColor(Color::AnsiValue(236)),
Print(marker),
ResetColor,
Print(" "),
Print(truncate_line_with_ellipsis(&row.display, content_width))
)?;
}
}
}
if has_preview {
match layout.placement {
PreviewPlacement::Right => {
let divider_x = list_width + 1;
for row_index in 0..body_height {
queue!(
stderr,
viewport_move_to(divider_x, header_height + row_index, top_offset),
Print("│")
)?;
}
}
PreviewPlacement::Bottom => {
queue!(
stderr,
viewport_move_to(0, preview_y, top_offset),
SetForegroundColor(Color::AnsiValue(59)),
Print("┌"),
Print("─".repeat(width.saturating_sub(2) as usize)),
Print("┐"),
ResetColor
)?;
}
}
let preview_content_height = match layout.placement {
PreviewPlacement::Bottom => preview_height.saturating_sub(2),
PreviewPlacement::Right => preview_height,
} as usize;
let preview_width_for_content = match layout.placement {
PreviewPlacement::Bottom => preview_width.saturating_sub(4),
PreviewPlacement::Right => preview_width,
} as usize;
let preview_lines = wrap_preview_lines(preview, preview_width_for_content.max(1));
let preview_scroll_offset = preview_scroll_offset.min(
preview_lines
.len()
.saturating_sub(preview_content_height.max(1)),
);
for row_index in 0..preview_height {
let y = preview_y + row_index;
if layout.placement == PreviewPlacement::Bottom && row_index == 0 {
continue;
}
if layout.placement == PreviewPlacement::Bottom
&& row_index == preview_height.saturating_sub(1)
{
queue!(
stderr,
viewport_move_to(preview_x, y, top_offset),
SetForegroundColor(Color::AnsiValue(59)),
Print("└"),
Print("─".repeat(preview_width.saturating_sub(2) as usize)),
Print("┘"),
ResetColor
)?;
continue;
}
let (content_x, content_width) = if layout.placement == PreviewPlacement::Bottom {
queue!(
stderr,
viewport_move_to(preview_x, y, top_offset),
SetForegroundColor(Color::AnsiValue(59)),
Print("│"),
viewport_move_to(preview_x + preview_width.saturating_sub(1), y, top_offset),
Print("│"),
ResetColor
)?;
(preview_x + 2, preview_width.saturating_sub(4))
} else {
(preview_x, preview_width)
};
queue!(
stderr,
viewport_move_to(content_x, y, top_offset),
Print(" ".repeat(content_width as usize))
)?;
let line_index = if layout.placement == PreviewPlacement::Bottom {
preview_scroll_offset + row_index.saturating_sub(1) as usize
} else {
preview_scroll_offset + row_index as usize
};
if let Some(line) = preview_lines.get(line_index) {
queue!(
stderr,
viewport_move_to(content_x, y, top_offset),
Print(truncate_line(line, content_width as usize))
)?;
}
if layout.placement == PreviewPlacement::Bottom
&& row_index == 1
&& !preview_lines.is_empty()
{
let indicator =
preview_scroll_indicator(preview_scroll_offset, preview_lines.len());
let indicator_width = indicator.chars().count() as u16;
if indicator_width.saturating_add(1) < preview_width {
queue!(
stderr,
viewport_move_to(
preview_x + preview_width.saturating_sub(indicator_width + 2),
y,
top_offset,
),
SetAttribute(Attribute::Reverse),
SetForegroundColor(Color::AnsiValue(144)),
Print(indicator),
ResetColor,
SetAttribute(Attribute::Reset),
SetForegroundColor(Color::AnsiValue(59)),
Print(" "),
Print("│"),
ResetColor
)?;
}
}
}
}
stderr.flush()?;
Ok(())
}
fn preview_scroll_indicator(scroll_offset: usize, line_count: usize) -> String {
format!("{}/{line_count}", scroll_offset.saturating_add(1))
}
fn wrap_preview_lines(preview: &str, max_width: usize) -> Vec<String> {
if max_width == 0 {
return Vec::new();
}
preview
.lines()
.flat_map(|line| wrap_ansi_line(line, max_width))
.collect()
}
fn wrap_ansi_line(line: &str, max_width: usize) -> Vec<String> {
const WRAP_ICON: &str = "↪ ";
const WRAP_ICON_WIDTH: usize = 2;
if line.is_empty() {
return vec![String::new()];
}
let mut wrapped_lines = Vec::new();
let mut current_line = String::new();
let mut visible_width = 0usize;
let mut chars = line.chars().peekable();
let mut is_continuation = false;
while let Some(ch) = chars.next() {
if ch == '\u{1b}' {
current_line.push(ch);
for ansi_ch in chars.by_ref() {
current_line.push(ansi_ch);
if ansi_ch.is_ascii_alphabetic() || ansi_ch == '~' {
break;
}
}
continue;
}
let current_limit = if is_continuation {
max_width.saturating_sub(WRAP_ICON_WIDTH).max(1)
} else {
max_width
};
if visible_width >= current_limit {
let pushed = if is_continuation {
format!("{WRAP_ICON}{current_line}")
} else {
current_line.clone()
};
wrapped_lines.push(pushed);
current_line = String::new();
visible_width = 0;
is_continuation = true;
}
current_line.push(ch);
visible_width = visible_width.saturating_add(1);
}
if !current_line.is_empty() {
let pushed = if is_continuation {
format!("{WRAP_ICON}{current_line}")
} else {
current_line
};
wrapped_lines.push(pushed);
}
if wrapped_lines.is_empty() {
vec![String::new()]
} else {
wrapped_lines
}
}
fn format_prompt_query(prompt: &str, query: &str) -> String {
if query.is_empty() || prompt.ends_with(char::is_whitespace) {
format!("{prompt}{query}")
} else {
format!("{prompt} {query}")
}
}
fn match_count_width(matched: usize, total: usize) -> u16 {
format!("{matched}/{total}").chars().count() as u16
}
fn truncate_line_with_ellipsis(value: &str, max_width: usize) -> String {
const ELLIPSIS: &str = "…";
let full_width = value.chars().count();
if full_width <= max_width {
return value.to_string();
}
if max_width <= ELLIPSIS.len() {
return ELLIPSIS.chars().take(max_width).collect();
}
let keep_width = max_width.saturating_sub(ELLIPSIS.len());
let prefix: String = value.chars().take(keep_width).collect();
format!("{prefix}{ELLIPSIS}")
}
fn truncate_line(value: &str, max_width: usize) -> String {
let mut rendered = String::new();
let mut visible_width = 0usize;
let mut chars = value.chars().peekable();
let mut truncated = false;
let mut has_ansi = false;
while let Some(ch) = chars.next() {
if ch == '\u{1b}' {
has_ansi = true;
rendered.push(ch);
for ansi_ch in chars.by_ref() {
rendered.push(ansi_ch);
if ansi_ch.is_ascii_alphabetic() || ansi_ch == '~' {
break;
}
}
continue;
}
if visible_width >= max_width {
truncated = true;
break;
}
rendered.push(ch);
visible_width = visible_width.saturating_add(1);
}
if truncated && has_ansi {
rendered.push_str("\u{1b}[0m");
}
rendered
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_desired_select_viewport_height_right_ignores_preview_line_count() {
let fixture = PreviewLayout { placement: PreviewPlacement::Right, percent: 50 };
let actual = desired_select_viewport_height(1, 2, 285, fixture);
let expected = 5;
assert_eq!(actual, expected);
}
#[test]
fn test_desired_select_viewport_height_bottom_includes_preview_line_count() {
let fixture = PreviewLayout { placement: PreviewPlacement::Bottom, percent: 50 };
let actual = desired_select_viewport_height(1, 2, 4, fixture);
let expected = 11;
assert_eq!(actual, expected);
}
#[test]
fn test_preview_select_viewport_height_keeps_prompt_safety_row() {
let fixture = 20;
let actual = preview_select_viewport_height(fixture);
let expected = 19;
assert_eq!(actual, expected);
}
#[test]
fn test_bottom_preview_height_keeps_preview_in_small_windows() {
let fixture = (10, 50);
let actual = bottom_preview_height(fixture.0, fixture.0, fixture.1);
let expected = 7;
assert_eq!(actual, expected);
}
#[test]
fn test_bottom_preview_height_caps_preview_to_keep_visible_list() {
let fixture = (20, 50);
let actual = bottom_preview_height(fixture.0, fixture.0, fixture.1);
let expected = 14;
assert_eq!(actual, expected);
}
#[test]
fn test_bottom_preview_height_uses_extra_body_space() {
let fixture = (28, 28, 50);
let actual = bottom_preview_height(fixture.0, fixture.1, fixture.2);
let expected = 20;
assert_eq!(actual, expected);
}
}