123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290 | package tui
import (
"strings"
"unicode"
"charm.land/bubbles/v2/textarea"
rw "github.com/mattn/go-runewidth"
"github.com/rivo/uniseg"
)
// newInput makes the box the user types into.
func newInput() textarea.Model {
in := textarea.New()
in.Placeholder = "Ask blinker to do something (alt+enter or ctrl+j for a new line, /help for commands)"
in.ShowLineNumbers = false
in.Prompt = "❯ "
in.DynamicHeight = true
in.MinHeight = 1
in.MaxHeight = 10
in.CharLimit = 0
// The terminal's cursor, which View places, since inputView draws the
// text instead of the textarea.
in.SetVirtualCursor(false)
in.Focus()
return in
}
// inputView draws the input with its Markdown rendered as it is typed,
// except where the cursor is: there it shows the syntax as typed, dimmed,
// so it can be edited. It returns where on the view the cursor is. The
// textarea draws each line in a single style, so it does the editing and
// this the drawing.
func (m Model) inputView() (view string, x, y int) {
in := m.input
promptWidth := uniseg.StringWidth(in.Prompt)
if in.Value() == "" {
return in.View(), promptWidth, 0 // the placeholder
}
styles := in.Styles().Blurred
if in.Focused() {
styles = in.Styles().Focused
}
prompt := styles.Prompt.Render(in.Prompt)
lines := strings.Split(in.Value(), "\n")
row, col := in.Line(), in.Column()
start, end, selected := in.Selection()
// The textarea's top row, which its rows may not line up with since
// the Markdown it shows is not hidden.
rawTop, rawRows, top := in.ScrollYOffset(), 0, -1
var rows []string
curRow, curX := 0, 0
colByte := len(string([]rune(lines[row])[:col]))
for lineNo, lineCells := range highlight(lines, row, colByte) {
// The selected runes of this line, as the textarea finds them.
from, to := 0, 0
if selected && lineNo >= start.Row && lineNo <= end.Row {
to = len(lineCells)
if lineNo == start.Row {
from = min(max(start.Col, 0), len(lineCells))
}
if lineNo == end.Row {
to = min(max(end.Col, 0), len(lineCells))
}
}
// The runes shown, the index in the line of each, and where among
// them the cursor is.
var shown []rune
var index []int
cur := -1
for j, c := range lineCells {
if lineNo == row && j == col {
cur = len(shown)
}
if !c.hide {
shown = append(shown, c.r)
index = append(index, j)
}
}
if lineNo == row && cur < 0 {
cur = len(shown)
}
segs := taWrap(shown, in.Width())
rawLineRows := len(taWrap([]rune(lines[lineNo]), in.Width()))
if top < 0 && rawTop < rawRows+rawLineRows {
top = len(rows) + min(rawTop-rawRows, len(segs)-1)
}
rawRows += rawLineRows
base := 0 // the index in shown of the row's first rune
for k, seg := range segs {
// At the end of a row that wraps, the cursor is at the start
// of the next, as in the textarea.
if cur >= base && (cur < base+len(seg) || cur == base+len(seg) && k == len(segs)-1) {
curRow, curX = len(rows), uniseg.StringWidth(string(seg[:cur-base]))
}
var b strings.Builder
b.WriteString(prompt)
var on style
for i, c := range seg {
var st style
if j := base + i; j < len(index) {
st = lineCells[index[j]].st
st.sel = index[j] >= from && index[j] < to
}
if st != on {
b.WriteString(st.sgr())
on = st
}
b.WriteRune(c)
}
if on != (style{}) {
b.WriteString(sgrReset)
}
rows = append(rows, b.String())
base += len(seg)
}
}
// As many rows as there are, within the textarea's limits, starting
// near where the textarea's do, and showing the cursor.
height := min(max(len(rows), in.MinHeight), in.MaxHeight)
top = min(max(top, 0), max(len(rows)-height, 0))
top = min(max(top, curRow-height+1), curRow)
out := make([]string, height)
for i := range out {
out[i] = prompt
if top+i < len(rows) {
out[i] = rows[top+i]
}
}
return strings.Join(out, "\n"), promptWidth + curX, curRow - top
}
// cell is how a rune of the input shows.
type cell struct {
r rune
st style
hide bool
}
// highlight styles the lines of raw Markdown rune by rune, as they would
// be rendered: the text styled and the syntax hidden, except that where
// the cursor is (on line row, at byte col) the syntax shows, dimmed.
// Syntax for a whole line, such as a heading's #s, shows while the cursor
// is on the line, and syntax within it while the cursor is within or next
// to what it marks up. Syntax before the cursor that opens a construct
// opens it even before it is closed, so what is being typed shows styled.
// Code blocks, rules and tables show as typed.
func highlight(lines []string, row, col int) [][]cell {
out := make([][]cell, len(lines))
fence := "" // the fence of the code block the line is in
for i, line := range lines {
trimmed := strings.TrimSpace(line)
lead := len(line) - len(strings.TrimLeft(line, " "))
here := i == row
var cs []cell
add := func(s string, st style, hide bool) {
for _, r := range s {
cs = append(cs, cell{r, st, hide})
}
}
lineSyntax := 0 // how much of the line is syntax for all of it
base := ""
switch {
case fence != "":
if closesFence(trimmed, fence) {
fence = ""
add(line, style{mark: true}, false)
} else {
add(line, style{code: true}, false)
}
case fenceOf(trimmed) != "":
fence = fenceOf(trimmed)
add(line, style{mark: true}, false)
case isRule(trimmed) || tableStart(lines, i):
add(line, style{mark: true}, false)
case headingLevel(trimmed) > 0:
lineSyntax, base = lead+headingLevel(trimmed), "1;94"
if lineSyntax < len(line) {
lineSyntax++ // the space after the #s
}
add(line[:lineSyntax], style{base: base, mark: true}, !here)
case strings.HasPrefix(trimmed, ">"):
lineSyntax, base = lead+1, "3"
if here {
add(line[:lineSyntax], style{mark: true}, false)
} else {
add(line[:lead], style{}, false)
add("│", style{mark: true}, false)
}
case listMarker(line) > 0:
lineSyntax = listMarker(line)
marker := line[:lineSyntax]
if !here && strings.IndexByte("-*+", line[lead]) >= 0 {
marker = line[:lead] + "•" + line[lead+1:lineSyntax]
}
add(marker, style{base: "1;94"}, false)
}
if len(cs) > 0 && lineSyntax == 0 {
out[i] = cs
continue
}
cursor := -1
if here && col >= lineSyntax {
cursor = col - lineSyntax
}
sps := spans(line[lineSyntax:], base, cursor)
// Where each construct's syntax starts and ends in the line.
bounds := map[int][2]int{}
off := lineSyntax
for _, sp := range sps {
if sp.group > 0 {
b, ok := bounds[sp.group]
if !ok {
b[0] = off
}
b[1] = off + len(sp.text)
if sp.unclosed {
b[1] = len(line)
}
bounds[sp.group] = b
}
off += len(sp.text)
}
for _, sp := range sps {
if sp.kind == 0 {
add(sp.text, sp.st, false)
continue
}
b := bounds[sp.group]
st := sp.st
st.mark = true
add(sp.text, st, !(here && col >= b[0] && col <= b[1]))
}
out[i] = cs
}
return out
}
// taWrap is the textarea's wrap, which it does not export, copied from
// charm.land/bubbles/v2 v2.2.1 (MIT): inputView must break lines exactly
// where it does. TestInputViewMatchesTextarea checks that it still does.
func taWrap(runes []rune, width int) [][]rune {
var (
lines = [][]rune{{}}
word = []rune{}
row int
spaces int
)
for _, r := range runes {
if unicode.IsSpace(r) {
spaces++
} else {
word = append(word, r)
}
if spaces > 0 {
if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces > width {
row++
lines = append(lines, []rune{})
}
lines[row] = append(lines[row], word...)
lines[row] = append(lines[row], []rune(strings.Repeat(" ", spaces))...)
spaces = 0
word = nil
} else {
// A double-width last rune may not fit on this line.
lastCharLen := rw.RuneWidth(word[len(word)-1])
if uniseg.StringWidth(string(word))+lastCharLen > width {
// The word fills the line, so it starts the next one,
// unless this one is empty.
if len(lines[row]) > 0 {
row++
lines = append(lines, []rune{})
}
lines[row] = append(lines[row], word...)
word = nil
}
}
}
if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces >= width {
lines = append(lines, []rune{})
lines[row+1] = append(lines[row+1], word...)
spaces++
lines[row+1] = append(lines[row+1], []rune(strings.Repeat(" ", spaces))...)
} else {
lines[row] = append(lines[row], word...)
spaces++
lines[row] = append(lines[row], []rune(strings.Repeat(" ", spaces))...)
}
return lines
}
|