cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / ui / lineedit / area.go
area.go 5.8K
1package lineedit23import (4 "slices"5 "unicode"67 tea "charm.land/bubbletea/v2"8 "github.com/charmbracelet/x/ansi"9)1011// Area is the multi-line editor of a notebook's cells: Line's text and12// caret, with line breaks, soft-wrapped to a width so no text is ever13// hidden. A line longer than the width breaks before a pipe (|) where it14// can, then after a space, then anywhere; the rows it continues on are15// indented by Indent. Up and Down, Home and End move by those rows.16type Area struct {17 Line18 goal int // one past the column Up and Down keep to, 0 for the caret's own19}2021// Indent is how far a wrapped line's continuation rows are indented.22const Indent = 22324// Row is one row of wrapped text: Buf[Start:End], a continuation of the25// row before it (drawn Indent further in) when Cont is set.26type Row struct {27 Start, End int28 Cont bool29}3031// SetText replaces the text, with the caret at its end.32func (a *Area) SetText(text string) {33 a.Buf, a.goal = nil, 034 a.Pos = 035 a.InsertText(text)36}3738// InsertText types text at the caret, line breaks included; a tab is39// two spaces, and other control characters are dropped.40func (a *Area) InsertText(text string) {41 a.goal = 042 for _, r := range text {43 switch {44 case r == '\r':45 continue46 case r == '\t':47 a.insert(' ', ' ')48 case r == '\n' || unicode.IsPrint(r):49 a.insert(r)50 }51 }52}5354func (a *Area) insert(rs ...rune) {55 a.Buf = slices.Insert(a.Buf, a.Pos, rs...)56 a.Pos += len(rs)57}5859// Wrap lays buf out in rows at most width columns wide.60func Wrap(buf []rune, width int) []Row {61 width = max(width, Indent+4)62 var rows []Row63 start := 064 for i := 0; i <= len(buf); i++ {65 if i < len(buf) && buf[i] != '\n' {66 continue67 }68 rows = wrapLine(rows, buf, start, i, width)69 start = i + 170 }71 return rows72}7374// wrapLine adds the rows of the line buf[from:to].75func wrapLine(rows []Row, buf []rune, from, to, width int) []Row {76 cont := false77 for {78 limit := width79 if cont {80 limit -= Indent81 }82 end, w := from, 083 for end < to {84 rw := runeWidth(buf[end])85 if w+rw > limit {86 break87 }88 w += rw89 end++90 }91 if end >= to {92 return append(rows, Row{Start: from, End: to, Cont: cont})93 }94 end = breakAt(buf, from, end)95 rows = append(rows, Row{Start: from, End: end, Cont: cont})96 from, cont = end, true97 }98}99100// breakAt is where a row that would run to end breaks: before the last101// pipe, else after the last space, else at end.102func breakAt(buf []rune, from, end int) int {103 for i := end; i > from+1; i-- {104 if buf[i] == '|' && buf[i-1] == ' ' {105 return i106 }107 }108 for i := end; i > from+1; i-- {109 if buf[i-1] == ' ' {110 return i111 }112 }113 return end114}115116func runeWidth(r rune) int { return max(ansi.StringWidth(string(r)), 1) }117118// RowWidth is the columns buf[from:to] takes.119func RowWidth(buf []rune, from, to int) int {120 w := 0121 for _, r := range buf[from:to] {122 w += runeWidth(r)123 }124 return w125}126127// Caret is the row the caret is on among rows, and its column there,128// continuation rows' indent included.129func Caret(rows []Row, buf []rune, pos int) (row, col int) {130 for i, r := range rows {131 last := i == len(rows)-1 || !rows[i+1].Cont132 if pos >= r.Start && (pos < r.End || pos == r.End && last) {133 col = RowWidth(buf, r.Start, pos)134 if r.Cont {135 col += Indent136 }137 return i, col138 }139 }140 return max(len(rows)-1, 0), 0141}142143// posAt is the position in row r nearest column col.144func posAt(r Row, buf []rune, col int, last bool) int {145 if r.Cont {146 col -= Indent147 }148 w := 0149 for i := r.Start; i < r.End; i++ {150 rw := runeWidth(buf[i])151 if w+rw > col {152 return i153 }154 w += rw155 }156 if !last && r.End > r.Start {157 return r.End - 1 // the next row starts at End158 }159 return r.End160}161162// Place puts the caret nearest column col of row row of the text163// wrapped at width, as a click there does.164func (a *Area) Place(width, row, col int) {165 rows := Wrap(a.Buf, width)166 a.goal = 0167 if len(rows) == 0 {168 a.Pos = 0169 return170 }171 row = min(max(row, 0), len(rows)-1)172 last := row == len(rows)-1 || !rows[row+1].Cont173 a.Pos = posAt(rows[row], a.Buf, max(col, 0), last)174}175176// Key applies an editing key at width, reporting whether it was one.177// Enter breaks the line, keeping its indent.178func (a *Area) Key(k tea.KeyPressMsg, width int) bool {179 key := k.String()180 rows := Wrap(a.Buf, width)181 row, col := Caret(rows, a.Buf, a.Pos)182 switch key {183 case "up", "down":184 if a.goal == 0 {185 a.goal = col + 1186 }187 to := row - 1188 if key == "down" {189 to = row + 1190 }191 if to < 0 || to >= len(rows) {192 return true193 }194 last := to == len(rows)-1 || !rows[to+1].Cont195 a.Pos = posAt(rows[to], a.Buf, a.goal-1, last)196 return true197 case "home":198 a.Pos = rows[row].Start199 case "end":200 last := row == len(rows)-1 || !rows[row+1].Cont201 a.Pos = rows[row].End202 if !last {203 a.Pos = max(rows[row].End-1, rows[row].Start)204 }205 case "ctrl+a":206 a.Pos = a.lineStart()207 case "ctrl+e":208 a.Pos = a.lineEnd()209 case "enter":210 indent := a.indent()211 a.insert('\n')212 a.insert(indent...)213 case "ctrl+u", "super+backspace":214 a.DeleteBack(a.lineStart())215 case "left", "right", "backspace", "delete":216 a.Line.Key(k)217 default:218 if IsWordKey(k.String()) {219 a.Line.Key(k)220 break221 }222 text := Typed(k)223 if text == "" {224 return false225 }226 a.InsertText(text)227 }228 a.goal = 0229 return true230}231232// lineStart and lineEnd are the ends of the caret's line.233func (a *Area) lineStart() int {234 i := a.Pos235 for i > 0 && a.Buf[i-1] != '\n' {236 i--237 }238 return i239}240241func (a *Area) lineEnd() int {242 i := a.Pos243 for i < len(a.Buf) && a.Buf[i] != '\n' {244 i++245 }246 return i247}248249// indent is the spaces the caret's line starts with.250func (a *Area) indent() []rune {251 var out []rune252 for i := a.lineStart(); i < len(a.Buf) && a.Buf[i] == ' '; i++ {253 out = append(out, ' ')254 }255 return out256}257258// Offset is the caret as a byte offset into the text, what a completer259// or checker is given.260func (a *Area) Offset() int { return len(string(a.Buf[:a.Pos])) }