From 98fa32e3fdda1050ff061dc325c93de4c8563506 Mon Sep 17 00:00:00 2001 From: brianzhouzc Date: Tue, 12 Nov 2024 14:45:20 -0800 Subject: [PATCH 1/4] feat: add automatic tooltip repositioning for viewport overflow Added logic that detects when tooltips would render outside the viewport and automatically flips their position to maintain visibility. --- tktooltip/tooltip.py | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/tktooltip/tooltip.py b/tktooltip/tooltip.py index f79f712..2f25924 100644 --- a/tktooltip/tooltip.py +++ b/tktooltip/tooltip.py @@ -10,6 +10,8 @@ from enum import Enum, auto from typing import Any, Callable +import screeninfo + # This code is based on Tucker Beck's implementation licensed under an MIT License # Original code: http://code.activestate.com/recipes/576688-tooltip-for-tkinter/ @@ -93,8 +95,9 @@ def __init__( self.delay = delay self.follow = follow self.refresh = refresh - self.x_offset = x_offset - self.y_offset = y_offset + # ensure a minimum offset of 2 to avoid flickering + self.x_offset = x_offset if x_offset > 0 else 2 + self.y_offset = y_offset if y_offset > 0 else 2 # visibility status of the ToolTip inside|outside|visible self.status = ToolTipStatus.OUTSIDE self.last_moved = 0 @@ -107,6 +110,7 @@ def __init__( **self.message_kwargs, ) self.message_widget.grid() + self.monitors = screeninfo.get_monitors() self.bindigs = self._init_bindings() def _init_bindings(self) -> list[Binding]: @@ -150,7 +154,34 @@ def _update_tooltip_coords(self, event: tk.Event) -> None: """ Updates the ToolTip's position. """ - self.geometry(f"+{event.x_root + self.x_offset}+{event.y_root + self.y_offset}") + w_left = event.x_root + self.x_offset + w_top = event.y_root + self.y_offset + + w_right = w_left + self.message_widget.winfo_width() + w_bottom = w_top + self.message_widget.winfo_height() + + for monitor in self.monitors: + # check if cursor is in monitor + if ( + monitor.x <= event.x_root < monitor.x + monitor.width + and monitor.y <= event.y_root < monitor.y + monitor.height + ): + # flip tooltip if it exceedes the monitor's boundaries + if w_right > monitor.x + monitor.width: + w_left = ( + event.x_root - self.x_offset - self.message_widget.winfo_width() + ) + + if w_bottom > monitor.y + monitor.height: + w_top = ( + event.y_root + - self.y_offset + - self.message_widget.winfo_height() + ) + + break + + self.geometry(f"+{w_left}+{w_top}") def _update_message(self) -> None: """Update the message displayed in the tooltip.""" From cd85389eb4ebc7df56cdda053856af16782b980c Mon Sep 17 00:00:00 2001 From: brianzhouzc Date: Wed, 13 Nov 2024 15:43:09 -0800 Subject: [PATCH 2/4] Made tooltip fit within monitor horizontally. Only flip vertically. Made tooltip top-most --- tktooltip/tooltip.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tktooltip/tooltip.py b/tktooltip/tooltip.py index 2f25924..eddc2fb 100644 --- a/tktooltip/tooltip.py +++ b/tktooltip/tooltip.py @@ -87,6 +87,8 @@ def __init__( self.withdraw() # Hide initially in case there is a delay # Disable ToolTip's title bar self.overrideredirect(True) + # Mkae ToolTip topmost to display over taskbar + self.attributes("-topmost", True) # StringVar instance for msg string|function self.msg_var = tk.StringVar() @@ -166,13 +168,17 @@ def _update_tooltip_coords(self, event: tk.Event) -> None: monitor.x <= event.x_root < monitor.x + monitor.width and monitor.y <= event.y_root < monitor.y + monitor.height ): - # flip tooltip if it exceedes the monitor's boundaries - if w_right > monitor.x + monitor.width: + # move tooltip left if it exceedes the monitor's right boundary + if w_right >= monitor.x + monitor.width: w_left = ( - event.x_root - self.x_offset - self.message_widget.winfo_width() + monitor.x + + monitor.width + - self.message_widget.winfo_width() + - 2 ) - if w_bottom > monitor.y + monitor.height: + # flip tooltip if it exceedes the monitor's bottom boundary + if w_bottom >= monitor.y + monitor.height: w_top = ( event.y_root - self.y_offset From 1c0e0a784735a507a7daa97c230b5fe7ccb104d2 Mon Sep 17 00:00:00 2001 From: brianzhouzc Date: Tue, 19 Nov 2024 12:41:00 -0800 Subject: [PATCH 3/4] Added screeninfo as a dependency to setup.cfg --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index 72e6abc..768ee38 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,6 +42,7 @@ python_requires = >= 3.7 install_requires = importlib-metadata; python_version < "3.8" typing-extensions; python_version < "3.8" + screeninfo [options.packages.find] exclude = From 3a95fb51b93f01bb4731b7ae14556587d4ef67a0 Mon Sep 17 00:00:00 2001 From: brianzhouzc Date: Thu, 21 Nov 2024 13:27:25 -0800 Subject: [PATCH 4/4] Added test case for tooltip overflow --- test/test_tktooltip.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/test_tktooltip.py b/test/test_tktooltip.py index 1688f85..989a25b 100644 --- a/test/test_tktooltip.py +++ b/test/test_tktooltip.py @@ -1,7 +1,9 @@ import tkinter as tk from typing import Callable +from unittest.mock import patch import pytest +from screeninfo.common import Monitor from tktooltip import ToolTip, ToolTipStatus @@ -91,3 +93,29 @@ def test_tooltip_destroy(widget: tk.Widget): tooltip.destroy() print(tooltip.bindigs) assert tooltip.bindigs == [] + + +@pytest.fixture +def get_monitors(): + return [ + Monitor(x=0, y=0, width=1920, height=1080, name="DISPLAY1", is_primary=True), + Monitor(x=1920, y=0, width=1366, height=768, name="DISPLAY2", is_primary=False), + Monitor( + x=3286, y=0, width=1920, height=1080, name="DISPLAY3", is_primary=False + ), + ] + + +def test_tooltip_overflow(widget: tk.Widget, get_monitors: list[Monitor]): + with patch("screeninfo.get_monitors", return_value=get_monitors): + tooltip = ToolTip(widget, msg="Test\nTest") + widget.event_generate("", rootx=3280, rooty=760) + tooltip._show() + assert ( + tooltip.message_widget.winfo_rootx() + tooltip.message_widget.winfo_width() + < 3286 + ) + assert ( + tooltip.message_widget.winfo_rooty() + tooltip.message_widget.winfo_height() + < 768 + )