From 0aede3502bfc226834142425200bcb8f51c992d5 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Wed, 29 Mar 2023 00:50:23 -0400 Subject: [PATCH] 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