diff --git a/kitty/cell_size.go b/kitty/cell_size.go new file mode 100644 index 0000000..b328bae --- /dev/null +++ b/kitty/cell_size.go @@ -0,0 +1,94 @@ +package kitty + +import ( + "log" + "os" + "regexp" + "strconv" + "time" + + "golang.org/x/term" +) + +const ( + defaultCellWidth = 8 + defaultCellHeight = 16 +) + +// getCellSize attempts to query the terminal for cell size in pixels. +// Uses CSI 16 t: "Report xterm window character cell size in pixels" -> CSI 6 ; height ; width t +// Returns default values on error. +func getCellSize() (width, height int) { + // Use defaults initially + width = defaultCellWidth + height = defaultCellHeight + + query := "\033[16t" + // Use stdin for raw mode check/restore, stdout for writing query + stdinFd := int(os.Stdin.Fd()) + stdoutFd := int(os.Stdout.Fd()) + + // Check if stdin/stdout are terminals + if !term.IsTerminal(stdinFd) || !term.IsTerminal(stdoutFd) { + log.Printf("Warning: Cannot query cell size: stdin/stdout not a terminal.") + return + } + + state, err := term.MakeRaw(stdinFd) + if err != nil { + log.Printf("Warning: Cannot query cell size: failed to enter raw mode: %v", err) + return + } + defer term.Restore(stdinFd, state) + + // Write query to stdout + _, err = os.Stdout.Write([]byte(query)) + if err != nil { + log.Printf("Warning: Cannot query cell size: failed to write query: %v", err) + return + } + + // Read response from stdin with timeout + responseChan := make(chan string) + readErrChan := make(chan error) + go func() { + var buf [64]byte // Buffer for response + n, readErr := os.Stdin.Read(buf[:]) + if readErr != nil { + readErrChan <- readErr + } else if n > 0 { + responseChan <- string(buf[:n]) + } else { + close(responseChan) // Should not happen? + } + }() + + var response string + select { + case resp := <-responseChan: + response = resp + case err = <-readErrChan: + log.Printf("Warning: Cannot query cell size: failed to read response: %v", err) + return + case <-time.After(150 * time.Millisecond): // Increased timeout slightly + log.Printf("Warning: Cannot query cell size: timeout waiting for response.") + return + } + + // Parse response: \033[6;;t + re := regexp.MustCompile(`\033\[6;(\d+);(\d+)t`) + matches := re.FindStringSubmatch(response) + + if len(matches) == 3 { + h, e1 := strconv.Atoi(matches[1]) + w, e2 := strconv.Atoi(matches[2]) + if e1 == nil && e2 == nil && h > 0 && w > 0 { + width = w + height = h + return + } + } + + log.Printf("Warning: Cannot query cell size: failed to parse response: %q", response) + return +} diff --git a/kitty/check.go b/kitty/check.go new file mode 100644 index 0000000..ef49f04 --- /dev/null +++ b/kitty/check.go @@ -0,0 +1,74 @@ +package kitty + +import ( + "fmt" + "os" + "strings" + "time" + + "golang.org/x/term" +) + +// CheckKittyGraphicsProtocol checks for Kitty graphics protocol support using the query method. +func CheckKittyGraphicsProtocol() bool { + // Use a unique ID for the query, e.g., based on process ID or random + queryID := uint32(os.Getpid() & 0xFFFFFFFF) // Example ID + if queryID == 0 { + queryID = 1 // Ensure non-zero ID + } + // Graphics query command (dummy 1x1 RGB pixel) + // https://sw.kovidgoyal.net/kitty/graphics-protocol/#querying-support-and-available-transmission-mediums + graphicsQuery := fmt.Sprintf("\033_Ga=q,i=%d,s=1,v=1,t=d,f=24;AAAA\033\\", queryID) + + // Need raw mode to send/receive control sequences without shell interference + fd := int(os.Stdout.Fd()) + state, err := term.MakeRaw(fd) + if err != nil { + return false // Cannot enter raw mode + } + defer term.Restore(fd, state) + + // Wrap graphics query for tmux if necessary + isTmux := os.Getenv("TMUX") != "" + if isTmux { + escapedQuery := strings.ReplaceAll(graphicsQuery, "\033", "\033\033") + graphicsQuery = fmt.Sprintf("\033Ptmux;%s\033\\", escapedQuery) + } + + // Write only the graphics query + _, err = os.Stdout.Write([]byte(graphicsQuery)) + if err != nil { + return false + } + os.Stdout.Sync() + + // Read response with timeout + responseChan := make(chan string) + go func() { + var buf [256]byte + n, readErr := os.Stdout.Read(buf[:]) + if readErr == nil && n > 0 { + responseChan <- string(buf[:n]) + } else { + close(responseChan) // Signal no response or error + } + }() + + var response string + select { + case resp, ok := <-responseChan: + if ok { + response = resp + } + case <-time.After(10 * time.Millisecond): + } + + // Check if the response is the graphics protocol ACK + // Expected format: \033_Gi=;OK\033\ (or an error message) + expectedGraphicsPrefix := fmt.Sprintf("\033_Gi=%d;", queryID) + if strings.HasPrefix(response, expectedGraphicsPrefix) && strings.HasSuffix(response, "\033\\") { + return true // Got a graphics response, assume support + } + // If we didn't get the graphics response, assume no support + return false +} diff --git a/kitty/diacrit_map.go b/kitty/diacrit_map.go new file mode 100644 index 0000000..4f09aca --- /dev/null +++ b/kitty/diacrit_map.go @@ -0,0 +1,45 @@ +package kitty + +// We only need the first 256 for row/col/id encoding. +var diacriticMap = [256]string{ + "\u0305", "\u030d", "\u030e", "\u0310", "\u0312", "\u033d", "\u033e", "\u033f", // 0-7 + "\u0346", "\u034a", "\u034b", "\u034c", "\u0350", "\u0351", "\u0352", "\u0357", // 8-15 + "\u035b", "\u0363", "\u0364", "\u0365", "\u0366", "\u0367", "\u0368", "\u0369", // 16-23 + "\u036a", "\u036b", "\u036c", "\u036d", "\u036e", "\u036f", "\u0483", "\u0484", // 24-31 + "\u0485", "\u0486", "\u0487", "\u0592", "\u0593", "\u0594", "\u0595", "\u0597", // 32-39 + "\u0598", "\u0599", "\u059c", "\u059d", "\u059e", "\u059f", "\u05a0", "\u05a1", // 40-47 + "\u05a8", "\u05a9", "\u05ab", "\u05ac", "\u05af", "\u05c4", "\u0610", "\u0611", // 48-55 + "\u0612", "\u0613", "\u0614", "\u0615", "\u0616", "\u0617", "\u0657", "\u0658", // 56-63 + "\u0659", "\u065a", "\u065b", "\u065d", "\u065e", "\u06d6", "\u06d7", "\u06d8", // 64-71 + "\u06d9", "\u06da", "\u06db", "\u06dc", "\u06df", "\u06e0", "\u06e1", "\u06e2", // 72-79 + "\u06e4", "\u06e7", "\u06e8", "\u06eb", "\u06ec", "\u0730", "\u0732", "\u0733", // 80-87 + "\u0735", "\u0736", "\u073a", "\u073d", "\u073f", "\u0740", "\u0741", "\u0743", // 88-95 + "\u0745", "\u0747", "\u0749", "\u074a", "\u07eb", "\u07ec", "\u07ed", "\u07ee", // 96-103 + "\u07ef", "\u07f0", "\u07f1", "\u07f3", "\u0816", "\u0817", "\u0818", "\u0819", // 104-111 + "\u081b", "\u081c", "\u081d", "\u081e", "\u081f", "\u0820", "\u0821", "\u0822", // 112-119 + "\u0823", "\u0825", "\u0826", "\u0827", "\u0829", "\u082a", "\u082b", "\u082c", // 120-127 + "\u082d", "\u0951", "\u0953", "\u0954", "\u0f82", "\u0f83", "\u0f86", "\u0f87", // 128-135 + "\u135d", "\u135e", "\u135f", "\u17dd", "\u193a", "\u1a17", "\u1a75", "\u1a76", // 136-143 + "\u1a77", "\u1a78", "\u1a79", "\u1a7a", "\u1a7b", "\u1a7c", "\u1b6b", "\u1b6d", // 144-151 + "\u1b6e", "\u1b6f", "\u1b70", "\u1b71", "\u1b72", "\u1b73", "\u1cd0", "\u1cd1", // 152-159 + "\u1cd2", "\u1cda", "\u1cdb", "\u1ce0", "\u1dc0", "\u1dc1", "\u1dc3", "\u1dc4", // 160-167 + "\u1dc5", "\u1dc6", "\u1dc7", "\u1dc8", "\u1dc9", "\u1dcb", "\u1dcc", "\u1dd1", // 168-175 + "\u1dd2", "\u1dd3", "\u1dd4", "\u1dd5", "\u1dd6", "\u1dd7", "\u1dd8", "\u1dd9", // 176-183 + "\u1dda", "\u1ddb", "\u1ddc", "\u1ddd", "\u1dde", "\u1ddf", "\u1de0", "\u1de1", // 184-191 + "\u1de2", "\u1de3", "\u1de4", "\u1de5", "\u1de6", "\u1dfe", "\u20d0", "\u20d1", // 192-199 + "\u20d4", "\u20d5", "\u20d6", "\u20d7", "\u20db", "\u20dc", "\u20e1", "\u20e7", // 200-207 + "\u20e9", "\u20f0", "\u2cef", "\u2cf0", "\u2cf1", "\u2de0", "\u2de1", "\u2de2", // 208-215 + "\u2de3", "\u2de4", "\u2de5", "\u2de6", "\u2de7", "\u2de8", "\u2de9", "\u2dea", // 216-223 + "\u2deb", "\u2dec", "\u2ded", "\u2dee", "\u2def", "\u2df0", "\u2df1", "\u2df2", // 224-231 + "\u2df3", "\u2df4", "\u2df5", "\u2df6", "\u2df7", "\u2df8", "\u2df9", "\u2dfa", // 232-239 + "\u2dfb", "\u2dfc", "\u2dfd", "\u2dfe", "\u2dff", "\ua66f", "\ua67c", "\ua67d", // 240-247 + "\ua6f0", "\ua6f1", "\ua8e0", "\ua8e1", "\ua8e2", "\ua8e3", "\ua8e4", "\ua8e5", // 248-255 +} + +// getDiacritic returns the combining character for a given number 0-255. +func getDiacritic(num int) string { + if num >= 0 && num < len(diacriticMap) { + return diacriticMap[num] + } + return "" // Or return a default/error indicator +} diff --git a/kitty/kitty.go b/kitty/kitty.go index 1792865..0399b54 100644 --- a/kitty/kitty.go +++ b/kitty/kitty.go @@ -8,23 +8,72 @@ import ( "image/png" "io" "math" + "math/rand" + "os" "strings" + "time" "github.com/disintegration/imaging" ) -// Encoder encode image to sixel format +// KittyMode defines the rendering mode for the Kitty terminal. +type KittyMode int + +const ( + // KittyModeNormal uses the standard Kitty graphics protocol. + KittyModeNormal KittyMode = iota + // KittyModeUnicodePlaceholder uses the Unicode placeholder method (for tmux compatibility). + KittyModeUnicodePlaceholder +) + +// Encoder encodes image for Kitty terminal. type Encoder struct { w io.Writer - Width int - Height int + Mode KittyMode + Width int // Optional: force width in pixels + Height int // Optional: force height in pixels + + // For placeholder mode + randSource *rand.Rand + isTmux bool // Flag to indicate if running under tmux + // Store actual or default cell dimensions + cellWidth int + cellHeight int } -// NewEncoder return new instance of Encoder -func NewEncoder(w io.Writer) *Encoder { - return &Encoder{w: w} +// NewEncoder returns a new Kitty encoder. +func NewEncoder(w io.Writer, mode KittyMode) *Encoder { + src := rand.NewSource(time.Now().UnixNano()) + isTmux := os.Getenv("TMUX") != "" + cellW, cellH := getCellSize() + return &Encoder{ + w: w, + Mode: mode, + randSource: rand.New(src), + isTmux: isTmux, + cellWidth: cellW, + cellHeight: cellH, + } } +// wrapForTmux wraps a given escape sequence for tmux passthrough. +func (e *Encoder) wrapForTmux(sequence string) string { + if !e.isTmux { + return sequence + } + // Escape internal ESC characters + escapedSequence := strings.ReplaceAll(sequence, "\033", "\033\033") + // Wrap in tmux DCS sequence + return fmt.Sprintf("\033Ptmux;%s\033\\", escapedSequence) +} + +// writeSequence writes the sequence, wrapping for tmux if necessary. +func (e *Encoder) writeSequence(sequence string) (int, error) { + wrappedSequence := e.wrapForTmux(sequence) + return e.w.Write([]byte(wrappedSequence)) +} + +// Encode encodes image to Kitty graphics protocol escape sequences. func (e *Encoder) Encode(img image.Image) error { width, height := img.Bounds().Dx(), img.Bounds().Dy() if width == 0 || height == 0 { @@ -47,12 +96,23 @@ func (e *Encoder) Encode(img image.Image) error { if e.Height != 0 { height = e.Height } + var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { return err } - b64data := base64.StdEncoding.EncodeToString(buf.Bytes()) + + switch e.Mode { + case KittyModeUnicodePlaceholder: + return e.encodeUnicodePlaceholder(b64data, width, height) + default: // KittyModeNormal + return e.encodeNormal(b64data, width, height) + } +} + +// encodeNormal sends the image using the standard Kitty graphics protocol. +func (e *Encoder) encodeNormal(b64data string, width, height int) error { chunk_size := 4096 var builder strings.Builder for i := 0; i < int(math.Ceil(float64(len(b64data))/float64(chunk_size))); i++ { @@ -70,3 +130,88 @@ func (e *Encoder) Encode(img image.Image) error { e.w.Write([]byte(builder.String())) return nil } + +// encodeUnicodePlaceholder sends the image using the Unicode placeholder method. +func (e *Encoder) encodeUnicodePlaceholder(b64data string, width, height int) error { + // 1. Generate Random 32-bit ID + imgID := uint32(0) + for imgID == 0 { + imgID = e.randSource.Uint32() + } + + // 2. Calculate Cell Dimensions using provided or default cell size + cols := int(math.Ceil(float64(width) / float64(e.cellWidth))) + rows := int(math.Ceil(float64(height) / float64(e.cellHeight))) + if cols == 0 { + cols = 1 + } + if rows == 0 { + rows = 1 + } + + // Check if image is too large for diacritic encoding (max 256 rows/cols) + if rows > 256 || cols > 256 { + return fmt.Errorf("image too large for Unicode placeholder (%dx%d cells, max 256x256)", cols, rows) + } + + // 3. Transfer Image Data and Set Virtual Placement in the *first* chunk command + chunk_size := 4096 + var transferSequence strings.Builder // Build sequence before potential wrapping + + for i := 0; i < int(math.Ceil(float64(len(b64data))/float64(chunk_size))); i++ { + chunk := b64data[i*chunk_size : min((i+1)*chunk_size, len(b64data))] + more := 0 + if (i+1)*chunk_size < len(b64data) { + more = 1 + } + + // Build the APC sequence for this chunk + var chunkBuilder strings.Builder + if i == 0 { + // First chunk: Combine a=T, U=1, and placement parameters (c, r) + chunkBuilder.WriteString(fmt.Sprintf( + "\033_Ga=T,U=1,q=2,i=%d,c=%d,r=%d,f=100,s=%d,v=%d,m=%d;", + imgID, cols, rows, width, height, more)) + } else { + // Subsequent chunks only need m=... + chunkBuilder.WriteString(fmt.Sprintf("\033_Gm=%d;", more)) + } + chunkBuilder.WriteString(chunk) + chunkBuilder.WriteString("\033\\") + + // Append the potentially wrapped chunk sequence + transferSequence.WriteString(e.wrapForTmux(chunkBuilder.String())) + } + // Write the complete (potentially wrapped) transfer/placement sequence + if _, err := e.w.Write([]byte(transferSequence.String())); err != nil { + return fmt.Errorf("failed to transfer image data/placement: %w", err) + } + + // 4. Output Placeholder Grid + var placeholderGrid strings.Builder + r_id := (imgID >> 16) & 0xFF + g_id := (imgID >> 8) & 0xFF + b_id := imgID & 0xFF + sgrColor := fmt.Sprintf("\033[38;2;%d;%d;%dm", r_id, g_id, b_id) + placeholderChar := "\U0010EEEE" + idMsbDiacritic := getDiacritic(int((imgID >> 24) & 0xFF)) + + for row := 0; row < rows; row++ { + placeholderGrid.WriteString(sgrColor) + rowDiacritic := getDiacritic(row) + for col := 0; col < cols; col++ { + colDiacritic := getDiacritic(col) + placeholderGrid.WriteString(placeholderChar) + placeholderGrid.WriteString(rowDiacritic) + placeholderGrid.WriteString(colDiacritic) + placeholderGrid.WriteString(idMsbDiacritic) + } + placeholderGrid.WriteString("\033[39m") + // Restore newline after every row + placeholderGrid.WriteString("\n") + } + + // Write the placeholder grid directly (NOT wrapped for tmux) + _, err := e.w.Write([]byte(placeholderGrid.String())) + return err +} diff --git a/main.go b/main.go index a9fa793..b40f65c 100644 --- a/main.go +++ b/main.go @@ -204,13 +204,16 @@ func checkKitty() bool { if os.Getenv("TERM_PROGRAM") == "ghostty" { return true } - return strings.HasPrefix(getDA2(), "\x1b[>1;4000;") // \x1b[>1;{major+4000};{minor}c + // \x1b[>1;{major+4000};{minor}c + if strings.HasPrefix(getDA2(), "\x1b[>1;4000;") { + return true + } + return kitty.CheckKittyGraphicsProtocol() } func checkExtraterm() bool { return os.Getenv("LC_EXTRATERM_COOKIE") != "" } - func check8BitColor() bool { if os.Getenv("TERM_PROGRAM") == "Apple_Terminal" { // Terminal.app return true @@ -413,7 +416,11 @@ func main() { } else if checkIterm() { enc = iterm.NewEncoder(&buf) } else if checkKitty() { - enc = kitty.NewEncoder(&buf) + kittyMode := kitty.KittyModeNormal + if os.Getenv("TMUX") != "" { + kittyMode = kitty.KittyModeUnicodePlaceholder + } + enc = kitty.NewEncoder(&buf, kittyMode) } else if checkSixel() { enc = sixel.NewEncoder(&buf) } else if checkExtraterm() {