Native SDL3 editor written in Odin with a piece-table buffer core, backed by a Kotlin/JVM daemon speaking newline-delimited JSON over localhost TCP for Gradle import, Kotlin/Java diagnostics, and heuristic completion/hover/definition/references/rename. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
538 lines
15 KiB
Odin
538 lines
15 KiB
Odin
package main
|
|
|
|
Piece_Source :: enum {
|
|
Original,
|
|
Add,
|
|
}
|
|
|
|
Piece :: struct {
|
|
source: Piece_Source,
|
|
start: int,
|
|
len: int,
|
|
}
|
|
|
|
Line_Index :: struct {
|
|
starts: [dynamic]int,
|
|
dirty: bool,
|
|
}
|
|
|
|
Buffer_Edit_Kind :: enum {
|
|
Insert,
|
|
Delete,
|
|
Replace,
|
|
}
|
|
|
|
Buffer_Edit :: struct {
|
|
kind: Buffer_Edit_Kind,
|
|
offset: int,
|
|
text: [dynamic]u8,
|
|
replacement: [dynamic]u8,
|
|
}
|
|
|
|
Buffer :: struct {
|
|
original: string,
|
|
add: [dynamic]u8,
|
|
pieces: [dynamic]Piece,
|
|
undo: [dynamic]Buffer_Edit,
|
|
redo: [dynamic]Buffer_Edit,
|
|
|
|
line_index: Line_Index,
|
|
version: int,
|
|
}
|
|
|
|
Cursor :: struct {
|
|
offset: int,
|
|
wanted_column: int,
|
|
}
|
|
|
|
buffer_make :: proc(original: string) -> Buffer {
|
|
buffer: Buffer
|
|
buffer.original = original
|
|
buffer.line_index.dirty = true
|
|
|
|
if len(original) > 0 {
|
|
append(&buffer.pieces, Piece{.Original, 0, len(original)})
|
|
}
|
|
|
|
return buffer
|
|
}
|
|
|
|
buffer_len :: proc(buffer: ^Buffer) -> int {
|
|
total := 0
|
|
for piece in buffer.pieces {
|
|
total += piece.len
|
|
}
|
|
return total
|
|
}
|
|
|
|
buffer_insert :: proc(buffer: ^Buffer, offset: int, text: string) {
|
|
if len(text) == 0 do return
|
|
|
|
edit_text := clone_bytes(transmute([]u8)text)
|
|
buffer_push_edit(&buffer.undo, Buffer_Edit{kind = .Insert, offset = clamp_int(offset, 0, buffer_len(buffer)), text = edit_text})
|
|
buffer_clear_edits(&buffer.redo)
|
|
buffer_insert_raw(buffer, offset, text)
|
|
}
|
|
|
|
buffer_insert_raw :: proc(buffer: ^Buffer, offset: int, text: string) {
|
|
buffer_insert_raw_internal(buffer, offset, text, true)
|
|
}
|
|
|
|
buffer_insert_raw_internal :: proc(buffer: ^Buffer, offset: int, text: string, bump_version: bool) {
|
|
if len(text) == 0 do return
|
|
|
|
insert_at := clamp_int(offset, 0, buffer_len(buffer))
|
|
add_start := len(buffer.add)
|
|
text_bytes := transmute([]u8)text
|
|
for b in text_bytes {
|
|
append(&buffer.add, b)
|
|
}
|
|
|
|
new_piece := Piece{.Add, add_start, len(text_bytes)}
|
|
new_pieces: [dynamic]Piece
|
|
inserted := false
|
|
cursor := 0
|
|
|
|
for piece in buffer.pieces {
|
|
piece_end := cursor + piece.len
|
|
|
|
if !inserted && insert_at <= piece_end {
|
|
inner := insert_at - cursor
|
|
if inner > 0 {
|
|
append(&new_pieces, Piece{piece.source, piece.start, inner})
|
|
}
|
|
append(&new_pieces, new_piece)
|
|
if inner < piece.len {
|
|
append(&new_pieces, Piece{piece.source, piece.start + inner, piece.len - inner})
|
|
}
|
|
inserted = true
|
|
} else {
|
|
append(&new_pieces, piece)
|
|
}
|
|
|
|
cursor = piece_end
|
|
}
|
|
|
|
if !inserted {
|
|
append(&new_pieces, new_piece)
|
|
}
|
|
|
|
delete(buffer.pieces)
|
|
buffer.pieces = new_pieces
|
|
if bump_version do buffer.version += 1
|
|
buffer.line_index.dirty = true
|
|
}
|
|
|
|
buffer_delete_range :: proc(buffer: ^Buffer, offset: int, count: int) {
|
|
if count <= 0 do return
|
|
|
|
start := clamp_int(offset, 0, buffer_len(buffer))
|
|
end := clamp_int(start + count, start, buffer_len(buffer))
|
|
if start == end do return
|
|
|
|
deleted := buffer_range_bytes(buffer, start, end - start)
|
|
buffer_push_edit(&buffer.undo, Buffer_Edit{kind = .Delete, offset = start, text = deleted})
|
|
buffer_clear_edits(&buffer.redo)
|
|
buffer_delete_range_raw(buffer, start, end - start)
|
|
}
|
|
|
|
buffer_replace_range :: proc(buffer: ^Buffer, offset: int, count: int, replacement: string) {
|
|
start := clamp_int(offset, 0, buffer_len(buffer))
|
|
end := clamp_int(start + max_int(count, 0), start, buffer_len(buffer))
|
|
if start == end && len(replacement) == 0 do return
|
|
|
|
deleted := buffer_range_bytes(buffer, start, end - start)
|
|
if bytes_equal(deleted[:], transmute([]u8)replacement) {
|
|
delete(deleted)
|
|
return
|
|
}
|
|
replacement_bytes := clone_bytes(transmute([]u8)replacement)
|
|
buffer_push_edit(&buffer.undo, Buffer_Edit{kind = .Replace, offset = start, text = deleted, replacement = replacement_bytes})
|
|
buffer_clear_edits(&buffer.redo)
|
|
buffer_delete_range_raw_internal(buffer, start, end - start, false)
|
|
buffer_insert_raw_internal(buffer, start, replacement, false)
|
|
buffer.version += 1
|
|
buffer.line_index.dirty = true
|
|
}
|
|
|
|
buffer_delete_range_raw :: proc(buffer: ^Buffer, offset: int, count: int) {
|
|
buffer_delete_range_raw_internal(buffer, offset, count, true)
|
|
}
|
|
|
|
buffer_delete_range_raw_internal :: proc(buffer: ^Buffer, offset: int, count: int, bump_version: bool) {
|
|
if count <= 0 do return
|
|
|
|
start := clamp_int(offset, 0, buffer_len(buffer))
|
|
end := clamp_int(start + count, start, buffer_len(buffer))
|
|
if start == end do return
|
|
|
|
new_pieces: [dynamic]Piece
|
|
cursor := 0
|
|
|
|
for piece in buffer.pieces {
|
|
piece_start := cursor
|
|
piece_end := cursor + piece.len
|
|
|
|
if piece_end <= start || piece_start >= end {
|
|
append(&new_pieces, piece)
|
|
} else {
|
|
keep_left := max_int(0, start - piece_start)
|
|
keep_right := max_int(0, piece_end - end)
|
|
|
|
if keep_left > 0 {
|
|
append(&new_pieces, Piece{piece.source, piece.start, keep_left})
|
|
}
|
|
if keep_right > 0 {
|
|
right_start := piece.start + piece.len - keep_right
|
|
append(&new_pieces, Piece{piece.source, right_start, keep_right})
|
|
}
|
|
}
|
|
|
|
cursor = piece_end
|
|
}
|
|
|
|
delete(buffer.pieces)
|
|
buffer.pieces = new_pieces
|
|
if bump_version do buffer.version += 1
|
|
buffer.line_index.dirty = true
|
|
}
|
|
|
|
buffer_undo :: proc(buffer: ^Buffer, cursor: ^Cursor) -> bool {
|
|
edit, ok := buffer_pop_edit(&buffer.undo)
|
|
if !ok do return false
|
|
|
|
switch edit.kind {
|
|
case .Insert:
|
|
buffer_delete_range_raw(buffer, edit.offset, len(edit.text))
|
|
cursor.offset = edit.offset
|
|
case .Delete:
|
|
buffer_insert_raw(buffer, edit.offset, string(edit.text[:]))
|
|
cursor.offset = edit.offset + len(edit.text)
|
|
case .Replace:
|
|
buffer_delete_range_raw_internal(buffer, edit.offset, len(edit.replacement), false)
|
|
buffer_insert_raw_internal(buffer, edit.offset, string(edit.text[:]), false)
|
|
buffer.version += 1
|
|
buffer.line_index.dirty = true
|
|
cursor.offset = edit.offset + len(edit.text)
|
|
}
|
|
|
|
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
|
|
buffer_push_edit(&buffer.redo, edit)
|
|
return true
|
|
}
|
|
|
|
buffer_redo :: proc(buffer: ^Buffer, cursor: ^Cursor) -> bool {
|
|
edit, ok := buffer_pop_edit(&buffer.redo)
|
|
if !ok do return false
|
|
|
|
switch edit.kind {
|
|
case .Insert:
|
|
buffer_insert_raw(buffer, edit.offset, string(edit.text[:]))
|
|
cursor.offset = edit.offset + len(edit.text)
|
|
case .Delete:
|
|
buffer_delete_range_raw(buffer, edit.offset, len(edit.text))
|
|
cursor.offset = edit.offset
|
|
case .Replace:
|
|
buffer_delete_range_raw_internal(buffer, edit.offset, len(edit.text), false)
|
|
buffer_insert_raw_internal(buffer, edit.offset, string(edit.replacement[:]), false)
|
|
buffer.version += 1
|
|
buffer.line_index.dirty = true
|
|
cursor.offset = edit.offset + len(edit.replacement)
|
|
}
|
|
|
|
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
|
|
buffer_push_edit(&buffer.undo, edit)
|
|
return true
|
|
}
|
|
|
|
buffer_bytes :: proc(buffer: ^Buffer) -> [dynamic]u8 {
|
|
out: [dynamic]u8
|
|
|
|
for piece in buffer.pieces {
|
|
switch piece.source {
|
|
case .Original:
|
|
chunk := buffer.original[piece.start:piece.start + piece.len]
|
|
chunk_bytes := transmute([]u8)chunk
|
|
for b in chunk_bytes {
|
|
append(&out, b)
|
|
}
|
|
case .Add:
|
|
for b in buffer.add[piece.start:piece.start + piece.len] {
|
|
append(&out, b)
|
|
}
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
buffer_rebuild_line_index :: proc(buffer: ^Buffer) {
|
|
delete(buffer.line_index.starts)
|
|
buffer.line_index.starts = make([dynamic]int)
|
|
append(&buffer.line_index.starts, 0)
|
|
|
|
text := buffer_bytes(buffer)
|
|
defer delete(text)
|
|
|
|
for b, i in text {
|
|
if b == '\n' {
|
|
append(&buffer.line_index.starts, i + 1)
|
|
}
|
|
}
|
|
|
|
buffer.line_index.dirty = false
|
|
}
|
|
|
|
buffer_line_count :: proc(buffer: ^Buffer) -> int {
|
|
if buffer.line_index.dirty {
|
|
buffer_rebuild_line_index(buffer)
|
|
}
|
|
return len(buffer.line_index.starts)
|
|
}
|
|
|
|
buffer_line_start :: proc(buffer: ^Buffer, line: int) -> int {
|
|
if buffer.line_index.dirty {
|
|
buffer_rebuild_line_index(buffer)
|
|
}
|
|
|
|
if len(buffer.line_index.starts) == 0 do return 0
|
|
index := clamp_int(line, 0, len(buffer.line_index.starts) - 1)
|
|
return buffer.line_index.starts[index]
|
|
}
|
|
|
|
buffer_line_end :: proc(buffer: ^Buffer, line: int) -> int {
|
|
if buffer.line_index.dirty {
|
|
buffer_rebuild_line_index(buffer)
|
|
}
|
|
|
|
line_count := len(buffer.line_index.starts)
|
|
if line_count == 0 do return 0
|
|
|
|
index := clamp_int(line, 0, line_count - 1)
|
|
if index + 1 < line_count {
|
|
return max_int(buffer.line_index.starts[index], buffer.line_index.starts[index + 1] - 1)
|
|
}
|
|
|
|
return buffer_len(buffer)
|
|
}
|
|
|
|
buffer_offset_to_line_col :: proc(buffer: ^Buffer, offset: int) -> (line: int, column: int) {
|
|
if buffer.line_index.dirty {
|
|
buffer_rebuild_line_index(buffer)
|
|
}
|
|
|
|
target := clamp_int(offset, 0, buffer_len(buffer))
|
|
result := 0
|
|
for start, i in buffer.line_index.starts {
|
|
if start > target do break
|
|
result = i
|
|
}
|
|
|
|
return result, target - buffer.line_index.starts[result]
|
|
}
|
|
|
|
buffer_line_col_to_offset :: proc(buffer: ^Buffer, line: int, column: int) -> int {
|
|
start := buffer_line_start(buffer, line)
|
|
end := buffer_line_end(buffer, line)
|
|
return clamp_int(start + max_int(column, 0), start, end)
|
|
}
|
|
|
|
buffer_line_bytes :: proc(buffer: ^Buffer, line: int) -> [dynamic]u8 {
|
|
start := buffer_line_start(buffer, line)
|
|
end := buffer_line_end(buffer, line)
|
|
return buffer_range_bytes(buffer, start, end - start)
|
|
}
|
|
|
|
buffer_range_bytes :: proc(buffer: ^Buffer, offset: int, count: int) -> [dynamic]u8 {
|
|
out: [dynamic]u8
|
|
if count <= 0 do return out
|
|
|
|
start := clamp_int(offset, 0, buffer_len(buffer))
|
|
end := clamp_int(start + count, start, buffer_len(buffer))
|
|
cursor := 0
|
|
|
|
for piece in buffer.pieces {
|
|
piece_start := cursor
|
|
piece_end := cursor + piece.len
|
|
|
|
if piece_end > start && piece_start < end {
|
|
local_start := max_int(start - piece_start, 0)
|
|
local_end := piece.len - max_int(piece_end - end, 0)
|
|
|
|
switch piece.source {
|
|
case .Original:
|
|
chunk := buffer.original[piece.start + local_start:piece.start + local_end]
|
|
chunk_bytes := transmute([]u8)chunk
|
|
for b in chunk_bytes {
|
|
append(&out, b)
|
|
}
|
|
case .Add:
|
|
for b in buffer.add[piece.start + local_start:piece.start + local_end] {
|
|
append(&out, b)
|
|
}
|
|
}
|
|
}
|
|
|
|
cursor = piece_end
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
cursor_insert :: proc(buffer: ^Buffer, cursor: ^Cursor, text: string) {
|
|
buffer_insert(buffer, cursor.offset, text)
|
|
cursor.offset += len(text)
|
|
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
|
|
}
|
|
|
|
cursor_backspace :: proc(buffer: ^Buffer, cursor: ^Cursor) {
|
|
if cursor.offset == 0 do return
|
|
buffer_delete_range(buffer, cursor.offset - 1, 1)
|
|
cursor.offset -= 1
|
|
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
|
|
}
|
|
|
|
cursor_delete_forward :: proc(buffer: ^Buffer, cursor: ^Cursor) -> bool {
|
|
if cursor.offset >= buffer_len(buffer) do return false
|
|
buffer_delete_range(buffer, cursor.offset, 1)
|
|
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
|
|
return true
|
|
}
|
|
|
|
cursor_insert_newline_auto_indent :: proc(buffer: ^Buffer, cursor: ^Cursor) {
|
|
line, _ := buffer_offset_to_line_col(buffer, cursor.offset)
|
|
line_bytes := buffer_line_bytes(buffer, line)
|
|
defer delete(line_bytes)
|
|
|
|
indent_len := 0
|
|
for b in line_bytes {
|
|
if b == ' ' || b == '\t' {
|
|
indent_len += 1
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
text: [dynamic]u8
|
|
defer delete(text)
|
|
append(&text, '\n')
|
|
for b in line_bytes[:indent_len] {
|
|
append(&text, b)
|
|
}
|
|
cursor_insert(buffer, cursor, string(text[:]))
|
|
}
|
|
|
|
cursor_move_word_left :: proc(buffer: ^Buffer, cursor: ^Cursor) {
|
|
text := buffer_bytes(buffer)
|
|
defer delete(text)
|
|
|
|
offset := clamp_int(cursor.offset, 0, len(text))
|
|
for offset > 0 && is_space_byte(text[offset - 1]) {
|
|
offset -= 1
|
|
}
|
|
if offset > 0 && is_identifier_byte(text[offset - 1]) {
|
|
for offset > 0 && is_identifier_byte(text[offset - 1]) {
|
|
offset -= 1
|
|
}
|
|
} else if offset > 0 {
|
|
offset -= 1
|
|
}
|
|
|
|
cursor.offset = offset
|
|
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
|
|
}
|
|
|
|
cursor_move_word_right :: proc(buffer: ^Buffer, cursor: ^Cursor) {
|
|
text := buffer_bytes(buffer)
|
|
defer delete(text)
|
|
|
|
offset := clamp_int(cursor.offset, 0, len(text))
|
|
if offset < len(text) && is_identifier_byte(text[offset]) {
|
|
for offset < len(text) && is_identifier_byte(text[offset]) {
|
|
offset += 1
|
|
}
|
|
} else if offset < len(text) {
|
|
offset += 1
|
|
}
|
|
for offset < len(text) && is_space_byte(text[offset]) {
|
|
offset += 1
|
|
}
|
|
|
|
cursor.offset = offset
|
|
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
|
|
}
|
|
|
|
cursor_move_to_line_col :: proc(buffer: ^Buffer, cursor: ^Cursor, line: int, column: int) {
|
|
cursor.offset = buffer_line_col_to_offset(buffer, line, column)
|
|
_, cursor.wanted_column = buffer_offset_to_line_col(buffer, cursor.offset)
|
|
}
|
|
|
|
cursor_move_vertical :: proc(buffer: ^Buffer, cursor: ^Cursor, delta: int) {
|
|
line, _ := buffer_offset_to_line_col(buffer, cursor.offset)
|
|
target_line := clamp_int(line + delta, 0, buffer_line_count(buffer) - 1)
|
|
cursor.offset = buffer_line_col_to_offset(buffer, target_line, cursor.wanted_column)
|
|
}
|
|
|
|
is_identifier_byte :: proc(b: u8) -> bool {
|
|
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
|
|
}
|
|
|
|
is_space_byte :: proc(b: u8) -> bool {
|
|
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
|
}
|
|
|
|
buffer_destroy :: proc(buffer: ^Buffer) {
|
|
buffer_clear_edits(&buffer.undo)
|
|
buffer_clear_edits(&buffer.redo)
|
|
delete(buffer.add)
|
|
delete(buffer.pieces)
|
|
delete(buffer.line_index.starts)
|
|
}
|
|
|
|
buffer_push_edit :: proc(stack: ^[dynamic]Buffer_Edit, edit: Buffer_Edit) {
|
|
append(stack, edit)
|
|
}
|
|
|
|
buffer_pop_edit :: proc(stack: ^[dynamic]Buffer_Edit) -> (edit: Buffer_Edit, ok: bool) {
|
|
if len(stack^) == 0 do return {}, false
|
|
|
|
index := len(stack^) - 1
|
|
edit = stack^[index]
|
|
resize(stack, index)
|
|
return edit, true
|
|
}
|
|
|
|
buffer_clear_edits :: proc(stack: ^[dynamic]Buffer_Edit) {
|
|
for edit in stack^ {
|
|
delete(edit.text)
|
|
delete(edit.replacement)
|
|
}
|
|
clear(stack)
|
|
}
|
|
|
|
clone_bytes :: proc(bytes: []u8) -> [dynamic]u8 {
|
|
out: [dynamic]u8
|
|
for b in bytes {
|
|
append(&out, b)
|
|
}
|
|
return out
|
|
}
|
|
|
|
bytes_equal :: proc(a, b: []u8) -> bool {
|
|
if len(a) != len(b) do return false
|
|
for value, index in a {
|
|
if value != b[index] do return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
clamp_int :: proc(value, low, high: int) -> int {
|
|
if value < low do return low
|
|
if value > high do return high
|
|
return value
|
|
}
|
|
|
|
max_int :: proc(a, b: int) -> int {
|
|
if a > b do return a
|
|
return b
|
|
}
|