-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlineedit.lua
More file actions
267 lines (229 loc) · 7.75 KB
/
Copy pathlineedit.lua
File metadata and controls
267 lines (229 loc) · 7.75 KB
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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
-- cluade vendor module: line editing with history and basic cursor movement
-- API: init(), readline(prompt), add_history(line)
local lineedit = {}
local is_tty = false
local history = {}
local history_max = 100
local saved_line = nil -- draft saved when scrolling history
-- ANSI SGR sequence: \27[ digits;semicolons m
local ansi_pat = "\27%[[%d;]*m"
-- Returns visual width of string after stripping ANSI color codes
local function _visual_width(s)
local stripped = s:gsub(ansi_pat, "")
-- also strip CSI cursor sequences \27[%d*[A-FHJK]
stripped = stripped:gsub("\27%[%d*%a", "")
return #stripped
end
-- Build the byte sequence to repaint the prompt+line and place the cursor at
-- `pos`. All cursor motion is RELATIVE to the cursor's current row, so it
-- survives terminal scrolling. A previous version anchored to an absolute saved
-- position (ESC[s / ESC[u); that broke -- and visibly duplicated the line --
-- as soon as a long (wrapping) paste scrolled the screen and the saved row
-- scrolled away.
--
-- prev_row: row offset (>=0) the cursor currently sits on, below the first row
-- of the input. Returns the output string and the cursor's new row offset.
local function _render(prompt, pw, line, pos, term_w, prev_row)
local out = {}
-- 1. Return to column 0 of the first input row, relative to where we are now.
if prev_row > 0 then out[#out + 1] = "\27[" .. prev_row .. "A" end
out[#out + 1] = "\r"
-- 2. Clear the old prompt/line and any wrapped rows below it.
out[#out + 1] = "\27[J"
-- 3. Repaint prompt and line from the stable column-0 reference.
out[#out + 1] = prompt
out[#out + 1] = line
local abs_end = pw + #line
-- 4. If the line ends exactly on a row boundary the terminal defers the wrap;
-- force the next row so our row math matches what the terminal actually did.
if #line > 0 and abs_end % term_w == 0 then
out[#out + 1] = "\r\n"
end
-- 5. Move the cursor from the line end up/across to `pos`.
local end_row = math.floor(abs_end / term_w)
local abs_target = pw + pos
local target_row = math.floor(abs_target / term_w)
local target_col = abs_target % term_w
if end_row > target_row then
out[#out + 1] = "\27[" .. (end_row - target_row) .. "A"
end
out[#out + 1] = "\r"
if target_col > 0 then
out[#out + 1] = "\27[" .. target_col .. "C"
end
return table.concat(out), target_row
end
-- Read the rest of a CSI escape sequence (after \27[)
local function _read_csi()
local buf = ""
local ch = io.stdin:read(1)
if not ch then return "", "" end
while ch and ch:match("[%d;]") do
buf = buf .. ch
ch = io.stdin:read(1)
end
return buf, ch or ""
end
-- Real terminal width. COLUMNS is a bash variable that is NOT exported to child
-- processes, so os.getenv("COLUMNS") is usually nil here and the old default of
-- 80 made the redraw scroll once per character whenever the real window was
-- narrower than 80. Ask the tty directly instead.
local function _term_cols()
local f = io.popen("stty size 2>/dev/null </dev/tty")
if f then
local out = f:read("*a") or ""
f:close()
local cols = tonumber(out:match("%d+%s+(%d+)"))
if cols and cols > 0 then return cols end
end
local env = tonumber(os.getenv("COLUMNS"))
if env and env > 0 then return env end
return 80
end
function lineedit.init()
is_tty = (os.execute("test -t 0 2>/dev/null") == 0)
return is_tty
end
function lineedit.readline(prompt)
if not is_tty then
io.write(prompt)
io.flush()
return io.read("*l")
end
saved_line = nil -- clear stale draft from previous readline call
io.write(prompt)
io.write("\27[?25h") -- ensure cursor is visible
io.write("\27[?2004h") -- enable bracketed paste mode
local line = ""
local pos = 0
local hist_idx = nil -- nil = editing current line; number = history index (1-based)
local paste_buf = nil -- non-nil = accumulating pasted text
local cursor_row = 0 -- cursor's current row offset below the first input row
local pw = _visual_width(prompt)
local term_w = _term_cols() -- query the real width once per prompt
io.flush()
local function refresh()
local out, new_row = _render(prompt, pw, line, pos, term_w, cursor_row)
io.write(out)
io.flush()
cursor_row = new_row
end
local function _move_left()
if pos > 0 then
pos = pos - 1
refresh()
end
end
local function _move_right()
if pos < #line then
pos = pos + 1
refresh()
end
end
local function _load_history(entry)
line = entry or ""
pos = #line
refresh()
end
while true do
local ch = io.stdin:read(1)
if not ch then
io.write("\n")
return nil
end
local byte = ch:byte()
-- Bracketed paste mode: accumulate until end marker
if paste_buf ~= nil then
paste_buf = paste_buf .. ch
local marker = "\27[201~"
if #paste_buf >= #marker and paste_buf:sub(-#marker) == marker then
local text = paste_buf:sub(1, -#marker - 1)
text = text:gsub("\r\n", "\n"):gsub("\r", "\n")
line = line:sub(1, pos) .. text .. line:sub(pos + 1)
pos = pos + #text
refresh()
paste_buf = nil
end
elseif byte == 3 then -- Ctrl+C
io.write("^C\n")
return ""
elseif byte == 4 and #line == 0 then -- Ctrl+D on empty line = EOF
io.write("\n")
return nil
elseif byte == 13 or byte == 10 then -- Enter: return line
io.write("\n")
return line
elseif ch == "\27" then -- escape sequence
local next_ch = io.stdin:read(1)
if not next_ch then break end
if next_ch == "[" then
local params, term = _read_csi()
if term == "A" then -- Up arrow
if hist_idx == nil then
saved_line = line
hist_idx = 1
elseif hist_idx < #history then
hist_idx = hist_idx + 1
end
if hist_idx <= #history then
_load_history(history[hist_idx])
end
elseif term == "B" then -- Down arrow
if hist_idx ~= nil then
if hist_idx > 1 then
hist_idx = hist_idx - 1
_load_history(history[hist_idx])
elseif hist_idx == 1 then
hist_idx = nil
_load_history(saved_line)
saved_line = nil
end
end
elseif term == "C" then -- Right arrow
_move_right()
elseif term == "D" then -- Left arrow
_move_left()
elseif term == "H" then -- Home
pos = 0
refresh()
elseif term == "F" then -- End (xterm)
pos = #line
refresh()
elseif term == "~" and params == "4" then -- End (vt100 alternate)
pos = #line
refresh()
elseif term == "~" and params == "3" then -- Delete key
if pos < #line then
line = line:sub(1, pos) .. line:sub(pos + 2)
refresh()
end
elseif term == "~" and params == "200" then -- Bracketed paste start
paste_buf = ""
end
end
elseif byte == 127 or byte == 8 then -- Backspace
if pos > 0 then
line = line:sub(1, pos - 1) .. line:sub(pos + 1)
pos = pos - 1
refresh()
end
elseif byte >= 32 then -- printable ASCII + UTF-8 continuation bytes
line = line:sub(1, pos) .. ch .. line:sub(pos + 1)
pos = pos + 1
refresh()
end
end
end
function lineedit.add_history(line)
if not line or #line == 0 then return end
if #history > 0 and history[1] == line then return end -- no consecutive dupes
table.insert(history, 1, line)
if #history > history_max then
history[#history] = nil
end
end
-- test seams (not used at runtime)
lineedit._render = _render
lineedit._term_cols = _term_cols
function lineedit._set_tty(v) is_tty = v end
return lineedit