# this is the text buffer implementation. # this whole module can later be replaced by a proper data structure like a Rope! # interface (inspired by ropey): # line (index from 0 yo) # change (both inserts and deletes) # also uses I32 indexes to make math easier :) use StrBuilder (StrBuilder) use Pos (Pos) use Highlight HighlightType, Highlight use CharType char-type Cursors cur Pos sel Maybe Pos Action from Pos fwd StrView # what we added to-fwd Pos # this could be removed (if we have a nice way to compute it from bwd) bwd StrView # what we removed to-bwd Pos # this could ALSO be removed. Changeset al parts List al Action # instead of before-cursors, use an enum to specify where a cursor should end up. before-cursors Cursors fn free-action (action Action, al Allocator) action.fwd StrView.free(al) action.bwd StrView.free(al) fn free-changeset (chs Changeset al, al al) <= Allocator al chs.parts for-each(fn a: a free-action(al)) List.free(&chs.parts) ChangesetInputType NoChangeset InputChangeset CustomChangeset inst Eq ChangesetInputType eq (l _, r _): (case (l, r)) (NoChangeset, NoChangeset): True (InputChangeset, InputChangeset): True (CustomChangeset, CustomChangeset): True _: False # ChangeSet = Slice Action Actions al list List al (Changeset al) cur Size input-changeset ChangesetInputType fn mk-actions (al al) -> Actions al: { list: List.mk(al), cur: 0, input-changeset: NoChangeset } fn trim-actions (actions Ptr (Actions Allocator)) al = actions&.list.al cur = actions&.cur for action in actions&.list drop(cur) action free-changeset(al) List.trim(&actions&.list, cur) Line al line List al U8 invalidated Bool highlights List al Highlight fn mk-line (initial List al U8) -> Line al: Line { line: initial , invalidated: True , highlights: List.mk(initial.al) } fn free-line (line Ptr (Line al)) <= Allocator al List.free(&line&.line) Filetype Text # it makes it a bit easier to set the filetype. I know that the idiomatic way would be Maybe Filetype... KC Markdown GCode Brainfuck fn detect-filetype-from-filename (filename StrView) mext = filename Path.extension() case mext None return Text Just(ext) if ext one-of(['kc', 'kkc']) return KC elif ext == 'md' return Markdown elif ext == 'gcode' return GCode elif ext one-of(['bf', 'b']) return Brainfuck else return Text TextBuf al al al buf List al (Line al) actions Actions al filename Maybe StrView filetype Filetype fn line (self Ptr (TextBuf al), li I32): (&self&.buf) List.get(li Mem.i32-size()) .line List.to-slice() StrView.from-bytes() fn num-lines (self Ptr (TextBuf al)): List.size(&self&.buf) Mem.size-i32() fn last-changeset (self Ptr (TextBuf al)) -> Maybe (Ptr (Changeset al)) cur = self&.actions.cur if cur == 0 return None else p = List.get-ptr(&self&.actions.list, cur) return Just(p) # should they be in TextBuf or here?? I guess here? fn right-pos (self Ptr (TextBuf al), pos Pos) -> Pos if pos.line >= self num-lines() return Pos.mk(self num-lines(), 0) line = self line(pos.line) bi = pos.bi Mem.i32-i64() if bi >= line StrView.num-bytes() Mem.size-i64() if pos.line + 1 >= self num-lines() return Pos.mk(pos.line, line StrView.num-bytes() Mem.size-i32()) # when on last line and last char. return Pos.mk(pos.line + 1, 0) # when on last char but not last line # on line off = Char.next-char(line.contents.ptr Mem.offset-ptr(bi)) Mem.size-i32() return Pos.mk(pos.line, pos.bi + off) fn left-pos (self Ptr (TextBuf al), pos Pos) -> Pos if pos == Pos.mk(0, 0) return Pos.mk(0, 0) if pos.bi == 0 assert(pos.line > 0, 'previous condition should have eliminated this possibility') return Pos.mk(pos.line - 1, self line(pos.line - 1) StrView.num-bytes() Mem.size-i32()) line = self line(pos.line) pos-bi = pos.bi Mem.i32-size() Math.clamp(0, line StrView.num-bytes()) off = Char.previous-char(line.contents.ptr Mem.offset-ptr(Mem.size-i64(pos-bi))) return Pos.mk(pos.line, Mem.size-i32(pos-bi - off)) fn add-to-spot (self Ptr (TextBuf Allocator), p Pos, bytes StrView) -> I32 al = self&.buf.al buf = &self&.buf extracted-line-buf = buf List.get(p.line Mem.i32-size()) remaining = extracted-line-buf.line List.to-slice() Slice.from(p.bi Mem.i32-size()) buf List.set(p.line Mem.i32-size(), mk-line(List.from-iter(extracted-line-buf.line List.to-slice() Slice.subslice(0, p.bi Mem.i32-size()), al))) last-line = buf List.get-ptr(p.line Mem.i32-size()) (&last-line&.line) List.add-all(bytes lines() head() or-else('') .contents) lines-added = 0 for (cline, i) in bytes StrView.split-by-each('\n') drop(1) zip(Iter.from(1)) nuline =& mk-line(cline.contents List.from-iter(al)) next-line = Mem.i32-size(p.line + i) buf List.insert(next-line, nuline&) last-line <= buf List.get-ptr(next-line) lines-added <= lines-added + 1 (&last-line&.line) List.add-all(remaining) free-line(&extracted-line-buf) return lines-added # this one is used by the action thing. # NOTE(24.06.26): i just realized. when we want to select something between cursors, including cursors, we have to move the cursor right. # but, should i move it right by one character or by one *byte*?? # currently, the implementation suggests it's one byte! fn change-no-action (self Ptr (TextBuf Allocator), from-pos Pos, to-pos Pos, bytes StrView) from = Math.min(from-pos, to-pos) to = Math.max(from-pos, to-pos) if to == from # if empty buffer, we can add text. al = self&.buf.al if List.size(&self&.buf) == Mem.i32-size(from.line) and from.bi == 0 # when we want to insert a new line. (&self&.buf) List.add(mk-line(List.mk(al))) _ = self add-to-spot(from, bytes) else lines-buf = &self&.buf first-line = lines-buf List.get-ptr(Mem.i32-size(from.line)) first-line <&.invalidated= True if from.line == to.line and to.bi < Mem.size-i32(List.size(&first-line&.line)) + 1 (&first-line&.line) List.remove-range(Mem.i32-size(from.bi), Mem.i32-size(to.bi)) _ = self add-to-spot(from, bytes) else # first, append first line to last line yahh. (&first-line&.line) List.trim(Mem.i32-size(from.bi)) last-line = lines-buf List.get-ptr(Mem.i32-size(to.line)) last-line <&.invalidated= True to <.bi= Math.min(to.bi, Mem.size-i32((&last-line&.line) List.size()) + 1) # idk, im trimming the index just cuz if to.bi == Mem.size-i32((&last-line&.line) List.size()) + 1 to <.line= to.line + 1 to <.bi= 0 # NOTE(15.06.26): i haven't tested it yet btw lines-added = self add-to-spot(from, bytes) to <.line= to.line + lines-added from <.line= from.line + lines-added last-line = lines-buf List.try-get(Mem.i32-size(to.line)) fmap-maybe(fn line: line.line List.to-slice()) or-else(Slice.empty()) (&first-line&.line) List.add-all(last-line Slice.from(Mem.i32-size(to.bi))) # remove second to last lines completely now. assert(to.line > from.line, 'there must be a line difference yo') (&self&.buf) List.remove-range(Mem.i32-size(from.line + 1), Mem.i32-size(to.line) + 1) fn write-to-file (self Ptr (TextBuf al), altname Maybe StrView) -> Maybe Size <= Allocator al al = self&.al nu-filename = altname Misc.fmap-maybe(fn s: s StrView.clone-0(al)) # allocate if setting a new filename. filename = altname Misc.maybe(Just, self&.filename) if filename is-none() return None # no filename set! # TEMP until we can write to a file partially. sb =& StrBuilder.mk(al) for ln in 0 to (self num-lines() - 1) sb StrBuilder.writeln(self line(ln)) filename0 = filename or-fail('expect filename (we should\'ve quit earlier.)') StrView.as-const-str() result = filename0 File.try-write-contents(sb) sb StrBuilder.free() if not result return None # if all-iz-well (if we wrote the file successfully(!!) saar), save the new altname. if altname is-just() case self&.filename Just(ole-filename) StrView.free(ole-filename, al) None pass self <&.filename= altname return Just(sb StrBuilder.as-str() StrView.num-bytes()) fn mk (al Allocator): TextBuf { al: al , buf: List.from-iter([mk-line(List.mk(al))], al) , actions: mk-actions(al) , filename: None , filetype: Text } fn mk-from-file (al Allocator, filename StrView) -> Maybe (TextBuf Allocator) tb =& mk(al) tb <&.filetype= filename detect-filetype-from-filename() filename0 = filename StrView.clone-0(al) case filename0 StrView.as-const-str() File.try-read-contents(al) None StrView.free(filename0, al) return None Just(read-file) # cut out last newline, cuz our stuff adds it automatically # (currently, we don't have that ~ empty newline.) if read-file.contents Slice.try-get(read-file.contents.count - 1) == Just('\n' Char.ascii-u8()) read-file <= read-file StrView.byte-substr(0, read-file.contents.count - 1) tb change-no-action(Pos.mk(0, 0), Pos.mk(0, 0), read-file) tb <&.filename= Just(filename0) return Just(tb&) fn action-bwd (self Ptr (TextBuf Allocator), action Action) -> Pos self change-no-action(action.from, action.to-fwd, action.bwd) return action.to-bwd fn action-fwd (self Ptr (TextBuf Allocator), action Action) -> Pos self change-no-action(action.from, action.to-bwd, action.fwd) return action.to-fwd fn pos-after-str (self Ptr (TextBuf Allocator), bytes StrView, from Pos) -> Pos lines = bytes StrView.split-by-each('\n') first-line = lines head() or-else('') headless = lines drop(1) if headless Iter.null() next-pos = Pos.mk(from.line, from.bi + first-line StrView.num-bytes() Mem.size-i32()) return next-pos else let (ll-bi, ll-line) = headless zip(Iter.from(from.line + 1)) last() or-fail('should not happen') return Pos.mk(ll-line, ll-bi StrView.num-bytes() Mem.size-i32()) fn flush-insert-action (self Ptr (TextBuf Allocator)) # this will force a new action to be added. self <&.actions.input-changeset= NoChangeset fn undo (self Ptr (TextBuf Allocator)) -> Maybe Cursors self flush-insert-action() actions = &self&.actions if actions&.cur == 0 return None actions <&.cur= actions&.cur - 1 changeset = actions&.list[actions&.cur] for action in changeset.parts List.to-slice() Slice.reversed() self action-bwd(action) return changeset.before-cursors Just() fn redo (self Ptr (TextBuf Allocator)) -> Maybe Cursors self flush-insert-action() actions = &self&.actions if actions&.cur >= List.size(&actions&.list) return None changeset = actions&.list[actions&.cur] to-fwd = @undefined for action in changeset.parts to-fwd <= self action-fwd(action) actions <&.cur= actions&.cur + 1 # TEMP(smol hack): # we don't track the future position of the cursors. so, we have to decide where we're gonna put the cursor. # so, when we're redoing single chars, we want to put the cursor after chars (makes writing nicer). this is what this if statement checks # so, we don't differentiate between a character insertion and paste, unlike Helix. fix it sometime later. last-action = changeset.parts List.to-slice() Slice.last() if last-action.fwd StrView.num-bytes() > 1 to-fwd = left-pos(self, to-fwd) return Cursors { cur: to-fwd, sel: Just(last-action.from) } Just() else return Cursors { cur: to-fwd, sel: None } Just() fn str-between (self Ptr (TextBuf Allocator), from Pos, to Pos) -> StrView al = self&.buf.al if from.line == to.line # if at the end of file if from.line == self num-lines() return '' StrView.clone(al) from-bi = Math.min(from.bi, to.bi) Mem.i32-size() to-bi = Math.max(from.bi, to.bi) Mem.i32-size() return self line(from.line) StrView.byte-substr(from-bi, to-bi) StrView.clone(al) else from-pos = Math.min(from, to) to-pos = Math.max(from, to) sb =& List.mk(al) first-line = self line(from-pos.line) sb List.add-all(first-line StrView.byte-substr(from-pos.bi Mem.i32-size(), first-line StrView.num-bytes()) .contents) sb List.add-all(('\n' as StrView).contents) for i in (from-pos.line + 1) Iter.to (to-pos.line - 1) sb List.add-all(self line(i) .contents) sb List.add-all(('\n' as StrView).contents) sb List.add-all(self line(to-pos.line) StrView.byte-substr(0, to-pos.bi Mem.i32-size()) .contents) return sb& List.to-slice() StrView.from-bytes() fn num-bytes-between (self Ptr (TextBuf Allocator), from Pos, to Pos) -> Size if from.line == to.line return (Math.max(from.bi, to.bi) - Math.min(from.bi, to.bi)) Mem.i32-size() else from-pos = Math.max(from, to) to-pos = Math.min(from, to) fbi = self line(from-pos.line) StrView.byte-substr-from(from-pos.bi Mem.i32-size()) StrView.num-bytes() tbi = self line(to-pos.line) StrView.byte-substr(0, to-pos.bi Mem.i32-size()) StrView.num-bytes() bbi = (from-pos.line + 1) Iter.to (to-pos.line - 1) map(fn li: self line(li) StrView.num-bytes()) sum() return fbi + tbi + bbi fn change (self Ptr (TextBuf Allocator), from Pos, to Pos, bytes StrView, before-cursors Maybe Cursors) from-pos = Math.min(from, to) to-pos = Math.max(from, to) actions = &self&.actions actions trim-actions() action = { from: from , fwd: bytes StrView.clone(self&.al) , to-fwd: self pos-after-str(bytes, from-pos) , bwd: self str-between(from-pos, to-pos) , to-bwd: to-pos } al = actions&.list.al is-typed-in = before-cursors is-none() if self&.actions.input-changeset == CustomChangeset or (self&.actions.input-changeset == InputChangeset and is-typed-in) last-changeset = actions&.list List.to-slice() Slice.last-ptr() List.add(&last-changeset&.parts, action) else changeset =& { parts: List.mk(al), before-cursors: before-cursors or-else({ cur: from, sel: if from == to: None else Just(to) }) } List.add(&changeset&.parts, action) List.add(&actions&.list, changeset&) actions <&.input-changeset = if is-typed-in: InputChangeset else NoChangeset actions <&.cur= actions&.cur + 1 to-fwd = self action-fwd(action) if action.fwd StrView.num-bytes() > 1 to-fwd = left-pos(self, to-fwd) return Cursors { cur: to-fwd, sel: Just(action.from) } Just() else return Cursors { cur: to-fwd, sel: None } Just() fn begin-changeset (self Ptr (TextBuf Allocator), before-cursors Cursors) actions = &self&.actions actions trim-actions() self flush-insert-action() assert(self&.actions.input-changeset == NoChangeset, 'expect no changeset bruh') al = self&.al changeset =& { parts: List.mk(al), before-cursors } List.add(&actions&.list, changeset&) actions <&.input-changeset = CustomChangeset actions <&.cur= actions&.cur + 1 fn end-changeset (self Ptr (TextBuf Allocator)) # yeahh. that's basically the only thing we have to do. self flush-insert-action() actions = &self&.actions if actions&.list List.to-slice() Slice.last-ptr()&.parts null() # empty changeset - discard it. actions <&.cur= actions&.cur - 1 actions <&.input-changeset = NoChangeset actions trim-actions() fn set-filetype (self Ptr (TextBuf al), type Filetype) self <&.filetype= type for line in self&.buf List.addresses() line <&.invalidated= True ## syntax highlighting fn recompute-highlights-at-line (self Ptr (TextBuf al), line Ptr (Line al)) <= Allocator al if not line&.invalidated return hls = &line&.highlights List.trim(hls, 0) line-sv = line&.line List.to-slice() StrView.from-bytes() case self&.filetype Text pass KC hls Highlight.kc(line-sv) Markdown hls Highlight.markdown(line-sv) GCode hls Highlight.gcode(line-sv) Brainfuck hls Highlight.brainfuck(line-sv) line <&.invalidated= False fn highlight-at (self Ptr (TextBuf al), pos Pos) -> Maybe HighlightType <= Allocator al line = (&self&.buf) List.get-ptr(pos.line Mem.i32-size()) self recompute-highlights-at-line(line) for hl in line&.highlights if pos.bi Math.between(hl.from, hl.to - 1) return Just(hl.type) if pos.bi < hl.from return None return None