package tui import ( "errors" "strings" "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" "github.com/johannSo/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()) }