package tui
import (
"math"
"slices"
"strings"
"github.com/charmbracelet/x/ansi"
"github.com/rivo/uniseg"
)
// Row marks are APC strings, which draw nothing: Bubble Tea drops them on
// the way to the screen, and widths and ansi.Strip skip them. They tell a
// copy what a row is beyond its text.
const (
// markChrome labels the transcript rather than being part of it: who
// speaks, a turn's signoff, tool glances.
markChrome = "\x1b_albedo:chrome\x1b\\"
// markWrap continues the row above, which broke at a space, and
// markSplit continues it mid-word.
markWrap = "\x1b_albedo:wrap\x1b\\"
markSplit = "\x1b_albedo:split\x1b\\"
)
// chrome marks every row of s.
func chrome(s string) string {
return markChrome + strings.ReplaceAll(s, "\n", "\n"+markChrome)
}
// wrapJoiner is what goes between a row and the row above it continues.
func wrapJoiner(line string) (string, bool) {
if strings.Contains(line, markWrap) {
return " ", true
}
return "", strings.Contains(line, markSplit)
}
// markChunks marks the rows line wrapped into as continuing the one before,
// and gives them the line's chrome.
func markChunks(line string, chunks []string) []string {
carried := ""
if strings.Contains(line, markChrome) {
carried = markChrome
}
for i := 1; i < len(chunks); i++ {
mark := markSplit
if strings.HasSuffix(ansi.Strip(chunks[i-1]), " ") {
mark = markWrap
}
chunks[i] = carried + mark + chunks[i]
}
return chunks
}
// markWraps marks the rows of wrapped, markdown rendered to a width, that
// continue the row above. flat is the same markdown unwrapped: a run of
// wrapped rows that spells one of its lines, in order, is that line. A row
// that spells none, like a table's, stays a line of its own.
func markWraps(wrapped, flat string) string {
rows := strings.Split(wrapped, "\n")
var lines []string
for line := range strings.SplitSeq(flat, "\n") {
lines = append(lines, rowText(line))
}
// rest is what the line being spelled has left, after a space when spaced
next, rest, spaced := 0, "", false
for i, row := range rows {
text := rowText(row)
if cont := strings.TrimLeft(text, " "); rest != "" && cont != "" && strings.HasPrefix(rest, cont) {
mark := markSplit
if spaced {
mark = markWrap
}
rows[i] = mark + row
rest, spaced = spell(rest, cont)
continue
}
rest = ""
// rows that spell nothing hold the search back, so it looks ahead
// past the flat lines they stand for
for k := next; k < min(len(lines), next+16); k++ {
if lines[k] == text || text != "" && strings.HasPrefix(lines[k], text) {
next = k + 1
rest, spaced = spell(lines[k], text)
break
}
}
}
return strings.Join(rows, "\n")
}
// spell is what line has left after text, and whether spaces came first.
func spell(line, text string) (string, bool) {
after := line[len(text):]
rest := strings.TrimLeft(after, " ")
return rest, len(rest) < len(after)
}
// rowText is what a row says: its plain text without quote bars or padding.
func rowText(line string) string {
_, text, _ := selectedRange(line, 0, math.MaxInt, quoteColumns(line))
return strings.TrimRight(text, " ")
}
type Point struct {
Row int
Col int
}
type Selection struct {
Anchor Point
Head Point
// Gutter is the decoration at the start of every row, which neither
// the highlight nor the copied text includes.
Gutter int
}
func (s Selection) IsEmpty() bool { return s.Anchor == s.Head }
func (s Selection) Normalized() (Point, Point) {
if s.Anchor.Row < s.Head.Row || (s.Anchor.Row == s.Head.Row && s.Anchor.Col <= s.Head.Col) {
return s.Anchor, s.Head
}
return s.Head, s.Anchor
}
// selectedRange splits line around the columns from to to. Columns in skip
// are left out of the selection.
func selectedRange(line string, from, to int, skip map[int]bool) (before, selected, after string) {
plain := ansi.Strip(line)
var chosen, prefix, suffix strings.Builder
col := 0
for len(plain) > 0 {
cluster, rest, _, _ := uniseg.FirstGraphemeClusterInString(plain, -1)
width := ansi.StringWidth(cluster)
switch {
case col+width > from && col < to:
if !skip[col] {
chosen.WriteString(cluster)
}
case col < from:
prefix.WriteString(cluster)
default:
suffix.WriteString(cluster)
}
plain = rest
col += width
}
return prefix.String(), chosen.String(), suffix.String()
}
// selBounds is the column range of sel on one row of lines.
func selBounds(line string, sel Selection, start, end Point, row int) (int, int) {
from, to := sel.Gutter, ansi.StringWidth(line)
if row == start.Row {
from = max(from, start.Col)
}
if row == end.Row {
to = end.Col
}
return from, to
}
// SelectedText is the text under sel: what the rows say, without what the
// transcript draws around it. Code block frames and quote bars are left out,
// and so are chrome rows unless nothing else is selected; wrapped rows join
// back into their lines.
func SelectedText(lines []string, sel Selection) string {
if sel.IsEmpty() || len(lines) == 0 {
return ""
}
start, end := sel.Normalized()
type piece struct {
text string
joiner string
chrome, joins bool
}
var picked []piece
onlyChrome := true
for row := max(0, start.Row); row <= end.Row && row < len(lines); row++ {
line := lines[row]
if strings.Contains(line, codeFence(fenceOpen)) {
continue
}
// a code block's closing frame also spaces the block from what
// follows, so it copies as a blank row
if strings.Contains(line, codeFence(fenceClose)) {
picked = append(picked, piece{})
continue
}
from, to := selBounds(line, sel, start, end, row)
_, text, _ := selectedRange(line, from, to, quoteColumns(line))
if act, ok := actionOf(line); ok && act.verb == verbOpen {
// the toggle is a control, not part of what the row says
text = strings.TrimPrefix(strings.TrimPrefix(text, toggleClosed), toggleOpen)
}
// rows are padded out to the viewport's width
p := piece{text: strings.TrimRight(text, " "), chrome: strings.Contains(line, markChrome)}
if row > start.Row {
p.joiner, p.joins = wrapJoiner(line)
}
onlyChrome = onlyChrome && (p.chrome || p.text == "")
picked = append(picked, p)
}
var rows []string
// joinable says the last row kept is the row above; dropped says chrome
// was left out since the last text, so a blank row after it is spare
joinable, dropped := false, false
for _, p := range picked {
switch {
case p.chrome && !onlyChrome:
joinable, dropped = false, true
case p.joins && joinable:
rows[len(rows)-1] += p.joiner + strings.TrimLeft(p.text, " ")
case p.text == "" && dropped && len(rows) > 0 && rows[len(rows)-1] == "":
joinable = false
default:
rows = append(rows, p.text)
joinable, dropped = true, dropped && p.text == ""
}
}
// and the viewport's height, so blank rows at either end are padding too
for len(rows) > 0 && rows[0] == "" {
rows = rows[1:]
}
for len(rows) > 0 && rows[len(rows)-1] == "" {
rows = rows[:len(rows)-1]
}
return strings.Join(rows, "\n")
}
// quoteColumns are the columns of line under quote bars.
func quoteColumns(line string) map[int]bool {
bar := quoteBar()
skip := map[int]bool{}
for at := 0; ; at += len(bar) {
i := strings.Index(line[at:], bar)
if i < 0 {
return skip
}
at += i
col := ansi.StringWidth(line[:at])
skip[col], skip[col+1] = true, true
}
}
func HighlightSelection(lines []string, sel Selection) []string {
if sel.IsEmpty() {
return lines
}
start, end := sel.Normalized()
out := slices.Clone(lines)
for row := max(0, start.Row); row <= end.Row && row < len(out); row++ {
from, to := selBounds(out[row], sel, start, end, row)
before, text, after := selectedRange(out[row], from, to, nil)
if text != "" {
out[row] = before + DefaultStyles.Cursor.Render(text) + after
}
}
return out
}