From 78110e30f8bdd56838053e2220050577ac01c5c0 Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Sun, 10 Mar 2024 14:03:29 -0700 Subject: [PATCH] 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. --- _demos/clipboard.go | 115 ++++++++++++++++++++++++++++++++++++++++++++ console_win.go | 6 +++ paste.go | 32 ++++++++++-- screen.go | 13 +++++ simulation.go | 21 +++++++- tscreen.go | 94 ++++++++++++++++++++++++++++++++++++ 6 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 _demos/clipboard.go diff --git a/_demos/clipboard.go b/_demos/clipboard.go new file mode 100644 index 00000000..6862b49a --- /dev/null +++ b/_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) + } + } +} diff --git a/console_win.go b/console_win.go index 573c1539..78077175 100644 --- a/console_win.go +++ b/console_win.go @@ -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 { diff --git a/paste.go b/paste.go index cbe6979f..f511f63c 100644 --- a/paste.go +++ b/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. @@ -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. @@ -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 +} diff --git a/screen.go b/screen.go index 69f7bdfe..18dc5519 100644 --- a/screen.go +++ b/screen.go @@ -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 @@ -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. diff --git a/simulation.go b/simulation.go index e2c29577..66efaa94 100644 --- a/simulation.go +++ b/simulation.go @@ -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 @@ -102,6 +105,7 @@ type simscreen struct { fillstyle Style fallback map[rune]string title string + clipboard []byte Screen sync.Mutex @@ -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 +} diff --git a/tscreen.go b/tscreen.go index f648435e..7b0f64fd 100644 --- a/tscreen.go +++ b/tscreen.go @@ -19,6 +19,7 @@ package tcell import ( "bytes" + "encoding/base64" "errors" "io" "os" @@ -175,6 +176,7 @@ type tScreen struct { saveTitle string restoreTitle string title string + setClipboard string sync.Mutex } @@ -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\\" } } @@ -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) @@ -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) { @@ -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 { @@ -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() +}