package tui
import (
"errors"
"strings"
"github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea"
"codey/internal/attach"
)
// Pasting a picture into a terminal is not one gesture but three, because no
// terminal sends image bytes to the program inside it:
//
// - ctrl-V reaches here on terminals that do not eat it (most Linux ones,
// where the paste key is ctrl-shift-V). The clipboard is then read
// directly, behind the keystroke.
// - A terminal that does eat ctrl-V pastes the clipboard's text form, which
// for a screenshot is nothing at all. An empty paste is therefore taken as
// a paste attempt too, and answered the same way.
// - Dragging a file in, or copying one in a file manager, pastes its path.
// A path ending in an image extension that is really on disk is read.
//
// What goes in the prompt box is [image 1]. The picture travels beside the
// text, and the marker is where it will sit in the message.
// imageMsg carries the result of a clipboard grab back to the UI. quiet marks
// the attempts that were only a guess — an empty paste, which is usually just
// an empty clipboard — so a miss passes without a word. text is the clipboard's
// words, fetched only when a miss should fall back to pasting them.
type imageMsg struct {
im attach.Image
text string
err error
quiet bool
}
// grabImage reads the clipboard off the UI goroutine. The helpers it shells
// out to can take a moment, and a prompt box that stops taking keys while the
// clipboard is consulted would be worse than no feature.
//
// withText asks for the clipboard's text as well, for the case where there is
// no picture on it. That is the fallback behind ctrl-V: the key means paste,
// and it should paste whatever is there.
func (m *model) grabImage(quiet, withText bool) tea.Cmd {
grab := m.grabFn
if grab == nil {
grab = attach.Clipboard
}
readText := m.textFn
if readText == nil {
readText = clipboard.ReadAll
}
return func() tea.Msg {
im, err := grab()
if err == nil || !withText {
return imageMsg{im: im, err: err, quiet: quiet}
}
// No picture. Whatever words are there are what the key was for.
text, textErr := readText()
if textErr != nil || text == "" {
return imageMsg{err: err, quiet: quiet}
}
return imageMsg{text: text, quiet: quiet}
}
}
// attachImage puts an image on the pending set and its marker in the prompt.
func (m *model) attachImage(im attach.Image) tea.Cmd {
n := m.imgs.Add(im)
marker := attach.Marker(n)
// A marker needs air around it: run into the words on either side it stops
// looking like one thing.
if v := m.input.Value(); v != "" && !strings.HasSuffix(v, " ") && !strings.HasSuffix(v, "\n") {
marker = " " + marker
}
m.input.InsertString(marker + " ")
m.growInput()
m.copied = "🖼 " + attach.Marker(n) + " attached"
if im.Origin != "" && im.Origin != "clipboard" {
m.copied += " from " + truncate(im.Origin, 24)
}
return nil
}
// onImage handles a finished grab.
func (m *model) onImage(msg imageMsg) tea.Cmd {
if msg.text != "" {
// No picture, but words: ctrl-V doing what it says on terminals that
// let the key through to us at all.
m.input.InsertString(msg.text)
m.growInput()
m.refreshMenu()
return nil
}
if msg.err != nil {
if msg.quiet || errors.Is(msg.err, attach.ErrNoImage) {
// Nothing on the clipboard worth having. The keystroke did what
// it always did; there is nothing to report.
return nil
}
m.tr.addError("could not paste the image: " + msg.err.Error())
return m.sync()
}
return m.attachImage(msg.im)
}
// onPasteKey looks at a paste before the prompt box gets it, and reports
// whether it took it. Ordinary pasted text is left alone.
func (m *model) onPasteKey(msg tea.KeyMsg) (tea.Cmd, bool) {
if !msg.Paste {
return nil, false
}
text := string(msg.Runes)
if strings.TrimSpace(text) == "" {
// The terminal answered a paste with nothing. Either the clipboard is
// empty or it holds something with no text form — an image, most
// likely, which is exactly the case this feature exists for.
return m.grabImage(true, false), true
}
if path, ok := attach.Path(text); ok {
im, err := attach.FromFile(path)
if err != nil {
// A dropped file that cannot be read as an image is still a path
// somebody meant to put in the box. Say why it did not become a
// picture, and let the text through as text.
m.tr.addError("pasted " + truncate(path, 40) + ": " + err.Error())
return m.sync(), false
}
return m.attachImage(im), true
}
return nil, false
}
// attachPath answers /image. Typing the path is the way that works on every
// terminal, including the ones that will not pass ctrl-V through and have no
// clipboard helper installed.
func (m *model) attachPath(path string) tea.Cmd {
path = strings.TrimSpace(path)
if path == "" {
// Bare /image means "take what is on the clipboard", which is the
// same thing ctrl-V does, said out loud.
return m.grabImage(false, false)
}
p, ok := attach.Path(path)
if !ok {
m.tr.addError("no image at " + truncate(path, 48) + " — want a png, jpeg, gif or webp that exists")
return m.sync()
}
im, err := attach.FromFile(p)
if err != nil {
m.tr.addError(err.Error())
return m.sync()
}
return tea.Batch(m.attachImage(im), m.sync())
}