diff --git a/tkscrolledframe b/tkscrolledframe new file mode 100644 index 0000000..5632098 --- /dev/null +++ b/tkscrolledframe @@ -0,0 +1,317 @@ +# Credit: https://github.com/bmjcode/tkScrolledFrame +"""Implementation of the scrollable frame widget.""" + +import sys + +try: + # Python 3 + import tkinter as tk +except (ImportError): + # Python 2 + import Tkinter as tk + +try: + try: + # Python 3 + import tkinter.ttk as ttk + except (ImportError): + # Python 2 + import ttk +except (ImportError): + # Can't provide ttk's Scrollbar + pass + + +__all__ = ["ScrolledFrame"] + + +class ScrolledFrame(tk.Frame): + """Scrollable Frame widget. + + Use display_widget() to set the interior widget. For example, + to display a Label with the text "Hello, world!", you can say: + + sf = ScrolledFrame(self) + sf.pack() + sf.display_widget(Label, text="Hello, world!") + + The constructor accepts the usual Tkinter keyword arguments, plus + a handful of its own: + + scrollbars (str; default: "both") + Which scrollbars to provide. + Must be one of "vertical", "horizontal," "both", or "neither". + + use_ttk (bool; default: False) + Whether to use ttk widgets if available. + The default is to use standard Tk widgets. This setting has + no effect if ttk is not available on your system. + """ + + def __init__(self, master=None, **kw): + """Return a new scrollable frame widget.""" + + tk.Frame.__init__(self, master) + + # Hold these names for the interior widget + self._interior = None + self._interior_id = None + + # Whether to fit the interior widget's width to the canvas + self._fit_width = False + + # Which scrollbars to provide + if "scrollbars" in kw: + scrollbars = kw["scrollbars"] + del kw["scrollbars"] + + if not scrollbars: + scrollbars = self._DEFAULT_SCROLLBARS + elif not scrollbars in self._VALID_SCROLLBARS: + raise ValueError("scrollbars parameter must be one of " + "'vertical', 'horizontal', 'both', or " + "'neither'") + else: + scrollbars = self._DEFAULT_SCROLLBARS + + # Whether to use ttk widgets if available + if "use_ttk" in kw: + if ttk and kw["use_ttk"]: + Scrollbar = ttk.Scrollbar + else: + Scrollbar = tk.Scrollbar + del kw["use_ttk"] + else: + Scrollbar = tk.Scrollbar + + # Default to a 1px sunken border + if not "borderwidth" in kw: + kw["borderwidth"] = 1 + if not "relief" in kw: + kw["relief"] = "sunken" + + # Set up the grid + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=1) + + # Canvas to hold the interior widget + c = self._canvas = tk.Canvas(self, + borderwidth=0, + highlightthickness=0, + takefocus=0) + + # Enable scrolling when the canvas has the focus + self.bind_arrow_keys(c) + self.bind_scroll_wheel(c) + + # Call _resize_interior() when the canvas widget is updated + c.bind("", self._resize_interior) + + # Scrollbars + xs = self._x_scrollbar = Scrollbar(self, + orient="horizontal", + command=c.xview) + ys = self._y_scrollbar = Scrollbar(self, + orient="vertical", + command=c.yview) + c.configure(xscrollcommand=xs.set, yscrollcommand=ys.set) + + # Lay out our widgets + c.grid(row=0, column=0, sticky="nsew") + if scrollbars == "vertical" or scrollbars == "both": + ys.grid(row=0, column=1, sticky="ns") + if scrollbars == "horizontal" or scrollbars == "both": + xs.grid(row=1, column=0, sticky="we") + + # Forward these to the canvas widget + self.bind = c.bind + self.focus_set = c.focus_set + self.unbind = c.unbind + self.xview = c.xview + self.xview_moveto = c.xview_moveto + self.yview = c.yview + self.yview_moveto = c.yview_moveto + + # Process our remaining configuration options + self.configure(**kw) + + def __setitem__(self, key, value): + """Configure resources of a widget.""" + + if key in self._CANVAS_KEYS: + # Forward these to the canvas widget + self._canvas.configure(**{key: value}) + + else: + # Handle everything else normally + tk.Frame.configure(self, **{key: value}) + + # ------------------------------------------------------------------------ + + def bind_arrow_keys(self, widget): + """Bind the specified widget's arrow key events to the canvas.""" + + widget.bind("", + lambda event: self._canvas.yview_scroll(-1, "units")) + + widget.bind("", + lambda event: self._canvas.yview_scroll(1, "units")) + + widget.bind("", + lambda event: self._canvas.xview_scroll(-1, "units")) + + widget.bind("", + lambda event: self._canvas.xview_scroll(1, "units")) + + def bind_scroll_wheel(self, widget): + """Bind the specified widget's mouse scroll event to the canvas.""" + + widget.bind("", self._scroll_canvas) + widget.bind("", self._scroll_canvas) + widget.bind("", self._scroll_canvas) + + def cget(self, key): + """Return the resource value for a KEY given as string.""" + + if key in self._CANVAS_KEYS: + return self._canvas.cget(key) + + else: + return tk.Frame.cget(self, key) + + # Also override this alias for cget() + __getitem__ = cget + + def configure(self, cnf=None, **kw): + """Configure resources of a widget.""" + + # This is overridden so we can use our custom __setitem__() + # to pass certain options directly to the canvas. + if cnf: + for key in cnf: + self[key] = cnf[key] + + for key in kw: + self[key] = kw[key] + + # Also override this alias for configure() + config = configure + + def display_widget(self, widget_class, fit_width=False, **kw): + """Create and display a new widget. + + If fit_width == True, the interior widget will be stretched as + needed to fit the width of the frame. + + Keyword arguments are passed to the widget_class constructor. + + Returns the new widget. + """ + + # Blank the canvas + self.erase() + + # Set width fitting + self._fit_width = fit_width + + # Set the new interior widget + self._interior = widget_class(self._canvas, **kw) + + # Add the interior widget to the canvas, and save its widget ID + # for use in _resize_interior() + self._interior_id = self._canvas.create_window(0, 0, + anchor="nw", + window=self._interior) + + # Call _update_scroll_region() when the interior widget is resized + self._interior.bind("", self._update_scroll_region) + + # Fit the interior widget to the canvas if requested + # We don't need to check fit_width here since _resize_interior() + # already does. + self._resize_interior() + + # Scroll to the top-left corner of the canvas + self.scroll_to_top() + + return self._interior + + def erase(self): + """Erase the displayed widget.""" + + # Clear the canvas + self._canvas.delete("all") + + # Delete the interior widget + del self._interior + del self._interior_id + + # Save these names + self._interior = None + self._interior_id = None + + # Reset width fitting + self._fit_width = False + + def scroll_to_top(self): + """Scroll to the top-left corner of the canvas.""" + + self._canvas.xview_moveto(0) + self._canvas.yview_moveto(0) + + # ------------------------------------------------------------------------ + + def _resize_interior(self, event=None): + """Resize the interior widget to fit the canvas.""" + + if self._fit_width and self._interior_id: + # The current width of the canvas + canvas_width = self._canvas.winfo_width() + + # The interior widget's requested width + requested_width = self._interior.winfo_reqwidth() + + if requested_width != canvas_width: + # Resize the interior widget + new_width = max(canvas_width, requested_width) + self._canvas.itemconfigure(self._interior_id, width=new_width) + + def _scroll_canvas(self, event): + """Scroll the canvas.""" + + c = self._canvas + + if sys.platform.startswith("darwin"): + # macOS + c.yview_scroll(-1 * event.delta, "units") + + elif event.num == 4: + # Unix - scroll up + c.yview_scroll(-1, "units") + + elif event.num == 5: + # Unix - scroll down + c.yview_scroll(1, "units") + + else: + # Windows + c.yview_scroll(-1 * (event.delta // 120), "units") + + def _update_scroll_region(self, event): + """Update the scroll region when the interior widget is resized.""" + + # The interior widget's requested width and height + req_width = self._interior.winfo_reqwidth() + req_height = self._interior.winfo_reqheight() + + # Set the scroll region to fit the interior widget + self._canvas.configure(scrollregion=(0, 0, req_width, req_height)) + + # ------------------------------------------------------------------------ + + # Keys for configure() to forward to the canvas widget + _CANVAS_KEYS = "width", "height", "takefocus" + + # Scrollbar-related configuration + _DEFAULT_SCROLLBARS = "both" + _VALID_SCROLLBARS = "vertical", "horizontal", "both", "neither"