Subject: Re: bug#28620: (PARTIAL SOLUTION) Mouse drag event records wrong window for release when crossing frames

From: Bob Weiner <[email-masked]>
Date: Mon, 16 Oct 2017 11:57:33 -0400

Robert Weiner <[email-masked]> writes:

> With Emacs 25.3 under MacOS 10.12, a drag with mouse-1 depressed from
> the text area of frame F1 to the text area of frame F2 improperly
> generates a drag event whose (posn-window (event-end <event>)) shows
> F1 rather than F2.
>
> Note that for a drag between frames, posn-window should return a frame
> (according to the Elisp manual but not its own doc string).  The bug is
> that the event itself records the wrong frame (the depress frame rather
> than the release frame).
>
> I have confirmed this with Emacs 25.2 under Windows 7 as well.

I have this fixed and working nicely for the GNU Hyperbole package,
which does extensive mouse drag handling.  Hyperbole can now drag buffer
menu items, dired items, and buffers themselves across frames and
display them in any window that receives a release event.  It can also
tell whether the topmost application window at the point of release was
an Emacs frame or a window of another application (for macOS windowing
only), so drags can start in Emacs and end outside.

But this required a good bit of code that I include below just
so you can see what was required.  It would be much simpler once the
drag event release C code in Emacs is improved based upon this work to
at least return the actual window of drag release.

Bob

----------
Code from forthcoming Hyperbole 6.0.2e pre-release (the Python script
at the end will be converted to Objective-C at some point):

(defvar action-key-depress-args nil
  "List of mouse event args from most recent depress of the Action Key.")
(defvar assist-key-depress-args nil
  "List of mouse event args from most recent depress of the Assist Key.")

(defvar action-key-release-args nil
  "List of mouse event args from most recent release of the Action Key.")
(defvar assist-key-release-args nil
  "List of mouse event args from most recent release of the Assist Key.")

(defvar action-key-depress-window nil
  "The last window in which the Action Key was depressed or nil.
This is set to nil when the depress is on an inactive minibuffer.")
(defvar assist-key-depress-window nil
  "The last window in which the Assist Key was depressed or nil.
This is set to nil when the depress is on an inactive minibuffer.")
(defvar action-key-release-window nil
  "The last window in which the Action Key was released or nil.")
(defvar assist-key-release-window nil
  "The last window in which the Assist Key was released or nil.")

(defvar action-key-depress-position nil
  "The last screen position at which the Action Key was depressed or nil.")
(defvar assist-key-depress-position nil
  "The last screen position at which the Assist Key was depressed or nil.")

(defun hmouse-key-release-args-emacs (event)
  "For GNU Emacs, return a possibly modified version of EVENT as a list.
For mouse drags and double and triple clicks, remove any depress location,
compute the actual release location and include that."
  (if (integerp event)
      (list event)
    (let ((ev-type-str (and (listp event) (symbol-name (car event)))))
      (if (or (and ev-type-str
		   (string-match "\\(double\\|triple\\)-mouse" ev-type-str))
	      (not (= (length event) 3)))
	  event
	(let ((pos (event-end event))
	      coords window-and-char-coords)
	  (when (and ev-type-str (string-match "drag-mouse" ev-type-str)
		     ;; end of drag event; If drag crossed frames, the location
		     ;; will contain the frame of the depress point and
		     ;; some relative coordinates; change these to the window of
		     ;; release and window's character coordinates if within a window
		     ;; and to nil if outside of Emacs (as best we can tell).
		     (framep (posn-window pos)))
	    (setq window-and-char-coords (hmouse-window-coordinates event)
		  window (car window-and-char-coords)
		  coords (cadr window-and-char-coords))
	    ;; Modify the values in the event-end structure even if no
	    ;; valid window was found.
	    (setcar pos window)
	    (setcar (nthcdr 2 pos) coords)))
	;; Remove depress coordinates and send only original release coordinates.
	(list (car event) (nth 2 event))))))

(defun hmouse-window-coordinates (&optional event)
  "Return a list (window (x-chars . y-chars)) for optional EVENT.
Always ignores EVENT coordinates and uses current mouse position.
The area of the EVENT is utilized. If EVENT is not given and the
free variable `assist-flag' is non-nil, EVENT is set to
`assist-key-release-args', otherwise, `action-key-release-args'.

The coordinates x-chars and y-chars are relative character
coordinates within the window.  If POSITION is not in a live
window, return nil.  Considers all windows on the selected frame's display."
  (interactive)
  (unless (eventp event)
    (setq event (if assist-flag assist-key-release-args action-key-release-args)))
  (let* ((position (mouse-absolute-pixel-position))
	 (pos-x (car position))
	 (pos-y (cdr position))
	 (window (hmouse-window-at-absolute-pixel-position position t))
	 (edges (when (window-live-p window) (window-edges window t t t)))
	 left top right bottom
	 frame)
    (when edges
      (setq left   (nth 0 edges)
	    top    (nth 1 edges)
	    right  (nth 2 edges)
	    bottom (nth 3 edges))
      (when (or (and event (eq (posn-area (event-start event)) 'mode-line))
		(and (>= pos-x left) (<= pos-x right)
		     (>= pos-y top)  (<= pos-y bottom)))
	;; If position is in a live window, compute position's character
	;; coordinates within the window and return the window with these
	;; coordinates.
	(setq frame (window-frame window)
	      pos-x (round (/ (- pos-x left) (frame-char-width frame)))
	      pos-y (round (/ (- pos-y top)  (+ (frame-char-height frame)
						(hmouse-vertical-line-spacing frame)))))))
    (when (called-interactively-p 'interactive)
      (message "%s at %s coordinates (%s . %s)"
	       (if edges window "No live Emacs window")
	       (if frame "character" "absolute pixel")
	       pos-x pos-y))
    (when edges (list window (cons pos-x pos-y)))))

(defvar hmouse-verify-release-window-flag t
  "When non-nil under the macOS window system, verifies the application of top-most window.
Forces a window system check at a screen position that the top-most
window there is an Emacs frame or treats the location as outside Emacs,
even though an Emacs frame may be below the top-most window.

See function `hmouse-window-at-absolute-pixel-position' for more details.")

(defun hmouse-window-at-absolute-pixel-position (&optional position release-flag)
  "Return the top-most Emacs window at optional POSITION ((x . y) in absolute pixels) or mouse position.
If POSITION is not in a window, return nil.  Considers all windows on
the same display as the selected frame.

If optional RELEASE-FLAG is non-nil, this is part of a Smart Key
release computation, so optimize window selection based on the depress
window already computed.

If the selected frame is a graphical macOS window and
`hmouse-verity-release-window-flag' is non-nil, then return the
top-most Emacs window only if it is the top-most application window at
the position (not below another application's window)."
  (interactive)
  (setq position (or position (mouse-absolute-pixel-position)))
  ;; Proper top-to-bottom listing of frames is available only in Emacs
  ;; 26 and above.  For prior versions, the ordering of the frames
  ;; returned is not guaranteed, so the frame whose window is returned
  ;; may not be the uppermost.
  (let* ((top-to-bottom-frames (if (fboundp 'frame-list-z-order)
				   (frame-list-z-order)
				 (frame-list)))
	 (pos-x (car position))
	 (pos-y (cdr position))
	 edges left top right bottom
	 frame
	 in-frame
	 window)
    ;; First find top-most frame containing position.
    (while (and (not in-frame) top-to-bottom-frames)
      (setq frame (car top-to-bottom-frames)
	    top-to-bottom-frames (cdr top-to-bottom-frames))
      ;; Check that in-frame is valid with frame-live-p since under macOS
      ;; when position is outside a frame, in-frame could be invalid and
      ;; frame-visible-p would trigger an error in that case.
      (when (and (frame-live-p frame) (frame-visible-p frame))
	(setq edges (frame-edges frame)
	      left   (nth 0 edges)
	      top    (nth 1 edges)
	      right  (nth 2 edges)
	      bottom (nth 3 edges))
	(when (and (>= pos-x left) (<= pos-x right)
		   (>= pos-y top)  (<= pos-y bottom))
	  (setq in-frame frame))))
    ;; If in-frame is found, find which of its windows contains
    ;; position and return that.  The window-at call below requires
    ;; character coordinates relative to in-frame, so compute them.
    (when in-frame
      (let ((depress-position (and release-flag (if assist-flag
						    assist-key-depress-position
						  action-key-depress-position)))
	    (depress-window  (and release-flag (if assist-flag
						   assist-key-depress-window
						 action-key-depress-window))))
	(if (and release-flag depress-window (equal position depress-position))
	    ;; This was a click, so we know that the frame of the click
	    ;; is topmost on screen or the mouse events would not have
	    ;; been routed to Emacs.  Reuse saved window of depress rather
	    ;; then running possibly expensive computation to find the
	    ;; topmost application window.
	    (setq window depress-window)
	  (let ((char-x (/ (- pos-x left) (frame-char-width in-frame)))
		(line-y (/ (- pos-y top) (+ (frame-char-height in-frame)
					    (hmouse-vertical-line-spacing in-frame)))))
	    (setq window (window-at char-x line-y in-frame)))
	  ;;
	  ;; Even if in-frame is found, under click-to-focus external window
	  ;; managers, Emacs may have received the drag release event when
	  ;; in-frame was covered by an external application's window.
	  ;; Emacs presently has no way to handle this.  However, for the
	  ;; macOS window system only, Hyperbole has a Python script, topwin, which
	  ;; computes the application of the topmost window at the point of release.
	  ;; If that is Emacs, then we have the right window and nothing need be
	  ;; done; otherwise, set window to nil and return.
	  ;;
	  (when (and hmouse-verify-release-window-flag
		     window (eq (window-system) 'ns))
	    (let ((topwin (executable-find "topwin"))
		  (case-fold-search t)
		  topmost-app)
	      (when (and topwin (file-executable-p topwin))
		(setq topmost-app (shell-command-to-string
				   (format "topwin %d %d" pos-x pos-y)))
		(cond ((string-match "emacs" topmost-app)) ; In an Emacs frame, do nothing.
		      ((or (equal topmost-app "")
			   ;; Any non-Emacs app window
			   (string-match "\\`\\[" topmost-app))
		       ;; Outside of any Emacs frame
		       (setq window nil))
		      (t		; topwin error message
		       ;; Setup of the topwin script is somewhat complicated,
		       ;; so don't trigger an error just because of it.  But
		       ;; display a message so the user knows something happened
		       ;; when topwin encounters an error.
		       (message "(Hyperbole): topwin Python script error: %s" topmost-app)))))))))

    (when (called-interactively-p 'interactive)
      (message "%s at absolute pixel position %s"
	       (or window "No Emacs window") position))
    window))

;; Based on code from subr.el.
(defun hmouse-vertical-line-spacing (frame)
  "Return any extra vertical spacing in pixels between text lines or 0 if none."
  (let ((spacing (when (display-graphic-p frame)
                   (or (with-current-buffer (window-buffer (frame-selected-window frame))
                         line-spacing)
		       (frame-parameter frame 'line-spacing)))))
    (cond ((floatp spacing)
	   (setq spacing (truncate (* spacing (frame-char-height frame)))))
	  ((null spacing)
	   (setq spacing 0)))
    spacing))

------
#!python2
#
# SUMMARY:      topwin: Outputs the [application name] of the topmost window at mouse screen position or nothing if none
# USAGE:        <script> <x-screen-coordinate> <y-screen-coordinate>
#
# REQUIRES:     macOS window system and the python2 and the PyObjC libraries available here: https://pythonhosted.org/pyobjc/install.html
#
# AUTHOR:       Bob Weiner <[email-masked]>
# ORIG-DATE:    14-Oct-17 at 16:21:53
#
# Copyright (C) 2017  Free Software Foundation, Inc.
# See the "HY-COPY" file for license information.
#
# DESCRIPTION:  
# DESCRIP-END.

import Quartz
from sys import argv, exit

if len(argv) < 3:
    print "%s: ERROR - Call with 2 numeric arguments, X and Y representing an absolute screen position" % argv[0]
    exit(1)

x = int(argv[1]); y = int(argv[2])

# Return the first window only that x,y falls within since the windows are listed in z-order (top of stack to bottom)
def filter_and_print_top_window(x, y):
    win_x = win_y = win_width = win_height = 0

    for v in Quartz.CGWindowListCopyWindowInfo( Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements, Quartz.kCGNullWindowID ):
        val = v.valueForKey_
        bounds_val = val('kCGWindowBounds').valueForKey_
        
        # If item has a non-zero WindowLayer, it is not an app and is probably system-level, so skip it.
        if not val('kCGWindowIsOnscreen') or val('kCGWindowLayer'):
            continue

        win_x = int(bounds_val('X')); win_y = int(bounds_val('Y'))
        win_width = int(bounds_val('Width')); win_height = int(bounds_val('Height'))

        if win_x <= x and x < win_x + win_width and win_y <= y and y < win_y + win_height:
            print ('[' + ((val('kCGWindowOwnerName') or '') + ']')).encode('utf8')
            # Add this line back in if you need to see the specific window within the app at the given position.
            # + ('' if val('kCGWindowName') is None else (' ' + val('kCGWindowName') or '')) \

            break

# Filter to just the topmost window at (x,y) screen coordinates and print the bracketed [window name], if any.
filter_and_print_top_window(x, y)