# windetect.tcl --
#
#	A library that provides the detection functionality for
#	tracking windows in a GUI.
#
# 
# Copyright 2020 Erik Leunissen
#
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# 
# 	http://www.apache.org/licenses/LICENSE-2.0
# 
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

namespace eval ::windetect {}


# ::windetect::init --
#
#	Sets initial values
#
# Arguments: none
#
# Results: none
#
# Side effects:
#	- sets initial values, configurable and not-configurable
#
proc ::windetect::init {} {
	variable Windetect
	set Windetect(version) 1.0.0
	
	# prerequisites
	set errMsg ""
	if {$::tcl_platform(os) eq "Darwin"} {
		set errMsg "windetect $Windetect(version) does not support macOS (or OS X / Mac OS X)"
	} elseif {[catch {package present Tk} result]} {
		set errMsg "windetect requires a running Tk application"
	} elseif {! [package vsatisfies $result 8.5]} {
		set errMsg "windetect $Windetect(version) requires Tk 8.5 or newer"
	}
	if {$errMsg ne ""} {
		namespace delete ::windetect
		return -code error $errMsg
	}
	
	# constants and initial values, not user-configurable
	array set Windetect {
		after ""
		callback callback.default
		delay 50
		detect 0
		options {callback delay}
		pointerWin ""
	}
	
	# save bindings that [detect] modifies
	set Windetect(sav,BindAllEnter) [bind all <Enter>]
	set Windetect(sav,BindAllLeave) [bind all <Leave>]
	set Windetect(sav,BindAllDestroy) [bind all <Destroy>]
	
	package provide windetect $Windetect(version)
}


# ::windetect::callback.default --
#
#	Writes default tracking information to stdout as a list
#
# Arguments:
#	W: the pathname for the window
#	X: the X-coordinate of the mouse pointer at the time of the detection event
#	Y: the Y-coordinate of the mouse pointer at the time of the detection event
#
# Results: none
#
# Side effects: as described above
#
proc ::windetect::callback.default {W X Y} {
	if {$W eq ""} {set W "{}"}
	puts "$W $X $Y"
}


# ::windetect::configure --
#
#	Sets or returns values for configurable variables
#
# Arguments: none
#
# Results: none if ok, or an error message
#
# Side effects: as described
#
proc ::windetect::configure {args} {
	variable Windetect
	
	if {[set nargs [llength $args]] == 0} {
		foreach option $Windetect(options) {
			lappend result -$option $Windetect($option)
		}
		return $result
	} elseif {$nargs == 1} {
		set mode getvalue
	} elseif {[expr {fmod($nargs, 2) != 0}]} {
		return -code error "uneven # arguments"
	} else {
		set mode setvalue
	}
	
	foreach {option value} $args {
		switch -- $option {
			-callback {
				if {$mode eq "getvalue"} {
					return $Windetect(callback)
				} else {
					set Windetect(callback) $value
				}
			}
			-delay {
				if {$mode eq "getvalue"} {
					return $Windetect(delay)
				} else {
					if {! [string is integer -strict $value] || $value < 0} {
						return -code error "invalid value \"$value\" for option \"$option\""
					}
					set Windetect(delay) $value
				}
			}
			default {
				return -code error "invalid option \"$option\""
			}
		}
	}
}


# ::windetect::detect --
#
#	Set or get the detection state
#
# Arguments:
#	state: on/off
#
# Results: the detection state if no argument was supplied, otherwise nothing
#
# Side effects:
#	If switched on, perform an initial [report].
#
proc ::windetect::detect {{state ""}} {
	variable Windetect
	if {$state ni "on off {}"} {
		return -code error "invalid value \"$state\" for state"
	}
	
	if {($state eq "off") && ($Windetect(detect) == 1)} {
		# switch detection off, restoring original bindings for tag "all"
		bind all <Enter> $Windetect(sav,BindAllEnter)
		bind all <Leave> $Windetect(sav,BindAllLeave)
		bind all <Destroy> $Windetect(sav,BindAllDestroy)
		trace remove execution grab leave ::windetect::handleGrabTrace
		after cancel $Windetect(after)
		set Windetect(after) {}
		set Windetect(detect) 0
		return
	} elseif {($state eq "on") && ($Windetect(detect) == 0)} {
		# switch detection on
		bind all <Enter> {+ ::windetect::handleDetectionEvent <Enter> %W %X %Y}
		bind all <Leave> {+ ::windetect::handleDetectionEvent <Leave> %W %X %Y}
		bind all <Destroy> {+ ::windetect::handlePointerWinDestruction %W}
		trace add execution grab leave ::windetect::handleGrabTrace
		
		# determine the initial window under the mouse pointer
		set X [winfo pointerx .]; set Y [winfo pointery .]
		set Windetect(pointerWin) [winfo containing $X $Y]
		
		report <Enter> $Windetect(pointerWin) $X $Y
		set Windetect(detect) 1
		return
	} else {
		return [expr {$Windetect(detect)?"on":"off"}]
	}
}


# ::windetect::exit --
#
#	Stops tracking windows, deletes the namespace and removes the package
#
# Arguments: none
#
# Results: none
#
# Side effects: as described
#
proc ::windetect::exit {} {
	detect off
	namespace delete ::windetect
	package forget windetect
}


# ::windetect::getPointerWindow --
#
#	Returns the pathname of the window under the mouse pointer
#
# Arguments: none
#
# Results: the pathname
#
# Side effects: none
#
proc ::windetect::getPointerWindow {} {
	return [winfo containing [winfo pointerx .] [winfo pointery .]]
}


# ::windetect::handleDetectionEvent --
#
#	Records the name of the window for which a detection event
#	was triggered, and the coordinates of the mouse pointer at the
#	time that the event was triggered.
#	Successive detection events overwrite the previous record; only the
#	last record remains for reporting when the reporting delay expires.
#	Schedules a reporting event if that hadn't been done already.
#
# Arguments:
#	args : the information to be recorded
#
# Results: none
#
# Side effects: see description
#
proc ::windetect::handleDetectionEvent {args} {
	variable Windetect
	
	set Windetect(lastRecord) $args
	if {$Windetect(delay) == 0} {
		report {*}$args
	} elseif {$Windetect(after) eq ""} {
		set Windetect(after) [after $Windetect(delay) {::windetect::report {*}$::windetect::Windetect(lastRecord)}]
	}
}


# ::windetect::handleGrabTrace --
#
#	(trace callback)
#	Record leaving the active area of the client GUI when a grab is set
#
# Arguments:
#	cmd : the complete command as called
#	code : the return code of the traced command
#	args : supplementary arguments appended by the trace mechanism, not used.
#
# Results: none
#
# Side effects: see description
#
proc ::windetect::handleGrabTrace {cmd code args} {
	if {[regexp {^(?:::)?grab(?:\s+set)?(?:\s+-global)?\s+(\..+)$} $cmd -- W]} {
		if {$code == 0} {
			if {([set pw [getPointerWindow]] eq "") || ([winfo toplevel $pw] ne [winfo toplevel $W])} {
				#
				# The mouse pointer resides outside the grabbed window when the grab was
				# set. This boils down to the mouse pointer having left the active area
				# of the client GUI. We handle that event as entering the "" window.
				#
				after idle {::windetect::handleDetectionEvent <Enter> "" -1 -1}
			}
		}
	}
}


# ::windetect::handlePointerWinDestruction --
#
#	Handles <Destroy> events. Doesn't accomplish anything unless the destroyed
#	window is the current pointer window. In that case, [handleDetectionEvent]
#	is called.
#
#	Note that all windows are bound to the <Destroy> event, and that this event
#	handler determines whether it is the last recorded pointer window that
#	triggered the <Destroy> event. This is deemed faster when operating the mouse
#	in a client GUI, than binding selectively to the last recorded pointer
#	window, and needing to update that binding to the new pointer window
#	at each detection event.
#
# Arguments:
#	W : the destroyed window
#
# Results: none
#
# Side effects: see description
#
proc ::windetect::handlePointerWinDestruction {W} {

	# Bail out if the window destroyed isn't the last recorded pointer window
	if {$W ne $::windetect::Windetect(pointerWin)} {
		return
	}
	::windetect::handleDetectionEvent <Destroy> {} [winfo pointerx .] [winfo pointery .]
}


# ::windetect::report --
#
#	Determines the pathname of the new pointer window in case of
#	a <Leave> or <Destroy> event. Records the new pointer window.
#	Calls the callback command, passing the detection information.
#
# Arguments:
#	event : <Leave>, <Enter> or <Destroy>
#	W	  : the window where the event triggered
#	X	  : x screen coordinate
#	Y	  : y screen coordinate
#
# Results: none
#
# Side effects: see description
#
proc ::windetect::report {event W X Y} {
	variable Windetect
	
	set Windetect(after) {}
	
	if {$event eq "<Enter>"} {
		set Windetect(pointerWin) $W
	} else {
		# <Leave> and <Destroy>
		#
		# The mouse pointer left a window and no subsequent <Enter> event occurred
		# within the reporting delay. This happens in the following cases:
		#
		# 1. the mouse pointer remained within the master of the window
		#    that was left.
		# 2. the mouse pointer entered an area of the screen that isn't used
		#    by the client application
		# 3. the mouse pointer left a grabbed window
		# 4. the window under the mouse pointer was destroyed
		#
		# For these cases, we need to determine the new window under the
		# mouse pointer here.
		#
		set Windetect(pointerWin) [winfo containing $X $Y]
	}
	eval [linsert $Windetect(callback) end $Windetect(pointerWin) $X $Y]
}


::windetect::init

#EOF
