Skip to content

Commit

Permalink
fixes #561 Add clipboard support.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
gdamore committed Mar 10, 2024
1 parent feef990 commit 78110e3
Show file tree
Hide file tree
Showing 6 changed files with 276 additions and 5 deletions.
115 changes: 115 additions & 0 deletions _demos/clipboard.go
@@ -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)
}
}
}
6 changes: 6 additions & 0 deletions console_win.go
Expand Up @@ -1312,6 +1312,12 @@ func (s *cScreen) HasMouse() bool {
return true
}

func (s *cScreen) SetClipboard(_ []byte) {
}

func (s *cScreen) GetClipboard() {
}

func (s *cScreen) Resize(int, int, int, int) {}

func (s *cScreen) HasKey(k Key) bool {
Expand Down
32 changes: 28 additions & 4 deletions paste.go
@@ -1,4 +1,4 @@
// Copyright 2020 The TCell Authors
// 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.
Expand All @@ -19,12 +19,14 @@ import (
)

// 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
// is pasted in. At the end, an event with .Start() false will be sent.
//
// An event with .Start() true will be sent to mark the start of a bracketed paste,
// followed by a number of keys (string data) for the content, ending with the
// an event with .End() true.
type EventPaste struct {
start bool
t time.Time
data []byte
}

// When returns the time when this EventPaste was created.
Expand All @@ -46,3 +48,25 @@ func (ev *EventPaste) End() bool {
func NewEventPaste(start bool) *EventPaste {
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
}
13 changes: 13 additions & 0 deletions screen.go
Expand Up @@ -272,6 +272,17 @@ type Screen interface {
// 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.
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
Expand Down Expand Up @@ -343,6 +354,8 @@ type screenImpl interface {
SetSize(int, int)
SetTitle(string)
Tty() (Tty, bool)
SetClipboard([]byte)
GetClipboard()

// Following methods are not part of the Screen api, but are used for interaction with
// the common layer code.
Expand Down
21 changes: 20 additions & 1 deletion simulation.go
Expand Up @@ -61,8 +61,11 @@ type SimulationScreen interface {
// GetCursor returns the cursor details.
GetCursor() (x int, y int, visible bool)

// GetTitle gets the set title
// GetTitle gets the previously set title.
GetTitle() string

// GetClipboardData gets the actual data for the clipboard.
GetClipboardData() []byte
}

// SimCell represents a simulated screen cell. The purpose of this
Expand Down Expand Up @@ -102,6 +105,7 @@ type simscreen struct {
fillstyle Style
fallback map[rune]string
title string
clipboard []byte

Screen
sync.Mutex
Expand Down Expand Up @@ -507,3 +511,18 @@ func (s *simscreen) SetTitle(title string) {
func (s *simscreen) GetTitle() string {
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
}
94 changes: 94 additions & 0 deletions tscreen.go
Expand Up @@ -19,6 +19,7 @@ package tcell

import (
"bytes"
"encoding/base64"
"errors"
"io"
"os"
Expand Down Expand Up @@ -175,6 +176,7 @@ type tScreen struct {
saveTitle string
restoreTitle string
title string
setClipboard string

sync.Mutex
}
Expand Down Expand Up @@ -447,7 +449,13 @@ func (t *tScreen) prepareExtendedOSC() {
t.restoreTitle = "\x1b[23;2t"
// this also tries to request that UTF-8 is allowed in the title
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\\"
}
}

Expand Down Expand Up @@ -499,6 +507,11 @@ func (t *tScreen) prepareKey(key Key, val string) {

func (t *tScreen) prepareKeys() {
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(KeyF1, ti.KeyF1)
t.prepareKey(KeyF2, ti.KeyF2)
Expand Down Expand Up @@ -1499,6 +1512,61 @@ func (t *tScreen) parseFocus(buf *bytes.Buffer, evs *[]Event) (bool, bool) {
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
// X11 mouse record.
func (t *tScreen) parseXtermMouse(buf *bytes.Buffer, evs *[]Event) (bool, bool) {
Expand Down Expand Up @@ -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 b[0] == '\x1b' {
if len(b) == 1 {
Expand Down Expand Up @@ -2053,3 +2129,21 @@ func (t *tScreen) SetTitle(title string) {
}
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()
}

0 comments on commit 78110e3

Please sign in to comment.