From 02a27447b9c60f3d324954e362bd9a8e9998623a Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Sat, 25 Mar 2023 13:32:48 -0400 Subject: [PATCH 01/17] Changed the clipboard API so that it will work with X In X, clipboard/selection data is specific to each window, and it may take some time before the clipboard data is fully transferred. This actually makes sense because there can be entire images in the clipboard and it is important the clipboard API supports large file transfer. Because of this, the Copy and Paste methods have been moved into Window, and Paste now returns a channel. --- elements/window.go | 13 +++++++++++-- tomo.go | 14 -------------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/elements/window.go b/elements/window.go index 7b432c3..83b2f79 100644 --- a/elements/window.go +++ b/elements/window.go @@ -1,13 +1,13 @@ package elements +import "io" import "image" +import "git.tebibyte.media/sashakoshka/tomo/data" // Window represents a top-level container generated by the currently running // backend. It can contain a single element. It is hidden by default, and must // be explicitly shown with the Show() method. type Window interface { - Parent - // Adopt sets the root element of the window. There can only be one of // these at one time. Adopt (Element) @@ -27,6 +27,15 @@ type Window interface { // NewModal creates a new modal dialog window. NewModal (width, height int) (window Window, err error) + + // Copy puts data into the clipboard. + Copy (data.Data) + + // Paste requests the data currently in the clipboard. When the data is + // available, it is sent on the returned channel. If there is no data on + // the clipboard matching the requested mime type, the channel will be + // sent nil. + Paste (accept data.Mime) <- chan io.Reader // Show shows the window. The window starts off hidden, so this must be // called after initial setup to make sure it is visible. diff --git a/tomo.go b/tomo.go index eb36c8e..33780cb 100644 --- a/tomo.go +++ b/tomo.go @@ -4,7 +4,6 @@ import "os" import "io" import "path/filepath" import "git.tebibyte.media/sashakoshka/tomo/dirs" -import "git.tebibyte.media/sashakoshka/tomo/data" import "git.tebibyte.media/sashakoshka/tomo/theme" import "git.tebibyte.media/sashakoshka/tomo/config" import "git.tebibyte.media/sashakoshka/tomo/elements" @@ -47,19 +46,6 @@ func NewWindow (width, height int) (window elements.MainWindow, err error) { return backend.NewWindow(width, height) } -// Copy puts data into the clipboard. -func Copy (data data.Data) { - assertBackend() - backend.Copy(data) -} - -// Paste returns the data currently in the clipboard. This method may -// return nil. -func Paste (accept []data.Mime) (data.Data) { - assertBackend() - return backend.Paste(accept) -} - // SetTheme sets the theme of all open windows. func SetTheme (theme theme.Theme) { backend.SetTheme(theme) -- 2.46.1 From 01a0fc1bd30a35ab14a58d2f58e7e215d74ddc6c Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Mon, 27 Mar 2023 20:44:39 -0400 Subject: [PATCH 02/17] You can fcucking PASTE now!!! --- backend.go | 7 ---- backends/x/event.go | 51 ++++++++++++++++++++++++ backends/x/window.go | 80 +++++++++++++++++++++++++++++++++++++- backends/x/x.go | 19 +-------- data/data.go | 11 ++++++ elements/window.go | 8 ++-- examples/clipboard/main.go | 60 ++++++++++++++++++++++++++++ examples/panels/main.go | 8 ++-- 8 files changed, 210 insertions(+), 34 deletions(-) create mode 100644 examples/clipboard/main.go diff --git a/backend.go b/backend.go index 5d28c31..c85dd5e 100644 --- a/backend.go +++ b/backend.go @@ -1,7 +1,6 @@ package tomo import "errors" -import "git.tebibyte.media/sashakoshka/tomo/data" import "git.tebibyte.media/sashakoshka/tomo/theme" import "git.tebibyte.media/sashakoshka/tomo/config" import "git.tebibyte.media/sashakoshka/tomo/elements" @@ -24,12 +23,6 @@ type Backend interface { // and returns a struct representing it that fulfills the MainWindow // interface. NewWindow (width, height int) (window elements.MainWindow, err error) - - // Copy puts data into the clipboard. - Copy (data.Data) - - // Paste returns the data currently in the clipboard. - Paste (accept []data.Mime) (data.Data) // SetTheme sets the theme of all open windows. SetTheme (theme.Theme) diff --git a/backends/x/event.go b/backends/x/event.go index ad05e97..4c0e4de 100644 --- a/backends/x/event.go +++ b/backends/x/event.go @@ -1,11 +1,14 @@ package x +import "bytes" import "image" +import "errors" import "git.tebibyte.media/sashakoshka/tomo/input" import "git.tebibyte.media/sashakoshka/tomo/elements" import "github.com/jezek/xgbutil" import "github.com/jezek/xgb/xproto" +import "github.com/jezek/xgbutil/xprop" import "github.com/jezek/xgbutil/xevent" type scrollSum struct { @@ -235,6 +238,54 @@ func (window *window) handleMotionNotify ( } } +func (window *window) handleSelectionNotify ( + connection *xgbutil.XUtil, + event xevent.SelectionNotifyEvent, +) { + // Follow: + // https://tronche.com/gui/x/icccm/sec-2.html#s-2.4 + if window.selectionRequest == nil { return } + die := func (err error) { window.selectionRequest(nil, err) } + + // When using GetProperty to retrieve the value of a selection, the + // property argument should be set to the corresponding value in the + // SelectionNotify event. Because the requestor has no way of knowing + // beforehand what type the selection owner will use, the type argument + // should be set to AnyPropertyType. Several GetProperty requests may be + // needed to retrieve all the data in the selection; each should set the + // long-offset argument to the amount of data received so far, and the + // size argument to some reasonable buffer size (see section 2.5). If + // the returned value of bytes-after is zero, the whole property has + // been transferred. + reply, err := xproto.GetProperty ( + connection.Conn(), false, window.xWindow.Id, event.Property, + xproto.GetPropertyTypeAny, 0, (1 << 32) - 1).Reply() + if err != nil { die(err); return } + if reply.Format == 0 { + die(errors.New("x: missing selection property")) + return + } + + // Once all the data in the selection has been retrieved (which may + // require getting the values of several properties &emdash; see section + // 2.7), the requestor should delete the property in the SelectionNotify + // request by using a GetProperty request with the delete argument set + // to True. As previously discussed, the owner has no way of knowing + // when the data has been transferred to the requestor unless the + // property is removed. + propertyAtom, err := xprop.Atm(window.backend.connection, "TOMO_SELECTION") + if err != nil { die(err); return } + err = xproto.DeletePropertyChecked ( + window.backend.connection.Conn(), + window.xWindow.Id, + propertyAtom).Check() + if err != nil { die(err); return } + + // TODO: possibly do some conversion here? + window.selectionRequest(bytes.NewReader(reply.Value), nil) + window.selectionRequest = nil +} + func (window *window) compressExpose ( firstEvent xproto.ExposeEvent, ) ( diff --git a/backends/x/window.go b/backends/x/window.go index e1edd8f..df9672b 100644 --- a/backends/x/window.go +++ b/backends/x/window.go @@ -1,12 +1,16 @@ package x +import "io" import "image" +import "errors" import "github.com/jezek/xgb/xproto" import "github.com/jezek/xgbutil/ewmh" import "github.com/jezek/xgbutil/icccm" +import "github.com/jezek/xgbutil/xprop" import "github.com/jezek/xgbutil/xevent" import "github.com/jezek/xgbutil/xwindow" import "github.com/jezek/xgbutil/xgraphics" +import "git.tebibyte.media/sashakoshka/tomo/data" import "git.tebibyte.media/sashakoshka/tomo/input" import "git.tebibyte.media/sashakoshka/tomo/theme" import "git.tebibyte.media/sashakoshka/tomo/config" @@ -30,6 +34,8 @@ type window struct { theme theme.Theme config config.Config + selectionRequest func (io.Reader, error) + metrics struct { width int height int @@ -89,6 +95,8 @@ func (backend *Backend) newWindow ( Connect(backend.connection, window.xWindow.Id) xevent.MotionNotifyFun(window.handleMotionNotify). Connect(backend.connection, window.xWindow.Id) + xevent.SelectionNotifyFun(window.handleSelectionNotify). + Connect(backend.connection, window.xWindow.Id) window.SetTheme(backend.theme) window.SetConfig(backend.config) @@ -246,7 +254,6 @@ func (window *window) setType (ty string) error { } func (window *window) setClientLeader (leader *window) error { - // FIXME: doe not fucking work hints, _ := icccm.WmHintsGet(window.backend.connection, window.xWindow.Id) if hints == nil { hints = &icccm.Hints { } @@ -275,6 +282,77 @@ func (window *window) Hide () { window.xWindow.Unmap() } +func (window *window) Copy (data data.Data) { + // TODO +} + +func (window *window) Paste (accept data.Mime, callback func (io.Reader, error)) { + // Follow: + // https://tronche.com/gui/x/icccm/sec-2.html#s-2.4 + + die := func (err error) { callback(nil, err) } + if window.selectionRequest != nil { + // TODO: add the request to a queue and take care of it when the + // current selection has completed + die(errors.New("there is already a selection request")) + } + + selectionName := "CLIPBOARD" + propertyName := "TOMO_SELECTION" + // TODO: change based on mime type + targetName := "TEXT" + + // get atoms + selectionAtom, err := xprop.Atm(window.backend.connection, selectionName) + if err != nil { die(err); return } + targetAtom, err := xprop.Atm(window.backend.connection, targetName) + if err != nil { die(err); return } + propertyAtom, err := xprop.Atm(window.backend.connection, propertyName) + if err != nil { die(err); return } + + // The requestor should set the property argument to the name of a + // property that the owner can use to report the value of the selection. + // Requestors should ensure that the named property does not exist on + // the window before issuing the ConvertSelection. The exception to this + // rule is when the requestor intends to pass parameters with the + // request. Some targets may be defined such that requestors can pass + // parameters along with the request. If the requestor wishes to provide + // parameters to a request, they should be placed in the specified + // property on the requestor window before the requestor issues the + // ConvertSelection request, and this property should be named in the + // request. + err = xproto.DeletePropertyChecked ( + window.backend.connection.Conn(), + window.xWindow.Id, + propertyAtom).Check() + if err != nil { die(err); return } + + // The selection argument specifies the particular selection involved, + // and the target argument specifies the required form of the + // information. For information about the choice of suitable atoms to + // use, see section 2.6. The requestor should set the requestor argument + // to a window that it created; the owner will place the reply property + // there. The requestor should set the time argument to the timestamp on + // the event that triggered the request for the selection value. Note + // that clients should not specify CurrentTime*. + err = xproto.ConvertSelectionChecked ( + window.backend.connection.Conn(), + window.xWindow.Id, + selectionAtom, + targetAtom, + propertyAtom, + // TODO: *possibly replace this zero with an actual timestamp + // received from the server. this is non-trivial as we cannot + // rely on the timestamp of the last received event, because + // there is a possibility that this method is invoked + // asynchronously from within tomo.Do(). + 0).Check() + if err != nil { die(err); return } + + window.selectionRequest = callback + return +} + func (window *window) Close () { if window.onClose != nil { window.onClose() } if window.modalParent != nil { diff --git a/backends/x/x.go b/backends/x/x.go index d72ce1f..8e25ac4 100644 --- a/backends/x/x.go +++ b/backends/x/x.go @@ -1,7 +1,6 @@ package x import "git.tebibyte.media/sashakoshka/tomo" -import "git.tebibyte.media/sashakoshka/tomo/data" import "git.tebibyte.media/sashakoshka/tomo/theme" import "git.tebibyte.media/sashakoshka/tomo/config" @@ -39,7 +38,7 @@ type Backend struct { func NewBackend () (output tomo.Backend, err error) { backend := &Backend { windows: map[xproto.Window] *window { }, - doChannel: make(chan func (), 0), + doChannel: make(chan func (), 32), theme: theme.Default { }, config: config.Default { }, open: true, @@ -97,22 +96,6 @@ func (backend *Backend) Do (callback func ()) { backend.doChannel <- callback } -// Copy puts data into the clipboard. This method is not yet implemented and -// will do nothing! -func (backend *Backend) Copy (data data.Data) { - backend.assert() - // TODO -} - -// Paste returns the data currently in the clipboard. This method may -// return nil. This method is not yet implemented and will do nothing! -func (backend *Backend) Paste (accept []data.Mime) (data data.Data) { - backend.assert() - // TODO - return -} - - // SetTheme sets the theme of all open windows. func (backend *Backend) SetTheme (theme theme.Theme) { backend.assert() diff --git a/data/data.go b/data/data.go index 8055711..e47497e 100644 --- a/data/data.go +++ b/data/data.go @@ -1,6 +1,7 @@ package data import "io" +import "bytes" // Data represents arbitrary polymorphic data that can be used for data transfer // between applications. @@ -18,3 +19,13 @@ type Mime struct { var MimePlain = Mime { "text", "plain" } var MimeFile = Mime { "text", "uri-list" } + +type byteReadCloser struct { *bytes.Reader } +func (byteReadCloser) Close () error { return nil } + +// Text returns plain text Data given a string. +func Text (text string) Data { + return Data { + MimePlain: byteReadCloser { bytes.NewReader([]byte(text)) }, + } +} diff --git a/elements/window.go b/elements/window.go index 83b2f79..404586a 100644 --- a/elements/window.go +++ b/elements/window.go @@ -32,10 +32,10 @@ type Window interface { Copy (data.Data) // Paste requests the data currently in the clipboard. When the data is - // available, it is sent on the returned channel. If there is no data on - // the clipboard matching the requested mime type, the channel will be - // sent nil. - Paste (accept data.Mime) <- chan io.Reader + // available, the callback is called with the clipboard data. If there + // was no data matching the requested mime type found, nil is passed to + // the callback instead. + Paste (accept data.Mime, callback func (io.Reader, error)) // Show shows the window. The window starts off hidden, so this must be // called after initial setup to make sure it is visible. diff --git a/examples/clipboard/main.go b/examples/clipboard/main.go new file mode 100644 index 0000000..391e66d --- /dev/null +++ b/examples/clipboard/main.go @@ -0,0 +1,60 @@ +package main + +import "io" +import "git.tebibyte.media/sashakoshka/tomo" +import "git.tebibyte.media/sashakoshka/tomo/data" +import "git.tebibyte.media/sashakoshka/tomo/theme" +import "git.tebibyte.media/sashakoshka/tomo/popups" +import "git.tebibyte.media/sashakoshka/tomo/layouts/basic" +import "git.tebibyte.media/sashakoshka/tomo/elements/basic" +import _ "git.tebibyte.media/sashakoshka/tomo/backends/all" +import "git.tebibyte.media/sashakoshka/tomo/elements/containers" + +func main () { + tomo.Run(run) +} + +func run () { + window, _ := tomo.NewWindow(2, 2) + window.SetTitle("Clipboard") + + container := containers.NewContainer(basicLayouts.Vertical { true, true }) + textInput := basicElements.NewTextBox("", "") + controlRow := containers.NewContainer(basicLayouts.Horizontal { true, false }) + copyButton := basicElements.NewButton("Copy") + copyButton.SetIcon(theme.IconCopy) + pasteButton := basicElements.NewButton("Paste") + pasteButton.SetIcon(theme.IconPaste) + + clipboardCallback := func (clipboard io.Reader, err error) { + if err != nil { + popups.NewDialog ( + popups.DialogKindError, + window, + "Error", + "No text data in clipboard:\n" + err.Error()) + return + } + + + text, _ := io.ReadAll(clipboard) + tomo.Do (func () { + textInput.SetValue(string(text)) + }) + } + copyButton.OnClick (func () { + window.Copy(data.Text(textInput.Value())) + }) + pasteButton.OnClick (func () { + window.Paste(data.MimePlain, clipboardCallback) + }) + + container.Adopt(textInput, true) + controlRow.Adopt(copyButton, true) + controlRow.Adopt(pasteButton, true) + container.Adopt(controlRow, false) + window.Adopt(container) + + window.OnClose(tomo.Stop) + window.Show() +} diff --git a/examples/panels/main.go b/examples/panels/main.go index a9110f7..425c807 100644 --- a/examples/panels/main.go +++ b/examples/panels/main.go @@ -13,7 +13,7 @@ func main () { } func run () { - window, _ := tomo.NewWindow(2, 2) + window, _ := tomo.NewWindow(256, 256) window.SetTitle("Main") container := containers.NewContainer(basicLayouts.Vertical { true, true }) @@ -24,9 +24,9 @@ func run () { window.Show() createPanel(window, 0) - // createPanel(window, 1) - // createPanel(window, 2) - // createPanel(window, 3) + createPanel(window, 1) + createPanel(window, 2) + createPanel(window, 3) } func createPanel (parent elements.MainWindow, id int) { -- 2.46.1 From 6f15ff3366e4e626519f175ddf12c9a0760f5bd6 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Tue, 28 Mar 2023 01:00:54 -0400 Subject: [PATCH 03/17] We now set the target atom properly --- backends/x/event.go | 12 +++++++++++- backends/x/window.go | 11 ++++++++--- data/data.go | 10 ++++++++++ examples/clipboard/main.go | 14 +++++++++++--- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/backends/x/event.go b/backends/x/event.go index 4c0e4de..f5b7570 100644 --- a/backends/x/event.go +++ b/backends/x/event.go @@ -245,7 +245,17 @@ func (window *window) handleSelectionNotify ( // Follow: // https://tronche.com/gui/x/icccm/sec-2.html#s-2.4 if window.selectionRequest == nil { return } - die := func (err error) { window.selectionRequest(nil, err) } + die := func (err error) { + window.selectionRequest(nil, err) + window.selectionRequest = nil + } + + // If the property argument is None, the conversion has been refused. + // This can mean either that there is no owner for the selection, that + // the owner does not support the conversion implied by the target, or + // that the server did not have sufficient space to accommodate the + // data. + if event.Property == 0 { die(nil); return } // When using GetProperty to retrieve the value of a selection, the // property argument should be set to the corresponding value in the diff --git a/backends/x/window.go b/backends/x/window.go index df9672b..080432f 100644 --- a/backends/x/window.go +++ b/backends/x/window.go @@ -290,7 +290,10 @@ func (window *window) Paste (accept data.Mime, callback func (io.Reader, error)) // Follow: // https://tronche.com/gui/x/icccm/sec-2.html#s-2.4 - die := func (err error) { callback(nil, err) } + die := func (err error) { + window.selectionRequest = nil + callback(nil, err) + } if window.selectionRequest != nil { // TODO: add the request to a queue and take care of it when the // current selection has completed @@ -299,8 +302,10 @@ func (window *window) Paste (accept data.Mime, callback func (io.Reader, error)) selectionName := "CLIPBOARD" propertyName := "TOMO_SELECTION" - // TODO: change based on mime type - targetName := "TEXT" + targetName := accept.String() + if accept == data.M("text", "plain") { + targetName = "UTF8_STRING" + } // get atoms selectionAtom, err := xprop.Atm(window.backend.connection, selectionName) diff --git a/data/data.go b/data/data.go index e47497e..c92eeca 100644 --- a/data/data.go +++ b/data/data.go @@ -16,6 +16,16 @@ type Mime struct { Type, Subtype string } +// M is shorthand for creating a MIME type. +func M (ty, subtype string) Mime { + return Mime { ty, subtype } +} + +// String returns the string representation of the MIME type. +func (mime Mime) String () string { + return mime.Type + "/" + mime.Subtype +} + var MimePlain = Mime { "text", "plain" } var MimeFile = Mime { "text", "uri-list" } diff --git a/examples/clipboard/main.go b/examples/clipboard/main.go index 391e66d..12a4535 100644 --- a/examples/clipboard/main.go +++ b/examples/clipboard/main.go @@ -26,16 +26,24 @@ func run () { pasteButton := basicElements.NewButton("Paste") pasteButton.SetIcon(theme.IconPaste) - clipboardCallback := func (clipboard io.Reader, err error) { + clipboardCallback := func (clipboard io.Reader, err error) { if err != nil { popups.NewDialog ( popups.DialogKindError, window, "Error", - "No text data in clipboard:\n" + err.Error()) + "Cannot get clipboard:\n" + err.Error()) + return + } + + if clipboard == nil { + popups.NewDialog ( + popups.DialogKindError, + window, + "Clipboard Empty", + "No text data in clipboard") return } - text, _ := io.ReadAll(clipboard) tomo.Do (func () { -- 2.46.1 From 0aede3502bfc226834142425200bcb8f51c992d5 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Wed, 29 Mar 2023 00:50:23 -0400 Subject: [PATCH 04/17] This should have been several separate commits --- backends/x/event.go | 56 +------------ backends/x/selection.go | 158 +++++++++++++++++++++++++++++++++++++ backends/x/window.go | 62 ++------------- data/data.go | 17 +++- elements/window.go | 3 +- examples/clipboard/main.go | 67 ++++++++++++++-- theme/theme.go | 8 +- 7 files changed, 247 insertions(+), 124 deletions(-) create mode 100644 backends/x/selection.go diff --git a/backends/x/event.go b/backends/x/event.go index f5b7570..0ee4aa6 100644 --- a/backends/x/event.go +++ b/backends/x/event.go @@ -1,14 +1,11 @@ package x -import "bytes" import "image" -import "errors" import "git.tebibyte.media/sashakoshka/tomo/input" import "git.tebibyte.media/sashakoshka/tomo/elements" import "github.com/jezek/xgbutil" import "github.com/jezek/xgb/xproto" -import "github.com/jezek/xgbutil/xprop" import "github.com/jezek/xgbutil/xevent" type scrollSum struct { @@ -242,58 +239,9 @@ func (window *window) handleSelectionNotify ( connection *xgbutil.XUtil, event xevent.SelectionNotifyEvent, ) { - // Follow: - // https://tronche.com/gui/x/icccm/sec-2.html#s-2.4 if window.selectionRequest == nil { return } - die := func (err error) { - window.selectionRequest(nil, err) - window.selectionRequest = nil - } - - // If the property argument is None, the conversion has been refused. - // This can mean either that there is no owner for the selection, that - // the owner does not support the conversion implied by the target, or - // that the server did not have sufficient space to accommodate the - // data. - if event.Property == 0 { die(nil); return } - - // When using GetProperty to retrieve the value of a selection, the - // property argument should be set to the corresponding value in the - // SelectionNotify event. Because the requestor has no way of knowing - // beforehand what type the selection owner will use, the type argument - // should be set to AnyPropertyType. Several GetProperty requests may be - // needed to retrieve all the data in the selection; each should set the - // long-offset argument to the amount of data received so far, and the - // size argument to some reasonable buffer size (see section 2.5). If - // the returned value of bytes-after is zero, the whole property has - // been transferred. - reply, err := xproto.GetProperty ( - connection.Conn(), false, window.xWindow.Id, event.Property, - xproto.GetPropertyTypeAny, 0, (1 << 32) - 1).Reply() - if err != nil { die(err); return } - if reply.Format == 0 { - die(errors.New("x: missing selection property")) - return - } - - // Once all the data in the selection has been retrieved (which may - // require getting the values of several properties &emdash; see section - // 2.7), the requestor should delete the property in the SelectionNotify - // request by using a GetProperty request with the delete argument set - // to True. As previously discussed, the owner has no way of knowing - // when the data has been transferred to the requestor unless the - // property is removed. - propertyAtom, err := xprop.Atm(window.backend.connection, "TOMO_SELECTION") - if err != nil { die(err); return } - err = xproto.DeletePropertyChecked ( - window.backend.connection.Conn(), - window.xWindow.Id, - propertyAtom).Check() - if err != nil { die(err); return } - - // TODO: possibly do some conversion here? - window.selectionRequest(bytes.NewReader(reply.Value), nil) - window.selectionRequest = nil + window.selectionRequest.handleSelectionNotify(connection, event) + if !window.selectionRequest.open() { window.selectionRequest = nil } } func (window *window) compressExpose ( diff --git a/backends/x/selection.go b/backends/x/selection.go new file mode 100644 index 0000000..406525a --- /dev/null +++ b/backends/x/selection.go @@ -0,0 +1,158 @@ +package x + +import "errors" +import "github.com/jezek/xgbutil" +import "github.com/jezek/xgb/xproto" +import "github.com/jezek/xgbutil/xprop" +import "github.com/jezek/xgbutil/xevent" +import "git.tebibyte.media/sashakoshka/tomo/data" + +type selReqState int; const ( + selReqStateClosed selReqState = iota + selReqStateAwaitSelectionNotify +) + +type selectionRequest struct { + state selReqState + window *window + source xproto.Atom + destination xproto.Atom + accept []data.Mime + callback func (data.Data, error) +} + +// TODO: take in multiple formats and check the TARGETS list against them. + +func (window *window) newSelectionRequest ( + source, destination xproto.Atom, + callback func (data.Data, error), + accept ...data.Mime, +) ( + request *selectionRequest, +) { + request = &selectionRequest { + source: source, + destination: destination, + window: window, + accept: accept, + callback: callback, + } + + // TODO: account for all types in accept slice + targetName := request.accept[0].String() + if request.accept[0] == data.M("text", "plain") { + targetName = "UTF8_STRING" + } + targetAtom, err := xprop.Atm(window.backend.connection, targetName) + if err != nil { request.die(err); return } + + // The requestor should set the property argument to the name of a + // property that the owner can use to report the value of the selection. + // Requestors should ensure that the named property does not exist on + // the window before issuing the ConvertSelection. The exception to this + // rule is when the requestor intends to pass parameters with the + // request. Some targets may be defined such that requestors can pass + // parameters along with the request. If the requestor wishes to provide + // parameters to a request, they should be placed in the specified + // property on the requestor window before the requestor issues the + // ConvertSelection request, and this property should be named in the + // request. + err = xproto.DeletePropertyChecked ( + window.backend.connection.Conn(), + window.xWindow.Id, + request.destination).Check() + if err != nil { request.die(err); return } + + // The selection argument specifies the particular selection involved, + // and the target argument specifies the required form of the + // information. For information about the choice of suitable atoms to + // use, see section 2.6. The requestor should set the requestor argument + // to a window that it created; the owner will place the reply property + // there. The requestor should set the time argument to the timestamp on + // the event that triggered the request for the selection value. Note + // that clients should not specify CurrentTime*. + err = xproto.ConvertSelectionChecked ( + request.window.backend.connection.Conn(), + request.window.xWindow.Id, + request.source, + targetAtom, + request.destination, + // TODO: *possibly replace this zero with an actual timestamp + // received from the server. this is non-trivial as we cannot + // rely on the timestamp of the last received event, because + // there is a possibility that this method is invoked + // asynchronously from within tomo.Do(). + 0).Check() + if err != nil { request.die(err); return } + + request.state = selReqStateAwaitSelectionNotify + return +} + +func (request *selectionRequest) die (err error) { + request.callback(nil, err) + request.state = selReqStateClosed +} + +func (request *selectionRequest) finalize (data data.Data) { + request.callback(data, nil) + request.state = selReqStateClosed +} + +func (request *selectionRequest) open () bool { + return request.state != selReqStateClosed +} + +func (request *selectionRequest) handleSelectionNotify ( + connection *xgbutil.XUtil, + event xevent.SelectionNotifyEvent, +) { + // Follow: + // https://tronche.com/gui/x/icccm/sec-2.html#s-2.4 + + // If the property argument is None, the conversion has been refused. + // This can mean either that there is no owner for the selection, that + // the owner does not support the conversion implied by the target, or + // that the server did not have sufficient space to accommodate the + // data. + if event.Property == 0 { request.die(nil); return } + + // TODO: handle INCR + + // When using GetProperty to retrieve the value of a selection, the + // property argument should be set to the corresponding value in the + // SelectionNotify event. Because the requestor has no way of knowing + // beforehand what type the selection owner will use, the type argument + // should be set to AnyPropertyType. Several GetProperty requests may be + // needed to retrieve all the data in the selection; each should set the + // long-offset argument to the amount of data received so far, and the + // size argument to some reasonable buffer size (see section 2.5). If + // the returned value of bytes-after is zero, the whole property has + // been transferred. + reply, err := xproto.GetProperty ( + connection.Conn(), false, request.window.xWindow.Id, + event.Property, xproto.GetPropertyTypeAny, + 0, (1 << 32) - 1).Reply() + if err != nil { request.die(err); return } + if reply.Format == 0 { + request.die(errors.New("x: missing selection property")) + return + } + + // Once all the data in the selection has been retrieved (which may + // require getting the values of several properties &emdash; see section + // 2.7), the requestor should delete the property in the SelectionNotify + // request by using a GetProperty request with the delete argument set + // to True. As previously discussed, the owner has no way of knowing + // when the data has been transferred to the requestor unless the + // property is removed. + if err != nil { request.die(err); return } + err = xproto.DeletePropertyChecked ( + request.window.backend.connection.Conn(), + request.window.xWindow.Id, + request.destination).Check() + if err != nil { request.die(err); return } + + // FIXME: get the mime type from the selection owner's response + request.finalize(data.Bytes(request.accept[0],reply.Value)) +} diff --git a/backends/x/window.go b/backends/x/window.go index 080432f..a040ad3 100644 --- a/backends/x/window.go +++ b/backends/x/window.go @@ -1,6 +1,5 @@ package x -import "io" import "image" import "errors" import "github.com/jezek/xgb/xproto" @@ -34,7 +33,7 @@ type window struct { theme theme.Theme config config.Config - selectionRequest func (io.Reader, error) + selectionRequest *selectionRequest metrics struct { width int @@ -286,75 +285,26 @@ func (window *window) Copy (data data.Data) { // TODO } -func (window *window) Paste (accept data.Mime, callback func (io.Reader, error)) { +func (window *window) Paste (callback func (data.Data, error), accept ...data.Mime) { // Follow: // https://tronche.com/gui/x/icccm/sec-2.html#s-2.4 - - die := func (err error) { - window.selectionRequest = nil - callback(nil, err) - } + die := func (err error) { callback(nil, err) } if window.selectionRequest != nil { // TODO: add the request to a queue and take care of it when the // current selection has completed die(errors.New("there is already a selection request")) + return } selectionName := "CLIPBOARD" propertyName := "TOMO_SELECTION" - targetName := accept.String() - if accept == data.M("text", "plain") { - targetName = "UTF8_STRING" - } - - // get atoms selectionAtom, err := xprop.Atm(window.backend.connection, selectionName) if err != nil { die(err); return } - targetAtom, err := xprop.Atm(window.backend.connection, targetName) - if err != nil { die(err); return } propertyAtom, err := xprop.Atm(window.backend.connection, propertyName) if err != nil { die(err); return } - // The requestor should set the property argument to the name of a - // property that the owner can use to report the value of the selection. - // Requestors should ensure that the named property does not exist on - // the window before issuing the ConvertSelection. The exception to this - // rule is when the requestor intends to pass parameters with the - // request. Some targets may be defined such that requestors can pass - // parameters along with the request. If the requestor wishes to provide - // parameters to a request, they should be placed in the specified - // property on the requestor window before the requestor issues the - // ConvertSelection request, and this property should be named in the - // request. - err = xproto.DeletePropertyChecked ( - window.backend.connection.Conn(), - window.xWindow.Id, - propertyAtom).Check() - if err != nil { die(err); return } - - // The selection argument specifies the particular selection involved, - // and the target argument specifies the required form of the - // information. For information about the choice of suitable atoms to - // use, see section 2.6. The requestor should set the requestor argument - // to a window that it created; the owner will place the reply property - // there. The requestor should set the time argument to the timestamp on - // the event that triggered the request for the selection value. Note - // that clients should not specify CurrentTime*. - err = xproto.ConvertSelectionChecked ( - window.backend.connection.Conn(), - window.xWindow.Id, - selectionAtom, - targetAtom, - propertyAtom, - // TODO: *possibly replace this zero with an actual timestamp - // received from the server. this is non-trivial as we cannot - // rely on the timestamp of the last received event, because - // there is a possibility that this method is invoked - // asynchronously from within tomo.Do(). - 0).Check() - if err != nil { die(err); return } - - window.selectionRequest = callback + window.selectionRequest = window.newSelectionRequest ( + selectionAtom, propertyAtom, callback, accept...) return } diff --git a/data/data.go b/data/data.go index c92eeca..9b43e20 100644 --- a/data/data.go +++ b/data/data.go @@ -35,7 +35,22 @@ func (byteReadCloser) Close () error { return nil } // Text returns plain text Data given a string. func Text (text string) Data { + return Bytes(MimePlain, []byte(text)) +} + +// Bytes constructs a Data given a buffer and a mime type. +func Bytes (mime Mime, buffer []byte) Data { return Data { - MimePlain: byteReadCloser { bytes.NewReader([]byte(text)) }, + mime: byteReadCloser { bytes.NewReader(buffer) }, } } + +// Merge combines several Datas together. If multiple Datas provide a reader for +// the same mime type, the ones further on in the list will take precedence. +func Merge (individual ...Data) (combined Data) { + for _, data := range individual { + for mime, reader := range data { + combined[mime] = reader + }} + return +} diff --git a/elements/window.go b/elements/window.go index 404586a..4a7a167 100644 --- a/elements/window.go +++ b/elements/window.go @@ -1,6 +1,5 @@ package elements -import "io" import "image" import "git.tebibyte.media/sashakoshka/tomo/data" @@ -35,7 +34,7 @@ type Window interface { // available, the callback is called with the clipboard data. If there // was no data matching the requested mime type found, nil is passed to // the callback instead. - Paste (accept data.Mime, callback func (io.Reader, error)) + Paste (callback func (data.Data, error), accept ...data.Mime) // Show shows the window. The window starts off hidden, so this must be // called after initial setup to make sure it is visible. diff --git a/examples/clipboard/main.go b/examples/clipboard/main.go index 12a4535..00f6721 100644 --- a/examples/clipboard/main.go +++ b/examples/clipboard/main.go @@ -1,6 +1,7 @@ package main import "io" +import "image" import "git.tebibyte.media/sashakoshka/tomo" import "git.tebibyte.media/sashakoshka/tomo/data" import "git.tebibyte.media/sashakoshka/tomo/theme" @@ -15,7 +16,7 @@ func main () { } func run () { - window, _ := tomo.NewWindow(2, 2) + window, _ := tomo.NewWindow(256, 2) window.SetTitle("Clipboard") container := containers.NewContainer(basicLayouts.Vertical { true, true }) @@ -25,8 +26,41 @@ func run () { copyButton.SetIcon(theme.IconCopy) pasteButton := basicElements.NewButton("Paste") pasteButton.SetIcon(theme.IconPaste) + pasteImageButton := basicElements.NewButton("Image") + pasteImageButton.SetIcon(theme.IconPictures) - clipboardCallback := func (clipboard io.Reader, err error) { + imageClipboardCallback := func (clipboard data.Data, err error) { + if err != nil { + popups.NewDialog ( + popups.DialogKindError, + window, + "Error", + "Cannot get clipboard:\n" + err.Error()) + return + } + + imageData, ok := clipboard[data.M("image", "png")] + if !ok { + popups.NewDialog ( + popups.DialogKindError, + window, + "Clipboard Empty", + "No image data in clipboard") + return + } + + img, _, err := image.Decode(imageData) + if err != nil { + popups.NewDialog ( + popups.DialogKindError, + window, + "Error", + "Cannot decode image:\n" + err.Error()) + return + } + imageWindow(img) + } + clipboardCallback := func (clipboard data.Data, err error) { if err != nil { popups.NewDialog ( popups.DialogKindError, @@ -36,7 +70,8 @@ func run () { return } - if clipboard == nil { + textData, ok := clipboard[data.MimePlain] + if !ok { popups.NewDialog ( popups.DialogKindError, window, @@ -45,24 +80,40 @@ func run () { return } - text, _ := io.ReadAll(clipboard) - tomo.Do (func () { - textInput.SetValue(string(text)) - }) + text, _ := io.ReadAll(textData) + textInput.SetValue(string(text)) } copyButton.OnClick (func () { window.Copy(data.Text(textInput.Value())) }) pasteButton.OnClick (func () { - window.Paste(data.MimePlain, clipboardCallback) + window.Paste(clipboardCallback, data.MimePlain) + }) + pasteImageButton.OnClick (func () { + window.Paste(imageClipboardCallback, data.M("image", "png")) }) container.Adopt(textInput, true) controlRow.Adopt(copyButton, true) controlRow.Adopt(pasteButton, true) + controlRow.Adopt(pasteImageButton, true) container.Adopt(controlRow, false) window.Adopt(container) window.OnClose(tomo.Stop) window.Show() } + +func imageWindow (image image.Image) { + window, _ := tomo.NewWindow(2, 2) + window.SetTitle("Clipboard Image") + container := containers.NewContainer(basicLayouts.Vertical { true, true }) + closeButton := basicElements.NewButton("Ok") + closeButton.SetIcon(theme.IconYes) + closeButton.OnClick(window.Close) + + container.Adopt(basicElements.NewImage(image), true) + container.Adopt(closeButton, false) + window.Adopt(container) + window.Show() +} diff --git a/theme/theme.go b/theme/theme.go index 44a45ac..fd1f00c 100644 --- a/theme/theme.go +++ b/theme/theme.go @@ -63,10 +63,12 @@ type Color int; const ( ) // Icon lists a number of cannonical icons, each with its own ID. -type Icon int; const ( - // IconNone specifies no icon. - IconNone = -1 +type Icon int +// IconNone specifies no icon. +const IconNone = -1 + +const ( // Place icons IconHome Icon = iota Icon3DObjects -- 2.46.1 From 39dc09bc4aabf8bd48a77d28f4d25deaa31d9a11 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Wed, 29 Mar 2023 02:55:12 -0400 Subject: [PATCH 05/17] X backend clipboard properly negotiates data type with owner The clipboard API has been changed to allow an application to accept a number of different mime types, and the X backend will now check the accepted types list against the owner's TARGETS list and choose the best one. --- backends/x/selection.go | 144 ++++++++++++++++++++++++++++++++----- backends/x/window.go | 1 + examples/clipboard/main.go | 21 +++++- 3 files changed, 146 insertions(+), 20 deletions(-) diff --git a/backends/x/selection.go b/backends/x/selection.go index 406525a..1a9cfe6 100644 --- a/backends/x/selection.go +++ b/backends/x/selection.go @@ -1,6 +1,8 @@ package x import "errors" +import "strings" +import "github.com/jezek/xgb" import "github.com/jezek/xgbutil" import "github.com/jezek/xgb/xproto" import "github.com/jezek/xgbutil/xprop" @@ -9,7 +11,9 @@ import "git.tebibyte.media/sashakoshka/tomo/data" type selReqState int; const ( selReqStateClosed selReqState = iota - selReqStateAwaitSelectionNotify + selReqStateAwaitTargets + selReqStateAwaitValue + selReqStateAwaitChunk ) type selectionRequest struct { @@ -18,11 +22,10 @@ type selectionRequest struct { source xproto.Atom destination xproto.Atom accept []data.Mime + mime data.Mime callback func (data.Data, error) } -// TODO: take in multiple formats and check the TARGETS list against them. - func (window *window) newSelectionRequest ( source, destination xproto.Atom, callback func (data.Data, error), @@ -38,14 +41,15 @@ func (window *window) newSelectionRequest ( callback: callback, } - // TODO: account for all types in accept slice - targetName := request.accept[0].String() - if request.accept[0] == data.M("text", "plain") { - targetName = "UTF8_STRING" - } - targetAtom, err := xprop.Atm(window.backend.connection, targetName) + targets, err := xprop.Atm(window.backend.connection, "TARGETS") if err != nil { request.die(err); return } + request.convertSelection(targets, selReqStateAwaitTargets) + return +} +func (request *selectionRequest) convertSelection ( + target xproto.Atom, switchTo selReqState, +) { // The requestor should set the property argument to the name of a // property that the owner can use to report the value of the selection. // Requestors should ensure that the named property does not exist on @@ -57,9 +61,9 @@ func (window *window) newSelectionRequest ( // property on the requestor window before the requestor issues the // ConvertSelection request, and this property should be named in the // request. - err = xproto.DeletePropertyChecked ( - window.backend.connection.Conn(), - window.xWindow.Id, + err := xproto.DeletePropertyChecked ( + request.window.backend.connection.Conn(), + request.window.xWindow.Id, request.destination).Check() if err != nil { request.die(err); return } @@ -75,7 +79,7 @@ func (window *window) newSelectionRequest ( request.window.backend.connection.Conn(), request.window.xWindow.Id, request.source, - targetAtom, + target, request.destination, // TODO: *possibly replace this zero with an actual timestamp // received from the server. this is non-trivial as we cannot @@ -84,9 +88,8 @@ func (window *window) newSelectionRequest ( // asynchronously from within tomo.Do(). 0).Check() if err != nil { request.die(err); return } - - request.state = selReqStateAwaitSelectionNotify - return + + request.state = switchTo } func (request *selectionRequest) die (err error) { @@ -103,10 +106,43 @@ func (request *selectionRequest) open () bool { return request.state != selReqStateClosed } +type confidence int; const ( + confidenceNone confidence = iota + confidencePartial + confidenceFull +) + +func targetToMime (name string) (data.Mime, confidence) { + // TODO: add stuff like PDFs, etc. reference this table: + // https://tronche.com/gui/x/icccm/sec-2.html#s-2.6.2 + // perhaps we should also have parameters for mime types so we can + // return an encoding here for things like STRING? + switch name { + case "UTF8_STRING": + return data.MimePlain, confidenceFull + case "TEXT": + return data.MimePlain, confidencePartial + case "STRING": + return data.MimePlain, confidencePartial + default: + if strings.Count(name, "/") == 1 { + ty, subtype, _ := strings.Cut(name, "/") + return data.M(ty, subtype), confidenceFull + } else { + return data.Mime { }, confidenceNone + } + } +} + func (request *selectionRequest) handleSelectionNotify ( connection *xgbutil.XUtil, event xevent.SelectionNotifyEvent, ) { + // the only valid states that we can process a SelectionNotify event in + if request.state != selReqStateAwaitValue && request.state != selReqStateAwaitTargets { + return + } + // Follow: // https://tronche.com/gui/x/icccm/sec-2.html#s-2.4 @@ -153,6 +189,78 @@ func (request *selectionRequest) handleSelectionNotify ( request.destination).Check() if err != nil { request.die(err); return } - // FIXME: get the mime type from the selection owner's response - request.finalize(data.Bytes(request.accept[0],reply.Value)) + switch request.state { + case selReqStateAwaitValue: + // we now have the full selection data in the property, so we + // finalize the request and are done. + // FIXME: get the type from the property and convert that to the + // mime value to pass to the application. + request.finalize(data.Bytes(request.mime, reply.Value)) + + case selReqStateAwaitTargets: + // make a list of the atoms we got + buffer := reply.Value + atoms := make([]xproto.Atom, len(buffer) / 4) + for index := range atoms { + atoms[index] = xproto.Atom(xgb.Get32(buffer[index * 4:])) + } + + // choose the best match out of all targets using a confidence + // system + confidentMatchFound := false + var chosenTarget xproto.Atom + var chosenMime data.Mime + for _, atom := range atoms { + targetName, err := xprop.AtomName ( + request.window.backend.connection, atom) + if err != nil { request.die(err); return } + + mime, confidence := targetToMime(targetName) + if confidence == confidenceNone { continue } + + // if the accepted types list is nil, just choose this + // one. however, if we are not 100% confident that this + // target can be directly converted into a mime type, + // don't mark it as the final match. we still want the + // mime type we give to the application to be as + // accurate as possible. + if request.accept == nil { + chosenTarget = atom + chosenMime = mime + if confidence == confidenceFull { + confidentMatchFound = true + } + } + + // run through the accepted types list if it exists, + // looking for a match. if one is found, then choose + // this target. however, if we are not 100% confident + // that this target directly corresponds to the mime + // type, don't mark it as the final match, because there + // may be a better target in the list. + for _, accept := range request.accept { + if accept == mime { + chosenTarget = atom + chosenMime = mime + if confidence == confidenceFull { + confidentMatchFound = true + } + break + }} + + if confidentMatchFound { break } + } + + // if we didn't find a match, finalize the request with an empty + // data map to inform the application that, although there were + // no errors, there wasn't a suitable target to choose from. + if chosenTarget == 0 { + request.finalize(data.Data { }) + return + } + + // await the selection value + request.mime = chosenMime + request.convertSelection(chosenTarget, selReqStateAwaitValue) + } } diff --git a/backends/x/window.go b/backends/x/window.go index a040ad3..c272ff9 100644 --- a/backends/x/window.go +++ b/backends/x/window.go @@ -305,6 +305,7 @@ func (window *window) Paste (callback func (data.Data, error), accept ...data.Mi window.selectionRequest = window.newSelectionRequest ( selectionAtom, propertyAtom, callback, accept...) + if !window.selectionRequest.open() { window.selectionRequest = nil } return } diff --git a/examples/clipboard/main.go b/examples/clipboard/main.go index 00f6721..65bcc25 100644 --- a/examples/clipboard/main.go +++ b/examples/clipboard/main.go @@ -2,6 +2,9 @@ package main import "io" import "image" +import _ "image/png" +import _ "image/gif" +import _ "image/jpeg" import "git.tebibyte.media/sashakoshka/tomo" import "git.tebibyte.media/sashakoshka/tomo/data" import "git.tebibyte.media/sashakoshka/tomo/theme" @@ -15,6 +18,12 @@ func main () { tomo.Run(run) } +var validImageTypes = []data.Mime { + data.M("image", "png"), + data.M("image", "gif"), + data.M("image", "jpeg"), +} + func run () { window, _ := tomo.NewWindow(256, 2) window.SetTitle("Clipboard") @@ -39,7 +48,15 @@ func run () { return } - imageData, ok := clipboard[data.M("image", "png")] + var imageData io.Reader + var ok bool + for mime, reader := range clipboard { + for _, mimeCheck := range validImageTypes { + if mime == mimeCheck { + imageData = reader + ok = true + }}} + if !ok { popups.NewDialog ( popups.DialogKindError, @@ -90,7 +107,7 @@ func run () { window.Paste(clipboardCallback, data.MimePlain) }) pasteImageButton.OnClick (func () { - window.Paste(imageClipboardCallback, data.M("image", "png")) + window.Paste(imageClipboardCallback, validImageTypes...) }) container.Adopt(textInput, true) -- 2.46.1 From ab6161501829c830b1bad38363834035588cb245 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Wed, 29 Mar 2023 03:03:13 -0400 Subject: [PATCH 06/17] X backend generates mime type from owner response --- backends/x/selection.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/backends/x/selection.go b/backends/x/selection.go index 1a9cfe6..949e193 100644 --- a/backends/x/selection.go +++ b/backends/x/selection.go @@ -22,7 +22,6 @@ type selectionRequest struct { source xproto.Atom destination xproto.Atom accept []data.Mime - mime data.Mime callback func (data.Data, error) } @@ -153,8 +152,6 @@ func (request *selectionRequest) handleSelectionNotify ( // data. if event.Property == 0 { request.die(nil); return } - // TODO: handle INCR - // When using GetProperty to retrieve the value of a selection, the // property argument should be set to the corresponding value in the // SelectionNotify event. Because the requestor has no way of knowing @@ -175,6 +172,8 @@ func (request *selectionRequest) handleSelectionNotify ( return } + // TODO: handle INCR. do it here. + // Once all the data in the selection has been retrieved (which may // require getting the values of several properties &emdash; see section // 2.7), the requestor should delete the property in the SelectionNotify @@ -191,11 +190,16 @@ func (request *selectionRequest) handleSelectionNotify ( switch request.state { case selReqStateAwaitValue: + // get the type from the property and convert that to the mime + // value to pass to the application. + targetName, err := xprop.AtomName ( + request.window.backend.connection, reply.Type) + if err != nil { request.die(err); return } + mime, _ := targetToMime(targetName) + // we now have the full selection data in the property, so we // finalize the request and are done. - // FIXME: get the type from the property and convert that to the - // mime value to pass to the application. - request.finalize(data.Bytes(request.mime, reply.Value)) + request.finalize(data.Bytes(mime, reply.Value)) case selReqStateAwaitTargets: // make a list of the atoms we got @@ -209,7 +213,6 @@ func (request *selectionRequest) handleSelectionNotify ( // system confidentMatchFound := false var chosenTarget xproto.Atom - var chosenMime data.Mime for _, atom := range atoms { targetName, err := xprop.AtomName ( request.window.backend.connection, atom) @@ -226,7 +229,6 @@ func (request *selectionRequest) handleSelectionNotify ( // accurate as possible. if request.accept == nil { chosenTarget = atom - chosenMime = mime if confidence == confidenceFull { confidentMatchFound = true } @@ -241,7 +243,6 @@ func (request *selectionRequest) handleSelectionNotify ( for _, accept := range request.accept { if accept == mime { chosenTarget = atom - chosenMime = mime if confidence == confidenceFull { confidentMatchFound = true } @@ -260,7 +261,6 @@ func (request *selectionRequest) handleSelectionNotify ( } // await the selection value - request.mime = chosenMime request.convertSelection(chosenTarget, selReqStateAwaitValue) } } -- 2.46.1 From 1ebf5e1103fd582c93312c84c7525a0f39db84eb Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Wed, 29 Mar 2023 12:27:23 -0400 Subject: [PATCH 07/17] Implemented INCR selection properties --- backends/x/selection.go | 82 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/backends/x/selection.go b/backends/x/selection.go index 949e193..bd9bb30 100644 --- a/backends/x/selection.go +++ b/backends/x/selection.go @@ -13,6 +13,7 @@ type selReqState int; const ( selReqStateClosed selReqState = iota selReqStateAwaitTargets selReqStateAwaitValue + selReqStateAwaitFirstChunk selReqStateAwaitChunk ) @@ -22,6 +23,8 @@ type selectionRequest struct { source xproto.Atom destination xproto.Atom accept []data.Mime + incrBuffer []byte + incrMime data.Mime callback func (data.Data, error) } @@ -138,9 +141,11 @@ func (request *selectionRequest) handleSelectionNotify ( event xevent.SelectionNotifyEvent, ) { // the only valid states that we can process a SelectionNotify event in - if request.state != selReqStateAwaitValue && request.state != selReqStateAwaitTargets { - return - } + invalidState := + request.state != selReqStateAwaitFirstChunk && + request.state != selReqStateAwaitValue && + request.state != selReqStateAwaitTargets + if invalidState { return } // Follow: // https://tronche.com/gui/x/icccm/sec-2.html#s-2.4 @@ -152,6 +157,13 @@ func (request *selectionRequest) handleSelectionNotify ( // data. if event.Property == 0 { request.die(nil); return } + // if we are waiting for the first INCR chunk, do the special stuff for + // that and not the other stuff. + if request.state == selReqStateAwaitFirstChunk { + request.handleINCRProperty(event.Property) + return + } + // When using GetProperty to retrieve the value of a selection, the // property argument should be set to the corresponding value in the // SelectionNotify event. Because the requestor has no way of knowing @@ -172,7 +184,30 @@ func (request *selectionRequest) handleSelectionNotify ( return } - // TODO: handle INCR. do it here. + // https://tronche.com/gui/x/icccm/sec-2.html#s-2.7.2 + // Requestors may receive a property of type INCR9 in response to any + // target that results in selection data. This indicates that the owner + // will send the actual data incrementally. The contents of the INCR + // property will be an integer, which represents a lower bound on the + // number of bytes of data in the selection. The requestor and the + // selection owner transfer the data in the selection in the following + // manner. The selection requestor starts the transfer process by + // deleting the (type==INCR) property forming the reply to the + // selection. + incr, err := xprop.Atm(request.window.backend.connection, "INCR") + if err != nil { request.die(err); return } + if reply.Type == incr { + // reply to the INCR selection + err = xproto.DeletePropertyChecked ( + request.window.backend.connection.Conn(), + request.window.xWindow.Id, + request.destination).Check() + if err != nil { request.die(err); return } + + // await the first chunk + request.state = selReqStateAwaitFirstChunk + return + } // Once all the data in the selection has been retrieved (which may // require getting the values of several properties &emdash; see section @@ -188,6 +223,8 @@ func (request *selectionRequest) handleSelectionNotify ( request.destination).Check() if err != nil { request.die(err); return } + // depending on which state the selection request is in, do something + // different with the property's value switch request.state { case selReqStateAwaitValue: // get the type from the property and convert that to the mime @@ -264,3 +301,40 @@ func (request *selectionRequest) handleSelectionNotify ( request.convertSelection(chosenTarget, selReqStateAwaitValue) } } + +func (request *selectionRequest) handlePropertyNotify ( + connection *xgbutil.XUtil, + event xevent.PropertyNotifyEvent, +) { + // the only valid state that we can process a PropertyNotify event in + if request.state != selReqStateAwaitChunk { return } + if event.State != xproto.PropertyNewValue { return } + + request.handleINCRProperty(event.Atom) +} + +func (request *selectionRequest) handleINCRProperty (property xproto.Atom) { + // Retrieving data using GetProperty with the delete argument True. + reply, err := xproto.GetProperty ( + request.window.backend.connection.Conn(), true, + request.window.xWindow.Id, property, xproto.GetPropertyTypeAny, + 0, (1 << 32) - 1).Reply() + if err != nil { request.die(err); return } + + if len(reply.Value) == 0 { + // a zero length property means the transfer has finished. we + // finalize the request with the data we have, and don't wait + // for more. + request.finalize(data.Bytes(request.incrMime, request.incrBuffer)) + } else { + // a property with content means the transfer is still ongoing. + // we append the data we got and wait for more. + request.state = selReqStateAwaitChunk + request.incrBuffer = append(request.incrBuffer, reply.Value...) + + targetName, err := xprop.AtomName ( + request.window.backend.connection, reply.Type) + if err != nil { request.die(err); return } + request.incrMime, _ = targetToMime(targetName) + } +} -- 2.46.1 From fc228a13d3706436f790fe5a33171221e372dfb0 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Wed, 29 Mar 2023 12:33:57 -0400 Subject: [PATCH 08/17] Fleshed out the mime type conversion method a bit --- backends/x/selection.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backends/x/selection.go b/backends/x/selection.go index bd9bb30..8bcec0e 100644 --- a/backends/x/selection.go +++ b/backends/x/selection.go @@ -115,11 +115,22 @@ type confidence int; const ( ) func targetToMime (name string) (data.Mime, confidence) { - // TODO: add stuff like PDFs, etc. reference this table: + // TODO: add other stuff. reference this table: // https://tronche.com/gui/x/icccm/sec-2.html#s-2.6.2 // perhaps we should also have parameters for mime types so we can // return an encoding here for things like STRING? switch name { + case "ADOBE_PORTABLE_DOCUMENT_FORMAT": + return data.M("application", "pdf"), confidenceFull + case "APPLE_PICT": + return data.M("image", "pict"), confidenceFull + case + "POSTSCRIPT", + "ENCAPSULATED_POSTSCRIPT", + "ENCAPSULATED_POSTSCRIPT_INTERCHANGE": + return data.M("application", "postscript"), confidenceFull + case "FILE_NAME": + return data.MimeFile, confidenceFull case "UTF8_STRING": return data.MimePlain, confidenceFull case "TEXT": -- 2.46.1 From 8abc4defa7c67a8c20c61815648675d01fc327c8 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Wed, 29 Mar 2023 23:24:42 -0400 Subject: [PATCH 09/17] Fixed INCR Oops! --- backends/x/event.go | 21 +++++++++++++++++++++ backends/x/selection.go | 16 +++++----------- backends/x/selectionclaim.go | 24 ++++++++++++++++++++++++ backends/x/window.go | 6 ++++++ 4 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 backends/x/selectionclaim.go diff --git a/backends/x/event.go b/backends/x/event.go index 0ee4aa6..c355134 100644 --- a/backends/x/event.go +++ b/backends/x/event.go @@ -244,6 +244,27 @@ func (window *window) handleSelectionNotify ( if !window.selectionRequest.open() { window.selectionRequest = nil } } +func (window *window) handlePropertyNotify ( + connection *xgbutil.XUtil, + event xevent.PropertyNotifyEvent, +) { + if window.selectionRequest == nil { return } + window.selectionRequest.handlePropertyNotify(connection, event) + if !window.selectionRequest.open() { window.selectionRequest = nil } +} + +func (window *window) handleSelectionClear ( + connection *xgbutil.XUtil, + event xevent.SelectionClearEvent, +) { + // TODO: schedule the claim to be deleted. when the event loop fires we + // will check to see if the claim is scheduled to be deleted and if it + // is, delete it. + if window.selectionClaim != nil { + window.selectionClaim.scheduledDelete = true + } +} + func (window *window) compressExpose ( firstEvent xproto.ExposeEvent, ) ( diff --git a/backends/x/selection.go b/backends/x/selection.go index 8bcec0e..26959fa 100644 --- a/backends/x/selection.go +++ b/backends/x/selection.go @@ -13,7 +13,6 @@ type selReqState int; const ( selReqStateClosed selReqState = iota selReqStateAwaitTargets selReqStateAwaitValue - selReqStateAwaitFirstChunk selReqStateAwaitChunk ) @@ -153,8 +152,7 @@ func (request *selectionRequest) handleSelectionNotify ( ) { // the only valid states that we can process a SelectionNotify event in invalidState := - request.state != selReqStateAwaitFirstChunk && - request.state != selReqStateAwaitValue && + request.state != selReqStateAwaitValue && request.state != selReqStateAwaitTargets if invalidState { return } @@ -168,13 +166,6 @@ func (request *selectionRequest) handleSelectionNotify ( // data. if event.Property == 0 { request.die(nil); return } - // if we are waiting for the first INCR chunk, do the special stuff for - // that and not the other stuff. - if request.state == selReqStateAwaitFirstChunk { - request.handleINCRProperty(event.Property) - return - } - // When using GetProperty to retrieve the value of a selection, the // property argument should be set to the corresponding value in the // SelectionNotify event. Because the requestor has no way of knowing @@ -216,7 +207,7 @@ func (request *selectionRequest) handleSelectionNotify ( if err != nil { request.die(err); return } // await the first chunk - request.state = selReqStateAwaitFirstChunk + request.state = selReqStateAwaitChunk return } @@ -337,6 +328,9 @@ func (request *selectionRequest) handleINCRProperty (property xproto.Atom) { // finalize the request with the data we have, and don't wait // for more. request.finalize(data.Bytes(request.incrMime, request.incrBuffer)) + + // we want to be extra sure we aren't holding onto this memory + request.incrBuffer = nil } else { // a property with content means the transfer is still ongoing. // we append the data we got and wait for more. diff --git a/backends/x/selectionclaim.go b/backends/x/selectionclaim.go new file mode 100644 index 0000000..39f0260 --- /dev/null +++ b/backends/x/selectionclaim.go @@ -0,0 +1,24 @@ +package x + +import "git.tebibyte.media/sashakoshka/tomo/data" + +type selectionClaim struct { + data data.Data + scheduledDelete bool +} + +func (window *window) newSelectionClaim (data data.Data) *selectionClaim { + return &selectionClaim{ + data: data, + } +} + +func (claim *selectionClaim) idle () bool { + // TODO +} + +func (claim *selectionClaim) handleSelectionRequest ( + // TODO +) { + // TODO +} diff --git a/backends/x/window.go b/backends/x/window.go index c272ff9..945daf9 100644 --- a/backends/x/window.go +++ b/backends/x/window.go @@ -34,6 +34,7 @@ type window struct { config config.Config selectionRequest *selectionRequest + selectionClaim *selectionClaim metrics struct { width int @@ -69,6 +70,7 @@ func (backend *Backend) newWindow ( err = window.xWindow.Listen ( xproto.EventMaskExposure, xproto.EventMaskStructureNotify, + xproto.EventMaskPropertyChange, xproto.EventMaskPointerMotion, xproto.EventMaskKeyPress, xproto.EventMaskKeyRelease, @@ -96,6 +98,10 @@ func (backend *Backend) newWindow ( Connect(backend.connection, window.xWindow.Id) xevent.SelectionNotifyFun(window.handleSelectionNotify). Connect(backend.connection, window.xWindow.Id) + xevent.PropertyNotifyFun(window.handlePropertyNotify). + Connect(backend.connection, window.xWindow.Id) + xevent.SelectionClearFun(window.handleSelectionClear). + Connect(backend.connection, window.xWindow.Id) window.SetTheme(backend.theme) window.SetConfig(backend.config) -- 2.46.1 From f9e55033200405053bc277b65105de294ea0d393 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Thu, 30 Mar 2023 13:10:58 -0400 Subject: [PATCH 10/17] Pasting implemented (nonworking) --- backends/x/event.go | 15 ++-- backends/x/selection.go | 19 ++++- backends/x/selectionclaim.go | 145 +++++++++++++++++++++++++++++++++-- backends/x/window.go | 7 +- 4 files changed, 171 insertions(+), 15 deletions(-) diff --git a/backends/x/event.go b/backends/x/event.go index c355134..53552d4 100644 --- a/backends/x/event.go +++ b/backends/x/event.go @@ -257,12 +257,15 @@ func (window *window) handleSelectionClear ( connection *xgbutil.XUtil, event xevent.SelectionClearEvent, ) { - // TODO: schedule the claim to be deleted. when the event loop fires we - // will check to see if the claim is scheduled to be deleted and if it - // is, delete it. - if window.selectionClaim != nil { - window.selectionClaim.scheduledDelete = true - } + window.selectionClaim = nil +} + +func (window *window) handleSelectionRequest ( + connection *xgbutil.XUtil, + event xevent.SelectionRequestEvent, +) { + if window.selectionClaim == nil { return } + window.selectionClaim.handleSelectionRequest(connection, event) } func (window *window) compressExpose ( diff --git a/backends/x/selection.go b/backends/x/selection.go index 26959fa..ddfb9c9 100644 --- a/backends/x/selection.go +++ b/backends/x/selection.go @@ -146,6 +146,23 @@ func targetToMime (name string) (data.Mime, confidence) { } } +func mimeToTargets (mime data.Mime) (names []string) { + names = append(names, mime.String()) + switch mime { + case data.M("application", "pdf"): + names = append(names, "ADOBE_PORTABLE_DOCUMENT_FORMAT") + case data.M("image", "pict"): + names = append(names, "APPLE_PICT") + case data.M("application", "postscript"): + names = append(names, "POSTSCRIPT") + case data.MimeFile: + names = append(names, "FILE_NAME") + case data.MimePlain: + names = append(names, "UTF8_STRING", "TEXT", "STRING") + } + return +} + func (request *selectionRequest) handleSelectionNotify ( connection *xgbutil.XUtil, event xevent.SelectionNotifyEvent, @@ -195,7 +212,7 @@ func (request *selectionRequest) handleSelectionNotify ( // selection owner transfer the data in the selection in the following // manner. The selection requestor starts the transfer process by // deleting the (type==INCR) property forming the reply to the - // selection. + // selection. incr, err := xprop.Atm(request.window.backend.connection, "INCR") if err != nil { request.die(err); return } if reply.Type == incr { diff --git a/backends/x/selectionclaim.go b/backends/x/selectionclaim.go index 39f0260..3ded6a8 100644 --- a/backends/x/selectionclaim.go +++ b/backends/x/selectionclaim.go @@ -1,24 +1,155 @@ package x +import "io" +import "github.com/jezek/xgb" +import "github.com/jezek/xgbutil" +import "github.com/jezek/xgb/xproto" +import "github.com/jezek/xgbutil/xprop" +import "github.com/jezek/xgbutil/xevent" import "git.tebibyte.media/sashakoshka/tomo/data" type selectionClaim struct { + window *window data data.Data - scheduledDelete bool + name xproto.Atom } -func (window *window) newSelectionClaim (data data.Data) *selectionClaim { - return &selectionClaim{ +func (window *window) claimSelection (name xproto.Atom, data data.Data) *selectionClaim { + // Follow: + // https://tronche.com/gui/x/icccm/sec-2.html#s-2.1 + + // A client wishing to acquire ownership of a particular selection + // should call SetSelectionOwner. The client should set the specified + // selection to the atom that represents the selection, set the + // specified owner to some window that the client created, and set the + // specified time to some time between the current last-change time of + // the selection concerned and the current server time. This time value + // usually will be obtained from the timestamp of the event that + // triggers the acquisition of the selection. Clients should not set the + // time value to CurrentTime, because if they do so, they have no way of + // finding when they gained ownership of the selection. Clients must use + // a window they created so that requestors can route events to the + // owner of the selection. + err := xproto.SetSelectionOwnerChecked ( + window.backend.connection.Conn(), + window.xWindow.Id, name, 0).Check() // FIXME: should not be zero + if err != nil { return nil } + + return &selectionClaim { + window: window, data: data, + name: name, } } -func (claim *selectionClaim) idle () bool { - // TODO +func (window *window) refuseSelectionRequest (request xevent.SelectionRequestEvent) { + // ... refuse the SelectionRequest by sending the requestor window a + // SelectionNotify event with the property set to None (by means of a + // SendEvent request with an empty event mask). + event := xproto.SelectionNotifyEvent { + Requestor: request.Requestor, + Selection: request.Selection, + Target: request.Target, + Property: 0, + }.Bytes() + xproto.SendEvent ( + window.backend.connection.Conn(), + false, request.Requestor, 0, string(event)) +} + +func (window *window) fulfillSelectionRequest ( + data []byte, + format byte, + request xevent.SelectionRequestEvent, +) { + die := func () { window.refuseSelectionRequest(request) } + + // If the specified property is not None, the owner should place the + // data resulting from converting the selection into the specified + // property on the requestor window and should set the property's type + // to some appropriate value, which need not be the same as the + // specified target. + err := xproto.ChangePropertyChecked ( + window.backend.connection.Conn(), + xproto.PropModeReplace, window.xWindow.Id, + request.Property, + request.Target, format, + uint32(len(data) / (int(format) / 8)), data).Check() + if err != nil { die() } + + // If the property is successfully stored, the owner should acknowledge + // the successful conversion by sending the requestor window a + // SelectionNotify event (by means of a SendEvent request with an empty + // mask). + event := xproto.SelectionNotifyEvent { + Requestor: request.Requestor, + Selection: request.Selection, + Target: request.Target, + Property: request.Property, + }.Bytes() + xproto.SendEvent ( + window.backend.connection.Conn(), + false, request.Requestor, 0, string(event)) } func (claim *selectionClaim) handleSelectionRequest ( - // TODO + connection *xgbutil.XUtil, + event xevent.SelectionRequestEvent, ) { - // TODO + // Follow: + // https://tronche.com/gui/x/icccm/sec-2.html#s-2.2 + + die := func () { claim.window.refuseSelectionRequest(event) } + + // When a requestor wants the value of a selection, the owner receives a + // SelectionRequest event. The specified owner and selection will be the + // values that were specified in the SetSelectionOwner request. The + // owner should compare the timestamp with the period it has owned the + // selection and, if the time is outside, refuse the SelectionRequest. + if event.Selection != claim.name { die(); return } + + // If the specified property is None , the requestor is an obsolete + // client. Owners are encouraged to support these clients by using the + // specified target atom as the property name to be used for the reply. + if event.Property == 0 { + event.Property = event.Target + } + + // Otherwise, the owner should use the target to decide the form into + // which the selection should be converted. Some targets may be defined + // such that requestors can pass parameters along with the request. The + // owner will find these parameters in the property named in the + // selection request. The type, format, and contents of this property + // are dependent upon the definition of the target. If the target is not + // defined to have parameters, the owner should ignore the property if + // it is present. If the selection cannot be converted into a form based + // on the target (and parameters, if any), the owner should refuse the + // SelectionRequest as previously described. + targetName, err := xprop.AtomName ( + claim.window.backend.connection, event.Target) + if err != nil { die(); return } + + switch targetName { + case "TARGETS": + targetNames := []string { } + for mime := range claim.data { + targetNames = append(targetNames, mimeToTargets(mime)...) + } + data := make([]byte, len(targetNames) * 4) + for index, name := range targetNames { + atom, err := xprop.Atm(claim.window.backend.connection, name) + if err != nil { die(); return } + xgb.Put32(data[:index * 4], uint32(atom)) + } + claim.window.fulfillSelectionRequest(data, 8, event) + + default: + mime, confidence := targetToMime(targetName) + if confidence == confidenceNone { die(); return } + reader, ok := claim.data[mime] + if !ok { die(); return } + data, err := io.ReadAll(reader) + if err != nil { die() } + claim.window.fulfillSelectionRequest(data, 32, event) + } } diff --git a/backends/x/window.go b/backends/x/window.go index 945daf9..2d0b750 100644 --- a/backends/x/window.go +++ b/backends/x/window.go @@ -102,6 +102,8 @@ func (backend *Backend) newWindow ( Connect(backend.connection, window.xWindow.Id) xevent.SelectionClearFun(window.handleSelectionClear). Connect(backend.connection, window.xWindow.Id) + xevent.SelectionRequestFun(window.handleSelectionRequest). + Connect(backend.connection, window.xWindow.Id) window.SetTheme(backend.theme) window.SetConfig(backend.config) @@ -288,7 +290,10 @@ func (window *window) Hide () { } func (window *window) Copy (data data.Data) { - // TODO + selectionName := "CLIPBOARD" + selectionAtom, err := xprop.Atm(window.backend.connection, selectionName) + if err != nil { return } + window.selectionClaim = window.claimSelection(selectionAtom, data) } func (window *window) Paste (callback func (data.Data, error), accept ...data.Mime) { -- 2.46.1 From 017543aa0f0d31fb99504ec5ae01016f6c2e8b6f Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Thu, 30 Mar 2023 18:05:29 -0400 Subject: [PATCH 11/17] Temporary redirect to patched xgbutil that will only work on my machine --- backends/x/event.go | 1 + backends/x/selection.go | 2 ++ backends/x/selectionclaim.go | 9 +++++++-- backends/x/window.go | 8 +++----- go.mod | 2 ++ 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/backends/x/event.go b/backends/x/event.go index 53552d4..58c6cf4 100644 --- a/backends/x/event.go +++ b/backends/x/event.go @@ -264,6 +264,7 @@ func (window *window) handleSelectionRequest ( connection *xgbutil.XUtil, event xevent.SelectionRequestEvent, ) { + println("got sel req") if window.selectionClaim == nil { return } window.selectionClaim.handleSelectionRequest(connection, event) } diff --git a/backends/x/selection.go b/backends/x/selection.go index ddfb9c9..8529d94 100644 --- a/backends/x/selection.go +++ b/backends/x/selection.go @@ -9,6 +9,8 @@ import "github.com/jezek/xgbutil/xprop" import "github.com/jezek/xgbutil/xevent" import "git.tebibyte.media/sashakoshka/tomo/data" +const clipboardName = "CLIPBOARD" + type selReqState int; const ( selReqStateClosed selReqState = iota selReqStateAwaitTargets diff --git a/backends/x/selectionclaim.go b/backends/x/selectionclaim.go index 3ded6a8..f3e0b6e 100644 --- a/backends/x/selectionclaim.go +++ b/backends/x/selectionclaim.go @@ -17,7 +17,7 @@ type selectionClaim struct { func (window *window) claimSelection (name xproto.Atom, data data.Data) *selectionClaim { // Follow: // https://tronche.com/gui/x/icccm/sec-2.html#s-2.1 - + // A client wishing to acquire ownership of a particular selection // should call SetSelectionOwner. The client should set the specified // selection to the atom that represents the selection, set the @@ -35,6 +35,11 @@ func (window *window) claimSelection (name xproto.Atom, data data.Data) *selecti window.xWindow.Id, name, 0).Check() // FIXME: should not be zero if err != nil { return nil } + ownerReply, err := xproto.GetSelectionOwner ( + window.backend.connection.Conn(), name).Reply() + if err != nil { return nil } + if ownerReply.Owner != window.xWindow.Id { return nil} + return &selectionClaim { window: window, data: data, @@ -139,7 +144,7 @@ func (claim *selectionClaim) handleSelectionRequest ( for index, name := range targetNames { atom, err := xprop.Atm(claim.window.backend.connection, name) if err != nil { die(); return } - xgb.Put32(data[:index * 4], uint32(atom)) + xgb.Put32(data[:(index + 1) * 4], uint32(atom)) } claim.window.fulfillSelectionRequest(data, 8, event) diff --git a/backends/x/window.go b/backends/x/window.go index 2d0b750..d8a273a 100644 --- a/backends/x/window.go +++ b/backends/x/window.go @@ -290,8 +290,7 @@ func (window *window) Hide () { } func (window *window) Copy (data data.Data) { - selectionName := "CLIPBOARD" - selectionAtom, err := xprop.Atm(window.backend.connection, selectionName) + selectionAtom, err := xprop.Atm(window.backend.connection, clipboardName) if err != nil { return } window.selectionClaim = window.claimSelection(selectionAtom, data) } @@ -307,9 +306,8 @@ func (window *window) Paste (callback func (data.Data, error), accept ...data.Mi return } - selectionName := "CLIPBOARD" - propertyName := "TOMO_SELECTION" - selectionAtom, err := xprop.Atm(window.backend.connection, selectionName) + propertyName := "TOMO_SELECTION" + selectionAtom, err := xprop.Atm(window.backend.connection, clipboardName) if err != nil { die(err); return } propertyAtom, err := xprop.Atm(window.backend.connection, propertyName) if err != nil { die(err); return } diff --git a/go.mod b/go.mod index 20559ab..7a0fc0a 100644 --- a/go.mod +++ b/go.mod @@ -22,3 +22,5 @@ require ( github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 // indirect github.com/jezek/xgb v1.1.0 ) + +replace github.com/jezek/xgbutil => /home/sashakoshka/repos/xgbutil -- 2.46.1 From a16f3c2cd7bef96766f008a125c615ab3e27b948 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Thu, 30 Mar 2023 18:32:14 -0400 Subject: [PATCH 12/17] TARGETS list is now properly assembled --- backends/x/event.go | 1 - backends/x/selectionclaim.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/backends/x/event.go b/backends/x/event.go index 58c6cf4..53552d4 100644 --- a/backends/x/event.go +++ b/backends/x/event.go @@ -264,7 +264,6 @@ func (window *window) handleSelectionRequest ( connection *xgbutil.XUtil, event xevent.SelectionRequestEvent, ) { - println("got sel req") if window.selectionClaim == nil { return } window.selectionClaim.handleSelectionRequest(connection, event) } diff --git a/backends/x/selectionclaim.go b/backends/x/selectionclaim.go index f3e0b6e..2ccb0b6 100644 --- a/backends/x/selectionclaim.go +++ b/backends/x/selectionclaim.go @@ -144,7 +144,7 @@ func (claim *selectionClaim) handleSelectionRequest ( for index, name := range targetNames { atom, err := xprop.Atm(claim.window.backend.connection, name) if err != nil { die(); return } - xgb.Put32(data[:(index + 1) * 4], uint32(atom)) + xgb.Put32(data[(index) * 4:], uint32(atom)) } claim.window.fulfillSelectionRequest(data, 8, event) -- 2.46.1 From 17422cc054590c141c6550503c78b37736ce1ed0 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Thu, 30 Mar 2023 18:42:40 -0400 Subject: [PATCH 13/17] selectionClaim seeks to the start of the data before reading it --- backends/x/selectionclaim.go | 1 + data/data.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backends/x/selectionclaim.go b/backends/x/selectionclaim.go index 2ccb0b6..2b13ec4 100644 --- a/backends/x/selectionclaim.go +++ b/backends/x/selectionclaim.go @@ -153,6 +153,7 @@ func (claim *selectionClaim) handleSelectionRequest ( if confidence == confidenceNone { die(); return } reader, ok := claim.data[mime] if !ok { die(); return } + reader.Seek(0, io.SeekStart) data, err := io.ReadAll(reader) if err != nil { die() } claim.window.fulfillSelectionRequest(data, 32, event) diff --git a/data/data.go b/data/data.go index 9b43e20..9e99b3b 100644 --- a/data/data.go +++ b/data/data.go @@ -5,7 +5,7 @@ import "bytes" // Data represents arbitrary polymorphic data that can be used for data transfer // between applications. -type Data map[Mime] io.ReadCloser +type Data map[Mime] io.ReadSeekCloser // Mime represents a MIME type. type Mime struct { -- 2.46.1 From 0d4104255c86769145f45d09035e5335e48730c5 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Thu, 30 Mar 2023 18:52:29 -0400 Subject: [PATCH 14/17] Selection data is property sent to the requestor --- backends/x/selectionclaim.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backends/x/selectionclaim.go b/backends/x/selectionclaim.go index 2b13ec4..0a9741e 100644 --- a/backends/x/selectionclaim.go +++ b/backends/x/selectionclaim.go @@ -76,7 +76,7 @@ func (window *window) fulfillSelectionRequest ( // specified target. err := xproto.ChangePropertyChecked ( window.backend.connection.Conn(), - xproto.PropModeReplace, window.xWindow.Id, + xproto.PropModeReplace, request.Requestor, request.Property, request.Target, format, uint32(len(data) / (int(format) / 8)), data).Check() @@ -146,7 +146,7 @@ func (claim *selectionClaim) handleSelectionRequest ( if err != nil { die(); return } xgb.Put32(data[(index) * 4:], uint32(atom)) } - claim.window.fulfillSelectionRequest(data, 8, event) + claim.window.fulfillSelectionRequest(data, 32, event) default: mime, confidence := targetToMime(targetName) @@ -156,6 +156,6 @@ func (claim *selectionClaim) handleSelectionRequest ( reader.Seek(0, io.SeekStart) data, err := io.ReadAll(reader) if err != nil { die() } - claim.window.fulfillSelectionRequest(data, 32, event) + claim.window.fulfillSelectionRequest(data, 8, event) } } -- 2.46.1 From 6456759bfc461c1e6fc9e2e90ce7b8d2cc0377e0 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Thu, 30 Mar 2023 20:51:11 -0400 Subject: [PATCH 15/17] The targets list now has the proper type of ATOM --- backends/x/selection.go | 1 - backends/x/selectionclaim.go | 17 ++++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/backends/x/selection.go b/backends/x/selection.go index 8529d94..886dc2c 100644 --- a/backends/x/selection.go +++ b/backends/x/selection.go @@ -237,7 +237,6 @@ func (request *selectionRequest) handleSelectionNotify ( // to True. As previously discussed, the owner has no way of knowing // when the data has been transferred to the requestor unless the // property is removed. - if err != nil { request.die(err); return } err = xproto.DeletePropertyChecked ( request.window.backend.connection.Conn(), request.window.xWindow.Id, diff --git a/backends/x/selectionclaim.go b/backends/x/selectionclaim.go index 0a9741e..c5fefb7 100644 --- a/backends/x/selectionclaim.go +++ b/backends/x/selectionclaim.go @@ -38,7 +38,7 @@ func (window *window) claimSelection (name xproto.Atom, data data.Data) *selecti ownerReply, err := xproto.GetSelectionOwner ( window.backend.connection.Conn(), name).Reply() if err != nil { return nil } - if ownerReply.Owner != window.xWindow.Id { return nil} + if ownerReply.Owner != window.xWindow.Id { return nil } return &selectionClaim { window: window, @@ -63,8 +63,9 @@ func (window *window) refuseSelectionRequest (request xevent.SelectionRequestEve } func (window *window) fulfillSelectionRequest ( - data []byte, - format byte, + data []byte, + format byte, + ty xproto.Atom, request xevent.SelectionRequestEvent, ) { die := func () { window.refuseSelectionRequest(request) } @@ -78,7 +79,7 @@ func (window *window) fulfillSelectionRequest ( window.backend.connection.Conn(), xproto.PropModeReplace, request.Requestor, request.Property, - request.Target, format, + ty, format, uint32(len(data) / (int(format) / 8)), data).Check() if err != nil { die() } @@ -136,7 +137,7 @@ func (claim *selectionClaim) handleSelectionRequest ( switch targetName { case "TARGETS": - targetNames := []string { } + targetNames := []string { "TARGETS", } for mime := range claim.data { targetNames = append(targetNames, mimeToTargets(mime)...) } @@ -146,7 +147,9 @@ func (claim *selectionClaim) handleSelectionRequest ( if err != nil { die(); return } xgb.Put32(data[(index) * 4:], uint32(atom)) } - claim.window.fulfillSelectionRequest(data, 32, event) + atomAtom, err := xprop.Atm(claim.window.backend.connection, "ATOM") + if err != nil { die(); return } + claim.window.fulfillSelectionRequest(data, 32, atomAtom, event) default: mime, confidence := targetToMime(targetName) @@ -156,6 +159,6 @@ func (claim *selectionClaim) handleSelectionRequest ( reader.Seek(0, io.SeekStart) data, err := io.ReadAll(reader) if err != nil { die() } - claim.window.fulfillSelectionRequest(data, 8, event) + claim.window.fulfillSelectionRequest(data, 8, event.Target, event) } } -- 2.46.1 From 6406b70077b47a2d2cc1815244639523f610dd7f Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Thu, 30 Mar 2023 21:33:49 -0400 Subject: [PATCH 16/17] Add cut capability to textmanip --- go.sum | 8 -------- textmanip/textmanip.go | 25 +++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/go.sum b/go.sum index 6cf2167..28b6771 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,3 @@ -git.tebibyte.media/sashakoshka/ezprof v0.0.0-20230309011813-4cd4374e3830 h1:McIAkTzD4y0tS7YprTOwRu8a8NTsMKtomQnvxnCdOmg= -git.tebibyte.media/sashakoshka/ezprof v0.0.0-20230309011813-4cd4374e3830/go.mod h1:cpXX8SAUDEvZX5m7scoyruavUhEqQ1SByfWzPFHkTbg= -git.tebibyte.media/sashakoshka/ezprof v0.0.0-20230309013013-f7ee80c8f908 h1:kFdch6JQ/gWESn/vLeKzImzf3Xx1it2MkZqBwrSnR1Q= -git.tebibyte.media/sashakoshka/ezprof v0.0.0-20230309013013-f7ee80c8f908/go.mod h1:cpXX8SAUDEvZX5m7scoyruavUhEqQ1SByfWzPFHkTbg= -git.tebibyte.media/sashakoshka/ezprof v0.0.0-20230309013201-fc0de8121523 h1:1KaoiGetWYIDQKts6yas1hW+4ObkuTm6+TkFpl6jZxg= -git.tebibyte.media/sashakoshka/ezprof v0.0.0-20230309013201-fc0de8121523/go.mod h1:cpXX8SAUDEvZX5m7scoyruavUhEqQ1SByfWzPFHkTbg= git.tebibyte.media/sashakoshka/ezprof v0.0.0-20230309044548-401cba83602b h1:vPFKR7vjN1VrMdMtpATMrKQobz/cqbPiRrA1EbtG6PM= git.tebibyte.media/sashakoshka/ezprof v0.0.0-20230309044548-401cba83602b/go.mod h1:cpXX8SAUDEvZX5m7scoyruavUhEqQ1SByfWzPFHkTbg= github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 h1:1qlsVAQJXZHsaM8b6OLVo6muQUQd4CwkH/D3fnnbHXA= @@ -27,8 +21,6 @@ github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/jezek/xgb v1.1.0 h1:wnpxJzP1+rkbGclEkmwpVFQWpuE2PUGNUzP8SbfFobk= github.com/jezek/xgb v1.1.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= -github.com/jezek/xgbutil v0.0.0-20210302171758-530099784e66 h1:+wPhoJD8EH0/bXipIq8Lc2z477jfox9zkXPCJdhvHj8= -github.com/jezek/xgbutil v0.0.0-20210302171758-530099784e66/go.mod h1:KACeV+k6b+aoLTVrrurywEbu3UpqoQcQywj4qX8aQKM= github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= diff --git a/textmanip/textmanip.go b/textmanip/textmanip.go index e86d6b7..5f2d181 100644 --- a/textmanip/textmanip.go +++ b/textmanip/textmanip.go @@ -42,6 +42,15 @@ func (dot Dot) Constrain (length int) Dot { return dot } +func (dot Dot) Width () int { + dot = dot.Canon() + return dot.End - dot.Start +} + +func (dot Dot) Slice (text []rune) []rune { + return text[dot.Start:dot.End] +} + func WordToLeft (text []rune, position int) (length int) { if position < 1 { return } if position > len(text) { position = len(text) } @@ -121,6 +130,22 @@ func Delete (text []rune, dot Dot, word bool) (result []rune, moved Dot) { } } +func Lift (text []rune, dot Dot) (result []rune, moved Dot, lifted []rune) { + dot = dot.Constrain(len(text)) + if dot.Empty() { + moved = dot + return + } + + dot = dot.Canon() + lifted = make([]rune, dot.Width()) + copy(lifted, dot.Slice(text)) + result = append(result, text[:dot.Start]...) + result = append(result, text[dot.End:]...) + moved = EmptyDot(dot.Start) + return +} + func Type (text []rune, dot Dot, character rune) (result []rune, moved Dot) { dot = dot.Constrain(len(text)) if dot.Empty() { -- 2.46.1 From e7ad588fb8aa38e1bb73479a08c1cc9a34cf5598 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Thu, 30 Mar 2023 21:37:57 -0400 Subject: [PATCH 17/17] Apparently go mod replace can do this! --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 7a0fc0a..2363371 100644 --- a/go.mod +++ b/go.mod @@ -23,4 +23,4 @@ require ( github.com/jezek/xgb v1.1.0 ) -replace github.com/jezek/xgbutil => /home/sashakoshka/repos/xgbutil +replace github.com/jezek/xgbutil => github.com/sashakoshka/xgbutil v0.0.0-20230330215824-bebecf813e8b diff --git a/go.sum b/go.sum index 28b6771..362be1c 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/sashakoshka/xgbutil v0.0.0-20230330215824-bebecf813e8b h1:jkUYWTFGCavJ5I4/lkireJ9KxOxEWKLHB9SPoieIHro= +github.com/sashakoshka/xgbutil v0.0.0-20230330215824-bebecf813e8b/go.mod h1:AHecLyFNy6AN9f/+0AH/h1MI7X1+JL5bmCz4XlVZk7Y= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -- 2.46.1