Files
Odin/core/text/edit/text_edit.odin
2023-06-07 22:40:46 +01:00

415 lines
9.1 KiB
Odin

package text_edit
/*
Based off the articles by rxi:
* https://rxi.github.io/textbox_behaviour.html
* https://rxi.github.io/a_simple_undo_system.html
*/
import "core:runtime"
import "core:time"
import "core:mem"
import "core:strings"
import "core:unicode/utf8"
DEFAULT_UNDO_TIMEOUT :: 300 * time.Millisecond
State :: struct {
selection: [2]int,
line_start, line_end: int,
// initialized each "frame" with `begin`
builder: ^strings.Builder, // let the caller store the text buffer data
up_index, down_index: int, // multi-lines
// undo
undo: [dynamic]^Undo_State,
redo: [dynamic]^Undo_State,
undo_text_allocator: runtime.Allocator,
id: u64, // useful for immediate mode GUIs
// Timeout information
current_time: time.Tick,
last_edit_time: time.Tick,
undo_timeout: time.Duration,
// Set these if you want cut/copy/paste functionality
set_clipboard: proc(user_data: rawptr, text: string) -> (ok: bool),
get_clipboard: proc(user_data: rawptr) -> (text: string, ok: bool),
clipboard_user_data: rawptr,
}
Undo_State :: struct {
selection: [2]int,
len: int,
text: [0]byte, // string(us.text[:us.len]) --- requiring #no_bounds_check
}
Translation :: enum u32 {
Start,
End,
Left,
Right,
Up,
Down,
Word_Left,
Word_Right,
Word_Start,
Word_End,
Soft_Line_Start,
Soft_Line_End,
}
init :: proc(s: ^State, undo_text_allocator, undo_state_allocator: runtime.Allocator, undo_timeout := DEFAULT_UNDO_TIMEOUT) {
s.undo_timeout = undo_timeout
// Used for allocating `Undo_State`
s.undo_text_allocator = undo_text_allocator
s.undo.allocator = undo_state_allocator
s.redo.allocator = undo_state_allocator
}
destroy :: proc(s: ^State) {
undo_clear(s, &s.undo)
undo_clear(s, &s.redo)
delete(s.undo)
delete(s.redo)
s.builder = nil
}
// Call at the beginning of each frame
begin :: proc(s: ^State, id: u64, builder: ^strings.Builder) {
assert(builder != nil)
if s.id != 0 {
end(s)
}
s.id = id
s.selection = {len(builder.buf), 0}
s.builder = builder
s.current_time = time.tick_now()
if s.undo_timeout <= 0 {
s.undo_timeout = DEFAULT_UNDO_TIMEOUT
}
set_text(s, string(s.builder.buf[:]))
undo_clear(s, &s.undo)
undo_clear(s, &s.redo)
}
// Call at the end of each frame
end :: proc(s: ^State) {
s.id = 0
s.builder = nil
}
set_text :: proc(s: ^State, text: string) {
strings.builder_reset(s.builder)
strings.write_string(s.builder, text)
}
undo_state_push :: proc(s: ^State, undo: ^[dynamic]^Undo_State) -> mem.Allocator_Error {
text := string(s.builder.buf[:])
item := (^Undo_State)(mem.alloc(size_of(Undo_State) + len(text), align_of(Undo_State), s.undo_text_allocator) or_return)
item.selection = s.selection
item.len = len(text)
#no_bounds_check {
runtime.copy(item.text[:len(text)], text)
}
append(undo, item) or_return
return nil
}
undo :: proc(s: ^State, undo, redo: ^[dynamic]^Undo_State) {
if len(undo) > 0 {
undo_state_push(s, redo)
item := pop(undo)
s.selection = item.selection
#no_bounds_check {
set_text(s, string(item.text[:item.len]))
}
free(item, s.undo_text_allocator)
}
}
undo_clear :: proc(s: ^State, undo: ^[dynamic]^Undo_State) {
for len(undo) > 0 {
item := pop(undo)
free(item, s.undo_text_allocator)
}
}
undo_check :: proc(s: ^State) {
undo_clear(s, &s.redo)
if time.tick_diff(s.last_edit_time, s.current_time) > s.undo_timeout {
undo_state_push(s, &s.undo)
}
s.last_edit_time = s.current_time
}
input_text :: proc(s: ^State, text: string) {
if len(text) == 0 {
return
}
if has_selection(s) {
selection_delete(s)
}
insert(s, s.selection[0], text)
offset := s.selection[0] + len(text)
s.selection = {offset, offset}
}
input_runes :: proc(s: ^State, text: []rune) {
if len(text) == 0 {
return
}
if has_selection(s) {
selection_delete(s)
}
offset := s.selection[0]
for r in text {
b, w := utf8.encode_rune(r)
insert(s, offset, string(b[:w]))
offset += w
}
s.selection = {offset, offset}
}
insert :: proc(s: ^State, at: int, text: string) {
undo_check(s)
inject_at(&s.builder.buf, at, text)
}
remove :: proc(s: ^State, lo, hi: int) {
undo_check(s)
remove_range(&s.builder.buf, lo, hi)
}
has_selection :: proc(s: ^State) -> bool {
return s.selection[0] != s.selection[1]
}
sorted_selection :: proc(s: ^State) -> (lo, hi: int) {
lo = min(s.selection[0], s.selection[1])
hi = max(s.selection[0], s.selection[1])
lo = clamp(lo, 0, len(s.builder.buf))
hi = clamp(hi, 0, len(s.builder.buf))
s.selection[0] = lo
s.selection[1] = hi
return
}
selection_delete :: proc(s: ^State) {
lo, hi := sorted_selection(s)
remove(s, lo, hi)
s.selection = {lo, lo}
}
translate_position :: proc(s: ^State, pos: int, t: Translation) -> int {
is_continuation_byte :: proc(b: byte) -> bool {
return b >= 0x80 && b < 0xc0
}
is_space :: proc(b: byte) -> bool {
return b == ' ' || b == '\t' || b == '\n'
}
buf := s.builder.buf[:]
pos := pos
pos = clamp(pos, 0, len(buf))
switch t {
case .Start:
pos = 0
case .End:
pos = len(buf)
case .Left:
pos -= 1
for pos >= 0 && is_continuation_byte(buf[pos]) {
pos -= 1
}
case .Right:
pos += 1
for pos < len(buf) && is_continuation_byte(buf[pos]) {
pos += 1
}
case .Up:
pos = s.up_index
case .Down:
pos = s.down_index
case .Word_Left:
for pos > 0 && is_space(buf[pos-1]) {
pos -= 1
}
for pos > 0 && !is_space(buf[pos-1]) {
pos -= 1
}
case .Word_Right:
for pos < len(buf) && !is_space(buf[pos]) {
pos += 1
}
for pos < len(buf) && is_space(buf[pos]) {
pos += 1
}
case .Word_Start:
for pos > 0 && !is_space(buf[pos-1]) {
pos -= 1
}
case .Word_End:
for pos < len(buf) && !is_space(buf[pos]) {
pos += 1
}
case .Soft_Line_Start:
pos = s.line_start
case .Soft_Line_End:
pos = s.line_end
}
return clamp(pos, 0, len(buf))
}
move_to :: proc(s: ^State, t: Translation) {
if t == .Left && has_selection(s) {
lo, _ := sorted_selection(s)
s.selection = {lo, lo}
} else if t == .Right && has_selection(s) {
_, hi := sorted_selection(s)
s.selection = {hi, hi}
} else {
pos := translate_position(s, s.selection[0], t)
s.selection = {pos, pos}
}
}
select_to :: proc(s: ^State, t: Translation) {
s.selection[0] = translate_position(s, s.selection[0], t)
}
delete_to :: proc(s: ^State, t: Translation) {
if has_selection(s) {
selection_delete(s)
} else {
lo := s.selection[0]
hi := translate_position(s, lo, t)
lo, hi = min(lo, hi), max(lo, hi)
remove(s, lo, hi)
s.selection = {lo, lo}
}
}
current_selected_text :: proc(s: ^State) -> string {
lo, hi := sorted_selection(s)
return string(s.builder.buf[lo:hi])
}
cut :: proc(s: ^State) -> bool {
if copy(s) {
selection_delete(s)
return true
}
return false
}
copy :: proc(s: ^State) -> bool {
if s.set_clipboard != nil {
return s.set_clipboard(s.clipboard_user_data, current_selected_text(s))
}
return s.set_clipboard != nil
}
paste :: proc(s: ^State) -> bool {
if s.get_clipboard != nil {
input_text(s, s.get_clipboard(s.clipboard_user_data) or_return)
}
return s.get_clipboard != nil
}
Command_Set :: distinct bit_set[Command; u32]
Command :: enum u32 {
None,
Undo,
Redo,
New_Line, // multi-lines
Cut,
Copy,
Paste,
Select_All,
Backspace,
Delete,
Delete_Word_Left,
Delete_Word_Right,
Left,
Right,
Up, // multi-lines
Down, // multi-lines
Word_Left,
Word_Right,
Start,
End,
Line_Start,
Line_End,
Select_Left,
Select_Right,
Select_Up, // multi-lines
Select_Down, // multi-lines
Select_Word_Left,
Select_Word_Right,
Select_Start,
Select_End,
Select_Line_Start,
Select_Line_End,
}
MULTILINE_COMMANDS :: Command_Set{.New_Line, .Up, .Down, .Select_Up, .Select_Down}
perform_command :: proc(s: ^State, cmd: Command) {
switch cmd {
case .None: /**/
case .Undo: undo(s, &s.undo, &s.redo)
case .Redo: undo(s, &s.redo, &s.undo)
case .New_Line: input_text(s, "\n")
case .Cut: cut(s)
case .Copy: copy(s)
case .Paste: paste(s)
case .Select_All: s.selection = {len(s.builder.buf), 0}
case .Backspace: delete_to(s, .Left)
case .Delete: delete_to(s, .Right)
case .Delete_Word_Left: delete_to(s, .Word_Left)
case .Delete_Word_Right: delete_to(s, .Word_Right)
case .Left: move_to(s, .Left)
case .Right: move_to(s, .Right)
case .Up: move_to(s, .Up)
case .Down: move_to(s, .Down)
case .Word_Left: move_to(s, .Word_Left)
case .Word_Right: move_to(s, .Word_Right)
case .Start: move_to(s, .Start)
case .End: move_to(s, .End)
case .Line_Start: move_to(s, .Soft_Line_Start)
case .Line_End: move_to(s, .Soft_Line_End)
case .Select_Left: select_to(s, .Left)
case .Select_Right: select_to(s, .Right)
case .Select_Up: select_to(s, .Up)
case .Select_Down: select_to(s, .Down)
case .Select_Word_Left: select_to(s, .Word_Left)
case .Select_Word_Right: select_to(s, .Word_Right)
case .Select_Start: select_to(s, .Start)
case .Select_End: select_to(s, .End)
case .Select_Line_Start: select_to(s, .Soft_Line_Start)
case .Select_Line_End: select_to(s, .Soft_Line_End)
}
}