Files
zero/internal/tui/composer.go
Anandan d7ac85cbf9 Polish Zero's TUI and harden patch, model, and background workflows (#916)
* Checkpoint UI polish

* Fix structured apply patch handling

* Restore tool previews when resuming sessions

* Improve theme-aware code rendering

* Replace persistent sidebar with run details

* Remove inert transcript interactions

* Cover resumed edit previews

* Smooth active run status feedback

* Preserve semantic colors behind overlays

* Normalize ChatGPT model aliases

* Render staged image attachments in terminal

* Polish transcript and tool feedback

* Resolve rebased diff renderer conflict

* Address UI polish review findings

* Fix smoke test regressions

* Harden patch and transcript edge cases

* Bind structured patches to workspace root

* Use unique structured patch temp files

* Retry structured patch temp allocation

* Reuse rooted patch temp allocator

* Fix Windows CI process checks

* Fix structured patch safety boundaries

Preserve read-before-write credit only when the model already knew the full source, reject ambiguous hunk matches, and retain CRLF line endings. Add regression coverage for unsafe overwrites, positive tracking cases, duplicate matches, and Windows-style files.

* Resolve plan test seam after rebase

Drop the obsolete sticky-plan height helper while retaining the PR status test seams brought forward by the UI changes.

* Address final review correctness gaps

Bound narrow waiting labels, reject malformed plan records, preserve missing final newlines, reject out-of-order EOF hunks, clear stale tracking after failed rollback, and keep deferred tool loading aligned with model visibility.

* Harden patch transactions and review edges

Publish new patch targets without overwriting raced destinations, journal only completed mutations, and create replacement temps beside their targets. Reject pre-plan diagnostics, cover tool-search transcript suppression, and leave sufficient cleanup time in the Windows process fixture.

* Bind patch rollback to committed versions

Capture file identity and content before publication, quarantine rollback targets atomically, and refuse destructive undo when a path was replaced or modified in place. Add deterministic coverage for publication and rollback races.

* Align structured patch failure semantics

Use prevalidated sequential application with explicit partial-success errors instead of unsafe multi-file rollback. Retain atomic per-file publication, no-overwrite creates, unsupported-hardlink fallback, and conservative tracker invalidation.

* Preserve patch context bytes

Track source offsets for structured-patch context lines and reuse the exact matched source text during replacement. This keeps tolerant matching from changing indentation or mixed line endings outside the requested edit.
2026-08-19 22:18:47 +05:30

763 lines
22 KiB
Go

package tui
import (
"sort"
"strconv"
"strings"
"unicode"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
type composerState struct {
text string
cursor int
}
type composerSelectionState struct {
active bool
anchor int
cursor int
}
type composerPastePreview struct {
active bool
start int
end int
label string
}
func insertComposerText(state composerState, text string) composerState {
state = normalizeComposerState(state)
if text == "" {
return state
}
runes := []rune(state.text)
insert := []rune(text)
out := make([]rune, 0, len(runes)+len(insert))
out = append(out, runes[:state.cursor]...)
out = append(out, insert...)
out = append(out, runes[state.cursor:]...)
return composerState{text: string(out), cursor: state.cursor + len(insert)}
}
func deleteComposerWordBefore(state composerState) composerState {
state = normalizeComposerState(state)
if state.cursor == 0 {
return state
}
runes := []rune(state.text)
start := state.cursor
for start > 0 && unicode.IsSpace(runes[start-1]) {
start--
}
for start > 0 && !unicode.IsSpace(runes[start-1]) {
start--
}
return deleteComposerRange(state, start, state.cursor)
}
func deleteComposerWordAfter(state composerState) composerState {
state = normalizeComposerState(state)
runes := []rune(state.text)
if state.cursor >= len(runes) {
return state
}
end := state.cursor
for end < len(runes) && unicode.IsSpace(runes[end]) {
end++
}
for end < len(runes) && !unicode.IsSpace(runes[end]) {
end++
}
for end < len(runes) && runes[end] != '\n' && unicode.IsSpace(runes[end]) {
end++
}
return deleteComposerRange(state, state.cursor, end)
}
func moveComposerWordBefore(state composerState) composerState {
state = normalizeComposerState(state)
runes := []rune(state.text)
pos := state.cursor
for pos > 0 && unicode.IsSpace(runes[pos-1]) {
pos--
}
for pos > 0 && !unicode.IsSpace(runes[pos-1]) {
pos--
}
state.cursor = pos
return state
}
func moveComposerWordAfter(state composerState) composerState {
state = normalizeComposerState(state)
runes := []rune(state.text)
pos := state.cursor
for pos < len(runes) && unicode.IsSpace(runes[pos]) {
pos++
}
for pos < len(runes) && !unicode.IsSpace(runes[pos]) {
pos++
}
state.cursor = pos
return state
}
func sanitizeComposerPaste(text string) string {
var out strings.Builder
runes := []rune(text)
for i := 0; i < len(runes); i++ {
switch r := runes[i]; r {
case '\r':
out.WriteRune('\n')
if i+1 < len(runes) && runes[i+1] == '\n' {
i++
}
case '\n':
out.WriteRune('\n')
case '\t':
out.WriteString(" ")
default:
if !unicode.IsControl(r) {
out.WriteRune(r)
}
}
}
return out.String()
}
func sanitizeComposerInput(text string) string {
return sanitizeComposerPaste(strings.ReplaceAll(text, "\n", ""))
}
func (m model) composerValue() string {
if m.composerActive {
return m.composer.text
}
return m.input.Value()
}
func (m model) currentComposerState() composerState {
if m.composerActive {
return normalizeComposerState(m.composer)
}
return composerState{text: m.input.Value(), cursor: m.input.Position()}
}
func (m *model) setComposerState(state composerState) {
m.composer = normalizeComposerState(state)
m.composerActive = true
m.syncInputFromComposer()
}
func (m *model) clearComposer() {
m.composer = composerState{}
m.composerActive = false
m.composerPastePreviews = nil
m.composerSelection = composerSelectionState{}
m.input.SetValue("")
}
func (m *model) resetComposerFromInput() {
m.composer = composerState{}
m.composerActive = false
m.composerPastePreviews = nil
m.composerSelection = composerSelectionState{}
}
func (m *model) syncInputFromComposer() {
display := strings.ReplaceAll(m.composer.text, "\n", " ")
m.input.SetValue(display)
m.input.SetCursor(composerDisplayCursor(m.composer))
}
func composerDisplayCursor(state composerState) int {
state = normalizeComposerState(state)
count := 0
for range []rune(state.text)[:state.cursor] {
count++
}
return count
}
func (selection composerSelectionState) rangeFor(state composerState) (int, int, bool) {
if !selection.active {
return 0, 0, false
}
state = normalizeComposerState(state)
start := clamp(selection.anchor, 0, len([]rune(state.text)))
end := clamp(selection.cursor, 0, len([]rune(state.text)))
if start > end {
start, end = end, start
}
return start, end, start != end
}
func (m model) selectedComposerText() string {
state := m.currentComposerState()
start, end, ok := m.composerSelection.rangeFor(state)
if !ok {
return ""
}
return string([]rune(state.text)[start:end])
}
func (m model) handleComposerSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd, bool) {
switch {
case mouseLeftPress(msg):
pos, ok := m.composerPositionAtMouse(msg)
if !ok {
if m.composerSelection.active {
m.composerSelection = composerSelectionState{}
return m, nil, true
}
return m, nil, false
}
state := m.currentComposerState()
state.cursor = pos
m.setComposerState(state)
m.copyStatus = ""
m.composerSelection = composerSelectionState{active: true, anchor: pos, cursor: pos}
return m, nil, true
case mouseMotion(msg):
if !m.composerSelection.active {
return m, nil, false
}
if pos, ok := m.composerPositionAtMouse(msg); ok {
state := m.currentComposerState()
state.cursor = pos
m.setComposerState(state)
m.composerSelection.active = true
m.composerSelection.cursor = pos
}
return m, nil, true
case mouseRelease(msg):
if !m.composerSelection.active {
return m, nil, false
}
if pos, ok := m.composerPositionAtMouse(msg); ok {
state := m.currentComposerState()
state.cursor = pos
m.setComposerState(state)
m.composerSelection.active = true
m.composerSelection.cursor = pos
}
text := m.selectedComposerText()
m.composerSelection = composerSelectionState{}
if strings.TrimSpace(text) == "" {
return m, nil, true
}
return m, copyTranscriptSelectionCmd(text), true
default:
return m, nil, false
}
}
func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
if !m.altScreen || m.height <= 0 || m.composerMouseSelectionBlocked() {
return 0, false
}
width := m.chatColumnWidth()
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
localX, localY, ok := frame.composerRect.local(mouseX(msg), mouseY(msg))
if !ok {
return 0, false
}
if width < 8 {
return m.composerPositionAtVisualCell(localX, localY, width)
}
contentY := localY - 1 - m.attachmentComposerPrefixRows(width)
if contentY < 0 {
return 0, false
}
return m.composerPositionAtVisualCell(localX-2, contentY, maxInt(1, width-4))
}
func (m model) composerMouseSelectionBlocked() bool {
return m.transcriptDetailed || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil || m.suggestionsActive()
}
func (m model) composerPositionAtVisualCell(x int, y int, width int) (int, bool) {
input := m.input
state := m.currentComposerState()
previews := validComposerPastePreviews(state, m.composerPastePreviews)
displayState := composerDisplayStateForPastePreviews(state, previews)
segments, _ := composerVisibleVisualLines(input, displayState, width)
if len(segments) == 0 {
return 0, y == 0
}
if y < 0 || y >= len(segments) {
return 0, false
}
segment := segments[y]
prefixWidth := lipgloss.Width(composerVisualLinePrefix(input, segment.first))
column := maxInt(0, x-prefixWidth)
displayCursor := composerCursorForVisualColumn(displayState, segment, column)
return clamp(composerOriginalCursorForPastePreviews(displayCursor, previews), 0, len([]rune(state.text))), true
}
func (m model) applyComposerKey(msg tea.KeyMsg) (model, bool) {
state := m.currentComposerState()
switch {
case keyIs(msg, tea.KeyEnter) && (keyAlt(msg) || keyShift(msg)):
m = m.insertComposerTextWithPastePreview(state, "\n", "")
case keyCtrl(msg, 'j'):
m = m.insertComposerTextWithPastePreview(state, "\n", "")
case keyAlt(msg) && keyText(msg) == "d":
end := deleteComposerWordAfter(state).cursor
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, end)
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
case keyAlt(msg) && keyIs(msg, tea.KeyLeft):
m.setComposerState(moveComposerWordBefore(state))
case keyAlt(msg) && keyIs(msg, tea.KeyRight):
m.setComposerState(moveComposerWordAfter(state))
case keyCtrlArrow(msg, tea.KeyLeft):
m.setComposerState(moveComposerWordBefore(state))
case keyCtrlArrow(msg, tea.KeyRight):
m.setComposerState(moveComposerWordAfter(state))
case keyAlt(msg) && keyText(msg) == "b":
m.setComposerState(moveComposerWordBefore(state))
case keyAlt(msg) && keyText(msg) == "f":
m.setComposerState(moveComposerWordAfter(state))
case keyIs(msg, tea.KeySpace):
m = m.insertComposerTextWithPastePreview(state, " ", "")
case keyPrintable(msg):
m = m.applyComposerText(state, keyText(msg), false)
case keyIs(msg, tea.KeyLeft) || keyCtrl(msg, 'b'):
if nextState, ok := moveComposerPastePreviewBoundary(state, m.composerPastePreviews, -1); ok {
m.setComposerState(nextState)
break
}
state.cursor--
m.setComposerState(state)
case keyIs(msg, tea.KeyRight) || keyCtrl(msg, 'f'):
if nextState, ok := moveComposerPastePreviewBoundary(state, m.composerPastePreviews, 1); ok {
m.setComposerState(nextState)
break
}
state.cursor++
m.setComposerState(state)
case keyIs(msg, tea.KeyHome) || keyCtrl(msg, 'a'):
state.cursor = composerLineStart(state)
m.setComposerState(state)
case keyIs(msg, tea.KeyEnd) || keyCtrl(msg, 'e'):
state.cursor = composerLineEnd(state)
m.setComposerState(state)
case keyCtrl(msg, 'u'):
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, composerLineStart(state), state.cursor)
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
case keyCtrl(msg, 'k'):
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, composerLineEnd(state))
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
case keyCtrl(msg, 'w') || (keyAlt(msg) && keyBackspace(msg)):
start := deleteComposerWordBefore(state).cursor
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, start, state.cursor)
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
case keyAlt(msg) && keyIs(msg, tea.KeyDelete):
end := deleteComposerWordAfter(state).cursor
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, end)
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
case keyBackspace(msg):
if nextState, nextPreviews, ok := deleteComposerPastePreviewBefore(state, m.composerPastePreviews); ok && !m.suggestionsActive() {
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
} else if start, end, ok := completedFileMentionRangeBefore(state); ok && !m.suggestionsActive() {
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, start, end)
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
} else {
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor-1, state.cursor)
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
}
case keyIs(msg, tea.KeyDelete) || keyCtrl(msg, 'd'):
if nextState, nextPreviews, ok := deleteComposerPastePreviewAfter(state, m.composerPastePreviews); ok {
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
} else {
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, state.cursor+1)
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
}
default:
return m, false
}
if strings.Contains(m.composer.text, "\n") {
m.clearSuggestions()
} else {
m.recomputeSuggestions()
}
return m, true
}
func (m model) applyComposerText(state composerState, text string, paste bool) model {
previewLabel := ""
if paste {
text = sanitizeComposerPaste(text)
previewLabel, _ = composerPastePreviewLabel(text, m.composerPastePreviewWrapWidth())
} else {
text = sanitizeComposerInput(text)
}
if shouldInsertCommandArgumentSpace(state, text) {
text = " " + text
if previewLabel != "" {
previewLabel = " " + previewLabel
}
}
return m.insertComposerTextWithPastePreview(state, text, previewLabel)
}
func (m model) insertComposerTextWithPastePreview(state composerState, text string, previewLabel string) model {
state = normalizeComposerState(state)
insertStart := state.cursor
insertedRunes := len([]rune(text))
nextPreviews := composerPastePreviewsAfterInsert(m.composerPastePreviews, insertStart, insertedRunes)
m.setComposerState(insertComposerText(state, text))
if previewLabel != "" && insertedRunes > 0 {
previewLabel = composerPastePreviewLabelWithIndex(previewLabel, len(nextPreviews)+1)
nextPreviews = append(nextPreviews, composerPastePreview{
active: true,
start: insertStart,
end: insertStart + insertedRunes,
label: previewLabel,
})
sortComposerPastePreviews(nextPreviews)
m.composerPastePreviews = nextPreviews
return m
}
m.composerPastePreviews = nextPreviews
return m
}
func (m model) replaceComposerRangeWithPastePreviews(state composerState, start int, end int, replacement string) model {
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, start, end)
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
if replacement == "" {
return m
}
return m.insertComposerTextWithPastePreview(m.currentComposerState(), replacement, "")
}
func (m model) composerPastePreviewWrapWidth() int {
width := chatWidth(m.width)
if width < 8 {
width = defaultStartupWidth
}
return maxInt(1, width-4-lipgloss.Width(m.input.Prompt))
}
func composerPastePreviewLabel(text string, wrapWidth int) (string, bool) {
text = strings.TrimSpace(text)
if text == "" {
return "", false
}
runeCount := len([]rune(text))
lineCount := composerPastePreviewVisualLineCount(text, wrapWidth)
if lineCount < 3 && runeCount < 220 {
return "", false
}
snippet := text
if newline := strings.IndexRune(snippet, '\n'); newline >= 0 {
snippet = snippet[:newline]
}
snippet = strings.Join(strings.Fields(snippet), " ")
if snippet == "" {
snippet = "pasted text"
}
snippet = truncateComposerPasteSnippet(snippet, 48)
label := "lines"
if lineCount == 1 {
label = "line"
}
return "[" + snippet + " · " + strconv.Itoa(lineCount) + " " + label + "]", true
}
func composerPastePreviewLabelWithIndex(label string, pasteNumber int) string {
if pasteNumber <= 1 || !strings.HasSuffix(label, "]") {
return label
}
return strings.TrimSuffix(label, "]") + ", paste " + strconv.Itoa(pasteNumber) + "]"
}
func composerPastePreviewVisualLineCount(text string, wrapWidth int) int {
wrapWidth = maxInt(1, wrapWidth)
total := 0
for _, line := range strings.Split(text, "\n") {
width := lipgloss.Width(line)
total += maxInt(1, (width+wrapWidth-1)/wrapWidth)
}
return total
}
func truncateComposerPasteSnippet(text string, limit int) string {
runes := []rune(text)
if limit <= 3 || len(runes) <= limit {
return text
}
return string(runes[:limit-3]) + "..."
}
func composerPastePreviewsAfterInsert(previews []composerPastePreview, pos int, length int) []composerPastePreview {
if length <= 0 || len(previews) == 0 {
return previews
}
next := make([]composerPastePreview, 0, len(previews))
for _, preview := range previews {
if !preview.active {
continue
}
switch {
case pos <= preview.start:
preview.start += length
preview.end += length
case pos < preview.end:
continue
}
next = append(next, preview)
}
sortComposerPastePreviews(next)
return next
}
func moveComposerPastePreviewBoundary(state composerState, previews []composerPastePreview, direction int) (composerState, bool) {
state = normalizeComposerState(state)
for _, preview := range validComposerPastePreviews(state, previews) {
switch {
case direction < 0 && state.cursor > preview.start && state.cursor <= preview.end:
state.cursor = preview.start
return state, true
case direction > 0 && state.cursor >= preview.start && state.cursor < preview.end:
state.cursor = preview.end
return state, true
}
}
return state, false
}
func deleteComposerPastePreviewBefore(state composerState, previews []composerPastePreview) (composerState, []composerPastePreview, bool) {
state = normalizeComposerState(state)
valid := validComposerPastePreviews(state, previews)
for index, preview := range valid {
if state.cursor != preview.end {
continue
}
return deleteComposerRange(state, preview.start, preview.end), composerPastePreviewsAfterDelete(valid, index), true
}
return state, previews, false
}
func deleteComposerPastePreviewAfter(state composerState, previews []composerPastePreview) (composerState, []composerPastePreview, bool) {
state = normalizeComposerState(state)
valid := validComposerPastePreviews(state, previews)
for index, preview := range valid {
if state.cursor != preview.start {
continue
}
return deleteComposerRange(state, preview.start, preview.end), composerPastePreviewsAfterDelete(valid, index), true
}
return state, previews, false
}
func deleteComposerRangeWithPastePreviews(state composerState, previews []composerPastePreview, start int, end int) (composerState, []composerPastePreview) {
state = normalizeComposerState(state)
runeCount := len([]rune(state.text))
start = clamp(start, 0, runeCount)
end = clamp(end, 0, runeCount)
if end < start {
start, end = end, start
}
if start == end {
return state, validComposerPastePreviews(state, previews)
}
valid := validComposerPastePreviews(state, previews)
deleteStart, deleteEnd := start, end
for {
expanded := false
for _, preview := range valid {
if !composerRangesOverlap(deleteStart, deleteEnd, preview.start, preview.end) {
continue
}
if preview.start < deleteStart {
deleteStart = preview.start
expanded = true
}
if preview.end > deleteEnd {
deleteEnd = preview.end
expanded = true
}
}
if !expanded {
break
}
}
nextState := deleteComposerRange(state, deleteStart, deleteEnd)
delta := deleteEnd - deleteStart
nextPreviews := make([]composerPastePreview, 0, len(valid))
for _, preview := range valid {
switch {
case composerRangesOverlap(deleteStart, deleteEnd, preview.start, preview.end):
continue
case preview.start >= deleteEnd:
preview.start -= delta
preview.end -= delta
}
nextPreviews = append(nextPreviews, preview)
}
sortComposerPastePreviews(nextPreviews)
return nextState, nextPreviews
}
func composerRangesOverlap(leftStart int, leftEnd int, rightStart int, rightEnd int) bool {
return leftStart < rightEnd && rightStart < leftEnd
}
func validComposerPastePreviews(state composerState, previews []composerPastePreview) []composerPastePreview {
state = normalizeComposerState(state)
if len(previews) == 0 {
return nil
}
runeCount := len([]rune(state.text))
valid := make([]composerPastePreview, 0, len(previews))
for _, preview := range previews {
if !preview.active || preview.label == "" || preview.start < 0 || preview.start >= preview.end || preview.end > runeCount {
continue
}
valid = append(valid, preview)
}
sortComposerPastePreviews(valid)
out := valid[:0]
lastEnd := -1
for _, preview := range valid {
if preview.start < lastEnd {
continue
}
out = append(out, preview)
lastEnd = preview.end
}
return out
}
func sortComposerPastePreviews(previews []composerPastePreview) {
sort.SliceStable(previews, func(i, j int) bool {
if previews[i].start == previews[j].start {
return previews[i].end < previews[j].end
}
return previews[i].start < previews[j].start
})
}
func composerPastePreviewsAfterDelete(previews []composerPastePreview, deleteIndex int) []composerPastePreview {
if deleteIndex < 0 || deleteIndex >= len(previews) {
return previews
}
deleted := previews[deleteIndex]
delta := deleted.end - deleted.start
next := make([]composerPastePreview, 0, len(previews)-1)
for index, preview := range previews {
if index == deleteIndex {
continue
}
if preview.start >= deleted.end {
preview.start -= delta
preview.end -= delta
}
next = append(next, preview)
}
sortComposerPastePreviews(next)
return next
}
func shouldInsertCommandArgumentSpace(state composerState, text string) bool {
if text == "" {
return false
}
first, _ := utf8.DecodeRuneInString(text)
if unicode.IsSpace(first) {
return false
}
state = normalizeComposerState(state)
if state.cursor != len([]rune(state.text)) {
return false
}
if strings.TrimRightFunc(state.text, unicode.IsSpace) != state.text {
return false
}
return commandArgumentHintForInput(state.text) != ""
}
func completedFileMentionRangeBefore(state composerState) (int, int, bool) {
state = normalizeComposerState(state)
runes := []rune(state.text)
if state.cursor <= 0 || state.cursor > len(runes) || !isPathQueryBoundary(runes[state.cursor-1]) {
return 0, 0, false
}
tokenEnd := state.cursor
for tokenEnd > 0 && isPathQueryBoundary(runes[tokenEnd-1]) {
tokenEnd--
}
tokenStart := tokenEnd
for tokenStart > 0 && !isPathQueryBoundary(runes[tokenStart-1]) {
tokenStart--
}
if tokenStart >= tokenEnd || runes[tokenStart] != '@' || tokenEnd-tokenStart <= 1 {
return 0, 0, false
}
return tokenStart, state.cursor, true
}
func deleteComposerRange(state composerState, start int, end int) composerState {
state = normalizeComposerState(state)
runes := []rune(state.text)
start = clamp(start, 0, len(runes))
end = clamp(end, 0, len(runes))
if end < start {
start, end = end, start
}
if start == end {
return state
}
out := make([]rune, 0, len(runes)-(end-start))
out = append(out, runes[:start]...)
out = append(out, runes[end:]...)
return composerState{text: string(out), cursor: start}
}
func normalizeComposerState(state composerState) composerState {
runes := []rune(state.text)
state.cursor = clamp(state.cursor, 0, len(runes))
return state
}
func composerLineStart(state composerState) int {
state = normalizeComposerState(state)
runes := []rune(state.text)
pos := state.cursor
for pos > 0 && runes[pos-1] != '\n' {
pos--
}
return pos
}
func composerLineEnd(state composerState) int {
state = normalizeComposerState(state)
runes := []rune(state.text)
pos := state.cursor
for pos < len(runes) && runes[pos] != '\n' {
pos++
}
return pos
}