Skip to content

Commit

Permalink
Floating scrollbars in Tkinter (#12)
Browse files Browse the repository at this point in the history
* Show scrollbars only when in use
* Consistent hide delay by cancelling old callback
* Do not hide scrollbars when hovering mouse pointer over them
  * If they are hidden, the mouse pointer is now inside the canvas, which triggers a mouse enter event, which shows the scrollbars again.
  * This cycle repeats indefinitely as long as the mouse is not moved, leading to flashing scrollbars.
  • Loading branch information
tfpf authored Aug 25, 2024
1 parent f6904f5 commit 69d9515
Show file tree
Hide file tree
Showing 3 changed files with 68 additions and 10 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ Add widgets to the `frame` attribute of a `ScrollableFrameTk` object.
* Scrolling the mouse wheel while holding down Shift or swiping horizontally with two fingers on the touchpad
triggers a horizontal scroll.
* Horizontally centres the contents if the window is wider.
* Reserves all space in the window for child widgets.
* The scrollbars do not take up any space. When scrolling or moving the cursor into the window, they are shown
briefly, and then hidden.

### Notes
`"<Button-4>"`, `"<Button-5>"` and `"<MouseWheel>"` are bound to all widgets using `bind_all` to handle mouse wheel
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "ScrollableContainers"
version = "2.0.4"
version = "2.1.0rc0"
authors = [
{ name = "Vishal Pankaj Chandratreya" },
]
Expand Down
73 changes: 64 additions & 9 deletions src/ScrollableContainers/_tk.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,27 @@ class ScrollableFrameTk(ttk.Frame):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

# Using the grid geometry manager ensures that the horizontal and
# vertical scrollbars do not touch.
self._xscrollbar = ttk.Scrollbar(self, orient=tk.HORIZONTAL, command=self._xview)
self._xscrollbar.bind("<Enter>", self._on_scrollbar_enter)
self._xscrollbar.bind("<Leave>", self._on_scrollbar_leave)
self._xscrollbar.grid(row=1, column=0, sticky=tk.EW)
self._yscrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self._yview)
self._yscrollbar.bind("<Enter>", self._on_scrollbar_enter)
self._yscrollbar.bind("<Leave>", self._on_scrollbar_leave)
self._yscrollbar.grid(row=0, column=1, sticky=tk.NS)
self._hide_scrollbars_id = None

# Scrollable canvas. This is the widget which actually manages
# scrolling. Using the grid geometry manager ensures that the
# horizontal and vertical scrollbars do not meet.
# scrolling. Initially, it will be above the scrollbars, so the latter
# won't be visible.
self._canvas = tk.Canvas(self)
self._canvas.bind("<Configure>", self._on_canvas_configure)
self._canvas.bind("<Enter>", self._on_canvas_enter)
self._canvas.bind("<Leave>", self._on_canvas_leave)
self._canvas.grid(row=0, column=0, sticky=tk.NSEW)

xscrollbar = ttk.Scrollbar(self, orient=tk.HORIZONTAL, command=self._xview)
xscrollbar.grid(row=1, column=0, sticky=tk.EW)
yscrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self._yview)
yscrollbar.grid(row=0, column=1, sticky=tk.NS)
self._canvas.configure(xscrollcommand=xscrollbar.set, yscrollcommand=yscrollbar.set)
self._canvas.configure(xscrollcommand=self._xscrollbar.set, yscrollcommand=self._yscrollbar.set)
self._canvas.grid(row=0, column=0, rowspan=2, columnspan=2, sticky=tk.NSEW)

self.grid_rowconfigure(0, weight=1)
self.grid_columnconfigure(0, weight=1)
Expand All @@ -46,6 +53,52 @@ def __init__(self, *args, **kwargs):
self._canvas.xview_moveto(0.0)
self._canvas.yview_moveto(0.0)

def _show_scrollbars(self):
"""
Move the horizontal and vertical scrollbars above the scrollable
canvas, effectively showing them.
"""
self._xscrollbar.lift()
self._yscrollbar.lift()

def _hide_scrollbars(self):
"""
Move the horizontal and vertical scrollbars below the scrollable
canvas, effectively hiding them.
"""
self._xscrollbar.lower()
self._yscrollbar.lower()

def _on_scrollbar_enter(self, _event: tk.Event | None = None):
"""
Called when the mouse pointer enters a scrollbar. Cancel the callback
which will hide the scollbars.
:param _event: Enter event.
"""
if self._hide_scrollbars_id:
self.after_cancel(self._hide_scrollbars_id)

def _on_scrollbar_leave(self, _event: tk.Event | None = None, ms: int = 1000):
"""
Called when the mouse pointer leaves a scrollbar. Hide the horizontal
and vertical scrollbars afer a delay.
:param _event: Leave event.
:param ms: Delay in milliseconds.
"""
self._hide_scrollbars_id = self.after(ms, self._hide_scrollbars)

def _peek_scrollbars(self):
"""
Show the horizontal and vertical scrollbars briefly.
"""
# Pretend that the mouse pointer entered and left a scrollbar to avoid
# code repetition.
self._on_scrollbar_enter()
self._show_scrollbars()
self._on_scrollbar_leave()

def _xview(self, *args, width: int | None = None):
"""
Called when a horizontal scroll is requested. Called by other callbacks
Expand Down Expand Up @@ -128,6 +181,7 @@ def _on_canvas_enter(self, _event: tk.Event | None = None):
self.bind_all("<Button-4>", self._on_mouse_scroll)
self.bind_all("<Button-5>", self._on_mouse_scroll)
self.bind_all("<MouseWheel>", self._on_mouse_scroll)
self._peek_scrollbars()

def _on_canvas_leave(self, _event: tk.Event | None = None):
"""
Expand Down Expand Up @@ -165,3 +219,4 @@ def _on_mouse_scroll(self, event: tk.Event):
case _:
message = f"event {event.num} on OS {_system!r} is not supported"
raise ValueError(message)
self._peek_scrollbars()

0 comments on commit 69d9515

Please sign in to comment.