Skip to content

Commit

Permalink
Show scrollbars on motion (#17)
Browse files Browse the repository at this point in the history
* Show scrollbars whenever the mouse is moved
  * This behaviour is closer to that of native scrollbars.
* Changed some functions to have descriptive names
* Updated documentation
* Show scrollbars when handling scroll events rather than when calling the handlers
  * This ensures they are shown even when resizing the window.
  * However, if the resizing action stops for some time, the scrollbars will disappear
  * They will appear again when the resizing action continues.
* Replaced Linux demo GIF with Windows demo GIF
  * Default appearance is better on Windows.
  • Loading branch information
tfpf authored Sep 7, 2024
1 parent 3d9a97e commit 813bedf
Show file tree
Hide file tree
Showing 2 changed files with 61 additions and 53 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ Add widgets to the `frame` attribute of a `ScrollableFrameTk` object.
briefly, and then hidden.

### Demo
![ScrollableFrameTk_demo](https://github.com/user-attachments/assets/6a035198-0296-49c3-9ef8-1ff0f196a6cf)
<!-- ![ScrollableFrameTk_demo](https://github.com/user-attachments/assets/6a035198-0296-49c3-9ef8-1ff0f196a6cf) -->
![ScrollableFrameTk_demo](https://github.com/user-attachments/assets/52d5cb6e-94ed-4bb0-8a6b-6ee7a085c042)

### Notes
`"<Button-4>"`, `"<Button-5>"` and `"<MouseWheel>"` are bound to all widgets using `bind_all` to handle mouse wheel
Expand Down
111 changes: 59 additions & 52 deletions src/ScrollableContainers/_tk.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,22 @@ def __init__(self, *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.bind("<Enter>", self._cancel_hide_scrollbars)
self._xscrollbar.bind("<Leave>", self._schedule_hide_scrollbars)
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.bind("<Enter>", self._cancel_hide_scrollbars)
self._yscrollbar.bind("<Leave>", self._schedule_hide_scrollbars)
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. 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.bind("<Configure>", self._configure_viewport_explicit)
self._canvas.bind("<Enter>", self._enable_scrolling)
self._canvas.bind("<Leave>", self._disable_scrolling)
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)

Expand All @@ -44,8 +44,11 @@ def __init__(self, *args, **kwargs):

self._frame = ttk.Frame(self._canvas)
self._window = self._canvas.create_window((0, 0), window=self._frame, anchor=tk.NW)
self._frame.bind("<Configure>", self._on_frame_configure)
self._on_frame_expose_id = self._frame.bind("<Expose>", self._on_frame_expose)
self._frame.bind("<Configure>", self._configure_viewport_implicit)
self._configure_viewport_implicit_wrapper_id = self._frame.bind(
"<Expose>", self._configure_viewport_implicit_wrapper
)
self._frame.bind("<Motion>", self._peek_scrollbars)

# Initially, the vertical scrollbar is a hair below its topmost
# position. Move it to said position. No harm in doing the equivalent
Expand All @@ -60,10 +63,13 @@ def frame(self):
def _show_scrollbars(self):
"""
Move the horizontal and vertical scrollbars above the scrollable
canvas, effectively showing them.
canvas (if the viewport does not show everything in their respective
dimensions), effectively showing them.
"""
self._xscrollbar.lift()
self._yscrollbar.lift()
if self._canvas.xview() != (0.0, 1.0):
self._xscrollbar.lift()
if self._canvas.yview() != (0.0, 1.0):
self._yscrollbar.lift()

def _hide_scrollbars(self):
"""
Expand All @@ -73,17 +79,17 @@ def _hide_scrollbars(self):
self._xscrollbar.lower()
self._yscrollbar.lower()

def _on_scrollbar_enter(self, _event: tk.Event | None = None):
def _cancel_hide_scrollbars(self, _event: tk.Event | None = None):
"""
Called when the mouse pointer enters a scrollbar. Cancel the callback
which will hide the scollbars.
which will hide the scrollbars.
: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):
def _schedule_hide_scrollbars(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.
Expand All @@ -93,24 +99,24 @@ def _on_scrollbar_leave(self, _event: tk.Event | None = None, ms: int = 1000):
"""
self._hide_scrollbars_id = self.after(ms, self._hide_scrollbars)

def _peek_scrollbars(self):
def _peek_scrollbars(self, _event: tk.Event | None = None):
"""
Show the horizontal and vertical scrollbars briefly.
:param _event: Motion event.
"""
# Pretend that the mouse pointer entered and left a scrollbar to avoid
# code repetition.
self._on_scrollbar_enter()
self._cancel_hide_scrollbars()
self._show_scrollbars()
self._on_scrollbar_leave()
self._schedule_hide_scrollbars()

def _xview(self, *args, width: int | None = None):
"""
Called when a horizontal scroll is requested. Called by other callbacks
(``_on_canvas_configure`` and ``_on_frame_configure``) whenever it is
necessary to horizontally realign the contents of the canvas. Scroll
the view only if the contents are not completely visible. Otherwise,
move the scrollbar to such a position that they are horizontally
centred.
(``_configure_viewport_explicit`` and ``_configure_viewport_implicit``)
whenever it is necessary to horizontally realign the contents of the
canvas. Scroll the viewport only if it does not show everything in the
horizontal dimension. Otherwise, horizontally centre the contents of
the canvas.
:param args: Passed to ``tkinter.Canvas.xview``.
:param width: Width of the canvas.
Expand All @@ -125,30 +131,32 @@ def _xview(self, *args, width: int | None = None):
# supported (because the Tcl/Tk manual pages say that it must be a
# fraction between 0 and 1), but it works!
self._canvas.xview_moveto((1 - width / self._frame.winfo_width()) / 2)
self._peek_scrollbars()

def _yview(self, *args):
"""
Called when a vertical scroll is requested. Scroll the view only if the
contents are not completely visible.
Called when a vertical scroll is requested. Scroll the viewport only if
it does not show everything in the vertical dimension.
:param args: Passed to ``tkinter.Canvas.yview``.
"""
if self._canvas.yview() != (0.0, 1.0):
self._canvas.yview(*args)
self._peek_scrollbars()

def _on_canvas_configure(self, event: tk.Event):
def _configure_viewport_explicit(self, event: tk.Event):
"""
Called when the canvas is resized. Update the scrollable region.
Called when the canvas is resized. Update the viewport.
:param event: Configure event.
"""
self._canvas.configure(scrollregion=self._canvas.bbox(tk.ALL))
self._xview(tk.SCROLL, 0, tk.UNITS, width=event.width)

def _on_frame_configure(self, _event: tk.Event | None = None):
def _configure_viewport_implicit(self, _event: tk.Event | None = None):
"""
Called when the frame is resized or the canvas is scrolled. Update the
scrollable region.
viewport.
This method is necessary to handle updates which may occur after the
GUI loop has started.
Expand All @@ -158,10 +166,10 @@ def _on_frame_configure(self, _event: tk.Event | None = None):
self._canvas.configure(scrollregion=self._canvas.bbox(tk.ALL))
self._xview(tk.SCROLL, 0, tk.UNITS)

def _on_frame_expose(self, _event: tk.Event | None = None):
def _configure_viewport_implicit_wrapper(self, _event: tk.Event | None = None):
"""
Called when the frame becomes visible. Call ``_on_frame_configure`` and
then disable this callback.
Called when the frame becomes visible. Call
``_configure_viewport_implicit`` and then disable this callback.
This method is necessary because if a scrollable frame is put into,
say, a notebook (as opposed to a toplevel window), and the canvas is
Expand All @@ -172,39 +180,39 @@ def _on_frame_expose(self, _event: tk.Event | None = None):
:param _event: Expose event.
"""
self._on_frame_configure()
self._frame.unbind("<Expose>", self._on_frame_expose_id)
self._configure_viewport_implicit()
self._frame.unbind("<Expose>", self._configure_viewport_implicit_wrapper_id)

def _on_canvas_enter(self, _event: tk.Event | None = None):
def _enable_scrolling(self, _event: tk.Event | None = None):
"""
Called when the mouse pointer enters the canvas. Set up vertical
scrolling with the mouse wheel.
Called when the mouse pointer enters the canvas. Start listening for
scroll events.
:param _event: Enter event.
"""
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()
self.bind_all("<Button-4>", self._scroll_viewport)
self.bind_all("<Button-5>", self._scroll_viewport)
self.bind_all("<MouseWheel>", self._scroll_viewport)

def _on_canvas_leave(self, _event: tk.Event | None = None):
def _disable_scrolling(self, _event: tk.Event | None = None):
"""
Called when the mouse pointer leaves the canvas. Unset vertical
scrolling with the mouse wheel.
Called when the mouse pointer leaves the canvas. Stop listening for
scroll events, allowing some other scrollable frame to listen for and
respond to scroll events independently of this one.
:param _event: Leave event.
"""
self.unbind_all("<Button-4>")
self.unbind_all("<Button-5>")
self.unbind_all("<MouseWheel>")

def _on_mouse_scroll(self, event: tk.Event):
def _scroll_viewport(self, event: tk.Event):
"""
Called when the mouse wheel is scrolled or a two-finger swipe gesture
is performed on the touchpad. Ask to scroll the view horizontally if
the mouse wheel is scrolled with Shift held down (equivalent to a
horizontal two-finger swipe) and vertically otherwise (equivalent to a
vertical two-finger swipe).
is performed on the touchpad. Scroll the viewport horizontally if the
mouse wheel is scrolled with Shift held down (equivalent to a vertical
two-finger swipe with Shift held down or a horizontal two-finger swipe)
and vertically otherwise (equivalent to a vertical two-finger swipe).
:param event: Scroll event.
"""
Expand All @@ -223,4 +231,3 @@ 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 813bedf

Please sign in to comment.