File size: 5,842 Bytes
e5034c3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | use std::io::{self, IsTerminal};
use anyhow::Result;
use colored::Colorize;
use crossterm::execute;
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use rustyline::DefaultEditor;
use tracing::debug;
/// Strips bracketed-paste escape sequences from a string.
///
/// When bracketed paste mode is active in the terminal, pasted text is wrapped
/// in `\x1b[200~` (start) and `\x1b[201~` (end) markers. This function removes
/// those markers from the captured shell output so the raw input value is
/// clean.
fn strip_bracketed_paste(s: &str) -> String {
s.replace("\x1b[200~", "").replace("\x1b[201~", "")
}
/// Builder for input prompts.
pub struct InputBuilder {
pub(crate) message: String,
pub(crate) allow_empty: bool,
pub(crate) default: Option<String>,
pub(crate) default_display: Option<String>,
}
impl InputBuilder {
/// Allow empty input.
pub fn allow_empty(mut self, allow: bool) -> Self {
self.allow_empty = allow;
self
}
/// Set default value.
pub fn with_default<T>(mut self, default: T) -> Self
where
T: std::fmt::Display + AsRef<str>,
{
self.default = Some(default.as_ref().to_string());
self.default_display = Some(default.to_string());
self
}
/// Execute input prompt using rustyline.
///
/// Uses `rustyline::DefaultEditor` to provide full line editing (backspace,
/// arrow keys, Ctrl+A/E, etc.). Requires stdin to be a real tty — the
/// caller is responsible for ensuring this (e.g. via `</dev/tty` when
/// launched from a ZLE widget). When `allow_empty` is false and no default
/// is set, re-prompts until non-empty input is provided.
///
/// # Returns
///
/// - `Ok(Some(String))` - User provided input
/// - `Ok(None)` - User cancelled (EOF / Ctrl+D / Ctrl+C)
///
/// # Errors
///
/// Returns an error if rustyline fails to initialise or read input.
pub fn prompt(self) -> Result<Option<String>> {
// Bail immediately when stdin is not a terminal to prevent the process
// from blocking indefinitely on a detached or non-interactive session.
if !std::io::stdin().is_terminal() {
return Ok(None);
}
// Enter the alternate screen so that the prompt is always visible and
// cannot be scrolled out of the viewport. This fixes an issue in
// terminals like VS Code (xterm.js) where rustyline's per-keystroke
// redraw causes the viewport to jump back to the cursor position,
// scrolling the prompt out of view.
let _guard = AlternateScreenGuard::enter();
let mut rl = DefaultEditor::new()?;
// On Windows, rustyline miscounts ANSI escape bytes as visible characters,
// causing incorrect cursor placement and extra space before the editor.
let prompt_str = if cfg!(windows) {
format!("? {}: ", self.message)
} else {
format!("{} {}: ", "?".yellow().bold(), self.message.bold())
};
let initial = self.default.as_deref().unwrap_or("");
loop {
let readline = rl.readline_with_initial(&prompt_str, (initial, ""));
debug!(output = ?readline, "Readline input");
let line = match readline {
Ok(s) => s,
Err(rustyline::error::ReadlineError::Eof)
| Err(rustyline::error::ReadlineError::Interrupted) => return Ok(None),
Err(e) => return Err(e.into()),
};
let value = strip_bracketed_paste(&line);
let trimmed = value.trim();
if trimmed.is_empty() {
if let Some(ref default_val) = self.default {
return Ok(Some(default_val.clone()));
}
if self.allow_empty {
return Ok(Some(String::new()));
}
continue;
}
return Ok(Some(trimmed.to_string()));
}
}
}
/// Guard that enters the terminal alternate screen on creation and exits it on
/// drop. Failures are silently ignored — the alternate screen is a cosmetic
/// best-effort fix for terminal viewport issues.
struct AlternateScreenGuard;
impl AlternateScreenGuard {
fn enter() -> Option<Self> {
execute!(io::stdout(), EnterAlternateScreen).ok()?;
Some(Self)
}
}
impl Drop for AlternateScreenGuard {
fn drop(&mut self) {
let _ = execute!(io::stdout(), LeaveAlternateScreen);
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
use crate::ForgeWidget;
#[test]
fn test_input_builder_creates() {
let builder = ForgeWidget::input("Enter name");
assert_eq!(builder.message, "Enter name");
assert_eq!(builder.allow_empty, false);
}
#[test]
fn test_input_builder_with_default() {
let builder = ForgeWidget::input("Enter key").with_default("mykey");
assert_eq!(builder.default, Some("mykey".to_string()));
}
#[test]
fn test_input_builder_allow_empty() {
let builder = ForgeWidget::input("Enter").allow_empty(true);
assert_eq!(builder.allow_empty, true);
}
#[test]
fn test_strip_bracketed_paste() {
let fixture = "\x1b[200~myapikey\x1b[201~";
let actual = strip_bracketed_paste(fixture);
let expected = "myapikey";
assert_eq!(actual, expected);
let fixture = "myapikey";
let actual = strip_bracketed_paste(fixture);
let expected = "myapikey";
assert_eq!(actual, expected);
let fixture = "\x1b[200~myapikey";
let actual = strip_bracketed_paste(fixture);
let expected = "myapikey";
assert_eq!(actual, expected);
}
}
|