Files

1649 lines
42 KiB
Bash
Executable File

#!/usr/bin/env bash
set -uo pipefail
# Released under the terms of the WTFPL license https://www.wtfpl.net/
# Bim was partially created with bim
# Does that make it a recursive text editor?
# Behold! Vim in bash. Has anyone else ever created something so diabolically evil?
filePath="${1:-}"
mode="command"
statusMessage=""
renderedMessage=$'\001'
pendingInput=""
pendingCommand=""
commandCount=""
bimCommandActive=0
bimCommandName=""
lastChangeKeys=""
insertChangeKeys=""
replayKeysRemaining=0
hasYank=0
yankType="line"
dirty=0
cursorRow=0
cursorCol=0
topRow=0
declare -a lines=("")
declare -a yankBuffer=()
declare -a undoLines=()
hasUndo=0
undoCursorRow=0
undoCursorCol=0
undoTopRow=0
undoDirty=0
insertUndoSaved=0
startupMessage=""
declare -A bimCommands=()
trim_whitespace() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
load_bimrc() {
local bimrcPath="${HOME:-}/.bimrc"
local permissions line trimmed name commandText invalidCount=0
[[ -n "${HOME:-}" && -e "$bimrcPath" ]] || return
if [[ -L "$bimrcPath" || ! -f "$bimrcPath" || ! -O "$bimrcPath" ]]; then
startupMessage="Ignoring unsafe ~/.bimrc"
return
fi
permissions="$(stat -c %A "$bimrcPath" 2>/dev/null)" || {
startupMessage="Could not inspect ~/.bimrc"
return
}
if [[ "${permissions:5:1}" == "w" || "${permissions:8:1}" == "w" ]]; then
startupMessage="Ignoring group/world-writable ~/.bimrc"
return
fi
while IFS= read -r line || [[ -n "$line" ]]; do
trimmed="$(trim_whitespace "$line")"
[[ -z "$trimmed" || "${trimmed:0:1}" == "#" ]] && continue
if [[ "$line" != *"="* ]]; then
((invalidCount++))
continue
fi
name="$(trim_whitespace "${line%%=*}")"
commandText="$(trim_whitespace "${line#*=}")"
if [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ && -n "$commandText" ]]; then
bimCommands["$name"]="$commandText"
else
((invalidCount++))
fi
done < "$bimrcPath"
if ((invalidCount > 0)); then
startupMessage="Ignored $invalidCount invalid ~/.bimrc line(s)"
fi
}
load_bimrc
if [[ -n "$filePath" && -f "$filePath" ]]; then
mapfile -t lines < "$filePath"
((${#lines[@]} == 0)) && lines=("")
fi
cleanup() {
local rows
rows="$(tput lines 2>/dev/null || printf '24')"
printf '\033[H\033[J\033[%s;1H\033[?25h\033[?1049l' "$rows"
stty "$savedStty"
printf '\n'
}
die() {
cleanup
printf 'bim: %s\n' "$*" >&2
exit 1
}
savedStty="$(stty -g)"
trap cleanup EXIT
trap 'exit 130' INT TERM
stty raw -echo
printf '\033[?1049h\033[?25l'
statusMessage="$startupMessage"
screen_size() {
screenRows="$(tput lines 2>/dev/null || printf '24')"
screenCols="$(tput cols 2>/dev/null || printf '80')"
editRows=$((screenRows - 1))
((editRows < 1)) && editRows=1
}
line_length() {
local row="$1"
printf '%s' "${#lines[row]}"
}
clamp_cursor() {
local maxRow=$(( ${#lines[@]} - 1 ))
((cursorRow < 0)) && cursorRow=0
((cursorRow > maxRow)) && cursorRow="$maxRow"
local maxCol
maxCol="$(line_length "$cursorRow")"
((cursorCol < 0)) && cursorCol=0
((cursorCol > maxCol)) && cursorCol="$maxCol"
if ((cursorRow < topRow)); then
topRow="$cursorRow"
elif ((cursorRow >= topRow + editRows)); then
topRow=$((cursorRow - editRows + 1))
fi
}
file_info() {
local name="${filePath:-[No Name]}"
local dirtyMark=""
[[ "$dirty" == 1 ]] && dirtyMark=" [+]"
statusMessage="${name}${dirtyMark} line $((cursorRow + 1)) of ${#lines[@]}, col $((cursorCol + 1))"
}
draw_message() {
local message="$statusMessage"
if ((bimCommandActive)); then
message=",$bimCommandName"
elif [[ -n "$pendingCommand" || -n "$commandCount" ]]; then
message="${commandCount}${pendingCommand}"
fi
if [[ "$message" != "$renderedMessage" ]]; then
printf '\033[%s;1H%-*.*s' "$screenRows" "$screenCols" "$screenCols" "$message"
renderedMessage="$message"
fi
}
draw_screen() {
screen_size
clamp_cursor
printf '\033[H'
local row line text
for ((row = 0; row < editRows; row++)); do
line=$((topRow + row))
printf '\033[%s;1H\033[K' "$((row + 1))"
if ((line < ${#lines[@]})); then
text="${lines[line]}"
printf '%-*.*s' "$screenCols" "$screenCols" "$text"
else
printf '~'
fi
done
draw_message
if [[ "$mode" == "command" ]] && ((${#lines[cursorRow]} > 0 && cursorCol >= ${#lines[cursorRow]})); then
cursorCol=$(( ${#lines[cursorRow]} - 1 ))
fi
printf '\033[%s;%sH' "$((cursorRow - topRow + 1))" "$((cursorCol + 1))"
}
read_key() {
local char next third rest
if [[ -n "$pendingInput" ]]; then
key="${pendingInput:0:1}"
pendingInput="${pendingInput:1}"
((replayKeysRemaining > 0)) && ((replayKeysRemaining--))
return 0
fi
IFS= read -rsN1 char || return 1
if [[ "$char" == $'\033' ]]; then
if IFS= read -rsN1 -t 0.01 next; then
if [[ "$next" == "[" ]]; then
rest="$next"
while IFS= read -rsN1 -t 0.001 char; do
rest+="$char"
done
key=$'\033'"$rest"
elif [[ "$next" == "O" ]]; then
if IFS= read -rsN1 -t 0.001 third; then
if [[ "$third" =~ [ABCD] ]]; then
key=$'\033'"O$third"
else
pendingInput="O$third"
key=$'\033'
fi
else
pendingInput="O"
key=$'\033'
fi
else
pendingInput="$next"
key=$'\033'
fi
else
key=$'\033'
fi
else
key="$char"
fi
}
move_left() {
if ((cursorCol > 0)); then
((cursorCol--))
fi
}
move_right() {
local maxCol
maxCol="$(line_length "$cursorRow")"
if ((cursorCol < maxCol)); then
((cursorCol++))
fi
}
move_up() {
((cursorRow > 0)) && ((cursorRow--))
}
move_down() {
((cursorRow + 1 < ${#lines[@]})) && ((cursorRow++))
}
is_blank_char() {
local char="$1"
[[ "$char" =~ [[:space:]] ]]
}
is_last_buffer_position() {
local row="$1"
local col="$2"
((row == ${#lines[@]} - 1 && col >= ${#lines[row]}))
}
step_position_forward() {
local -n rowRef="$1"
local -n colRef="$2"
if ((colRef < ${#lines[rowRef]})); then
((colRef++))
elif ((rowRef + 1 < ${#lines[@]})); then
((rowRef++))
colRef=0
fi
}
step_position_backward() {
local -n rowRef="$1"
local -n colRef="$2"
if ((colRef > 0)); then
((colRef--))
elif ((rowRef > 0)); then
((rowRef--))
colRef="${#lines[rowRef]}"
fi
}
move_word_forward() {
local count="$1"
local i row col char
for ((i = 0; i < count; i++)); do
row="$cursorRow"
col="$cursorCol"
if is_last_buffer_position "$row" "$col"; then
break
fi
if ((col < ${#lines[row]})); then
char="${lines[row]:col:1}"
if ! is_blank_char "$char"; then
while ! is_last_buffer_position "$row" "$col"; do
if ((col >= ${#lines[row]})); then
break
fi
char="${lines[row]:col:1}"
is_blank_char "$char" && break
step_position_forward row col
done
fi
fi
while ! is_last_buffer_position "$row" "$col"; do
if ((col >= ${#lines[row]})); then
step_position_forward row col
continue
fi
char="${lines[row]:col:1}"
! is_blank_char "$char" && break
step_position_forward row col
done
cursorRow="$row"
cursorCol="$col"
done
}
move_word_backward() {
local count="$1"
local i row col char
for ((i = 0; i < count; i++)); do
row="$cursorRow"
col="$cursorCol"
if ((row == 0 && col == 0)); then
break
fi
step_position_backward row col
while ((row > 0 || col > 0)); do
if ((col >= ${#lines[row]})); then
step_position_backward row col
continue
fi
char="${lines[row]:col:1}"
! is_blank_char "$char" && break
step_position_backward row col
done
while ((row > 0 || col > 0)); do
if ((col == 0)); then
break
fi
char="${lines[row]:col-1:1}"
is_blank_char "$char" && break
step_position_backward row col
done
cursorRow="$row"
cursorCol="$col"
done
}
move_word_end_forward() {
local count="$1"
local i row col nextRow nextCol char nextChar
for ((i = 0; i < count; i++)); do
row="$cursorRow"
col="$cursorCol"
if is_last_buffer_position "$row" "$col"; then
break
fi
if ((col < ${#lines[row]})); then
char="${lines[row]:col:1}"
nextRow="$row"
nextCol="$col"
step_position_forward nextRow nextCol
if ! is_blank_char "$char"; then
if ((nextCol >= ${#lines[nextRow]})); then
row="$nextRow"
col="$nextCol"
else
nextChar="${lines[nextRow]:nextCol:1}"
if is_blank_char "$nextChar"; then
row="$nextRow"
col="$nextCol"
fi
fi
fi
fi
while ! is_last_buffer_position "$row" "$col"; do
if ((col >= ${#lines[row]})); then
step_position_forward row col
continue
fi
char="${lines[row]:col:1}"
! is_blank_char "$char" && break
step_position_forward row col
done
while ! is_last_buffer_position "$row" "$col"; do
nextRow="$row"
nextCol="$col"
step_position_forward nextRow nextCol
if ((nextCol >= ${#lines[nextRow]})); then
break
fi
nextChar="${lines[nextRow]:nextCol:1}"
is_blank_char "$nextChar" && break
row="$nextRow"
col="$nextCol"
done
cursorRow="$row"
cursorCol="$col"
done
}
move_word_end_backward() {
local count="$1"
local i row col char
for ((i = 0; i < count; i++)); do
row="$cursorRow"
col="$cursorCol"
if ((row == 0 && col == 0)); then
break
fi
step_position_backward row col
while ((row > 0 || col > 0)); do
if ((col >= ${#lines[row]})); then
step_position_backward row col
continue
fi
char="${lines[row]:col:1}"
! is_blank_char "$char" && break
step_position_backward row col
done
cursorRow="$row"
cursorCol="$col"
done
}
move_to_first_nonblank() {
local line="${lines[cursorRow]}"
local col=0
while ((col < ${#line})); do
if ! is_blank_char "${line:col:1}"; then
break
fi
((col++))
done
cursorCol="$col"
}
move_to_line() {
local lineNumber="$1"
((lineNumber < 1)) && lineNumber=1
((lineNumber > ${#lines[@]})) && lineNumber="${#lines[@]}"
cursorRow=$((lineNumber - 1))
clamp_cursor
}
command_count() {
if [[ -n "$commandCount" ]]; then
printf '%s' "$commandCount"
else
printf '1'
fi
}
record_last_change() {
local keys="$1"
if ((replayKeysRemaining == 0)); then
lastChangeKeys="$keys"
fi
}
start_insert_change() {
local keys="$1"
if ((replayKeysRemaining == 0)); then
insertChangeKeys="$keys"
fi
}
finish_insert_change() {
if ((replayKeysRemaining == 0 && ${#insertChangeKeys} > 0)); then
lastChangeKeys="${insertChangeKeys}"$'\033'
fi
insertChangeKeys=""
}
repeat_last_change() {
local count="$1"
local repeatedKeys=""
local i
if [[ -z "$lastChangeKeys" ]]; then
statusMessage="No change to repeat"
return
fi
for ((i = 0; i < count; i++)); do
repeatedKeys+="$lastChangeKeys"
done
pendingInput="${repeatedKeys}${pendingInput}"
replayKeysRemaining=$((replayKeysRemaining + ${#repeatedKeys}))
statusMessage=""
}
bim_command_exists() {
local name="$1"
[[ -n "${bimCommands[$name]+set}" ]]
}
bim_command_has_prefix() {
local prefix="$1"
local name
for name in "${!bimCommands[@]}"; do
[[ "$name" == "$prefix"* ]] && return 0
done
return 1
}
run_bim_command() {
local name="$1"
local commandText="${bimCommands[$name]}"
local output exitStatus
output="$(bash -c "$commandText" </dev/null 2>/dev/null)"
exitStatus=$?
if ((exitStatus != 0)); then
statusMessage=",$name failed with status $exitStatus"
return
fi
if [[ -z "$output" ]]; then
statusMessage=",$name produced no output"
return
fi
insert_text_at_cursor "${output} "
statusMessage="Inserted ,$name"
}
handle_bim_command_key() {
local key="$1"
case "$key" in
$'\033')
bimCommandActive=0
bimCommandName=""
statusMessage=""
return
;;
$'\r'|$'\n')
if bim_command_exists "$bimCommandName"; then
run_bim_command "$bimCommandName"
else
statusMessage="Unknown command: ,$bimCommandName"
fi
bimCommandActive=0
bimCommandName=""
return
;;
$'\177'|$'\b')
if ((${#bimCommandName} > 0)); then
bimCommandName="${bimCommandName:0:${#bimCommandName}-1}"
fi
return
;;
esac
if [[ "$key" =~ ^[A-Za-z0-9_-]$ ]]; then
bimCommandName+="$key"
if ! bim_command_has_prefix "$bimCommandName"; then
statusMessage="Unknown command: ,$bimCommandName"
bimCommandActive=0
bimCommandName=""
fi
else
statusMessage="Invalid command name: ,$bimCommandName"
bimCommandActive=0
bimCommandName=""
fi
}
save_undo() {
undoLines=("${lines[@]}")
undoCursorRow="$cursorRow"
undoCursorCol="$cursorCol"
undoTopRow="$topRow"
undoDirty="$dirty"
hasUndo=1
}
ensure_insert_undo() {
if ((insertUndoSaved == 0)); then
save_undo
insertUndoSaved=1
fi
}
enter_insert_mode() {
local saveSnapshot="${1:-1}"
if ((saveSnapshot == 1)); then
save_undo
fi
insertUndoSaved=1
mode="insert"
statusMessage=""
}
undo_last_change() {
if ((hasUndo == 0)); then
statusMessage="Already at oldest change"
return
fi
lines=("${undoLines[@]}")
cursorRow="$undoCursorRow"
cursorCol="$undoCursorCol"
topRow="$undoTopRow"
dirty="$undoDirty"
hasUndo=0
insertUndoSaved=0
statusMessage="Undone"
clamp_cursor
}
insert_char() {
local char="$1"
local line="${lines[cursorRow]}"
ensure_insert_undo
lines[cursorRow]="${line:0:cursorCol}${char}${line:cursorCol}"
((cursorCol++))
dirty=1
}
split_line() {
local line="${lines[cursorRow]}"
local before="${line:0:cursorCol}"
local after="${line:cursorCol}"
ensure_insert_undo
lines[cursorRow]="$before"
lines=("${lines[@]:0:cursorRow+1}" "$after" "${lines[@]:cursorRow+1}")
((cursorRow++))
cursorCol=0
dirty=1
}
backspace_insert() {
local line previous
if ((cursorCol > 0)); then
ensure_insert_undo
line="${lines[cursorRow]}"
lines[cursorRow]="${line:0:cursorCol-1}${line:cursorCol}"
((cursorCol--))
dirty=1
elif ((cursorRow > 0)); then
ensure_insert_undo
previous="${lines[cursorRow-1]}"
cursorCol="${#previous}"
lines[cursorRow-1]+="${lines[cursorRow]}"
lines=("${lines[@]:0:cursorRow}" "${lines[@]:cursorRow+1}")
((cursorRow--))
dirty=1
fi
}
insert_text_at_cursor() {
local text="$1"
local line="${lines[cursorRow]}"
local prefix="${line:0:cursorCol}"
local suffix="${line:cursorCol}"
local rest="$text"
local segment
local -a parts=()
local i lastIndex
[[ -n "$text" ]] || return 1
while [[ "$rest" == *$'\n'* ]]; do
segment="${rest%%$'\n'*}"
parts+=("$segment")
rest="${rest#*$'\n'}"
done
parts+=("$rest")
save_undo
if ((${#parts[@]} == 1)); then
lines[cursorRow]="${prefix}${parts[0]}${suffix}"
cursorCol=$((cursorCol + ${#parts[0]}))
else
lastIndex=$((${#parts[@]} - 1))
parts[0]="${prefix}${parts[0]}"
parts[lastIndex]="${parts[lastIndex]}${suffix}"
lines=("${lines[@]:0:cursorRow}" "${parts[@]}" "${lines[@]:cursorRow+1}")
cursorRow=$((cursorRow + lastIndex))
cursorCol=$((${#parts[lastIndex]} - ${#suffix}))
fi
dirty=1
insertUndoSaved=0
clamp_cursor
}
delete_under_cursor() {
local line="${lines[cursorRow]}"
if ((cursorCol < ${#line})); then
lines[cursorRow]="${line:0:cursorCol}${line:cursorCol+1}"
dirty=1
fi
}
compare_positions() {
local leftRow="$1"
local leftCol="$2"
local rightRow="$3"
local rightCol="$4"
if ((leftRow < rightRow || (leftRow == rightRow && leftCol < rightCol))); then
printf -- '-1'
elif ((leftRow == rightRow && leftCol == rightCol)); then
printf '0'
else
printf '1'
fi
}
delete_range() {
local startRow="$1"
local startCol="$2"
local endRow="$3"
local endCol="$4"
local comparison line prefix suffix
comparison="$(compare_positions "$startRow" "$startCol" "$endRow" "$endCol")"
if [[ "$comparison" == "0" ]]; then
return
elif [[ "$comparison" == "1" ]]; then
local tempRow="$startRow"
local tempCol="$startCol"
startRow="$endRow"
startCol="$endCol"
endRow="$tempRow"
endCol="$tempCol"
fi
if ((startRow == endRow)); then
line="${lines[startRow]}"
lines[startRow]="${line:0:startCol}${line:endCol}"
else
prefix="${lines[startRow]:0:startCol}"
suffix="${lines[endRow]:endCol}"
lines=("${lines[@]:0:startRow}" "${prefix}${suffix}" "${lines[@]:endRow+1}")
fi
if ((${#lines[@]} == 0)); then
lines=("")
fi
cursorRow="$startRow"
cursorCol="$startCol"
dirty=1
statusMessage=""
clamp_cursor
}
line_end_for_operator() {
local row="$1"
printf '%s' "${#lines[row]}"
}
yank_range() {
local startRow="$1"
local startCol="$2"
local endRow="$3"
local endCol="$4"
local comparison row
comparison="$(compare_positions "$startRow" "$startCol" "$endRow" "$endCol")"
if [[ "$comparison" == "0" ]]; then
return 1
elif [[ "$comparison" == "1" ]]; then
local tempRow="$startRow"
local tempCol="$startCol"
startRow="$endRow"
startCol="$endCol"
endRow="$tempRow"
endCol="$tempCol"
fi
yankType="char"
if ((startRow == endRow)); then
yankBuffer=("${lines[startRow]:startCol:endCol-startCol}")
else
yankBuffer=("${lines[startRow]:startCol}")
for ((row = startRow + 1; row < endRow; row++)); do
yankBuffer+=("${lines[row]}")
done
yankBuffer+=("${lines[endRow]:0:endCol}")
fi
hasYank=1
}
delete_to_end_of_line() {
local startRow="$cursorRow"
local startCol="$cursorCol"
local endCol
endCol="${#lines[startRow]}"
if ((startCol >= endCol)); then
return 1
fi
delete_range "$startRow" "$startCol" "$startRow" "$endCol"
}
delete_word_forward() {
local count="$1"
local startRow="$cursorRow"
local startCol="$cursorCol"
save_undo
move_word_forward "$count"
yank_range "$startRow" "$startCol" "$cursorRow" "$cursorCol"
delete_range "$startRow" "$startCol" "$cursorRow" "$cursorCol"
}
delete_word_backward() {
local count="$1"
local startRow="$cursorRow"
local startCol="$cursorCol"
save_undo
move_word_backward "$count"
yank_range "$startRow" "$startCol" "$cursorRow" "$cursorCol"
delete_range "$startRow" "$startCol" "$cursorRow" "$cursorCol"
}
yank_word_forward() {
local count="$1"
local startRow="$cursorRow"
local startCol="$cursorCol"
move_word_forward "$count"
if yank_range "$startRow" "$startCol" "$cursorRow" "$cursorCol"; then
statusMessage="text yanked"
else
statusMessage="Nothing to yank"
fi
cursorRow="$startRow"
cursorCol="$startCol"
}
yank_word_backward() {
local count="$1"
local startRow="$cursorRow"
local startCol="$cursorCol"
move_word_backward "$count"
if yank_range "$startRow" "$startCol" "$cursorRow" "$cursorCol"; then
statusMessage="text yanked"
else
statusMessage="Nothing to yank"
fi
cursorRow="$startRow"
cursorCol="$startCol"
}
yank_to_end_of_line() {
local startRow="$cursorRow"
local startCol="$cursorCol"
local endCol
endCol="$(line_end_for_operator "$startRow")"
if yank_range "$startRow" "$startCol" "$startRow" "$endCol"; then
statusMessage="text yanked"
else
statusMessage="Nothing to yank"
fi
}
change_word() {
local count="$1"
local startRow="$cursorRow"
local startCol="$cursorCol"
local endRow endCol
save_undo
move_word_end_forward "$count"
endRow="$cursorRow"
endCol="$cursorCol"
if ! is_last_buffer_position "$endRow" "$endCol"; then
step_position_forward endRow endCol
elif ((endCol < ${#lines[endRow]})); then
((endCol++))
fi
delete_range "$startRow" "$startCol" "$endRow" "$endCol"
enter_insert_mode 0
}
change_current_line() {
save_undo
lines[cursorRow]=""
cursorCol=0
dirty=1
enter_insert_mode 0
}
change_to_end_of_line() {
save_undo
delete_to_end_of_line
enter_insert_mode 0
}
substitute_chars() {
local count="$1"
local startCol="$cursorCol"
local endCol=$((cursorCol + count))
local lineLength
lineLength="${#lines[cursorRow]}"
((endCol > lineLength)) && endCol="$lineLength"
save_undo
if ((startCol < endCol)); then
delete_range "$cursorRow" "$startCol" "$cursorRow" "$endCol"
fi
enter_insert_mode 0
}
replace_chars() {
local count="$1"
local replacement="$2"
local line="${lines[cursorRow]}"
local lineLength="${#line}"
local actual=0
local i targetCol
if [[ ! "$replacement" =~ [[:print:]] ]] || ((cursorCol >= lineLength)); then
statusMessage=""
return
fi
save_undo
for ((i = 0; i < count; i++)); do
targetCol=$((cursorCol + i))
((targetCol >= lineLength)) && break
line="${line:0:targetCol}${replacement}${line:targetCol+1}"
((actual++))
done
lines[cursorRow]="$line"
if ((actual > 0)); then
cursorCol=$((cursorCol + actual - 1))
fi
dirty=1
statusMessage=""
}
join_lines() {
local count="$1"
local i nextLine
if ((cursorRow + 1 >= ${#lines[@]})); then
return
fi
save_undo
for ((i = 0; i < count && cursorRow + 1 < ${#lines[@]}; i++)); do
nextLine="${lines[cursorRow+1]}"
lines[cursorRow]="${lines[cursorRow]} ${nextLine#"${nextLine%%[![:space:]]*}"}"
lines=("${lines[@]:0:cursorRow+1}" "${lines[@]:cursorRow+2}")
done
dirty=1
statusMessage=""
}
search_from_position() {
local pattern="$1"
local direction="$2"
local startRow="$3"
local startCol="$4"
local offset row col line searchPart matchPrefix
if [[ -z "$pattern" ]]; then
return 1
fi
for ((offset = 0; offset < ${#lines[@]}; offset++)); do
if [[ "$direction" == "forward" ]]; then
row=$(( (startRow + offset) % ${#lines[@]} ))
col=0
((offset == 0)) && col="$startCol"
else
row=$(( (startRow - offset + ${#lines[@]}) % ${#lines[@]} ))
col="${#lines[row]}"
((offset == 0)) && col="$startCol"
fi
line="${lines[row]}"
if [[ "$direction" == "forward" ]]; then
((col < 0)) && col=0
((col > ${#line})) && col="${#line}"
searchPart="${line:col}"
if [[ "$searchPart" == *"$pattern"* ]]; then
matchPrefix="${searchPart%%"$pattern"*}"
cursorRow="$row"
cursorCol=$((col + ${#matchPrefix}))
((offset > 0 && row <= startRow)) && statusMessage="search wrapped"
return 0
fi
else
((col < 0)) && col=0
((col > ${#line})) && col="${#line}"
searchPart="${line:0:col}"
if [[ "$searchPart" == *"$pattern"* ]]; then
matchPrefix="${searchPart%"$pattern"*}"
cursorRow="$row"
cursorCol="${#matchPrefix}"
((offset > 0 && row >= startRow)) && statusMessage="search wrapped"
return 0
fi
fi
done
return 1
}
lastSearchPattern=""
lastSearchDirection="forward"
run_search() {
local pattern="$1"
local direction="$2"
local startRow="$cursorRow"
local startCol="$cursorCol"
if [[ "$direction" == "forward" ]]; then
((startCol++))
else
((startCol--))
fi
if search_from_position "$pattern" "$direction" "$startRow" "$startCol"; then
lastSearchPattern="$pattern"
lastSearchDirection="$direction"
[[ "$statusMessage" == "search wrapped" ]] || statusMessage=""
clamp_cursor
else
statusMessage="Pattern not found: $pattern"
fi
}
repeat_search() {
local direction="$1"
if [[ -z "$lastSearchPattern" ]]; then
statusMessage="No previous search"
return
fi
run_search "$lastSearchPattern" "$direction"
}
prompt_search() {
local direction="$1"
local prompt key pattern
if [[ "$direction" == "forward" ]]; then
prompt="/"
else
prompt="?"
fi
pattern="$prompt"
while true; do
statusMessage="$pattern"
draw_screen
read_key || return
case "$key" in
$'\033')
statusMessage=""
return
;;
$'\r'|$'\n')
break
;;
$'\177'|$'\b')
if ((${#pattern} > 1)); then
pattern="${pattern:0:${#pattern}-1}"
fi
;;
*)
if [[ "$key" =~ [[:print:]] ]]; then
pattern+="$key"
fi
;;
esac
done
pattern="${pattern#"$prompt"}"
if [[ -z "$pattern" ]]; then
statusMessage="Empty search"
return
fi
run_search "$pattern" "$direction"
}
open_line_below() {
lines=("${lines[@]:0:cursorRow+1}" "" "${lines[@]:cursorRow+1}")
((cursorRow++))
cursorCol=0
enter_insert_mode 0
dirty=1
}
open_line_above() {
lines=("${lines[@]:0:cursorRow}" "" "${lines[@]:cursorRow}")
cursorCol=0
enter_insert_mode 0
dirty=1
}
yank_lines() {
local requested="$1"
local available=$(( ${#lines[@]} - cursorRow ))
local actual="$requested"
((actual > available)) && actual="$available"
((actual < 1)) && actual=1
yankType="line"
yankBuffer=("${lines[@]:cursorRow:actual}")
hasYank=1
statusMessage="$actual line yanked"
((actual != 1)) && statusMessage="$actual lines yanked"
}
delete_lines() {
local requested="$1"
local available=$(( ${#lines[@]} - cursorRow ))
local actual="$requested"
((actual > available)) && actual="$available"
((actual < 1)) && actual=1
save_undo
yankType="line"
yankBuffer=("${lines[@]:cursorRow:actual}")
hasYank=1
lines=("${lines[@]:0:cursorRow}" "${lines[@]:cursorRow+actual}")
if ((${#lines[@]} == 0)); then
lines=("")
cursorRow=0
elif ((cursorRow >= ${#lines[@]})); then
cursorRow=$(( ${#lines[@]} - 1 ))
fi
cursorCol=0
statusMessage="$actual line deleted"
((actual != 1)) && statusMessage="$actual lines deleted"
dirty=1
}
paste_line_below() {
local count="$1"
local insertAt=$((cursorRow + 1))
local firstPastedRow="$insertAt"
if ((hasYank == 0)); then
statusMessage="Nothing in yank buffer"
return
fi
save_undo
local i
for ((i = 0; i < count; i++)); do
lines=("${lines[@]:0:insertAt}" "${yankBuffer[@]}" "${lines[@]:insertAt}")
insertAt=$((insertAt + ${#yankBuffer[@]}))
done
cursorRow="$firstPastedRow"
cursorCol=0
statusMessage=""
dirty=1
}
paste_char_after_cursor() {
local count="$1"
local pasteRow="$cursorRow"
local pasteCol="$cursorCol"
local line prefix suffix i insertAt
if ((hasYank == 0)); then
statusMessage="Nothing in yank buffer"
return
fi
if [[ "$yankType" == "line" ]]; then
paste_line_below "$count"
return
fi
save_undo
if ((pasteCol < ${#lines[pasteRow]})); then
((pasteCol++))
fi
for ((i = 0; i < count; i++)); do
line="${lines[pasteRow]}"
prefix="${line:0:pasteCol}"
suffix="${line:pasteCol}"
if ((${#yankBuffer[@]} == 1)); then
lines[pasteRow]="${prefix}${yankBuffer[0]}${suffix}"
cursorRow="$pasteRow"
cursorCol=$((pasteCol + ${#yankBuffer[0]} - 1))
pasteCol=$((cursorCol + 1))
else
lines[pasteRow]="${prefix}${yankBuffer[0]}"
insertAt=$((pasteRow + 1))
if ((${#yankBuffer[@]} > 2)); then
lines=("${lines[@]:0:insertAt}" "${yankBuffer[@]:1:${#yankBuffer[@]}-2}" "${lines[@]:insertAt}")
insertAt=$((insertAt + ${#yankBuffer[@]} - 2))
fi
lines=("${lines[@]:0:insertAt}" "${yankBuffer[-1]}${suffix}" "${lines[@]:insertAt}")
cursorRow="$insertAt"
cursorCol=$(( ${#yankBuffer[-1]} - 1 ))
pasteRow="$cursorRow"
pasteCol=$((cursorCol + 1))
fi
done
dirty=1
statusMessage=""
clamp_cursor
}
save_file() {
local target="$1"
[[ -z "$target" ]] && return 1
local line
: > "$target"
for line in "${lines[@]}"; do
printf '%s\n' "$line" >> "$target"
done
filePath="$target"
dirty=0
statusMessage="Wrote $target"
}
prompt_command() {
local command key
command=":"
while true; do
statusMessage="$command"
draw_screen
read_key || return
case "$key" in
$'\033')
statusMessage=""
return
;;
$'\r'|$'\n')
break
;;
$'\177'|$'\b')
if ((${#command} > 1)); then
command="${command:0:${#command}-1}"
fi
;;
*)
if [[ "$key" =~ [[:print:]] ]]; then
command+="$key"
fi
;;
esac
done
command="${command#:}"
case "$command" in
q)
if [[ "$dirty" == 1 ]]; then
statusMessage="Unsaved changes. Use :q! to quit anyway."
else
exit 0
fi
;;
q!)
exit 0
;;
w)
if save_file "$filePath"; then
:
else
statusMessage="No file name. Use :w path"
fi
;;
w\ *)
save_file "${command#w }" || statusMessage="Could not write file"
;;
wq)
if save_file "$filePath"; then
exit 0
else
statusMessage="No file name. Use :w path"
fi
;;
wq\ *)
save_file "${command#wq }" && exit 0
statusMessage="Could not write file"
;;
*)
statusMessage="Unknown command: :$command"
;;
esac
}
handle_command_key() {
local key="$1"
local count i
if ((bimCommandActive)); then
handle_bim_command_key "$key"
commandCount=""
pendingCommand=""
return
fi
if [[ -n "$pendingCommand" ]]; then
count="$(command_count)"
if [[ "$pendingCommand" == "r" ]]; then
record_last_change "${commandCount}${pendingCommand}${key}"
replace_chars "$count" "$key"
pendingCommand=""
commandCount=""
return
fi
case "${pendingCommand}${key}" in
dd)
record_last_change "${commandCount}${pendingCommand}${key}"
delete_lines "$count"
;;
d'$')
record_last_change "${commandCount}${pendingCommand}${key}"
save_undo
yank_to_end_of_line
delete_to_end_of_line
;;
dw)
record_last_change "${commandCount}${pendingCommand}${key}"
delete_word_forward "$count"
;;
db)
record_last_change "${commandCount}${pendingCommand}${key}"
delete_word_backward "$count"
;;
y'$')
yank_to_end_of_line
;;
yw)
yank_word_forward "$count"
;;
yb)
yank_word_backward "$count"
;;
cc)
start_insert_change "${commandCount}${pendingCommand}${key}"
change_current_line
;;
c'$')
start_insert_change "${commandCount}${pendingCommand}${key}"
change_to_end_of_line
;;
cw)
start_insert_change "${commandCount}${pendingCommand}${key}"
change_word "$count"
;;
ge)
move_word_end_backward "$count"
;;
gg)
if [[ -n "$commandCount" ]]; then
move_to_line "$commandCount"
else
move_to_line 1
fi
;;
yy)
yank_lines "$count"
;;
*)
statusMessage=""
;;
esac
pendingCommand=""
commandCount=""
return
fi
case "$key" in
[1-9])
commandCount+="$key"
statusMessage=""
;;
0)
if [[ -n "$commandCount" ]]; then
commandCount+="$key"
statusMessage=""
else
cursorCol=0
fi
;;
i)
start_insert_change "${commandCount}${key}"
commandCount=""
enter_insert_mode
;;
a)
start_insert_change "${commandCount}${key}"
commandCount=""
if ((cursorCol < $(line_length "$cursorRow"))); then
((cursorCol++))
fi
enter_insert_mode
;;
I)
start_insert_change "${commandCount}${key}"
commandCount=""
cursorCol=0
enter_insert_mode
;;
A)
start_insert_change "${commandCount}${key}"
commandCount=""
cursorCol="$(line_length "$cursorRow")"
enter_insert_mode
;;
h|$'\033[D')
count="$(command_count)"
for ((i = 0; i < count; i++)); do move_left; done
commandCount=""
;;
l|$'\033[C')
count="$(command_count)"
for ((i = 0; i < count; i++)); do move_right; done
commandCount=""
;;
k|$'\033[A')
count="$(command_count)"
for ((i = 0; i < count; i++)); do move_up; done
commandCount=""
;;
j|$'\033[B')
count="$(command_count)"
for ((i = 0; i < count; i++)); do move_down; done
commandCount=""
;;
w)
count="$(command_count)"
move_word_forward "$count"
commandCount=""
;;
e)
count="$(command_count)"
move_word_end_forward "$count"
commandCount=""
;;
b)
count="$(command_count)"
move_word_backward "$count"
commandCount=""
;;
G)
if [[ -n "$commandCount" ]]; then
move_to_line "$commandCount"
else
move_to_line "${#lines[@]}"
fi
commandCount=""
;;
'^')
commandCount=""
move_to_first_nonblank
;;
'$')
commandCount=""
cursorCol="$(line_length "$cursorRow")"
;;
x)
count="$(command_count)"
record_last_change "${commandCount}${key}"
save_undo
yank_range "$cursorRow" "$cursorCol" "$cursorRow" "$((cursorCol + count))"
for ((i = 0; i < count; i++)); do delete_under_cursor; done
commandCount=""
;;
D)
record_last_change "${commandCount}${key}"
commandCount=""
save_undo
yank_to_end_of_line
delete_to_end_of_line
;;
C)
start_insert_change "${commandCount}${key}"
commandCount=""
change_to_end_of_line
;;
s)
count="$(command_count)"
start_insert_change "${commandCount}${key}"
yank_range "$cursorRow" "$cursorCol" "$cursorRow" "$((cursorCol + count))"
substitute_chars "$count"
commandCount=""
;;
r)
pendingCommand="$key"
statusMessage=""
;;
d|y|c|g)
pendingCommand="$key"
statusMessage=""
;;
p)
count="$(command_count)"
record_last_change "${commandCount}${key}"
paste_char_after_cursor "$count"
commandCount=""
;;
J)
count="$(command_count)"
record_last_change "${commandCount}${key}"
join_lines "$count"
commandCount=""
;;
o)
count="$(command_count)"
start_insert_change "${commandCount}${key}"
save_undo
for ((i = 0; i < count; i++)); do open_line_below; done
commandCount=""
;;
O)
count="$(command_count)"
start_insert_change "${commandCount}${key}"
save_undo
for ((i = 0; i < count; i++)); do open_line_above; done
commandCount=""
;;
/)
commandCount=""
prompt_search forward
;;
'?')
commandCount=""
prompt_search backward
;;
n)
commandCount=""
repeat_search "$lastSearchDirection"
;;
N)
commandCount=""
if [[ "$lastSearchDirection" == "forward" ]]; then
repeat_search backward
else
repeat_search forward
fi
;;
'.')
count="$(command_count)"
commandCount=""
repeat_last_change "$count"
;;
:)
commandCount=""
prompt_command
;;
,)
commandCount=""
if ((${#bimCommands[@]} == 0)); then
statusMessage="No ~/.bimrc commands"
else
bimCommandActive=1
bimCommandName=""
statusMessage=""
fi
;;
$'\007')
commandCount=""
file_info
;;
u)
commandCount=""
undo_last_change
;;
$'\003')
commandCount=""
statusMessage="Use :q to quit"
;;
*)
commandCount=""
;;
esac
}
handle_insert_key() {
local key="$1"
case "$key" in
$'\033')
finish_insert_change
mode="command"
insertUndoSaved=0
statusMessage=""
;;
$'\033[D')
insertChangeKeys+="$key"
move_left
;;
$'\033[C')
insertChangeKeys+="$key"
move_right
;;
$'\033[A')
insertChangeKeys+="$key"
move_up
;;
$'\033[B')
insertChangeKeys+="$key"
move_down
;;
$'\r'|$'\n')
insertChangeKeys+="$key"
split_line
;;
$'\177'|$'\b')
insertChangeKeys+="$key"
backspace_insert
;;
$'\007')
file_info
;;
$'\003')
finish_insert_change
mode="command"
insertUndoSaved=0
statusMessage="Use :q to quit"
;;
*)
if [[ "$key" =~ [[:print:]] ]]; then
insertChangeKeys+="$key"
insert_char "$key"
fi
;;
esac
}
while true; do
draw_screen
read_key || exit 0
if [[ "$mode" == "insert" ]]; then
handle_insert_key "$key"
else
handle_command_key "$key"
fi
done