mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* Add terminal companions * Polish interactive terminal companions * Fix terminal companion edge cases * Strengthen terminal companion regression tests * Preserve config layout when selecting pets * Handle sparse pet preference layouts * fix(terminalpet): reset SGR before erasing a sixel companion On Windows Terminal the companion draws a filled black rectangle over the interface, and dragging it mangles text under and around it. It looks like a platform quirk. It is an untested code path that only Windows reaches. Sixel is the only protocol here that erases by PAINTING CHARACTERS. Kitty hands the terminal an image id and the placement goes away. A sixel is already in the cell grid, so clearRenderedImage writes literal spaces over the cells it occupied. Those spaces are ordinary characters and carry whatever SGR the TUI last set, and nothing normalised it, so every erase stamped a filled block in the ambient background. A drag erases and repaints on every pixel, so the block was continuously refreshed over the UI. It stayed invisible everywhere else because ghostty, kitty, WezTerm and iTerm all resolve to the Kitty protocol and delete by id, never reaching this code. Windows Terminal is the only mainstream terminal that lands on the sixel branch, which is why testing on Linux and macOS could not have caught it. The erase now resets SGR before painting, and uses DECSC/DECRC rather than CSI s/u because only DECSC saves the attributes this clobbers. Restoring them matters: the erase is emitted mid-frame, between styled writes the TUI has already begun. Measured before and after rather than reasoned about. Before: \x1b[s\x1b[3;41H \x1b[4;41H \x1b[5;41H \x1b[u no reset anywhere. After, the reset leads and DECRC closes. Tests assert the bytes, since bytes are what the terminal sees: SGR is reset BEFORE the first row is painted rather than merely present somewhere, the erase still covers every claimed cell so the fix cannot be satisfied by erasing nothing, and the Kitty path still deletes by id rather than acquiring the character-painting erase. Verified by mutation: restoring the old sequence fails the first of those with the exact reported symptom. Deliberately not fixed here, since both are design calls rather than defects: the erase rectangle is computed from the app's own cell maths rather than the terminal's real footprint, and the sixel path has no in-place move, so every drag pixel is a full erase and repaint where Kitty gets a cheap reposition. * fix(tui): ask sixel terminals for their cell size and erase what was painted The companion blanks a row and a column of live interface on Windows Terminal. The cause is one predicate. petPixelProtocolSupported gates two unrelated questions that only looked like one: whether pixel-precise dragging is available, and whether to ask the terminal for its cell size with CSI 16 t. It answers true for Kitty only, so on a sixel terminal the request was never sent, the reply never arrived, and petCellPixelWidth/Height stayed zero for the whole session. Everything downstream then took its fallback. petImageHeightPixels returned its blind preferredHeight of 75 pixels instead of the cell-aware branch below it, and the erase in clearRenderedImage used petImageColumns and petImageRows. Those constants describe the RESERVED area, not what was painted. Windows Terminal reports a 20x10 cell, so a 75-pixel sprite covers 4 rows and about 8 columns while the erase blanked 5 by 9, taking a row and a column of interface with it on every move. A drag repaints per pixel, so it did that continuously. Windows Terminal answers CSI 16 t, confirmed by hand: it replies ESC [6;20;10t. This was never a question of terminal support. Nothing asked. So the request now goes out for every image protocol, via a predicate separate from the dragging one, and the sixel footprint is computed from the rendered pixel height and the reported cell size rather than from the constants. It is still clamped to the reserved area, because the layout keeps that many cells clear and the drag clamps to it, so a larger computed footprint would mean erasing into live interface. Deliberately NOT widening petPixelProtocolSupported itself: that would enable pixel-precise dragging on sixel as a side effect, which is a separate feature decision rather than part of this defect. Kitty is untouched and a test pins that. There Columns and Rows are the placement REQUEST handed to the terminal, which owns the region and scales into it, so recomputing them would resize the image rather than fix an erase. Tests use the real 20x10 measurement: the sixel footprint is 4 rows not 5, Kitty keeps its requested placement, the fallback without metrics stays the reserved area rather than collapsing to zero and erasing nothing, and every image protocol asks for the measurement. * Keep Sixel companions in the reserved dock --------- Co-authored-by: Vasanthdev2004 <[email protected]>
295 lines
8.1 KiB
Go
295 lines
8.1 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
)
|
|
|
|
type jsonObjectSpan struct {
|
|
start int
|
|
end int
|
|
members []jsonMemberSpan
|
|
}
|
|
|
|
type jsonMemberSpan struct {
|
|
key string
|
|
leadingStart int
|
|
keyStart int
|
|
valueStart int
|
|
valueEnd int
|
|
commaAfter int
|
|
}
|
|
|
|
// setPetPreferenceJSON changes only preferences.pet. It keeps the original
|
|
// bytes for every unrelated member so choosing a companion does not reorder or
|
|
// reformat the user's configuration file.
|
|
func setPetPreferenceJSON(data []byte, pet string) ([]byte, error) {
|
|
rootStart := skipJSONSpace(data, 0)
|
|
root, err := parseJSONObject(data, rootStart)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
preferencesIndex := lastJSONMember(root.members, "preferences")
|
|
if preferencesIndex < 0 {
|
|
if pet == "" {
|
|
return data, nil
|
|
}
|
|
encodedPet, err := json.Marshal(pet)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode pet preference: %w", err)
|
|
}
|
|
return insertJSONMember(data, root, "preferences", petJSONObject(encodedPet)), nil
|
|
}
|
|
|
|
preferenceMember := root.members[preferencesIndex]
|
|
preferenceValue := bytes.TrimSpace(data[preferenceMember.valueStart:preferenceMember.valueEnd])
|
|
if bytes.Equal(preferenceValue, []byte("null")) {
|
|
if pet == "" {
|
|
if lastJSONMember(root.members[:preferencesIndex], "preferences") >= 0 {
|
|
return data, nil
|
|
}
|
|
return removeJSONMember(data, root, preferencesIndex), nil
|
|
}
|
|
encodedPet, err := json.Marshal(pet)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode pet preference: %w", err)
|
|
}
|
|
return replaceJSONRange(data, preferenceMember.valueStart, preferenceMember.valueEnd, petJSONObject(encodedPet)), nil
|
|
}
|
|
preferences, err := parseJSONObject(data, preferenceMember.valueStart)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("preferences must be a JSON object: %w", err)
|
|
}
|
|
petIndex := lastJSONMember(preferences.members, "pet")
|
|
if pet != "" {
|
|
encodedPet, err := json.Marshal(pet)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode pet preference: %w", err)
|
|
}
|
|
if petIndex >= 0 {
|
|
member := preferences.members[petIndex]
|
|
return replaceJSONRange(data, member.valueStart, member.valueEnd, encodedPet), nil
|
|
}
|
|
return insertJSONMember(data, preferences, "pet", encodedPet), nil
|
|
}
|
|
|
|
for petIndex >= 0 {
|
|
data = removeJSONMember(data, preferences, petIndex)
|
|
root, err = parseJSONObject(data, rootStart)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
preferencesIndex = lastJSONMember(root.members, "preferences")
|
|
if preferencesIndex < 0 {
|
|
return data, nil
|
|
}
|
|
preferenceMember = root.members[preferencesIndex]
|
|
preferences, err = parseJSONObject(data, preferenceMember.valueStart)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
petIndex = lastJSONMember(preferences.members, "pet")
|
|
}
|
|
if len(preferences.members) == 0 {
|
|
if lastJSONMember(root.members[:preferencesIndex], "preferences") >= 0 {
|
|
return data, nil
|
|
}
|
|
return removeJSONMember(data, root, preferencesIndex), nil
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func parseJSONObject(data []byte, start int) (jsonObjectSpan, error) {
|
|
if start < 0 || start >= len(data) || data[start] != '{' {
|
|
return jsonObjectSpan{}, fmt.Errorf("expected JSON object")
|
|
}
|
|
object := jsonObjectSpan{start: start}
|
|
leadingStart := start + 1
|
|
for {
|
|
i := skipJSONSpace(data, leadingStart)
|
|
if i >= len(data) {
|
|
return jsonObjectSpan{}, fmt.Errorf("unterminated JSON object")
|
|
}
|
|
if data[i] == '}' {
|
|
object.end = i
|
|
return object, nil
|
|
}
|
|
if data[i] != '"' {
|
|
return jsonObjectSpan{}, fmt.Errorf("expected JSON object key")
|
|
}
|
|
keyStart := i
|
|
keyEnd, err := scanJSONString(data, i)
|
|
if err != nil {
|
|
return jsonObjectSpan{}, err
|
|
}
|
|
var key string
|
|
if err := json.Unmarshal(data[keyStart:keyEnd], &key); err != nil {
|
|
return jsonObjectSpan{}, err
|
|
}
|
|
i = skipJSONSpace(data, keyEnd)
|
|
if i >= len(data) || data[i] != ':' {
|
|
return jsonObjectSpan{}, fmt.Errorf("expected colon after JSON object key")
|
|
}
|
|
valueStart := skipJSONSpace(data, i+1)
|
|
valueEnd, err := scanJSONValue(data, valueStart)
|
|
if err != nil {
|
|
return jsonObjectSpan{}, err
|
|
}
|
|
i = skipJSONSpace(data, valueEnd)
|
|
member := jsonMemberSpan{
|
|
key: key,
|
|
leadingStart: leadingStart,
|
|
keyStart: keyStart,
|
|
valueStart: valueStart,
|
|
valueEnd: valueEnd,
|
|
commaAfter: -1,
|
|
}
|
|
if i < len(data) && data[i] == ',' {
|
|
member.commaAfter = i
|
|
object.members = append(object.members, member)
|
|
leadingStart = i + 1
|
|
continue
|
|
}
|
|
if i >= len(data) || data[i] != '}' {
|
|
return jsonObjectSpan{}, fmt.Errorf("expected comma or end of JSON object")
|
|
}
|
|
object.members = append(object.members, member)
|
|
object.end = i
|
|
return object, nil
|
|
}
|
|
}
|
|
|
|
func scanJSONString(data []byte, start int) (int, error) {
|
|
for i := start + 1; i < len(data); i++ {
|
|
switch data[i] {
|
|
case '\\':
|
|
i++
|
|
case '"':
|
|
return i + 1, nil
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("unterminated JSON string")
|
|
}
|
|
|
|
func scanJSONValue(data []byte, start int) (int, error) {
|
|
if start >= len(data) {
|
|
return 0, fmt.Errorf("missing JSON value")
|
|
}
|
|
if data[start] == '"' {
|
|
return scanJSONString(data, start)
|
|
}
|
|
if data[start] == '{' || data[start] == '[' {
|
|
stack := []byte{data[start]}
|
|
for i := start + 1; i < len(data); i++ {
|
|
if data[i] == '"' {
|
|
end, err := scanJSONString(data, i)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
i = end - 1
|
|
continue
|
|
}
|
|
switch data[i] {
|
|
case '{', '[':
|
|
stack = append(stack, data[i])
|
|
case '}', ']':
|
|
open := stack[len(stack)-1]
|
|
if (open == '{' && data[i] != '}') || (open == '[' && data[i] != ']') {
|
|
return 0, fmt.Errorf("mismatched JSON delimiter")
|
|
}
|
|
stack = stack[:len(stack)-1]
|
|
if len(stack) == 0 {
|
|
return i + 1, nil
|
|
}
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("unterminated JSON value")
|
|
}
|
|
for i := start; i < len(data); i++ {
|
|
switch data[i] {
|
|
case ',', '}', ']', ' ', '\t', '\r', '\n':
|
|
if i == start {
|
|
return 0, fmt.Errorf("missing JSON value")
|
|
}
|
|
return i, nil
|
|
}
|
|
}
|
|
return len(data), nil
|
|
}
|
|
|
|
func skipJSONSpace(data []byte, start int) int {
|
|
for start < len(data) {
|
|
switch data[start] {
|
|
case ' ', '\t', '\r', '\n':
|
|
start++
|
|
default:
|
|
return start
|
|
}
|
|
}
|
|
return start
|
|
}
|
|
|
|
func lastJSONMember(members []jsonMemberSpan, key string) int {
|
|
for i := len(members) - 1; i >= 0; i-- {
|
|
if members[i].key == key {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func petJSONObject(encodedPet []byte) []byte {
|
|
result := append([]byte(`{"pet":`), encodedPet...)
|
|
return append(result, '}')
|
|
}
|
|
|
|
func replaceJSONRange(data []byte, start, end int, replacement []byte) []byte {
|
|
result := make([]byte, 0, len(data)-(end-start)+len(replacement))
|
|
result = append(result, data[:start]...)
|
|
result = append(result, replacement...)
|
|
return append(result, data[end:]...)
|
|
}
|
|
|
|
func removeJSONMember(data []byte, object jsonObjectSpan, index int) []byte {
|
|
member := object.members[index]
|
|
start, end := member.leadingStart, member.valueEnd
|
|
if member.commaAfter >= 0 {
|
|
end = member.commaAfter + 1
|
|
} else if index > 0 {
|
|
start = object.members[index-1].commaAfter
|
|
}
|
|
return replaceJSONRange(data, start, end, nil)
|
|
}
|
|
|
|
func insertJSONMember(data []byte, object jsonObjectSpan, key string, value []byte) []byte {
|
|
encodedKey, _ := json.Marshal(key)
|
|
entry := append(append(encodedKey, ':', ' '), value...)
|
|
if len(object.members) == 0 {
|
|
interior := data[object.start+1 : object.end]
|
|
if newline := bytes.LastIndexByte(interior, '\n'); newline >= 0 {
|
|
closingIndent := interior[newline+1:]
|
|
indentUnit := []byte(" ")
|
|
if bytes.Contains(closingIndent, []byte("\t")) {
|
|
indentUnit = []byte("\t")
|
|
}
|
|
lineBreak := []byte("\n")
|
|
if newline > 0 && interior[newline-1] == '\r' {
|
|
lineBreak = []byte("\r\n")
|
|
}
|
|
insert := append(append(append(lineBreak, closingIndent...), indentUnit...), entry...)
|
|
return replaceJSONRange(data, object.start+1, object.start+1, insert)
|
|
}
|
|
return replaceJSONRange(data, object.end, object.end, entry)
|
|
}
|
|
last := object.members[len(object.members)-1]
|
|
separator := []byte(", ")
|
|
if bytes.Contains(data[last.valueEnd:object.end], []byte("\n")) {
|
|
lineStart := bytes.LastIndexByte(data[:last.keyStart], '\n') + 1
|
|
indent := data[lineStart:last.keyStart]
|
|
separator = append([]byte(",\n"), indent...)
|
|
}
|
|
insert := append(separator, entry...)
|
|
return replaceJSONRange(data, last.valueEnd, last.valueEnd, insert)
|
|
}
|