fixes #561 Add clipboard support.

This is not supported for Windows or WebAssembly yet.
It's possible for applications to post to the clipboard using
Screen.SetClipboard (any data), and they can retrieve the clipboard
(if permitted) using GetClipboard.  The terminal may well reject either
of these.

Retrieval will arrive as a new EventClipboard, if it can.  (There is
no good way to make this synchronous.)

This work was inspired by a PR submitted by Consolatis (#562), and
has some work based on it, but it was also substantially improved and
now includes both sides of the clipboard access pattern.
This commit is contained in:
Garrett D'Amore 2024-03-10 14:03:29 -07:00
parent feef990b56
commit 78110e30f8
6 changed files with 276 additions and 5 deletions

115
_demos/clipboard.go Normal file
View File

@ -0,0 +1,115 @@
//go:build ignore
// +build ignore
// Copyright 2024 The TCell Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use file except in compliance with the License.
// You may obtain a copy of the license at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"fmt"
"os"
"unicode/utf8"
"github.com/gdamore/tcell/v2"
"github.com/gdamore/tcell/v2/encoding"
"github.com/mattn/go-runewidth"
)
func emitStr(s tcell.Screen, x, y int, style tcell.Style, str string) {
for _, c := range str {
var comb []rune
w := runewidth.RuneWidth(c)
if w == 0 {
comb = []rune{c}
c = ' '
w = 1
}
s.SetContent(x, y, c, comb, style)
x += w
}
}
var clipboard []byte
func displayHelloWorld(s tcell.Screen) {
w, h := s.Size()
s.Clear()
style := tcell.StyleDefault.Foreground(tcell.ColorCadetBlue.TrueColor()).Background(tcell.ColorWhite)
emitStr(s, w/2-14, h/2, style, "Press 1 to set clipboard")
emitStr(s, w/2-14, h/2+1, style, "Press 2 to get clipboard")
msg := ""
if utf8.Valid(clipboard) {
cp := string(clipboard)
if len(cp) >= w-25 {
cp = cp[:21] + " ..."
}
msg = fmt.Sprintf("Clipboard (%d bytes): %s", len(clipboard), cp)
} else if clipboard != nil {
msg = fmt.Sprintf("Clipboard (%d bytes) Not Valid UTF-8", len(clipboard))
} else {
msg = "No clipboard data"
}
emitStr(s, (w-len(msg))/2, h/2+3, tcell.StyleDefault, msg)
emitStr(s, w/2-9, h/2+5, tcell.StyleDefault, "Press ESC to exit.")
s.Show()
}
// This program just prints "Hello, World!". Press ESC to exit.
func main() {
encoding.Register()
s, e := tcell.NewScreen()
if e != nil {
fmt.Fprintf(os.Stderr, "%v\n", e)
os.Exit(1)
}
if e := s.Init(); e != nil {
fmt.Fprintf(os.Stderr, "%v\n", e)
os.Exit(1)
}
defStyle := tcell.StyleDefault.
Background(tcell.ColorBlack).
Foreground(tcell.ColorWhite)
s.SetStyle(defStyle)
displayHelloWorld(s)
for {
switch ev := s.PollEvent().(type) {
case *tcell.EventResize:
s.Sync()
displayHelloWorld(s)
case *tcell.EventKey:
switch ev.Key() {
case tcell.KeyRune:
switch ev.Rune() {
case '1':
s.SetClipboard([]byte("Enjoy your new clipboard content!"))
case '2':
s.GetClipboard()
}
case tcell.KeyEscape:
s.Fini()
os.Exit(0)
}
case *tcell.EventClipboard:
clipboard = ev.Data()
displayHelloWorld(s)
}
}
}

View File

@ -1312,6 +1312,12 @@ func (s *cScreen) HasMouse() bool {
return true return true
} }
func (s *cScreen) SetClipboard(_ []byte) {
}
func (s *cScreen) GetClipboard() {
}
func (s *cScreen) Resize(int, int, int, int) {} func (s *cScreen) Resize(int, int, int, int) {}
func (s *cScreen) HasKey(k Key) bool { func (s *cScreen) HasKey(k Key) bool {

View File

@ -1,4 +1,4 @@
// Copyright 2020 The TCell Authors // Copyright 2024 The TCell Authors
// //
// Licensed under the Apache License, Version 2.0 (the "License"); // Licensed under the Apache License, Version 2.0 (the "License");
// you may not use file except in compliance with the License. // you may not use file except in compliance with the License.
@ -19,12 +19,14 @@ import (
) )
// EventPaste is used to mark the start and end of a bracketed paste. // EventPaste is used to mark the start and end of a bracketed paste.
// An event with .Start() true will be sent to mark the start. //
// Then a number of keys will be sent to indicate that the content // An event with .Start() true will be sent to mark the start of a bracketed paste,
// is pasted in. At the end, an event with .Start() false will be sent. // followed by a number of keys (string data) for the content, ending with the
// an event with .End() true.
type EventPaste struct { type EventPaste struct {
start bool start bool
t time.Time t time.Time
data []byte
} }
// When returns the time when this EventPaste was created. // When returns the time when this EventPaste was created.
@ -46,3 +48,25 @@ func (ev *EventPaste) End() bool {
func NewEventPaste(start bool) *EventPaste { func NewEventPaste(start bool) *EventPaste {
return &EventPaste{t: time.Now(), start: start} return &EventPaste{t: time.Now(), start: start}
} }
// NewEventClipboard returns a new NewEventClipboard with a data payload
func NewEventClipboard(data []byte) *EventClipboard {
return &EventClipboard{t: time.Now(), data: data}
}
// EventClipboard represents data from the clipboard,
// in response to a GetClipboard request.
type EventClipboard struct {
t time.Time
data []byte
}
// Data returns the attached binary data.
func (ev *EventClipboard) Data() []byte {
return ev.data
}
// When returns the time when this event was created.
func (ev *EventClipboard) When() time.Time {
return ev.t
}

View File

@ -272,6 +272,17 @@ type Screen interface {
// Tcell may attempt to save and restore the window title on entry and exit, but // Tcell may attempt to save and restore the window title on entry and exit, but
// the results may vary. Use of unicode characters may not be supported. // the results may vary. Use of unicode characters may not be supported.
SetTitle(string) SetTitle(string)
// SetClipboard is used to post arbitrary data to the system clipboard.
// This need not be UTF-8 string data. It's up to the recipient to decode the
// data meaningfully. Terminals may prevent this for security reasons.
SetClipboard([]byte)
// GetClipboard is used to request the clipboard contents. It may be ignored.
// If the terminal is willing, it will be post the clipboard contents using an
// EventPaste with the clipboard content as the Data() field. Terminals may
// prevent this for security reasons.
GetClipboard()
} }
// NewScreen returns a default Screen suitable for the user's terminal // NewScreen returns a default Screen suitable for the user's terminal
@ -343,6 +354,8 @@ type screenImpl interface {
SetSize(int, int) SetSize(int, int)
SetTitle(string) SetTitle(string)
Tty() (Tty, bool) Tty() (Tty, bool)
SetClipboard([]byte)
GetClipboard()
// Following methods are not part of the Screen api, but are used for interaction with // Following methods are not part of the Screen api, but are used for interaction with
// the common layer code. // the common layer code.

View File

@ -61,8 +61,11 @@ type SimulationScreen interface {
// GetCursor returns the cursor details. // GetCursor returns the cursor details.
GetCursor() (x int, y int, visible bool) GetCursor() (x int, y int, visible bool)
// GetTitle gets the set title // GetTitle gets the previously set title.
GetTitle() string GetTitle() string
// GetClipboardData gets the actual data for the clipboard.
GetClipboardData() []byte
} }
// SimCell represents a simulated screen cell. The purpose of this // SimCell represents a simulated screen cell. The purpose of this
@ -102,6 +105,7 @@ type simscreen struct {
fillstyle Style fillstyle Style
fallback map[rune]string fallback map[rune]string
title string title string
clipboard []byte
Screen Screen
sync.Mutex sync.Mutex
@ -507,3 +511,18 @@ func (s *simscreen) SetTitle(title string) {
func (s *simscreen) GetTitle() string { func (s *simscreen) GetTitle() string {
return s.title return s.title
} }
func (s *simscreen) SetClipboard(data []byte) {
s.clipboard = data
}
func (s *simscreen) GetClipboard() {
if s.clipboard != nil {
ev := NewEventClipboard(s.clipboard)
s.postEvent(ev)
}
}
func (s *simscreen) GetClipboardData() []byte {
return s.clipboard
}

View File

@ -19,6 +19,7 @@ package tcell
import ( import (
"bytes" "bytes"
"encoding/base64"
"errors" "errors"
"io" "io"
"os" "os"
@ -175,6 +176,7 @@ type tScreen struct {
saveTitle string saveTitle string
restoreTitle string restoreTitle string
title string title string
setClipboard string
sync.Mutex sync.Mutex
} }
@ -447,7 +449,13 @@ func (t *tScreen) prepareExtendedOSC() {
t.restoreTitle = "\x1b[23;2t" t.restoreTitle = "\x1b[23;2t"
// this also tries to request that UTF-8 is allowed in the title // this also tries to request that UTF-8 is allowed in the title
t.setTitle = "\x1b[>2t\x1b]2;%p1%s\x1b\\" t.setTitle = "\x1b[>2t\x1b]2;%p1%s\x1b\\"
}
if t.setClipboard == "" && t.ti.XTermLike {
// this string takes a base64 string and sends it to the clipboard.
// it will also be able to retrieve the clipboard using "?" as the
// sent string, when we support that.
t.setClipboard = "\x1b]52;c;%p1%s\x1b\\"
} }
} }
@ -499,6 +507,11 @@ func (t *tScreen) prepareKey(key Key, val string) {
func (t *tScreen) prepareKeys() { func (t *tScreen) prepareKeys() {
ti := t.ti ti := t.ti
if strings.HasPrefix(ti.Name, "xterm") {
// assume its some form of XTerm clone
t.ti.XTermLike = true
ti.XTermLike = true
}
t.prepareKey(KeyBackspace, ti.KeyBackspace) t.prepareKey(KeyBackspace, ti.KeyBackspace)
t.prepareKey(KeyF1, ti.KeyF1) t.prepareKey(KeyF1, ti.KeyF1)
t.prepareKey(KeyF2, ti.KeyF2) t.prepareKey(KeyF2, ti.KeyF2)
@ -1499,6 +1512,61 @@ func (t *tScreen) parseFocus(buf *bytes.Buffer, evs *[]Event) (bool, bool) {
return true, false return true, false
} }
func (t *tScreen) parseClipboard(buf *bytes.Buffer, evs *[]Event) (bool, bool) {
b := buf.Bytes()
state := 0
prefix := []byte("\x1b]52;c;")
if len(prefix) >= len(b) {
if bytes.HasPrefix(prefix, b) {
// inconclusive so far
return true, false
}
// definitely not a match
return false, false
}
b = b[len(prefix):]
for _, c := range b {
// valid base64 digits
if (state == 0) {
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || (c == '+') || (c == '/') || (c == '=') {
continue
}
if (c == '\x1b') {
state = 1
continue
}
if (c == '\a') {
// matched with BEL instead of ST
b = b[:len(b)-1] // drop the trailing BEL
decoded := make([]byte, base64.StdEncoding.DecodedLen(len(b)))
if num, err := base64.StdEncoding.Decode(decoded, b); err == nil {
*evs = append(*evs, NewEventClipboard(decoded[:num]))
}
_, _ = buf.ReadBytes('\a')
return true, true
}
return false, false
}
if (state == 1) {
if (c == '\\') {
b = b[:len(b)-2] // drop the trailing ST (\x1b\\)
// now decode the data
decoded := make([]byte, base64.StdEncoding.DecodedLen(len(b)))
if num, err := base64.StdEncoding.Decode(decoded, b); err == nil {
*evs = append(*evs, NewEventClipboard(decoded[:num]))
}
_, _ = buf.ReadBytes('\\')
return true, true
}
return false, false
}
}
// not enough data yet (not terminated)
return true, false
}
// parseXtermMouse is like parseSgrMouse, but it parses a legacy // parseXtermMouse is like parseSgrMouse, but it parses a legacy
// X11 mouse record. // X11 mouse record.
func (t *tScreen) parseXtermMouse(buf *bytes.Buffer, evs *[]Event) (bool, bool) { func (t *tScreen) parseXtermMouse(buf *bytes.Buffer, evs *[]Event) (bool, bool) {
@ -1702,6 +1770,14 @@ func (t *tScreen) collectEventsFromInput(buf *bytes.Buffer, expire bool) []Event
} }
} }
if t.setClipboard != "" {
if part, comp := t.parseClipboard(buf, &res); comp {
continue
} else if part {
partials++
}
}
if partials == 0 || expire { if partials == 0 || expire {
if b[0] == '\x1b' { if b[0] == '\x1b' {
if len(b) == 1 { if len(b) == 1 {
@ -2053,3 +2129,21 @@ func (t *tScreen) SetTitle(title string) {
} }
t.Unlock() t.Unlock()
} }
func (t *tScreen) SetClipboard(data []byte) {
// Post binary data to the system clipboard. It might be UTF-8, it might not be.
t.Lock()
if t.setClipboard != "" {
encoded := base64.StdEncoding.EncodeToString(data)
t.TPuts(t.ti.TParm(t.setClipboard, encoded))
}
t.Unlock()
}
func (t *tScreen) GetClipboard() {
t.Lock()
if t.setClipboard != "" {
t.TPuts(t.ti.TParm(t.setClipboard, "?"))
}
t.Unlock()
}