lib/edit.sh

13.6 KB · 572 lines · bash

1#!/usr/bin/env bash
2
3brt_edit() {
4 local file="${1:-}"
5 if [[ -z "$file" ]]; then
6 brt_error "Usage: brt -edit <file>"
7 exit 1
8 fi
9
10 if [[ ! -t 0 || ! -t 1 ]]; then
11 brt_error "Editor requires an interactive terminal"
12 exit 1
13 fi
14
15 # Prefer real nano when available — same UX, fewer terminal quirks.
16 if [[ -z "${BRT_EDITOR_FORCE:-}" ]] && command -v nano &>/dev/null; then
17 nano "$file"
18 return 0
19 fi
20
21 local tmp="${file}.brt_edit_$$"
22 [[ -f "$file" ]] && cp "$file" "$tmp" || : > "$tmp"
23
24 local -a lines=()
25 local cur_line=1
26 local cur_col=1
27 local scroll_top=1
28 local dirty=0
29 local cols rows view_h
30 local old_stty=""
31 local prompt=""
32 local prompt_reply=""
33 local paste_buffer=""
34 local EDITOR_PASTE_TEXT=""
35
36 _load() {
37 lines=()
38 if [[ -s "$tmp" ]]; then
39 while IFS= read -r line || [[ -n "$line" ]]; do
40 lines+=("$line")
41 done < "$tmp"
42 fi
43 [[ ${#lines[@]} -eq 0 ]] && lines=("")
44 if (( cur_line < 1 )); then cur_line=1; fi
45 if (( cur_line > ${#lines[@]} )); then cur_line=${#lines[@]}; fi
46 _clamp_col
47 }
48
49 _clamp_col() {
50 local len=${#lines[$((cur_line - 1))]}
51 if (( cur_col < 1 )); then cur_col=1; fi
52 if (( cur_col > len + 1 )); then cur_col=$((len + 1)); fi
53 }
54
55 _save() {
56 if ((${#lines[@]} == 1 && -z "${lines[0]}")); then
57 : > "$file"
58 else
59 printf '%s\n' "${lines[@]}" > "$file"
60 fi
61 cp "$file" "$tmp"
62 dirty=0
63 }
64
65 _term_size() {
66 cols=$(tput cols 2>/dev/null || echo 80)
67 rows=$(tput lines 2>/dev/null || echo 24)
68 view_h=$((rows - 5))
69 if (( view_h < 1 )); then view_h=1; fi
70 }
71
72 _ensure_visible() {
73 _term_size
74 if (( cur_line < scroll_top )); then
75 scroll_top=$cur_line
76 elif (( cur_line >= scroll_top + view_h )); then
77 scroll_top=$((cur_line - view_h + 1))
78 fi
79 if (( scroll_top < 1 )); then scroll_top=1; fi
80 }
81
82 _insert_text() {
83 local text="$1"
84 [[ -z "$text" ]] && return 0
85 text="${text//$'\r'/}"
86 text="${text%$'\n'}"
87
88 local line="${lines[$((cur_line - 1))]}"
89 local before="${line:0:cur_col - 1}"
90 local after="${line:cur_col - 1}"
91 local -a paste_lines=()
92 local remaining last
93
94 if [[ "$text" == *$'\n'* ]]; then
95 remaining="$text"
96 while [[ "$remaining" == *$'\n'* ]]; do
97 paste_lines+=("${remaining%%$'\n'*}")
98 remaining="${remaining#*$'\n'}"
99 done
100 paste_lines+=("$remaining")
101 else
102 paste_lines=("$text")
103 fi
104
105 paste_lines[0]="${before}${paste_lines[0]}"
106 last=$((${#paste_lines[@]} - 1))
107 paste_lines[$last]="${paste_lines[$last]}${after}"
108
109 local head=("${lines[@]:0:$((cur_line - 1))}")
110 local tail=("${lines[@]:cur_line}")
111 lines=("${head[@]}" "${paste_lines[@]}" "${tail[@]}")
112
113 cur_line=$((cur_line + last))
114 cur_col=$((${#paste_lines[$last]} - ${#after} + 1))
115 dirty=1
116 }
117
118 _clipboard_copy() {
119 local text="$1"
120 [[ -z "$text" ]] && return 0
121 if command -v pbcopy &>/dev/null; then
122 printf '%s' "$text" | pbcopy
123 elif command -v wl-copy &>/dev/null; then
124 printf '%s' "$text" | wl-copy
125 elif command -v xclip &>/dev/null; then
126 printf '%s' "$text" | xclip -selection clipboard
127 elif command -v xsel &>/dev/null; then
128 printf '%s' "$text" | xsel --clipboard --input
129 fi
130 }
131
132 _clipboard_paste() {
133 if command -v pbpaste &>/dev/null; then
134 pbpaste
135 elif command -v wl-paste &>/dev/null; then
136 wl-paste 2>/dev/null
137 elif command -v xclip &>/dev/null; then
138 xclip -selection clipboard -o 2>/dev/null
139 elif command -v xsel &>/dev/null; then
140 xsel --clipboard --output 2>/dev/null
141 fi
142 }
143
144 _copy_line() {
145 paste_buffer="${lines[$((cur_line - 1))]}"
146 _clipboard_copy "$paste_buffer"
147 }
148
149 _paste_system() {
150 local text
151 text=$(_clipboard_paste)
152 [[ -n "$text" ]] && _insert_text "$text"
153 }
154
155 _paste_buffer() {
156 [[ -n "$paste_buffer" ]] && _insert_text "$paste_buffer"
157 }
158
159 _insert_char() {
160 local ch="$1"
161 local line="${lines[$((cur_line - 1))]}"
162 local before="${line:0:cur_col - 1}"
163 local after="${line:cur_col - 1}"
164 lines[$((cur_line - 1))]="${before}${ch}${after}"
165 ((cur_col++)) || true
166 dirty=1
167 }
168
169 _backspace() {
170 if (( cur_col > 1 )); then
171 local line="${lines[$((cur_line - 1))]}"
172 lines[$((cur_line - 1))]="${line:0:cur_col - 2}${line:cur_col - 1}"
173 ((cur_col--)) || true
174 dirty=1
175 elif (( cur_line > 1 )); then
176 local prev="${lines[$((cur_line - 2))]}"
177 local cur="${lines[$((cur_line - 1))]}"
178 cur_col=$((${#prev} + 1))
179 lines[$((cur_line - 2))]="${prev}${cur}"
180 local nb=() na=()
181 nb=("${lines[@]:0:$((cur_line - 1))}")
182 na=("${lines[@]:cur_line}")
183 lines=("${nb[@]}" "${na[@]}")
184 ((cur_line--)) || true
185 dirty=1
186 fi
187 }
188
189 _delete_char() {
190 local line="${lines[$((cur_line - 1))]}"
191 local len=${#line}
192 if (( cur_col <= len )); then
193 lines[$((cur_line - 1))]="${line:0:cur_col - 1}${line:cur_col}"
194 dirty=1
195 elif (( cur_line < ${#lines[@]} )); then
196 lines[$((cur_line - 1))]="${line}${lines[$cur_line]}"
197 local nb=() na=()
198 nb=("${lines[@]:0:cur_line}")
199 na=("${lines[@]:$((cur_line + 1))}")
200 lines=("${nb[@]}" "${na[@]}")
201 dirty=1
202 fi
203 }
204
205 _enter() {
206 local line="${lines[$((cur_line - 1))]}"
207 local before="${line:0:cur_col - 1}"
208 local after="${line:cur_col - 1}"
209 lines[$((cur_line - 1))]="$before"
210 local head=() tail=()
211 head=("${lines[@]:0:cur_line}")
212 tail=("${lines[@]:cur_line}")
213 lines=("${head[@]}" "$after" "${tail[@]}")
214 ((cur_line++)) || true
215 cur_col=1
216 dirty=1
217 }
218
219 _cut_line() {
220 paste_buffer="${lines[$((cur_line - 1))]}"
221 _clipboard_copy "$paste_buffer"
222 if ((${#lines[@]} <= 1)); then
223 lines=("")
224 cur_line=1
225 cur_col=1
226 else
227 local nb=() na=()
228 nb=("${lines[@]:0:$((cur_line - 1))}")
229 na=("${lines[@]:cur_line}")
230 lines=("${nb[@]}" "${na[@]}")
231 if (( cur_line > ${#lines[@]} )); then cur_line=${#lines[@]}; fi
232 cur_col=1
233 fi
234 dirty=1
235 }
236
237 _move_up() {
238 if (( cur_line <= 1 )); then return 0; fi
239 ((cur_line--)) || true
240 _clamp_col
241 _ensure_visible
242 }
243
244 _move_down() {
245 if (( cur_line >= ${#lines[@]} )); then return 0; fi
246 ((cur_line++)) || true
247 _clamp_col
248 _ensure_visible
249 }
250
251 _move_left() {
252 if (( cur_col > 1 )); then
253 ((cur_col--)) || true
254 elif (( cur_line > 1 )); then
255 ((cur_line--)) || true
256 cur_col=$((${#lines[$((cur_line - 1))]} + 1))
257 fi
258 _ensure_visible
259 }
260
261 _move_right() {
262 local len=${#lines[$((cur_line - 1))]}
263 if (( cur_col <= len )); then
264 ((cur_col++)) || true
265 elif (( cur_line < ${#lines[@]} )); then
266 ((cur_line++)) || true
267 cur_col=1
268 fi
269 _ensure_visible
270 }
271
272 _move_home() { cur_col=1; }
273 _move_end() { cur_col=$((${#lines[$((cur_line - 1))]} + 1)); }
274
275 _tty_raw_on() {
276 stty -echo -icanon min 1 time 0 -ixon 2>/dev/null || \
277 stty -echo -icanon min 1 time 0 2>/dev/null || true
278 }
279
280 _tty_raw_off() {
281 stty echo icanon 2>/dev/null || true
282 }
283
284 _decode_key_byte() {
285 case "$1" in
286 $'\x18') printf 'ctrl-x' ;;
287 $'\x0f') printf 'ctrl-o' ;;
288 $'\x07') printf 'ctrl-g' ;;
289 $'\x0b') printf 'ctrl-k' ;;
290 $'\x03') printf 'ctrl-c' ;;
291 $'\x16') printf 'ctrl-v' ;;
292 $'\x15') printf 'ctrl-u' ;;
293 $'\x08'|$'\x7f') printf 'backspace' ;;
294 $'\x04') printf 'delete' ;;
295 $'\r'|$'\n') printf 'enter' ;;
296 $'\t') printf '' ;;
297 *)
298 if [[ "$1" < $'\x20' ]]; then
299 printf ''
300 else
301 printf '%s' "$1"
302 fi
303 ;;
304 esac
305 }
306
307 _decode_csi() {
308 local seq="$1"
309 local last="${seq: -1}"
310 case "$last" in
311 A) printf 'up' ;;
312 B) printf 'down' ;;
313 C) printf 'right' ;;
314 D) printf 'left' ;;
315 H) printf 'home' ;;
316 F) printf 'end' ;;
317 ~)
318 case "$seq" in
319 1|'1'|'1;2'|'1;5') printf 'home' ;;
320 2|'2'|'2;2'|'2;5') printf 'insert' ;;
321 3|'3'|'3;2'|'3;5') printf 'delete' ;;
322 4|'4'|'4;2'|'4;5') printf 'end' ;;
323 5|'5'|'5;2'|'5;5') printf 'pageup' ;;
324 6|'6'|'6;2'|'6;5') printf 'pagedown' ;;
325 *) printf 'esc' ;;
326 esac
327 ;;
328 *) printf 'esc' ;;
329 esac
330 }
331
332 _read_bracketed_paste_content() {
333 local content="" c pending=""
334 EDITOR_PASTE_TEXT=""
335 while IFS= read -rsn1 c; do
336 if [[ -z "$pending" ]]; then
337 if [[ "$c" == $'\e' ]]; then
338 pending="$c"
339 continue
340 fi
341 content+="$c"
342 continue
343 fi
344 pending+="$c"
345 if [[ "$pending" == $'\e[201~' ]]; then
346 EDITOR_PASTE_TEXT="$content"
347 printf 'paste'
348 return 0
349 fi
350 if ((${#pending} >= 5)); then
351 content+="$pending"
352 pending=""
353 fi
354 done
355 EDITOR_PASTE_TEXT="$content"
356 printf 'paste'
357 }
358
359 _read_key() {
360 local k k2 seq c i
361 IFS= read -rsn1 k || return 1
362
363 if [[ $k != $'\x1b' ]]; then
364 _decode_key_byte "$k"
365 return 0
366 fi
367
368 if ! IFS= read -rsn1 -t 1 k2; then
369 printf 'esc'
370 return 0
371 fi
372
373 case "$k2" in
374 '[')
375 seq=""
376 for (( i=0; i<16; i++ )); do
377 if ! IFS= read -rsn1 -t 1 c; then
378 break
379 fi
380 seq+="$c"
381 if [[ "$seq" == "200~" ]]; then
382 _read_bracketed_paste_content
383 return 0
384 fi
385 [[ "$c" =~ [A-Za-z~] ]] && break
386 done
387 _decode_csi "$seq"
388 ;;
389 O)
390 if ! IFS= read -rsn1 -t 1 c; then
391 printf 'esc'
392 return 0
393 fi
394 case "$c" in
395 A) printf 'up' ;;
396 B) printf 'down' ;;
397 C) printf 'right' ;;
398 D) printf 'left' ;;
399 H) printf 'home' ;;
400 F) printf 'end' ;;
401 *) printf 'esc' ;;
402 esac
403 ;;
404 *)
405 printf 'esc'
406 ;;
407 esac
408 }
409
410 _draw_help() {
411 _tty_raw_off
412 clear
413 cat <<'HELP'
414 brt editor — shortcuts
415
416 Arrow keys Move cursor
417 Home / End Start / end of line
418 Enter New line
419 Backspace/Del Delete character
420 ^K Cut line (copies to clipboard)
421 ^C Copy line to clipboard
422 ^V Paste from clipboard
423 ^U Paste last cut/copied line
424 Cmd+V Paste (terminal bracketed paste)
425 ^O Save file
426 ^X Exit (prompts if modified)
427 ^G This help
428
429 Set BRT_EDITOR_FORCE=1 to use the built-in editor instead of nano.
430 Press any key to return...
431HELP
432 IFS= read -rsn1 _ 2>/dev/null || read -r _ || true
433 _tty_raw_on
434 }
435
436 _draw() {
437 _term_size
438 printf '\033[H\033[J\033[?25l'
439
440 local mod=""
441 if (( dirty )); then mod=" [Modified]"; fi
442 printf '\033[7m brt — %s%s\033[0m\n' "$file" "$mod"
443 printf '%*s\n' "$cols" '' | tr ' ' '-'
444
445 local i row=0
446 for ((i = scroll_top; i < scroll_top + view_h; i++)); do
447 if (( i > ${#lines[@]} )); then break; fi
448 local marker=' '
449 if (( i == cur_line )); then marker='>'; fi
450 local text="${lines[$((i - 1))]}"
451 if ((${#text} > cols - 8)); then
452 text="${text:0:cols - 11}..."
453 fi
454 if (( i == cur_line )); then
455 printf '\033[33m%s %4d \033[0m%s\n' "$marker" "$i" "$text"
456 else
457 printf '%s %4d %s\n' "$marker" "$i" "$text"
458 fi
459 ((row++)) || true
460 done
461
462 while (( row < view_h )); do
463 printf '~\n'
464 ((row++)) || true
465 done
466
467 if [[ -n "$prompt" ]]; then
468 printf '\033[7m%s\033[0m\n' "$prompt"
469 else
470 printf 'Line %d/%d Col %d\n' "$cur_line" "${#lines[@]}" "$cur_col"
471 printf '^\033[32mX\033[0m Exit ^\033[32mO\033[0m Save ^\033[32mC\033[0m Copy ^\033[32mV\033[0m Paste ^\033[32mG\033[0m Help\n'
472 fi
473
474 local disp_row=$((cur_line - scroll_top + 3))
475 local disp_col=$((cur_col + 7))
476 if (( disp_row >= 3 && disp_row < rows - 2 )); then
477 printf '\033[%d;%dH\033[?25h' "$disp_row" "$disp_col"
478 else
479 printf '\033[?25h'
480 fi
481 }
482
483 _quit_confirm() {
484 if (( ! dirty )); then
485 return 0
486 fi
487 _tty_raw_off
488 prompt='Save modified buffer before exit? [Y/n]'
489 _draw
490 IFS= read -rsn1 prompt_reply || prompt_reply=""
491 prompt=""
492 _tty_raw_on
493 case "$prompt_reply" in
494 ''|y|Y)
495 _save
496 return 0
497 ;;
498 n|N) return 0 ;;
499 *) return 1 ;;
500 esac
501 }
502
503 _load
504
505 if ! old_stty=$(stty -g 2>/dev/null); then
506 brt_error "Could not configure terminal for editor"
507 rm -f "$tmp"
508 exit 1
509 fi
510
511 cleanup() {
512 stty "$old_stty" 2>/dev/null || true
513 printf '\033[?2004l\033[?25h\033[0m\n'
514 clear
515 rm -f "$tmp"
516 }
517 trap cleanup EXIT INT TERM
518
519 _tty_raw_on
520 printf '\033[?2004h'
521
522 while true; do
523 _ensure_visible
524 _draw
525 local key
526 key=$(_read_key) || break
527 key=${key:-}
528
529 if [[ -n "$prompt" ]]; then
530 continue
531 fi
532
533 case "$key" in
534 up) _move_up ;;
535 down) _move_down ;;
536 left) _move_left ;;
537 right) _move_right ;;
538 home) _move_home ;;
539 end) _move_end ;;
540 pageup) _move_up; _move_up; _move_up; _move_up; _move_up ;;
541 pagedown) _move_down; _move_down; _move_down; _move_down; _move_down ;;
542 backspace) _backspace ;;
543 delete|insert) _delete_char ;;
544 enter) _enter ;;
545 ctrl-o)
546 _save
547 prompt='File saved'
548 _draw
549 sleep 1
550 prompt=''
551 ;;
552 ctrl-c) _copy_line ;;
553 ctrl-v) _paste_system ;;
554 ctrl-u) _paste_buffer ;;
555 ctrl-k) _cut_line ;;
556 ctrl-g) _draw_help ;;
557 ctrl-x)
558 if _quit_confirm; then
559 break
560 fi
561 ;;
562 paste) _insert_text "$EDITOR_PASTE_TEXT" ;;
563 esc) ;;
564 '')
565 ;;
566 *)
567 _insert_char "$key"
568 ;;
569 esac
570 done
571}
572