| 1 | #!/usr/bin/env bash |
| 2 | |
| 3 | brt_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... |
| 431 | HELP |
| 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 | |