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);
    }
}