From 39dc09bc4aabf8bd48a77d28f4d25deaa31d9a11 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Wed, 29 Mar 2023 02:55:12 -0400 Subject: [PATCH] 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)