Download
290 lines 8.4 KiBGoModified 2026-10-31 raw
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
}