diff --git a/AUTHORS.md b/AUTHORS.md index 5e60a2d5..a12101a7 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -24,5 +24,6 @@ This file contains a list of all the authors of widgets in this repository. Plea * `AutoHideScrollbar` based on an idea by [Fredrik Lundh](effbot.org/zone/tkinter-autoscrollbar.htm) * All color widgets: `askcolor`, `ColorPicker`, `GradientBar` and `ColorSquare`, `LimitVar`, `Spinbox`, `AlphaBar` and supporting functions in `functions.py`. * `AutocompleteEntryListbox` + * [`Notebook`](https://github.com/j4321/PyTkEditor/blob/master/pytkeditorlib/notebook.py), modified by [Dogeek](https://github.com/Dogeek) - Multiple authors: * `ScaleEntry` (RedFantom and Juliette Monsel) diff --git a/docs/source/ttkwidgets/ttkwidgets.rst b/docs/source/ttkwidgets/ttkwidgets.rst index eb2f17f4..26604c7a 100644 --- a/docs/source/ttkwidgets/ttkwidgets.rst +++ b/docs/source/ttkwidgets/ttkwidgets.rst @@ -17,9 +17,9 @@ ttkwidgets DebugWindow ItemsCanvas LinkLabel + Notebook ScaleEntry ScrolledListbox Table TickScale TimeLine - diff --git a/docs/source/ttkwidgets/ttkwidgets/ttkwidgets.Notebook.rst b/docs/source/ttkwidgets/ttkwidgets/ttkwidgets.Notebook.rst new file mode 100644 index 00000000..631c80b4 --- /dev/null +++ b/docs/source/ttkwidgets/ttkwidgets/ttkwidgets.Notebook.rst @@ -0,0 +1,10 @@ +Notebook +======== + +.. currentmodule:: ttkwidgets + +.. autoclass:: Notebook + :show-inheritance: + :members: + + .. automethod:: __init__ diff --git a/examples/example_notebook.py b/examples/example_notebook.py new file mode 100644 index 00000000..e06eb114 --- /dev/null +++ b/examples/example_notebook.py @@ -0,0 +1,30 @@ +try: + import tkinter as tk + from tkinter import ttk +except ImportError: + import Tkinter as tk + import ttk +from ttkwidgets import Notebook + + +class MainWindow(ttk.Frame): + def __init__(self, master): + ttk.Frame.__init__(self, master) + colors = ['red', 'blue', 'green', 'yellow', 'cyan', 'magenta', 'black', 'white', 'purple', 'brown'] + self.nb = Notebook(self, tabdrag=True, tabmenu=True, closebutton=True, closecommand=self.closecmd) + self.frames = [tk.Frame(self, width=300, height=300, bg=color) for i, color in enumerate(colors)] + for i, w in enumerate(self.frames): + self.nb.add(w, text="Frame " + str(i)) + w.grid() + self.nb.grid() + + def closecmd(self, tab_id): + print("Close tab " + str(tab_id)) + self.nb.forget(tab_id) + + +root = tk.Tk() +root.title("Notebook Example") +gui = MainWindow(root) +gui.grid() +root.mainloop() diff --git a/tests/test_notebook.py b/tests/test_notebook.py new file mode 100644 index 00000000..379e7e04 --- /dev/null +++ b/tests/test_notebook.py @@ -0,0 +1,109 @@ +# Copyright (c) Dogeek 2019 +# For license see LICENSE +from ttkwidgets import Notebook +from tests import BaseWidgetTest +from tkinter import ttk +import tkinter as tk + + +class TestNotebook(BaseWidgetTest): + def test_notebook_init(self): + nb = Notebook(self.window) + nb.grid() + self.window.update() + + def test_notebook_add_tab(self): + nb = Notebook(self.window) + frame = ttk.Frame(self.window, width=200, height=200) + nb.add(frame, text="Frame") + nb.grid() + self.window.update() + + def test_notebook_select_tab(self): + nb = Notebook(self.window) + frame = ttk.Frame(self.window, width=200, height=200) + frame2 = ttk.Frame(self.window, width=200, height=200) + nb.add(frame, text="Frame") + nb.add(frame2, text="Frame2") + nb.grid() + nb.select_next() + nb.select_next() + nb.select_prev() + self.window.update() + + def test_notebook_move_tab(self): + nb = Notebook(self.window, drag_to_toplevel=False) + frames = [] + for i in range(3): + frame = ttk.Frame(self.window, width=200, height=200) + frames.append(frame) + nb.add(frame, text="Frame" + str(i)) + nb._dragged_tab = nb._tab_labels[0] + nb._swap(nb._tab_labels[1]) + nb._on_click(None) + self.assertEqual(nb._visible_tabs, [1, 0, 2]) + + def test_notebook_insert(self): + nb = Notebook(self.window, drag_to_toplevel=False) + ids = list() + n = 3 + for i in range(n): + frame = ttk.Frame(self.window, width=200, height=200) + ids.append(nb.add(frame, text="Frame" + str(i))) + print(ids) + id = nb.insert(n-2, ttk.Frame(self.window, width=200, height=200), text="Added") + tabs = nb.tabs() + self.assertIn(id, tabs) + self.assertEquals(tabs.index(id), n-2) + self.assertEquals(nb.index(id), n-2) + self.assertEqual(nb._visible_tabs, [0, 3, 1, 2]) + + def test_notebook_index(self): + nb = Notebook(self.window) + ids = list() + frames = list() + n = 10 + for i in range(n): + frame = ttk.Frame(self.window, width=200, height=200) + frames.append(frame) + ids.append(nb.add(frame, text="Frame" + str(i))) + + with self.assertRaises(KeyError): + nb.index(str(self.window) + '.!frame11') + + self.assertTrue(all(ids.index(id) == nb.index(id) for id in ids)) + self.assertTrue(all(nb.index(id) == nb.index(frame) for id, frame in zip(ids, frames))) + + self.assertEqual(nb.index(tk.END), n) + nb.current_tab = 0 + self.assertEqual(nb.index(tk.CURRENT), 0) + + self.window.update() + + def test_notebook_forget_tab(self): + nb = Notebook(self.window) + ids = list() + n = 3 + for i in range(n): + frame = ttk.Frame(self.window, width=200, height=200) + id = nb.add(frame, text="Frame" + str(i)) + ids.append(id) + + tabs = nb.tabs() + self.assertIn(id, tabs) + nb.forget(id) # Test forgetting of the last created tab + tabs = nb.tabs() + self.assertEquals(len(tabs), n-1) + self.assertNotIn(id, tabs) + + def test_notebook_config_tab(self): + nb = Notebook(self.window) + for i in range(10): + frame = ttk.Frame(self.window, width=200, height=200) + nb.add(frame, text="Frame" + str(i)) + + with self.assertRaises(ValueError): + nb.tab(tk.CURRENT, state='random') + + nb.tab(tk.CURRENT, text="Changed") + self.window.update() diff --git a/tests/test_utilities.py b/tests/test_utilities.py new file mode 100644 index 00000000..229cd584 --- /dev/null +++ b/tests/test_utilities.py @@ -0,0 +1,218 @@ +# Copyright (c) Dogeek 2019 +# For license see LICENSE +from ttkwidgets.utilities import move_widget, parse_geometry, coords_in_box +from tests import BaseWidgetTest +import tkinter as tk +from tkinter import ttk + + +class TestUtilities(BaseWidgetTest): + def assertGeometryInfoEquals(self, info1, info2): + info1.pop("in", None) + info2.pop("in", None) + self.assertEquals(info1, info2) + + def setUp(self): + BaseWidgetTest.setUp(self) + self._dummy_flag = False + + def assertIsChild(self, child, parent): + self.assertIn(child, parent.children.values()) + parent_of_parent = parent.winfo_parent() + if not parent_of_parent.endswith("."): + parent_of_parent += "." + self.assertEquals(child.winfo_parent(), parent_of_parent + parent.winfo_name()) + + def assertHasBeenInvoked(self): + self.assertTrue(self._dummy_flag) + self._dummy_flag = False + + def _dummy_bind(self, _=None): + self._dummy_flag = True + + def test_move_widget(self): + label = ttk.Label(self.window) + tl = tk.Toplevel(self.window) + label = move_widget(label, tl) + self.assertIsChild(label, tl) + + def test_move_widget_pack(self): + label = ttk.Label(self.window) + label.pack(side=tk.LEFT) + info = label.pack_info() + tl = tk.Toplevel(self.window) + label = move_widget(label, tl, preserve_geometry=True) + self.assertIsChild(label, tl) + self.assertIn(label, tl.pack_slaves()) + self.assertNotIn(label, self.window.pack_slaves()) + self.assertGeometryInfoEquals(info, label.pack_info()) + + def test_move_widget_grid(self): + label = ttk.Label(self.window) + label.grid(row=1, column=1) + info = label.grid_info() + tl = tk.Toplevel(self.window) + label = move_widget(label, tl, preserve_geometry=True) + self.assertIsChild(label, tl) + self.assertIn(label, tl.grid_slaves(row=1, column=1)) + self.assertNotIn(label, self.window.grid_slaves(row=1, column=1)) + self.assertGeometryInfoEquals(info, label.grid_info()) + + def test_move_widget_place(self): + label = ttk.Label(self.window) + label.place(x=0, y=10) + info = label.place_info() + tl = tk.Toplevel(self.window) + label = move_widget(label, tl, preserve_geometry=True) + self.assertIsChild(label, tl) + self.assertIn(label, tl.place_slaves()) + self.assertNotIn(label, self.window.place_slaves()) + self.assertGeometryInfoEquals(info, label.place_info()) + + def test_move_widget_none(self): + label = ttk.Label(self.window) + self.assertFalse(label.place_info() is True) + self.assertFalse(label.grid_info() is True) + self.assertRaises(tk.TclError, label.pack_info) + tl = tk.Toplevel(self.window) + label = move_widget(label, tl, preserve_geometry=True) + self.assertFalse(label.place_info() is True) + self.assertFalse(label.grid_info() is True) + self.assertRaises(tk.TclError, label.pack_info) + + def test_move_widget_with_binding(self): + label = ttk.Label(self.window) + label.bind('', self._dummy_bind) + label.pack() + tl = tk.Toplevel(self.window) + label = move_widget(label, tl) + label.pack() + self.assertIsChild(label, tl) + self.assertIn('', label.bind()) + + def test_move_widget_with_command(self): + widget = ttk.Button(self.window, command=self._dummy_bind) + self.assertIsChild(widget, self.window) + widget.invoke() + self.assertHasBeenInvoked() + + parent = tk.Toplevel() + child = move_widget(widget, parent) + self.assertIsChild(child, parent) + child.invoke() + self.assertHasBeenInvoked() + + def test_move_widget_with_bound_method_on_parent(self): + tl1 = tk.Toplevel(self.window) + tl2 = tk.Toplevel(self.window) + tl1._dummy_bind = self._dummy_bind + + button = ttk.Button(tl1) + button.bind("", tl1._dummy_bind) + button.event_generate("") + self.assertHasBeenInvoked() + + button = move_widget(button, tl2) + button.event_generate("") + self.assertHasBeenInvoked() + + def test_move_widget_with_command_method_on_parent(self): + tl1 = tk.Toplevel(self.window) + tl2 = tk.Toplevel(self.window) + tl1._dummy_bind = self._dummy_bind + + button = ttk.Button(tl1, command=tl1._dummy_bind) + button.invoke() + self.assertHasBeenInvoked() + + button = move_widget(button, tl2) + button.invoke() + self.assertHasBeenInvoked() + + def test_move_widget_with_binding_on_parent(self): + widget = ttk.Label(self.window) + widget._dummy_bind = self._dummy_bind + self.window.bind("", widget._dummy_bind) + + self.window.event_generate("") + self.assertHasBeenInvoked() + + move_widget(widget, tk.Toplevel()) + self.window.event_generate("") + self.assertHasBeenInvoked() + + def test_move_widget_with_children_pack(self): + frame = ttk.Frame(self.window) + label = ttk.Label(frame) + parent = tk.Toplevel() + label.pack(side=tk.BOTTOM) + info = label.pack_info() + frame.pack(expand=True) + + frame = move_widget(frame, parent) + self.assertTrue(len(frame.pack_slaves()) == 1) + label2 = frame.nametowidget(frame.pack_slaves()[0]) + self.assertTrue(label is not label2) + + self.assertGeometryInfoEquals(info, label2.pack_info()) + self.assertRaises(tk.TclError, label.pack_info) + self.assertRaises(tk.TclError, frame.pack_info) # Frame is not packed + self.assertIn(label2, frame.pack_slaves()) + + def test_move_widget_with_children_grid(self): + frame = ttk.Frame(self.window) + label = ttk.Label(frame) + parent = tk.Toplevel() + label.grid(row=1, column=1) + info = label.grid_info() + frame.grid(row=1, column=1) + + frame = move_widget(frame, parent) + self.assertTrue(len(frame.grid_slaves()) == 1) + label2 = frame.nametowidget(frame.grid_slaves()[0]) + self.assertTrue(label is not label2) + + self.assertGeometryInfoEquals(info, label2.grid_info()) + self.assertRaises(tk.TclError, label.grid_info) + self.assertTrue(len(frame.grid_info()) == 0) # Frame is not in grid + self.assertIn(label2, frame.grid_slaves()) + + def test_move_widget_with_children_place(self): + frame = ttk.Frame(self.window) + label = ttk.Label(frame) + parent = tk.Toplevel() + label.place(x=53, y=13) + info = label.place_info() + frame.place(x=100, y=10) + + frame = move_widget(frame, parent) + self.assertTrue(len(frame.place_slaves()) == 1) + label2 = frame.nametowidget(frame.place_slaves()[0]) + self.assertTrue(label is not label2) + + self.assertGeometryInfoEquals(info, label2.place_info()) + self.assertRaises(tk.TclError, label.place_info) + self.assertTrue(len(frame.place_info()) == 0) + self.assertIn(label2, frame.place_slaves()) + + def test_move_widget_to_new_tk(self): + label = tk.Label(self.window) + window = tk.Tk() + self.assertRaises(RuntimeError, move_widget, label, window) + + def test_parse_geometry(self): + g = parse_geometry('1x1+1+1') + self.assertEqual(g, (1, 1, 1, 1)) + g = parse_geometry('1x1-1-1') + self.assertEqual(g, (-1, -1, 1, 1)) + + def test_coordinates_in_box(self): + with self.assertRaises(ValueError): + coords_in_box((1,), (1, 1, 3, 3)) + + with self.assertRaises(ValueError): + coords_in_box((1, 1), (1, 1, 3, 3, 4)) + + self.assertTrue(coords_in_box((1, 1), (0, 0, 2, 2))) + self.assertFalse(coords_in_box((1, 1), (1, 1, 2, 2), include_edges=False)) + self.assertTrue(coords_in_box((0, 0), (-1, -1, 1, 1), bbox_is_x1y1x2y2=True)) diff --git a/ttkwidgets/__init__.py b/ttkwidgets/__init__.py index 9788fb40..24e99be8 100644 --- a/ttkwidgets/__init__.py +++ b/ttkwidgets/__init__.py @@ -11,3 +11,4 @@ from ttkwidgets.timeline import TimeLine from ttkwidgets.tickscale import TickScale from ttkwidgets.table import Table +from ttkwidgets.notebook import Notebook diff --git a/ttkwidgets/notebook.py b/ttkwidgets/notebook.py new file mode 100644 index 00000000..d6f29f17 --- /dev/null +++ b/ttkwidgets/notebook.py @@ -0,0 +1,933 @@ +""" +Copyright 2018-2019 Juliette Monsel +Copyright 2019 Dogeek +Copyright 2020 RedFantom + +Adapted from PyTkEditor - Python IDE's Notebook widget by +Juliette Monsel. Adapted by Dogeek + +PyTkEditor is distributed with the GNU GPL license. + +Notebook with draggable / scrollable tabs +""" +from functools import partial +import tkinter as tk +from tkinter import ttk +from ttkwidgets.utilities import move_widget, parse_geometry, coords_in_box + + +class Tab(ttk.Frame): + """Notebook tab.""" + def __init__(self, master=None, tab_nb=0, **kwargs): + """ + :param master: parent widget + :param tab_nb: tab index + :param **kwargs: keyword arguments for ttk::Label widgets + """ + ttk.Frame.__init__(self, master, class_="Notebook.Tab", + style="Notebook.Tab", padding=1) + self._state = kwargs.pop("state", "normal") + self.tab_nb = tab_nb + self.hovering_tab = False + self._closebutton = kwargs.pop("closebutton", True) + self._closecommand = kwargs.pop("closecommand", None) + self.frame = ttk.Frame(self, style="Notebook.Tab.Frame") + self.label = ttk.Label(self.frame, style="Notebook.Tab.Label", anchor="center", takefocus=False, **kwargs) + self.closebtn = ttk.Button( + self.frame, style="Notebook.Tab.Close", command=self.closecommand, + class_="Notebook.Tab.Close", takefocus=False) + self.label.pack(side="left", padx=(6, 0)) + if self._closebutton: + self.closebtn.pack(side="right", padx=(0, 6)) + self.update_idletasks() + self.configure(width=self.frame.winfo_reqwidth() + 6, + height=self.frame.winfo_reqheight() + 6) + self.frame.place(bordermode="inside", anchor="nw", x=0, y=0, + relwidth=1, relheight=1) + self.label.bind("", self._resize) + if self._state == "disabled": + self.state(["disabled"]) + elif self._state != "normal": + raise ValueError("state option should be 'normal' or 'disabled'") + + self.bind("", self._b2_press) + self.bind("", self._on_enter_tab) + self.bind("", self._on_leave_tab) + self.bind("", partial(self._on_mousewheel, None)) + self.bind("", partial(self._on_mousewheel, True)) # Linux mousewheel bind + self.bind("", partial(self._on_mousewheel, False)) + + def _on_mousewheel(self, updown, event): + if self.hovering_tab: + if (updown is None and event.delta > 0) or updown is True: + self.master.master.select_prev(True) + else: + self.master.master.select_next(True) + + def _on_enter_tab(self, event): + self.hovering_tab = True + + def _on_leave_tab(self, event): + self.hovering_tab = False + + def _b2_press(self, event): + if self.identify(event.x, event.y): + self.closecommand() + + def _resize(self, event): + self.configure(width=self.frame.winfo_reqwidth() + 6, + height=self.frame.winfo_reqheight() + 6) + + def closecommand(self): + """ + Calls the closecommand callback with the tab index as an argument + """ + self._closecommand(self.tab_nb) + + def state(self, *args): + res = ttk.Frame.state(self, *args) + self.label.state(*args) + self.frame.state(*args) + self.closebtn.state(*args) + if args and "selected" in self.state(): + self.configure(width=self.frame.winfo_reqwidth() + 6, + height=self.frame.winfo_reqheight() + 6) + self.frame.place_configure(relheight=1.1) + else: + self.frame.place_configure(relheight=1) + self.configure(width=self.frame.winfo_reqwidth() + 6, + height=self.frame.winfo_reqheight() + 6) + return res + + def bind(self, sequence=None, func=None, add=None): + return self.frame.bind(sequence, func, add), self.label.bind(sequence, func, add) + + def unbind(self, sequence, funcids=(None, None)): + self.label.unbind(sequence, funcids[1]) + self.frame.unbind(sequence, funcids[0]) + + def tab_configure(self, **kwargs): + if "closecommand" in kwargs: + self._closecommand = kwargs.pop("closecommand") + if "closebutton" in kwargs: + self._closebutton = kwargs.pop("closebutton") + if self._closebutton: + self.closebtn.pack(side="right", padx=(0, 6)) + else: + self.closebtn.pack_forget() + self.update_idletasks() + self.configure(width=self.frame.winfo_reqwidth() + 6, + height=self.frame.winfo_reqheight() + 6) + if "state" in kwargs: + state = kwargs.pop("state") + if state == "normal": + self.state(["!disabled"]) + elif state == "disabled": + self.state(["disabled"]) + else: + raise ValueError("state option should be 'normal' or 'disabled'") + self._state = state + if not kwargs: + return + self.label.configure(**kwargs) + + def tab_cget(self, option): + if option == "closecommand": + return self._closecommand + elif option == "closebutton": + return self._closebutton + elif option == "state": + return self._state + else: + return self.label.cget(option) + + +class Notebook(ttk.Frame): + """ + Notebook widget. + + Unlike the ttk.Notebook, the tab width is constant and determine by the tab + label. When there are too many tabs to fit in the widget, buttons appear on + the left and the right of the Notebook to navigate through the tabs. + + The tab have an optional close button and the notebook has an optional tab + menu. Tabs can be optionnaly dragged. + """ + + _initialized = False + + def __init__(self, master=None, **kwargs): + """ + Create a Notebook widget with parent master. + + STANDARD OPIONS + + class, cursor, style, takefocus + + WIDGET-SPECIFIC OPTIONS + + closebutton: boolean (default True) + whether to display a close button on the tabs + + closecommand: function or None (default Notebook.forget) + command executed when the close button of a tab is pressed, + the tab index is passed in argument. + + tabdrag: boolean (default True) + whether to enable dragging of tab labels + + drag_to_toplevel : boolean (default tabdrag) + whether to enable dragging tabs to Toplevel windows + + tabmenu: boolean (default True) + whether to display a menu showing the tab labels in alphabetical order + + TAB OPTIONS + + state, sticky, padding, text, image, compound + + TAB IDENTIFIERS (tab_id) + + The tab_id argument found in several methods may take any of + the following forms: + + * An integer between zero and the number of tabs + * The name of a child window + * The string "current", which identifies the + currently-selected tab + * The string "end", which returns the number of tabs (only + valid for method index) + + """ + self._init_kwargs = kwargs.copy() + + self._closebutton = bool(kwargs.pop("closebutton", True)) + self._closecommand = kwargs.pop("closecommand", self.forget) + self._tabdrag = bool(kwargs.pop("tabdrag", True)) + self._drag_to_toplevel = bool(kwargs.pop("drag_to_toplevel", self._tabdrag)) + self._tabmenu = bool(kwargs.pop("tabmenu", True)) + + ttk.Frame.__init__(self, master, class_="Notebook", padding=(0, 0, 0, 1), **kwargs) + + if not Notebook._initialized: + self.setup_style() + + self.rowconfigure(1, weight=1) + self.columnconfigure(2, weight=1) + + self._tab_var = tk.IntVar(self, -1) + + self._visible_tabs = [] + self._active_tabs = [] # not disabled + self._hidden_tabs = [] + self._tab_labels = {} + self._tab_menu_entries = {} + self._tabs = {} + self._tab_options = {} + self._indexes = {} + self._nb_tab = 0 + self.current_tab = -1 + self._dragged_tab = None + self._toplevels = [] + + style = ttk.Style(self) + bg = style.lookup("TFrame", "background") + + # --- widgets + # to display current tab content + self._body = ttk.Frame(self, padding=1, style="Notebook", + relief="flat") + self._body.rowconfigure(0, weight=1) + self._body.columnconfigure(0, weight=1) + self._body.grid_propagate(False) + # tab labels + # canvas to scroll through tab labels + self._canvas = tk.Canvas(self, bg=bg, highlightthickness=0, + borderwidth=0, takefocus=False) + self._tab_frame2 = ttk.Frame(self, height=26, style="Notebook", + relief="flat") + # self._tab_frame2 is a trick to be able to drag a tab on the full + # canvas width even if self._tab_frame is smaller. + self._tab_frame = ttk.Frame(self._tab_frame2, style="Notebook", + relief="flat", height=26) # to display tab labels + self._sep = ttk.Separator(self._tab_frame2, orient="horizontal") + self._sep.place(bordermode="outside", anchor="sw", x=0, rely=1, + relwidth=1, height=1) + self._tab_frame.pack(side="left") + + self._canvas.create_window(0, 0, anchor="nw", window=self._tab_frame2, + tags="window") + self._canvas.configure(height=self._tab_frame.winfo_reqheight()) + # empty frame to show the spot formerly occupied by the tab + self._dummy_frame = ttk.Frame(self._tab_frame, style="Notebook", relief="flat") + self._dummy_sep = ttk.Separator(self._tab_frame, orient="horizontal") + self._dummy_sep.place(in_=self._dummy_frame, x=0, relwidth=1, height=1, + y=0, anchor="sw", bordermode="outside") + # tab navigation + self._tab_menu = tk.Menu(self, tearoff=False, relief="sunken", + bg=style.lookup("TEntry", "fieldbackground", + default="white"), + activebackground=style.lookup("TEntry", + "selectbackground", + ["focus"], "gray70"), + activeforeground=style.lookup("TEntry", + "selectforeground", + ["focus"], "gray70")) + self._tab_list = ttk.Menubutton(self, width=1, menu=self._tab_menu, + style="Notebook.TMenubutton", + padding=0) + self._tab_list.state(["disabled"]) + self._btn_left = ttk.Button(self, style="Left.Notebook.TButton", + command=self.select_prev, takefocus=False) + self._btn_right = ttk.Button(self, style="Right.Notebook.TButton", + command=self.select_next, takefocus=False) + + # --- grid + self._tab_list.grid(row=0, column=0, sticky="ns", pady=(0, 1)) + if not self._tabmenu: + self._tab_list.grid_remove() + self._btn_left.grid(row=0, column=1, sticky="ns", pady=(0, 1)) + self._canvas.grid(row=0, column=2, sticky="ew") + self._btn_right.grid(row=0, column=3, sticky="ns", pady=(0, 1)) + self._body.grid(row=1, columnspan=4, sticky="ewns", padx=1, pady=1) + + ttk.Frame(self, height=1, + style="separator.TFrame").place(x=1, anchor="nw", + rely=1, height=1, + relwidth=1) + + self._border_left = ttk.Frame(self, width=1, style="separator.TFrame") + self._border_right = ttk.Frame(self, width=1, style="separator.TFrame") + self._border_left.place(bordermode="outside", in_=self._body, x=-1, y=-2, + width=1, height=self._body.winfo_reqheight() + 2, relheight=1) + self._border_right.place(bordermode="outside", in_=self._body, relx=1, y=-2, + width=1, height=self._body.winfo_reqheight() + 2, relheight=1) + + # --- bindings + self._tab_frame.bind("", self._on_configure) + self._canvas.bind("", self._on_configure) + self.bind_all("", self._on_click) + self.bind_all() + + self.config = self.configure + Notebook._initialized = True + + def __getitem__(self, key): + return self.cget(key) + + def __setitem__(self, key, value): + self.configure(**{key: value}) + + def setup_style(self, bg=None, activebg=None, pressedbg=None, fg=None, fieldbg=None, lightcolor="#ededed", + darkcolor="#cfcdc8", bordercolor="#888888", focusbordercolor="#5e5e5e", selectbg=None, + selectfg=None, unselectfg="#999999", disabledfg=None, disabledbg=None): + """ + Setups the style for the notebook. + :param bg: + :param activebg: + :param pressedbg: + :param fg: + :param fieldbg: + :param lightcolor: + :param darkcolor: + :param bordercolor: + :param focusbordercolor: + :param selectbg: + :param selectfg: + :param unselectfg: + :param disabledfg: + :param disabledbg: + """ + style = ttk.Style(self) + + theme = {"bg": bg or style.lookup(".", "background", default="#dddddd"), + "activebg": activebg or style.lookup(".", "background", ("active",), default="#efefef"), + "pressedbg": pressedbg or style.lookup(".", "selectbackground", default="#c1c1c1"), + "fg": fg or style.lookup(".", "foreground", default="black"), + "fieldbg": fieldbg or style.lookup(".", "fieldbackground", default="white"), + "lightcolor": lightcolor or style.lookup(".", "focuscolor", default="#ededed"), + "darkcolor": darkcolor or style.lookup(".", "throughcolor", default="#cfcdc8"), + "bordercolor": bordercolor, + "focusbordercolor": focusbordercolor, + "selectbg": selectbg or style.lookup(".", "selectbackground", default="#c1c1c1"), + "selectfg": selectfg or style.lookup(".", "selectforeground", default="black"), + "unselectedfg": unselectfg, + "disabledfg": disabledfg or style.lookup(".", "foreground", ("disabled",), default="#999999"), + "disabledbg": disabledbg or style.lookup(".", "background", ("disabled",), default="#dddddd")} + + self.images = ( # Must be on self to keep reference + tk.PhotoImage("img_close", data=""" + R0lGODlhCAAIAMIBAAAAADs7O4+Pj9nZ2Ts7Ozs7Ozs7Ozs7OyH+EUNyZWF0ZWQg + d2l0aCBHSU1QACH5BAEKAAQALAAAAAAIAAgAAAMVGDBEA0qNJyGw7AmxmuaZhWEU + 5kEJADs= + """, master=self), + tk.PhotoImage("img_closeactive", data=""" + R0lGODlhCAAIAMIEAAAAAP/SAP/bNNnZ2cbGxsbGxsbGxsbGxiH5BAEKAAQALAAA + AAAIAAgAAAMVGDBEA0qNJyGw7AmxmuaZhWEU5kEJADs= + """, master=self), + tk.PhotoImage("img_closepressed", data=""" + R0lGODlhCAAIAMIEAAAAAOUqKv9mZtnZ2Ts7Ozs7Ozs7Ozs7OyH+EUNyZWF0ZWQg + d2l0aCBHSU1QACH5BAEKAAQALAAAAAAIAAgAAAMVGDBEA0qNJyGw7AmxmuaZhWEU + 5kEJADs= + """, master=self) + ) + + for seq in self.bind_class("TButton"): + self.bind_class("Notebook.Tab.Close", seq, self.bind_class("TButton", seq), True) + + style_config = {"bordercolor": theme["bordercolor"], + "background": theme["bg"], + "foreground": theme["fg"], + "arrowcolor": theme["fg"], + "gripcount": 0, + "lightcolor": theme["lightcolor"], + "darkcolor": theme["darkcolor"], + "troughcolor": theme["pressedbg"]} + + style.element_create("close", "image", "img_close", + ("active", "pressed", "!disabled", "img_closepressed"), + ("active", "!disabled", "img_closeactive"), + sticky="") + style.layout("Notebook", style.layout("TFrame")) + style.layout("Notebook.TMenubutton", + [("Menubutton.border", + {"sticky": "nswe", + "children": [("Menubutton.focus", + {"sticky": "nswe", + "children": [("Menubutton.indicator", + {"side": "right", "sticky": ""}), + ("Menubutton.padding", + {"expand": "1", + "sticky": "we"})]})]})]) + style.layout("Notebook.Tab", style.layout("TFrame")) + style.layout("Notebook.Tab.Frame", style.layout("TFrame")) + style.layout("Notebook.Tab.Label", style.layout("TLabel")) + style.layout("Notebook.Tab.Close", + [("Close.padding", + {"sticky": "nswe", + "children": [("Close.border", + {"border": "1", + "sticky": "nsew", + "children": [("Close.close", + {"sticky": "ewsn"})]})]})]) + style.layout("Left.Notebook.TButton", + [("Button.padding", + {"sticky": "nswe", + "children": [("Button.leftarrow", {"sticky": "nswe"})]})]) + style.layout("Right.Notebook.TButton", + [("Button.padding", + {"sticky": "nswe", + "children": [("Button.rightarrow", {"sticky": "nswe"})]})]) + style.configure("Notebook", **style_config) + style.configure("Notebook.Tab", relief="raised", borderwidth=1, + **style_config) + style.configure("Notebook.Tab.Frame", relief="flat", borderwidth=0, + **style_config) + style.configure("Notebook.Tab.Label", relief="flat", borderwidth=1, + padding=0, **style_config) + style.configure("Notebook.Tab.Label", foreground=theme["unselectedfg"]) + style.configure("Notebook.Tab.Close", relief="flat", borderwidth=1, + padding=0, **style_config) + style.configure("Notebook.Tab.Frame", background=theme["bg"]) + style.configure("Notebook.Tab.Label", background=theme["bg"]) + style.configure("Notebook.Tab.Close", background=theme["bg"]) + + style.map("Notebook.Tab.Frame", + **{"background": [("selected", "!disabled", theme["activebg"])]}) + style.map("Notebook.Tab.Label", + **{"background": [("selected", "!disabled", theme["activebg"])], + "foreground": [("selected", "!disabled", theme["fg"])]}) + style.map("Notebook.Tab.Close", + **{"background": [("selected", theme["activebg"]), + ("pressed", theme["darkcolor"]), + ("active", theme["activebg"])], + "relief": [("hover", "!disabled", "raised"), + ("active", "!disabled", "raised"), + ("pressed", "!disabled", "sunken")], + "lightcolor": [("pressed", theme["darkcolor"])], + "darkcolor": [("pressed", theme["lightcolor"])]}) + style.map("Notebook.Tab", + **{"background": [("selected", "!disabled", theme["activebg"])]}) + + style.configure("Left.Notebook.TButton", padding=0) + style.configure("Right.Notebook.TButton", padding=0) + + def _on_configure(self, event=None): + self.update_idletasks() + # ensure that canvas has the same height as the tabs + h = self._tab_frame.winfo_reqheight() + self._canvas.configure(height=h) + # ensure that _tab_frame2 fills the canvas if _tab_frame is smaller + self._canvas.itemconfigure("window", width=max(self._canvas.winfo_width(), self._tab_frame.winfo_reqwidth())) + # update canvas scrollregion + self._canvas.configure(scrollregion=self._canvas.bbox("all")) + # ensure visibility of current tab + self.see(self.current_tab) + # check whether next/prev buttons needs to be displayed + if self._tab_frame.winfo_reqwidth() < self._canvas.winfo_width(): + self._btn_left.grid_remove() + self._btn_right.grid_remove() + elif len(self._visible_tabs) > 1: + self._btn_left.grid() + self._btn_right.grid() + + def _on_press(self, event, tab): + # show clicked tab content + self._show(tab) + + if not self._tabdrag or self.tab(tab, "state") == "disabled": + return + + # prepare dragging + widget = self._tab_labels[tab] + x = widget.winfo_x() + y = widget.winfo_y() + # replace tab by blank space (dummy) + self._dummy_frame.configure(width=widget.winfo_reqwidth(), + height=widget.winfo_reqheight()) + self._dummy_frame.grid(**widget.grid_info()) + self.update_idletasks() + self._dummy_sep.place_configure(in_=self._dummy_frame, y=self._dummy_frame.winfo_height()) + widget.grid_remove() + # place tab above the rest to drag it + widget.place(bordermode="outside", x=x, y=y) + widget.lift() + self._dragged_tab = widget + self._dx = - event.x_root # - current mouse x position on screen + self._y = event.y_root # current y mouse position on screen + self._distance_to_dragged_border = widget.winfo_rootx() - event.x_root + widget.bind_all("", self._on_drag) + + def _on_drag(self, event): + self._dragged_tab.place_configure(x=self._dragged_tab.winfo_x() + event.x_root + self._dx) + x_border = event.x_root + self._distance_to_dragged_border + # get tab below dragged_tab + if event.x_root > - self._dx: + # move towards right + w = self._dragged_tab.winfo_width() + tab_below = self._tab_frame.winfo_containing(x_border + w + 2, self._y) + else: + # move towards left + tab_below = self._tab_frame.winfo_containing(x_border - 2, self._y) + if tab_below and tab_below.master in self._tab_labels.values(): + tab_below = tab_below.master + elif tab_below not in self._tab_labels: + tab_below = None + + if tab_below and abs(x_border - tab_below.winfo_rootx()) < tab_below.winfo_width() / 2: + # swap + self._swap(tab_below) + + self._dx = - event.x_root + + def _swap(self, tab): + """Swap dragged_tab with tab.""" + g1, g2 = self._dummy_frame.grid_info(), tab.grid_info() + self._dummy_frame.grid(**g2) + tab.grid(**g1) + i1 = self._visible_tabs.index(self._dragged_tab.tab_nb) + i2 = self._visible_tabs.index(tab.tab_nb) + self._visible_tabs[i1] = tab.tab_nb + self._visible_tabs[i2] = self._dragged_tab.tab_nb + self.see(self._dragged_tab.tab_nb) + + def _on_click(self, event): + """Stop dragging.""" + if self._dragged_tab: + self._dragged_tab.unbind_all("") + self._dragged_tab.grid(**self._dummy_frame.grid_info()) + self._dummy_frame.grid_forget() + + if self._drag_to_toplevel: + end_pos_in_widget = coords_in_box((event.x_root, event.y_root), + parse_geometry(self.winfo_toplevel().winfo_geometry())) + if not end_pos_in_widget: + self.move_to_toplevel(self._dragged_tab) + self._dragged_tab = None + print(self._visible_tabs) + + def _menu_insert(self, tab, text): + menu = [] + for t in self._tabs.keys(): + menu.append((self.tab(t, "text"), t)) + menu.sort() + ind = menu.index((text, tab)) + self._tab_menu.insert_radiobutton(ind, label=text, + variable=self._tab_var, value=tab, + command=lambda t=tab: self._show(t)) + for i, (text, tab) in enumerate(menu): + self._tab_menu_entries[tab] = i + + def _resize(self): + """Resize the notebook so that all widgets can be displayed fully.""" + w, h = 0, 0 + for tab in self._visible_tabs: + widget = self._tabs[tab] + w = max(w, widget.winfo_reqwidth()) + h = max(h, widget.winfo_reqheight()) + w = max(w, self._tab_frame.winfo_reqwidth()) + self._canvas.configure(width=w) + self._body.configure(width=w, height=h) + self._on_configure() + + def _show(self, tab_id, new=False, update=False): + if self.tab(tab_id, "state") == "disabled": + if tab_id in self._active_tabs: + self._active_tabs.remove(tab_id) + return + # hide current tab body + if self._current_tab >= 0: + self._tabs[self.current_tab].grid_remove() + self._tab_labels[self.current_tab].state(["!selected"]) + + # restore tab if hidden + if tab_id in self._hidden_tabs: + self._tab_labels[tab_id].grid(in_=self._tab_frame) + self._visible_tabs.insert(self._tab_labels[tab_id].grid_info()["column"], tab_id) + self._active_tabs = [t for t in self._visible_tabs + if self._tab_options[t]["state"] == "normal"] + self._hidden_tabs.remove(tab_id) + + # update current tab + self.current_tab = tab_id + self._tab_var.set(tab_id) + self._tab_labels[tab_id].state(["selected"]) + + if new: + # add new tab + c, r = self._tab_frame.grid_size() + self._tab_labels[tab_id].grid(in_=self._tab_frame, row=0, column=c, sticky="s") + self._visible_tabs.append(tab_id) + + self.update_idletasks() + self._on_configure() + # ensure tab visibility + self.see(tab_id) + # display body + if update: + sticky = self._tab_options[tab_id]["sticky"] + pad = self._tab_options[tab_id]["padding"] + self._tabs[tab_id].grid(in_=self._body, sticky=sticky, padx=pad, pady=pad) + else: + self._tabs[tab_id].grid(in_=self._body) + self.update_idletasks() + self.event_generate("<>") + + def _popup_menu(self, event, tab): + self._show(tab) + if hasattr(self, "menu") and self.menu is not None: + self.menu.tk_popup(event.x_root, event.y_root) + + @property + def current_tab(self): + """ Gets the current tab """ + return self._current_tab + + @current_tab.setter + def current_tab(self, tab_nb): + self._current_tab = tab_nb + self._tab_var.set(tab_nb) + + def cget(self, key): + if key == "closebutton": + return self._closebutton + elif key == "closecommand": + return self._closecommand + elif key == "tabmenu": + return self._tabmenu + elif key == "tabdrag": + return self._tabdrag + elif key == "drag_to_toplevel": + return self._drag_to_toplevel + else: + return ttk.Frame.cget(self, key) + + def configure(self, cnf=None, **kw): + """ + Configures this Notebook widget. + + :param closebutton: If a close button should show on the tabs + :type closebutton: bool + :param closecommand: A callable to call when the tab is closed, takes one argument, the tab_id + :type closecommand: callable + :param tabdrag: Enable/disable tab dragging and reordering + :type tabdrag: bool + :param drag_to_toplevel: Enable/disable tab dragging to toplevel windows + :type drag_to_toplevel: bool + :param **kw: Other keyword arguments as expected by ttk.Notebook + """ + if cnf: + kwargs = cnf.copy() + kwargs.update(kw) + else: + kwargs = kw.copy() + tab_kw = {} + if "closebutton" in kwargs: + self._closebutton = bool(kwargs.pop("closebutton")) + tab_kw["closebutton"] = self._closebutton + if "closecommand" in kwargs: + self._closecommand = kwargs.pop("closecommand") + tab_kw["closecommand"] = self._closecommand + if "tabdrag" in kwargs: + self._tabdrag = bool(kwargs.pop("tabdrag")) + if "drag_to_toplevel" in kwargs: + self._drag_to_toplevel = bool(kwargs.pop("drag_to_toplevel")) + if "tabmenu" in kwargs: + self._tabmenu = bool(kwargs.pop("tabmenu")) + if self._tabmenu: + self._tab_list.grid() + else: + self._tab_list.grid_remove() + self.update_idletasks() + self._on_configure() + if tab_kw: + for tab, label in self._tab_labels.items(): + label.tab_configure(**tab_kw) + self.update_idletasks() + ttk.Frame.configure(self, **kwargs) + + def keys(self): + keys = ttk.Frame.keys(self) + return keys + ["closebutton", "closecommand", "tabmenu"] + + def add(self, widget, **kwargs): + """ + Add widget (or redisplay it if it was hidden) in the notebook and return + the tab index. + + :param text: tab label + :param image: tab image + :param compound: how the tab label and image are organized + :param sticky: for the widget inside the notebook + :param padding: padding (int) around the widget in the notebook + :param state: state ("normal" or "disabled") of the tab + """ + # Todo: underline + name = str(widget) + if name in self._indexes: + ind = self._indexes[name] + self.tab(ind, **kwargs) + self._show(ind) + self.update_idletasks() + else: + sticky = kwargs.pop("sticky", "ewns") + padding = kwargs.pop("padding", 0) + self._tabs[self._nb_tab] = widget + ind = self._nb_tab + self._indexes[name] = ind + self._tab_labels[ind] = Tab(self._tab_frame2, tab_nb=ind, + closecommand=self._closecommand, + closebutton=self._closebutton, + **kwargs) + self._tab_labels[ind].bind("", self._on_click) + self._tab_labels[ind].bind("", lambda e: self._popup_menu(e, ind)) + self._tab_labels[ind].bind("", lambda e: self._on_press(e, ind)) + self._body.configure(height=max(self._body.winfo_height(), widget.winfo_reqheight()), + width=max(self._body.winfo_width(), widget.winfo_reqwidth())) + + self._tab_options[ind] = dict(text="", image="", compound="none", state="normal") + self._tab_options[ind].update(kwargs) + self._tab_options[ind].update(dict(padding=padding, sticky=sticky)) + self._tab_menu_entries[ind] = self._tab_menu.index("end") + self._tab_list.state(["!disabled"]) + self._active_tabs.append(ind) + self._show(self._nb_tab, new=True, update=True) + + self._nb_tab += 1 + self._menu_insert(ind, kwargs.get("text", "")) + return ind + + def insert(self, where, widget, **kwargs): + """ + Insert WIDGET at the position given by WHERE in the notebook. + + For keyword options, see add method. + """ + existing = str(widget) in self._indexes + index = self.add(widget, **kwargs) + if where == "end": + if not existing: + return + where = self.index(where) + self._visible_tabs.remove(index) + self._visible_tabs.insert(where, index) + for i in range(where, len(self._visible_tabs)): + ind = self._visible_tabs[i] + self._tab_labels[ind].grid_configure(column=i) + self.update_idletasks() + self._on_configure() + return index + + def enable_traversal(self): + self.bind("", lambda e: self.select_next(True)) + self.bind("", lambda e: self.select_prev(True)) + + def index(self, tab_id): + """Return the tab index of TAB_ID.""" + if tab_id == tk.END: + return len(self._tabs) + elif tab_id == tk.CURRENT: + return self.current_tab + elif tab_id in self._tabs: + return tab_id + else: # tab_id is str or tk.Widget + try: + return self._visible_tabs[self._indexes[str(tab_id)]] + except KeyError as e: + e.message = "No such tab in the Notebook: {}".format(tab_id) + raise + + def select_next(self, rotate=False): + """Go to next tab.""" + if self.current_tab >= 0: + index = self._visible_tabs.index(self.current_tab) + index += 1 + if index < len(self._visible_tabs): + self._show(self._visible_tabs[index]) + elif rotate: + self._show(self._visible_tabs[0]) + + def select_prev(self, rotate=False): + """Go to prev tab.""" + if self.current_tab >= 0: + index = self._visible_tabs.index(self.current_tab) + index -= 1 + if index >= 0: + self._show(self._visible_tabs[index]) + elif rotate: + self._show(self._visible_tabs[-1]) + + def see(self, tab_id): + """Make label of tab TAB_ID visible.""" + if tab_id < 0: + return + tab = self.index(tab_id) + w = self._tab_frame.winfo_reqwidth() + label = self._tab_labels[tab] + x1 = label.winfo_x() / w + x2 = x1 + label.winfo_reqwidth() / w + xc1, xc2 = self._canvas.xview() + if x1 < xc1: + self._canvas.xview_moveto(x1) + elif x2 > xc2: + self._canvas.xview_moveto(xc1 + x2 - xc2) + i = self._visible_tabs.index(tab) + if i == 0: + self._btn_left.state(["disabled"]) + if len(self._visible_tabs) > 1: + self._btn_right.state(["!disabled"]) + elif i == len(self._visible_tabs) - 1: + self._btn_right.state(["disabled"]) + self._btn_left.state(["!disabled"]) + else: + self._btn_right.state(["!disabled"]) + self._btn_left.state(["!disabled"]) + + def hide(self, tab_id): + """Hide tab TAB_ID.""" + tab = self.index(tab_id) + if tab in self._visible_tabs: + self._visible_tabs.remove(tab) + if tab in self._active_tabs: + self._active_tabs.remove(tab) + self._hidden_tabs.append(tab) + self._tab_labels[tab].grid_remove() + if self.current_tab == tab: + if self._active_tabs: + self._show(self._active_tabs[0]) + else: + self.current_tab = -1 + self._tabs[tab].grid_remove() + self.update_idletasks() + self._on_configure() + self._resize() + + def forget(self, tab_id): + """Remove tab TAB_ID from notebook.""" + tab = self.index(tab_id) + if tab in self._hidden_tabs: + self._hidden_tabs.remove(tab) + elif tab in self._visible_tabs: + if tab in self._active_tabs: + self._active_tabs.remove(tab) + self._visible_tabs.remove(tab) + self._tab_labels[tab].grid_forget() + if self.current_tab == tab: + if self._active_tabs: + self._show(self._active_tabs[0]) + else: + self.current_tab = -1 + if not self._visible_tabs and not self._hidden_tabs: + self._tab_list.state(["disabled"]) + self._tabs[tab].grid_forget() + del self._tab_labels[tab] + del self._indexes[str(self._tabs[tab])] + del self._tabs[tab] + self.update_idletasks() + self._on_configure() + i = self._tab_menu_entries[tab] + for t, ind in self._tab_menu_entries.items(): + if ind > i: + self._tab_menu_entries[t] -= 1 + self._tab_menu.delete(self._tab_menu_entries[tab]) + del self._tab_menu_entries[tab] + self._resize() + + def move_to_toplevel(self, tab): + tl = tk.Toplevel(self) + move_widget(tab, tl) + tab.grid() + self._toplevels.append(tl) + + def select(self, tab_id=None): + """Select tab TAB_ID. If TAB_ID is None, return currently selected tab.""" + if tab_id is None: + return self.current_tab + self._show(self.index(tab_id)) + + def tab(self, tab_id, option=None, **kw): + """ + Query or modify TAB_ID options. + + The widget corresponding to tab_id can be obtained by passing the option + "widget" but cannot be modified. + """ + tab = self.index(tab_id) + if option == "widget": + return self._tabs[tab] + elif option: + return self._tab_options[tab][option] + else: + self._tab_options[tab].update(kw) + sticky = kw.pop("padding", None) + padding = kw.pop("sticky", None) + self._tab_labels[tab].tab_configure(**kw) + if sticky is not None or padding is not None and self.current_tab == tab: + self._show(tab, update=True) + if "text" in kw: + self._tab_menu.delete(self._tab_menu_entries[tab]) + self._menu_insert(tab, kw["text"]) + if "state" in kw: + self._tab_menu.entryconfigure(self._tab_menu_entries[tab], + state=kw["state"]) + if kw["state"] == "disabled": + if tab in self._active_tabs: + self._active_tabs.remove(tab) + if tab == self.current_tab: + tabs = self._visible_tabs.copy() + if tab in tabs: + tabs.remove(tab) + if tabs: + self._show(tabs[0]) + else: + self._tabs[tab].grid_remove() + self.current_tab = -1 + else: + self._active_tabs = [t for t in self._visible_tabs + if self._tab_options[t]["state"] == "normal"] + if self.current_tab == -1: + self._show(tab) + + def tabs(self): + """Return the tuple of visible tab ids in the order of display.""" + return tuple(self._visible_tabs) diff --git a/ttkwidgets/utilities.py b/ttkwidgets/utilities.py index 22bf5134..184430a4 100644 --- a/ttkwidgets/utilities.py +++ b/ttkwidgets/utilities.py @@ -1,10 +1,17 @@ """ Author: The ttkwidgets authors -License: GNU GPLv3 -Source: The ttkwidgets repository +License: GNU GPLv3, as in LICENSE.md +Copyright (c) 2016-2019 The ttkwidgets authors + +For author details, see AUTHORS.md """ import os from PIL import Image, ImageTk +import re +try: + import Tkinter as tk +except ImportError: + import tkinter as tk def get_assets_directory(): @@ -22,3 +29,142 @@ def parse_geometry_string(string): e = e[1].split("+") h, x, y = map(int, e) return x, y, w, h + + +def get_widget_options(widget): + """ + Gets the options from a widget + + :param widget: tkinter.Widget instance to get the config options from + :return: dict of options that you can pass on to widget.config() + """ + options = {} + for key in widget.keys(): + value = widget.cget(key) + if value not in ("", None): + options[key] = value + return options + + +def copy_widget(widget, new_parent, level=0): + """ + Recursive function that copies a widget to a new parent. + + Ported to python from this tcl code : + https://stackoverflow.com/questions/6285648/can-you-change-a-widgets-parent-in-python-tkinter + + :param widget: widget to copy (tkinter.Widget instance) + :param new_parent: new widget to parent to. + :param level: (default: 0) current level of the recursive algorithm + + :return: tkinter.Widget instance, the copied widget. + """ + if widget.tk is not new_parent.tk: + raise RuntimeError("Widget may not be copied into new Tk instance") + widget._tclCommands = None # Preserve bound functions + rv = widget.__class__(master=new_parent, **get_widget_options(widget)) + + for b in widget.bind(): + script = widget.bind(b) + rv.bind(b, script) + + if level > 0: + try: + pack_info = widget.pack_info() + except tk.TclError: + pack_info = {} + if widget.grid_info(): # if geometry manager is grid + temp = widget.grid_info() + del temp['in'] + rv.grid(**temp) + elif widget.place_info(): # if geometry manager is place + temp = widget.place_info() + del temp['in'] + rv.place(**temp) + elif pack_info: # if geometry manager is pack + temp = widget.pack_info() + del temp['in'] + rv.pack(**temp) + else: # No geometry manager configured + pass + level += 1 + + if widget.pack_slaves(): # subwidgets are using the pack() geometry manager + for child in widget.pack_slaves(): + copy_widget(child, rv, level) + else: + for child in widget.winfo_children(): + copy_widget(child, rv, level) + + widget.destroy() + return rv + + +def move_widget(widget, new_parent, preserve_geometry=False): + """ + Moves widget to new_parent + + :param widget: widget to move + :type widget: tk.Widget + :param new_parent: new parent for the widget + :type new_parent: tk.Widget + :param preserve_geometry: Whether to preserve the geometry of the + widget in the old parent into the new parent + :type preserve_geometry: bool + + :return: moved widget reference + """ + rv = copy_widget(widget, new_parent, level=preserve_geometry) + widget.destroy() + return rv + + +def parse_geometry(geometry): + """ + Parses a tkinter geometry string into a 4-tuple (x, y, width, height) + + :param geometry: a tkinter geometry string in the format (wxh+x+y) + :type geometry: str + :returns: 4-tuple (x, y, width, height) + :rtype: tuple + """ + match = re.search(r'(\d+)x(\d+)(\+|-)(\d+)(\+|-)(\d+)', geometry) + xmod = -1 if match.group(3) == '-' else 1 + ymod = -1 if match.group(5) == '-' else 1 + return ( xmod * int(match.group(4)), ymod * int(match.group(6)), + int(match.group(1)), int(match.group(2))) + + +def coords_in_box(coords, bbox, include_edges=True, bbox_is_x1y1x2y2=False): + """ + Checks whether coords are inside bbox + + :param coords: 2-tuple of coordinates x, y + :type coords: tuple + :param bbox: 4-tuple (x, y, width, height) of a bounding box + :type bbox: tuple + :param include_edges: default True whether to include the edges + :type include_edges: bool + :param bbox_is_x1y1x2y2: default False whether the bbox is in + (x, y, width, height) or (x1, y1, x2, y2) format + :type bbox_is_x1y1x2y2: bool + :returns: whether coords is inside bbox + :rtype: bool + :raises: ValueError if length of bbox or coords do not match the specifications + """ + if len(coords) != 2: + raise ValueError("Coords argument is supposed to be of length 2") + if len(bbox) != 4: + raise ValueError("Bbox argument is supposed to be of length 4") + + x, y = coords + if bbox_is_x1y1x2y2: + xmin, ymin, xmax, ymax = bbox + else: + xmin, ymin, width, height = bbox + xmax, ymax = xmin + width, ymin + height + + if include_edges: + return xmin <= x <= xmax and ymin <= y <= ymax + else: + return xmin < x < xmax and ymin < y < ymax