diff --git a/_demos/clipboard.go b/_demos/clipboard.go new file mode 100644 index 0000000..6862b49 --- /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 573c153..7807717 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 cbe6979..f511f63 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 69f7bdf..18dc551 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 e2c2957..66efaa9 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 f648435..7b0f64f 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() +}