From 2889fa62ac03b112c98938bab904b1e52e86a19f Mon Sep 17 00:00:00 2001 From: Dogeek Date: Sun, 1 Dec 2019 05:41:09 +0100 Subject: [PATCH 01/12] notebook --- AUTHORS.md | 2 + ttkwidgets/notebooks/__init__.py | 4 + ttkwidgets/notebooks/closeable_notebook.py | 96 +++ ttkwidgets/notebooks/draggable_notebook.py | 65 ++ ttkwidgets/notebooks/notebook.py | 877 +++++++++++++++++++++ 5 files changed, 1044 insertions(+) create mode 100644 ttkwidgets/notebooks/__init__.py create mode 100644 ttkwidgets/notebooks/closeable_notebook.py create mode 100644 ttkwidgets/notebooks/draggable_notebook.py create mode 100644 ttkwidgets/notebooks/notebook.py diff --git a/AUTHORS.md b/AUTHORS.md index aad1e045..3a23ba69 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -29,5 +29,7 @@ This file contains a list of all the authors of widgets in this repository. Plea * `get_bitmap` * `PopupMenu` * `DirTree` + * `DraggableNotebook` + * `CloseableNotebook` - Multiple authors: * `ScaleEntry` (RedFantom and Juliette Monsel) diff --git a/ttkwidgets/notebooks/__init__.py b/ttkwidgets/notebooks/__init__.py new file mode 100644 index 00000000..17a88392 --- /dev/null +++ b/ttkwidgets/notebooks/__init__.py @@ -0,0 +1,4 @@ +from ttkwidgets.notebook import Notebook + +from ttkwidgets.closeable_notebook import CloseableNotebook +from ttkwidgets.draggable_notebook import DraggableNotebook diff --git a/ttkwidgets/notebooks/closeable_notebook.py b/ttkwidgets/notebooks/closeable_notebook.py new file mode 100644 index 00000000..fc954e74 --- /dev/null +++ b/ttkwidgets/notebooks/closeable_notebook.py @@ -0,0 +1,96 @@ +import tkinter as tk +import tkinter.ttk as ttk + + +class CloseableNotebook(ttk.Notebook): + """A ttk Notebook with close buttons on each tab""" + + __initialized = False + + def __init__(self, *args, **kwargs): + """ + CloseableNotebook generates a <> when a tab is closed. + The event object has a tab keyword argument containing the tab id that was just closed. + + args and kwargs are the same as for a regular ttk::Notebook + """ + if not self.__initialized: + self.__initialize_custom_style() + self.__inititialized = True + + kwargs["style"] = "CloseableNotebook" + ttk.Notebook.__init__(self, *args, **kwargs) + self._active = None + self.bind("", self.on_close_press, True) + self.bind("", self.on_close_release) + + def on_close_press(self, event): + """Called when the button is pressed over the close button""" + + element = self.identify(event.x, event.y) + + if "close" in element: + index = self.index("@%d,%d" % (event.x, event.y)) + self.state(['pressed']) + self._active = index + + def on_close_release(self, event): + """Called when the button is released over the close button""" + if not self.instate(['pressed']): + return + + element = self.identify(event.x, event.y) + index = self.index("@%d,%d" % (event.x, event.y)) + + if "close" in element and self._active == index: + self.forget(index) + self.event_generate("<>", tab=self._active) + + self.state(["!pressed"]) + self._active = None + + def __initialize_custom_style(self): + style = ttk.Style() + self.images = ( + tk.PhotoImage("img_close", data=''' + R0lGODlhCAAIAMIBAAAAADs7O4+Pj9nZ2Ts7Ozs7Ozs7Ozs7OyH+EUNyZWF0ZWQg + d2l0aCBHSU1QACH5BAEKAAQALAAAAAAIAAgAAAMVGDBEA0qNJyGw7AmxmuaZhWEU + 5kEJADs= + '''), + tk.PhotoImage("img_closeactive", data=''' + R0lGODlhCAAIAMIEAAAAAP/SAP/bNNnZ2cbGxsbGxsbGxsbGxiH5BAEKAAQALAAA + AAAIAAgAAAMVGDBEA0qNJyGw7AmxmuaZhWEU5kEJADs= + '''), + tk.PhotoImage("img_closepressed", data=''' + R0lGODlhCAAIAMIEAAAAAOUqKv9mZtnZ2Ts7Ozs7Ozs7Ozs7OyH+EUNyZWF0ZWQg + d2l0aCBHSU1QACH5BAEKAAQALAAAAAAIAAgAAAMVGDBEA0qNJyGw7AmxmuaZhWEU + 5kEJADs= + ''') + ) + try: + style.element_create("close", "image", "img_close", + ("active", "pressed", "!disabled", "img_closepressed"), + ("active", "!disabled", "img_closeactive"), border=8, sticky='e') + except tk.TclError: + pass + + style.layout("CloseableNotebook", [("CloseableNotebook.client", {"sticky": "nswe"})]) + + style.layout("CloseableNotebook.Tab", [ + ("CloseableNotebook.tab", { + "sticky": "nswe", + "expand": 1, + "children": [ + ("CloseableNotebook.padding", { + "side": "top", + "sticky": "nswe", + "children": [ + ("CloseableNotebook.focus", { + "side": "top", + "sticky": "nswe", + "children": [ + ("CloseableNotebook.label", {"side": "left", "sticky": 'w'}), + ("CloseableNotebook.close", {"side": "left", "sticky": 'e'}), + ]}) + ]}) + ]})]) diff --git a/ttkwidgets/notebooks/draggable_notebook.py b/ttkwidgets/notebooks/draggable_notebook.py new file mode 100644 index 00000000..f622a728 --- /dev/null +++ b/ttkwidgets/notebooks/draggable_notebook.py @@ -0,0 +1,65 @@ +import tkinter as tk +import tkinter.ttk as ttk +import _tkinter + + +class DraggableNotebook(ttk.Notebook): + """ + DraggableNotebook class. + + Allows the user to drag tabs around to reorder them. Subclass of the ttk::Notebook widget. + + Code partly translated from this Tcl/Tk snippet : + https://wiki.tcl-lang.org/page/Drag+and+Drop+Notebook+Tabs + + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._src_index = None + self._toplevels = [] + self._children = [] + + self.bind("", self._on_mouse_button_1_pressed) + self.bind("", self._on_mouse_button_1_released) + self.bind("", self._on_mouse_button_1_motion) + # self.bind("<>", self._on_notebook_tab_changed) + + def _on_mouse_button_1_pressed(self, event=None): + self._src_index = self.index(f'@{event.x},{event.y}') + + def _on_mouse_button_1_released(self, event=None): + dst_index = None + if isinstance(self._src_index, int): + try: + dst_index = self.index(f'@{event.x},{event.y}') + except _tkinter.TclError: + dst_index = None + if isinstance(dst_index, int): + tab = self.tabs()[self._src_index] + self.insert(dst_index, tab) + + def _on_mouse_button_1_motion(self, event=None): + # TODO: Pass down the event through the event queue to subwidgets + # https://wiki.tcl-lang.org/page/Drag+and+Drop+Notebook+Tabs + # https://wiki.tcl-lang.org/page/ttk::notebook + # https://github.com/RedFantom/ttkwidgets/blob/master/ttkwidgets/table.py + pass + + def _on_notebook_tab_changed(self, event=None): + if self._mouse_button_1_pressed: + self.insert(f"@{event.x},{event.y}", self.identify(*self._mouse_button_1_pressed)) + + def _create_toplevel(self, child, tabkw): + # TODO: Allow dragging the tabs to a new tkinter.Toplevel. Use new move_widget function + + tl = tk.Toplevel(self) + nb = DraggableNotebook(tl) + child.master = nb + nb.add(child, **tabkw) + nb.pack() + self._toplevels.append(tl) + + def add(self, child, **kw): + rv = super().add(child, **kw) + self._children.append(child) + return rv diff --git a/ttkwidgets/notebooks/notebook.py b/ttkwidgets/notebooks/notebook.py new file mode 100644 index 00000000..9e1098b9 --- /dev/null +++ b/ttkwidgets/notebooks/notebook.py @@ -0,0 +1,877 @@ +#! /usr/bin/python3 +# -*- coding: utf-8 -*- +""" +PyTkEditor - Python IDE +Copyright 2018-2019 Juliette Monsel + +PyTkEditor is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +PyTkEditor is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + + +Notebook with draggable / scrollable tabs +""" +from tkinter import ttk +import tkinter as tk +from PIL import Image, ImageTk +from io import BytesIO + + +class Tab(ttk.Frame): + """Notebook tab.""" + def __init__(self, master=None, tab_nb=0, **kwargs): + 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._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', **kwargs, + anchor='center', takefocus=False) + 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) + + 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): + 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 + + 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._closebutton = bool(kwargs.pop('closebutton', True)) + self._closecommand = kwargs.pop('closecommand', self.forget) + self._tabdrag = bool(kwargs.pop('tabdrag', True)) + self._tabmenu = bool(kwargs.pop('tabmenu', True)) + + ttk.Frame.__init__(self, master, class_='Notebook', padding=(0, 0, 0, 1), + **kwargs) + 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 + + 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.config = self.configure + + def __getitem__(self, key): + return self.cget(key) + + def __setitem__(self, key, value): + self.configure(**{key: value}) + + def setup_style(self, bg="#dddddd", activebg="#efefef", pressedbg="#c1c1c1", + fg="black", fieldbg="white", lightcolor="#ededed", darkcolor="##cfcdc8", + bordercolor="#888888", focusbordercolor="#5e5e5e", selectbg="#c1c1c1", + selectfg="black", unselectfg="#999999", disabledfg='#999999', disabledbg="#dddddd"): + theme = {'bg': bg, + 'activebg': activebg, + 'pressedbg': pressedbg, + 'fg': fg, + 'fieldbg': fieldbg, + 'lightcolor': lightcolor, + 'darkcolor': darkcolor, + 'bordercolor': bordercolor, + 'focusbordercolor': focusbordercolor, + 'selectbg': selectbg, + 'selectfg': selectfg, + 'unselectedfg': unselectfg, + 'disabledfg': disabledfg, + 'disabledbg': disabledbg} + + self.images = ( + tk.PhotoImage("img_close", data=''' + R0lGODlhCAAIAMIBAAAAADs7O4+Pj9nZ2Ts7Ozs7Ozs7Ozs7OyH+EUNyZWF0ZWQg + d2l0aCBHSU1QACH5BAEKAAQALAAAAAAIAAgAAAMVGDBEA0qNJyGw7AmxmuaZhWEU + 5kEJADs= + '''), + tk.PhotoImage("img_closeactive", data=''' + R0lGODlhCAAIAMIEAAAAAP/SAP/bNNnZ2cbGxsbGxsbGxsbGxiH5BAEKAAQALAAA + AAAIAAgAAAMVGDBEA0qNJyGw7AmxmuaZhWEU5kEJADs= + '''), + tk.PhotoImage("img_closepressed", data=''' + R0lGODlhCAAIAMIEAAAAAOUqKv9mZtnZ2Ts7Ozs7Ozs7Ozs7OyH+EUNyZWF0ZWQg + d2l0aCBHSU1QACH5BAEKAAQALAAAAAAIAAgAAAMVGDBEA0qNJyGw7AmxmuaZhWEU + 5kEJADs= + ''') + ) + + 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 = ttk.Style(self) + 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', + **{'ba.ckground': [('selected', '!disabled', theme['activebg'])]}) + + style.configure('Left.Notebook.TButton', padding=0) + style.configure('Right.Notebook.TButton', padding=0) + + style.configure('TNotebook.Tab', background=theme['bg'], + foreground=theme['unselectedfg']) + style.map('TNotebook.Tab', + **{'background': [('selected', '!disabled', theme['activebg'])], + 'foreground': [('selected', '!disabled', theme['fg'])]}) + + 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 wheter 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._dragged_tab = None + self._dummy_frame.grid_forget() + + 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 self.menu is not None: + self.menu.tk_popup(event.x_root, event.y_root) + + @property + def current_tab(self): + 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 + else: + return ttk.Frame.cget(self, key) + + def configure(self, cnf=None, **kw): + 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 '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. + + * text: tab label + * image: tab image + * compound: how the tab label and image are organized + * sticky: for the widget inside the notebook + * padding: padding (int) around the widget in the notebook + * 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 WIDEGT 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() + + 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: + try: + return self._indexes[str(tab_id)] + except KeyError: + raise ValueError('No such tab in the Notebook: %s' % tab_id) + + 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 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) + + +if __name__ == '__main__': + root = tk.Tk() + gui = Notebook(root) + frames = [tk.Frame(gui) for i in range(10)] + for i, w in enumerate(frames): + tk.Canvas(w, width=300, height=300).grid(sticky="nswe") + gui.add(w, text="Frame " + str(i)) + w.grid() + gui.grid() + root.mainloop() From 619607d754e427f920324fd3e5ccdc6c25ee4348 Mon Sep 17 00:00:00 2001 From: Dogeek Date: Sun, 1 Dec 2019 05:43:48 +0100 Subject: [PATCH 02/12] license/doc --- ttkwidgets/notebooks/notebook.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/ttkwidgets/notebooks/notebook.py b/ttkwidgets/notebooks/notebook.py index 9e1098b9..811a7ecd 100644 --- a/ttkwidgets/notebooks/notebook.py +++ b/ttkwidgets/notebooks/notebook.py @@ -1,25 +1,18 @@ #! /usr/bin/python3 # -*- coding: utf-8 -*- + """ -PyTkEditor - Python IDE Copyright 2018-2019 Juliette Monsel +Copyright 2019 Dogeek -PyTkEditor is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -PyTkEditor is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . +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 tkinter import ttk import tkinter as tk from PIL import Image, ImageTk From 3bcbba1ef8cbb728ff14316586c82ea5c74dd80e Mon Sep 17 00:00:00 2001 From: Dogeek Date: Mon, 2 Dec 2019 01:00:04 +0100 Subject: [PATCH 03/12] Added Notebook example --- examples/example_notebook.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 examples/example_notebook.py diff --git a/examples/example_notebook.py b/examples/example_notebook.py new file mode 100644 index 00000000..aa9461a5 --- /dev/null +++ b/examples/example_notebook.py @@ -0,0 +1,22 @@ +import tkinter as tk +import tkinter.ttk as ttk +from ttkwidgets import Notebook + + +class MainWindow(ttk.Frame): + def __init__(self, master=None): + super().__init__(master) + self.nb = Notebook(self) + self.frames = [tk.Frame(self) for i in range(10)] + for i, w in enumerate(self.frames): + tk.Canvas(w, width=300, height=300).grid(sticky="nswe") + self.nb.add(w, text="Frame " + str(i)) + w.grid() + self.nb.grid() + + +root = tk.Tk() +root.title("Notebook Example") +gui = MainWindow(root) +gui.grid() +root.mainloop() \ No newline at end of file From 66de80a9be20005016965cd97ad837234d307b24 Mon Sep 17 00:00:00 2001 From: Dogeek Date: Mon, 2 Dec 2019 01:01:00 +0100 Subject: [PATCH 04/12] Removed my own notebooks, in favor of the way better Notebook from @j4321 --- ttkwidgets/__init__.py | 1 + ttkwidgets/{notebooks => }/notebook.py | 36 ++++---- ttkwidgets/notebooks/__init__.py | 4 - ttkwidgets/notebooks/closeable_notebook.py | 96 ---------------------- ttkwidgets/notebooks/draggable_notebook.py | 65 --------------- 5 files changed, 23 insertions(+), 179 deletions(-) rename ttkwidgets/{notebooks => }/notebook.py (98%) delete mode 100644 ttkwidgets/notebooks/__init__.py delete mode 100644 ttkwidgets/notebooks/closeable_notebook.py delete mode 100644 ttkwidgets/notebooks/draggable_notebook.py diff --git a/ttkwidgets/__init__.py b/ttkwidgets/__init__.py index 326585d7..6354eb34 100644 --- a/ttkwidgets/__init__.py +++ b/ttkwidgets/__init__.py @@ -13,5 +13,6 @@ from ttkwidgets.table import Table from ttkwidgets.popupmenu import PopupMenu from ttkwidgets.dirtreewidget import DirTree +from ttkwidgets.notebook import Notebook from ttkwidgets.errors import TtkWidgetsError, I18NError, AssetNotFoundError, AssetMaskNotFoundError diff --git a/ttkwidgets/notebooks/notebook.py b/ttkwidgets/notebook.py similarity index 98% rename from ttkwidgets/notebooks/notebook.py rename to ttkwidgets/notebook.py index 811a7ecd..470544ea 100644 --- a/ttkwidgets/notebooks/notebook.py +++ b/ttkwidgets/notebook.py @@ -15,17 +15,21 @@ from tkinter import ttk import tkinter as tk -from PIL import Image, ImageTk -from io import BytesIO 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::Frame 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') @@ -50,6 +54,22 @@ def __init__(self, master=None, tab_nb=0, **kwargs): 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('', self._on_mousewheel) + + def _on_mousewheel(self, event): + if self.hovering_tab: + if event.delta > 0: + 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): @@ -856,15 +876,3 @@ def tab(self, tab_id, option=None, **kw): def tabs(self): """Return the tuple of visible tab ids in the order of display.""" return tuple(self._visible_tabs) - - -if __name__ == '__main__': - root = tk.Tk() - gui = Notebook(root) - frames = [tk.Frame(gui) for i in range(10)] - for i, w in enumerate(frames): - tk.Canvas(w, width=300, height=300).grid(sticky="nswe") - gui.add(w, text="Frame " + str(i)) - w.grid() - gui.grid() - root.mainloop() diff --git a/ttkwidgets/notebooks/__init__.py b/ttkwidgets/notebooks/__init__.py deleted file mode 100644 index 17a88392..00000000 --- a/ttkwidgets/notebooks/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from ttkwidgets.notebook import Notebook - -from ttkwidgets.closeable_notebook import CloseableNotebook -from ttkwidgets.draggable_notebook import DraggableNotebook diff --git a/ttkwidgets/notebooks/closeable_notebook.py b/ttkwidgets/notebooks/closeable_notebook.py deleted file mode 100644 index fc954e74..00000000 --- a/ttkwidgets/notebooks/closeable_notebook.py +++ /dev/null @@ -1,96 +0,0 @@ -import tkinter as tk -import tkinter.ttk as ttk - - -class CloseableNotebook(ttk.Notebook): - """A ttk Notebook with close buttons on each tab""" - - __initialized = False - - def __init__(self, *args, **kwargs): - """ - CloseableNotebook generates a <> when a tab is closed. - The event object has a tab keyword argument containing the tab id that was just closed. - - args and kwargs are the same as for a regular ttk::Notebook - """ - if not self.__initialized: - self.__initialize_custom_style() - self.__inititialized = True - - kwargs["style"] = "CloseableNotebook" - ttk.Notebook.__init__(self, *args, **kwargs) - self._active = None - self.bind("", self.on_close_press, True) - self.bind("", self.on_close_release) - - def on_close_press(self, event): - """Called when the button is pressed over the close button""" - - element = self.identify(event.x, event.y) - - if "close" in element: - index = self.index("@%d,%d" % (event.x, event.y)) - self.state(['pressed']) - self._active = index - - def on_close_release(self, event): - """Called when the button is released over the close button""" - if not self.instate(['pressed']): - return - - element = self.identify(event.x, event.y) - index = self.index("@%d,%d" % (event.x, event.y)) - - if "close" in element and self._active == index: - self.forget(index) - self.event_generate("<>", tab=self._active) - - self.state(["!pressed"]) - self._active = None - - def __initialize_custom_style(self): - style = ttk.Style() - self.images = ( - tk.PhotoImage("img_close", data=''' - R0lGODlhCAAIAMIBAAAAADs7O4+Pj9nZ2Ts7Ozs7Ozs7Ozs7OyH+EUNyZWF0ZWQg - d2l0aCBHSU1QACH5BAEKAAQALAAAAAAIAAgAAAMVGDBEA0qNJyGw7AmxmuaZhWEU - 5kEJADs= - '''), - tk.PhotoImage("img_closeactive", data=''' - R0lGODlhCAAIAMIEAAAAAP/SAP/bNNnZ2cbGxsbGxsbGxsbGxiH5BAEKAAQALAAA - AAAIAAgAAAMVGDBEA0qNJyGw7AmxmuaZhWEU5kEJADs= - '''), - tk.PhotoImage("img_closepressed", data=''' - R0lGODlhCAAIAMIEAAAAAOUqKv9mZtnZ2Ts7Ozs7Ozs7Ozs7OyH+EUNyZWF0ZWQg - d2l0aCBHSU1QACH5BAEKAAQALAAAAAAIAAgAAAMVGDBEA0qNJyGw7AmxmuaZhWEU - 5kEJADs= - ''') - ) - try: - style.element_create("close", "image", "img_close", - ("active", "pressed", "!disabled", "img_closepressed"), - ("active", "!disabled", "img_closeactive"), border=8, sticky='e') - except tk.TclError: - pass - - style.layout("CloseableNotebook", [("CloseableNotebook.client", {"sticky": "nswe"})]) - - style.layout("CloseableNotebook.Tab", [ - ("CloseableNotebook.tab", { - "sticky": "nswe", - "expand": 1, - "children": [ - ("CloseableNotebook.padding", { - "side": "top", - "sticky": "nswe", - "children": [ - ("CloseableNotebook.focus", { - "side": "top", - "sticky": "nswe", - "children": [ - ("CloseableNotebook.label", {"side": "left", "sticky": 'w'}), - ("CloseableNotebook.close", {"side": "left", "sticky": 'e'}), - ]}) - ]}) - ]})]) diff --git a/ttkwidgets/notebooks/draggable_notebook.py b/ttkwidgets/notebooks/draggable_notebook.py deleted file mode 100644 index f622a728..00000000 --- a/ttkwidgets/notebooks/draggable_notebook.py +++ /dev/null @@ -1,65 +0,0 @@ -import tkinter as tk -import tkinter.ttk as ttk -import _tkinter - - -class DraggableNotebook(ttk.Notebook): - """ - DraggableNotebook class. - - Allows the user to drag tabs around to reorder them. Subclass of the ttk::Notebook widget. - - Code partly translated from this Tcl/Tk snippet : - https://wiki.tcl-lang.org/page/Drag+and+Drop+Notebook+Tabs - - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._src_index = None - self._toplevels = [] - self._children = [] - - self.bind("", self._on_mouse_button_1_pressed) - self.bind("", self._on_mouse_button_1_released) - self.bind("", self._on_mouse_button_1_motion) - # self.bind("<>", self._on_notebook_tab_changed) - - def _on_mouse_button_1_pressed(self, event=None): - self._src_index = self.index(f'@{event.x},{event.y}') - - def _on_mouse_button_1_released(self, event=None): - dst_index = None - if isinstance(self._src_index, int): - try: - dst_index = self.index(f'@{event.x},{event.y}') - except _tkinter.TclError: - dst_index = None - if isinstance(dst_index, int): - tab = self.tabs()[self._src_index] - self.insert(dst_index, tab) - - def _on_mouse_button_1_motion(self, event=None): - # TODO: Pass down the event through the event queue to subwidgets - # https://wiki.tcl-lang.org/page/Drag+and+Drop+Notebook+Tabs - # https://wiki.tcl-lang.org/page/ttk::notebook - # https://github.com/RedFantom/ttkwidgets/blob/master/ttkwidgets/table.py - pass - - def _on_notebook_tab_changed(self, event=None): - if self._mouse_button_1_pressed: - self.insert(f"@{event.x},{event.y}", self.identify(*self._mouse_button_1_pressed)) - - def _create_toplevel(self, child, tabkw): - # TODO: Allow dragging the tabs to a new tkinter.Toplevel. Use new move_widget function - - tl = tk.Toplevel(self) - nb = DraggableNotebook(tl) - child.master = nb - nb.add(child, **tabkw) - nb.pack() - self._toplevels.append(tl) - - def add(self, child, **kw): - rv = super().add(child, **kw) - self._children.append(child) - return rv From d3e43717f68a3bd03bcc6a04792cd564cffd15c6 Mon Sep 17 00:00:00 2001 From: Dogeek Date: Mon, 2 Dec 2019 01:09:42 +0100 Subject: [PATCH 05/12] documentation --- ttkwidgets/notebook.py | 44 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/ttkwidgets/notebook.py b/ttkwidgets/notebook.py index 470544ea..082ca449 100644 --- a/ttkwidgets/notebook.py +++ b/ttkwidgets/notebook.py @@ -80,6 +80,9 @@ def _resize(self, event): 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): @@ -311,6 +314,23 @@ def setup_style(self, bg="#dddddd", activebg="#efefef", pressedbg="#c1c1c1", fg="black", fieldbg="white", lightcolor="#ededed", darkcolor="##cfcdc8", bordercolor="#888888", focusbordercolor="#5e5e5e", selectbg="#c1c1c1", selectfg="black", unselectfg="#999999", disabledfg='#999999', disabledbg="#dddddd"): + """ + 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 selectfb: + :param unselectfg: + :param disabledfg: + :param disabledbg: + """ theme = {'bg': bg, 'activebg': activebg, 'pressedbg': pressedbg, @@ -591,6 +611,7 @@ def _popup_menu(self, event, tab): @property def current_tab(self): + """ Gets the current tab """ return self._current_tab @current_tab.setter @@ -611,6 +632,17 @@ def cget(self, key): 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 **kw: Other keyword arguments as expected by ttk.Notebook + """ if cnf: kwargs = cnf.copy() kwargs.update(kw) @@ -648,12 +680,12 @@ def add(self, widget, **kwargs): Add widget (or redisplay it if it was hidden) in the notebook and return the tab index. - * text: tab label - * image: tab image - * compound: how the tab label and image are organized - * sticky: for the widget inside the notebook - * padding: padding (int) around the widget in the notebook - * state: state ('normal' or 'disabled') of the tab + :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) From 8a59536e2fe1b8c389097a7130e8d52150069d07 Mon Sep 17 00:00:00 2001 From: Dogeek Date: Mon, 2 Dec 2019 01:45:39 +0100 Subject: [PATCH 06/12] first draft : dragging tab to toplevel --- ttkwidgets/notebook.py | 34 +++++++++++++++++++++++++++-- ttkwidgets/utilities.py | 48 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/ttkwidgets/notebook.py b/ttkwidgets/notebook.py index 082ca449..516f33ff 100644 --- a/ttkwidgets/notebook.py +++ b/ttkwidgets/notebook.py @@ -15,6 +15,7 @@ from tkinter import ttk import tkinter as tk +from ttkwidgets.utilities import parse_geometry, coordinates_in_box, move_widget class Tab(ttk.Frame): @@ -176,6 +177,9 @@ def __init__(self, master=None, **kwargs): 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 @@ -197,15 +201,19 @@ def __init__(self, master=None, **kwargs): 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)) + dont_setup_style = bool(kwargs.pop('dont_setup_style', False)) ttk.Frame.__init__(self, master, class_='Notebook', padding=(0, 0, 0, 1), **kwargs) - self.setup_style() + if not dont_setup_style: + self.setup_style() self.rowconfigure(1, weight=1) self.columnconfigure(2, weight=1) @@ -223,6 +231,7 @@ def __init__(self, master=None, **kwargs): self._nb_tab = 0 self.current_tab = -1 self._dragged_tab = None + self._toplevels = [] style = ttk.Style(self) bg = style.lookup('TFrame', 'background') @@ -534,9 +543,15 @@ def _on_click(self, event): if self._dragged_tab: self._dragged_tab.unbind_all('') self._dragged_tab.grid(**self._dummy_frame.grid_info()) - self._dragged_tab = None self._dummy_frame.grid_forget() + if self._drag_to_toplevel: + end_pos_in_widget = coordinates_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 + def _menu_insert(self, tab, text): menu = [] for t in self._tabs.keys(): @@ -628,6 +643,8 @@ def cget(self, key): 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) @@ -641,6 +658,8 @@ def configure(self, cnf=None, **kw): :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: @@ -657,6 +676,8 @@ def configure(self, cnf=None, **kw): 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: @@ -856,6 +877,15 @@ def forget(self, tab_id): del self._tab_menu_entries[tab] self._resize() + def move_to_toplevel(self, tab): + tl = tk.Toplevel(self) + nb = Notebook(tl, dont_setup_style=True, **self._init_kwargs) + move_widget(tab, nb) + nb.add(tab) + nb.grid() + self._toplevels.append(tl) + tl.mainloop() + def select(self, tab_id=None): """Select tab TAB_ID. If TAB_ID is None, return currently selected tab.""" if tab_id is None: diff --git a/ttkwidgets/utilities.py b/ttkwidgets/utilities.py index 0fdb3d75..de7a3183 100644 --- a/ttkwidgets/utilities.py +++ b/ttkwidgets/utilities.py @@ -6,6 +6,7 @@ import tkinter as tk import textwrap import string +import re from ttkwidgets.errors import I18NError, AssetNotFoundError, AssetMaskNotFoundError import ttkwidgets.bitmap_assets @@ -126,6 +127,7 @@ def get_i18n_dict(file_path): rv[key] = value return rv + def get_widget_type(widget): """ Gets the type of a given widget @@ -140,6 +142,7 @@ def get_widget_type(widget): class_ = class_.lower() return class_ + def get_widget_options(widget): """ Gets the options from a widget @@ -213,3 +216,48 @@ def move_widget(widget, new_parent): rv = copy_widget(widget, new_parent) 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) + return ( int(match.group(3)), int(match.group(4)), + int(match.group(1)), int(match.group(2))) + + +def coordinates_in_box(coords, bbox, include_edges=True): + """ + 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 + :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 + xmin, ymin, width, height = bbox + xmax, ymax = xmin + width, ymin + height + if include_edges: + xmin = max(xmin - 1, 0) + xmax += 1 + ymin = max(ymin - 1, 0) + ymax += 1 + return xmin < x < xmax and ymin < y < ymax + \ No newline at end of file From 44ea3ffd0fdc8c6ebd4468926b9ad3b2696d48c4 Mon Sep 17 00:00:00 2001 From: Dogeek Date: Mon, 2 Dec 2019 01:46:27 +0100 Subject: [PATCH 07/12] Update AUTHORS.md --- AUTHORS.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/AUTHORS.md b/AUTHORS.md index 3a23ba69..ecebdfe0 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -29,7 +29,6 @@ This file contains a list of all the authors of widgets in this repository. Plea * `get_bitmap` * `PopupMenu` * `DirTree` - * `DraggableNotebook` - * `CloseableNotebook` - Multiple authors: * `ScaleEntry` (RedFantom and Juliette Monsel) + * `Notebook` (Dogeek and Juliette Monsel) From 03a50cdfd4f4f73a744cd28f44e20ded27ac1676 Mon Sep 17 00:00:00 2001 From: Dogeek Date: Mon, 2 Dec 2019 17:09:35 +0100 Subject: [PATCH 08/12] Fixed minor reviewed changes - dropped `dont_setup_style` in favor of the Notebook._initialized class attribute - defined images now have a proper defined master - PEP8 compliance - `ba.ckground` typo fix - Removed theme change on 'Left.Notebook.TButton' and 'Right.Notebook.TButton' --- ttkwidgets/notebook.py | 43 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/ttkwidgets/notebook.py b/ttkwidgets/notebook.py index 516f33ff..d0de5846 100644 --- a/ttkwidgets/notebook.py +++ b/ttkwidgets/notebook.py @@ -208,11 +208,10 @@ def __init__(self, master=None, **kwargs): 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)) - dont_setup_style = bool(kwargs.pop('dont_setup_style', False)) ttk.Frame.__init__(self, master, class_='Notebook', padding=(0, 0, 0, 1), **kwargs) - if not dont_setup_style: + if not Notebook._initialized: self.setup_style() self.rowconfigure(1, weight=1) @@ -312,6 +311,7 @@ def __init__(self, master=None, **kwargs): self.bind_all('', self._on_click) self.config = self.configure + Notebook._initialized = True def __getitem__(self, key): return self.cget(key) @@ -341,35 +341,35 @@ def setup_style(self, bg="#dddddd", activebg="#efefef", pressedbg="#c1c1c1", :param disabledbg: """ theme = {'bg': bg, - 'activebg': activebg, - 'pressedbg': pressedbg, - 'fg': fg, - 'fieldbg': fieldbg, - 'lightcolor': lightcolor, - 'darkcolor': darkcolor, - 'bordercolor': bordercolor, - 'focusbordercolor': focusbordercolor, - 'selectbg': selectbg, - 'selectfg': selectfg, - 'unselectedfg': unselectfg, - 'disabledfg': disabledfg, - 'disabledbg': disabledbg} + 'activebg': activebg, + 'pressedbg': pressedbg, + 'fg': fg, + 'fieldbg': fieldbg, + 'lightcolor': lightcolor, + 'darkcolor': darkcolor, + 'bordercolor': bordercolor, + 'focusbordercolor': focusbordercolor, + 'selectbg': selectbg, + 'selectfg': selectfg, + 'unselectedfg': unselectfg, + 'disabledfg': disabledfg, + 'disabledbg': disabledbg} self.images = ( 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'): @@ -448,10 +448,7 @@ def setup_style(self, bg="#dddddd", activebg="#efefef", pressedbg="#c1c1c1", 'lightcolor': [('pressed', theme['darkcolor'])], 'darkcolor': [('pressed', theme['lightcolor'])]}) style.map('Notebook.Tab', - **{'ba.ckground': [('selected', '!disabled', theme['activebg'])]}) - - style.configure('Left.Notebook.TButton', padding=0) - style.configure('Right.Notebook.TButton', padding=0) + **{'background': [('selected', '!disabled', theme['activebg'])]}) style.configure('TNotebook.Tab', background=theme['bg'], foreground=theme['unselectedfg']) @@ -547,7 +544,7 @@ def _on_click(self, event): if self._drag_to_toplevel: end_pos_in_widget = coordinates_in_box((event.x_root, event.y_root), - parse_geometry(self.winfo_toplevel().winfo_geometry())) + parse_geometry(self.winfo_toplevel().winfo_geometry())) if not end_pos_in_widget: self.move_to_toplevel(self._dragged_tab) self._dragged_tab = None From d7f23af610e0bf0db635541042b665832e3c4f22 Mon Sep 17 00:00:00 2001 From: Dogeek Date: Mon, 2 Dec 2019 17:11:52 +0100 Subject: [PATCH 09/12] added splash of color to example + removed dont_setup_style kwarg --- examples/example_notebook.py | 4 ++-- ttkwidgets/notebook.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/example_notebook.py b/examples/example_notebook.py index aa9461a5..a7558b0a 100644 --- a/examples/example_notebook.py +++ b/examples/example_notebook.py @@ -7,9 +7,9 @@ class MainWindow(ttk.Frame): def __init__(self, master=None): super().__init__(master) self.nb = Notebook(self) - self.frames = [tk.Frame(self) for i in range(10)] + colors = ['red', 'blue', 'green', 'yellow', 'cyan', 'magenta', 'black', 'white', 'purple', 'brown'] + self.frames = [tk.Frame(self, width=300, height=300, bg=color) for i, color in enumerate(colors)] for i, w in enumerate(self.frames): - tk.Canvas(w, width=300, height=300).grid(sticky="nswe") self.nb.add(w, text="Frame " + str(i)) w.grid() self.nb.grid() diff --git a/ttkwidgets/notebook.py b/ttkwidgets/notebook.py index d0de5846..d0cde85c 100644 --- a/ttkwidgets/notebook.py +++ b/ttkwidgets/notebook.py @@ -876,7 +876,7 @@ def forget(self, tab_id): def move_to_toplevel(self, tab): tl = tk.Toplevel(self) - nb = Notebook(tl, dont_setup_style=True, **self._init_kwargs) + nb = Notebook(tl, **self._init_kwargs) move_widget(tab, nb) nb.add(tab) nb.grid() From e5d64b2947e71cce4398b0e48fdd2d070e5d51ac Mon Sep 17 00:00:00 2001 From: Dogeek Date: Fri, 6 Dec 2019 01:17:36 +0100 Subject: [PATCH 10/12] improved notebook example --- examples/example_notebook.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/example_notebook.py b/examples/example_notebook.py index a7558b0a..7f92d6cc 100644 --- a/examples/example_notebook.py +++ b/examples/example_notebook.py @@ -6,13 +6,19 @@ class MainWindow(ttk.Frame): def __init__(self, master=None): super().__init__(master) - self.nb = Notebook(self) - colors = ['red', 'blue', 'green', 'yellow', 'cyan', 'magenta', 'black', 'white', 'purple', 'brown'] + self.nb = Notebook(root, tabdrag=True, tabmenu=True, + closebutton=True, closecommand=self.closecmd) + colors = ['red', 'blue', 'green', 'yellow', 'cyan', + 'magenta', 'black', 'white', 'purple', 'brown'] 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() From b625f6d03b53610cbcc571df22d95c27b34959b5 Mon Sep 17 00:00:00 2001 From: Dogeek Date: Fri, 6 Dec 2019 01:20:28 +0100 Subject: [PATCH 11/12] style update --- ttkwidgets/notebook.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/ttkwidgets/notebook.py b/ttkwidgets/notebook.py index d0cde85c..d60e4311 100644 --- a/ttkwidgets/notebook.py +++ b/ttkwidgets/notebook.py @@ -450,11 +450,8 @@ def setup_style(self, bg="#dddddd", activebg="#efefef", pressedbg="#c1c1c1", style.map('Notebook.Tab', **{'background': [('selected', '!disabled', theme['activebg'])]}) - style.configure('TNotebook.Tab', background=theme['bg'], - foreground=theme['unselectedfg']) - style.map('TNotebook.Tab', - **{'background': [('selected', '!disabled', theme['activebg'])], - 'foreground': [('selected', '!disabled', theme['fg'])]}) + style.configure('Left.Notebook.TButton', padding=0) + style.configure('Right.Notebook.TButton', padding=0) def _on_configure(self, event=None): self.update_idletasks() From 3d11fd50f592b6e0120782c80997a5f95d152874 Mon Sep 17 00:00:00 2001 From: Dogeek Date: Fri, 6 Dec 2019 01:22:42 +0100 Subject: [PATCH 12/12] unit test for Notebook widget --- tests/test_notebook.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 tests/test_notebook.py diff --git a/tests/test_notebook.py b/tests/test_notebook.py new file mode 100644 index 00000000..c37deaf6 --- /dev/null +++ b/tests/test_notebook.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +""" +Author : Dogeek +(C) 2019 +""" + +import os +from ttkwidgets import Notebook +from tests import BaseWidgetTest + + +class TestDirTree(BaseWidgetTest): + def test_notebook_init(self): + nb = Notebook(self.window) + nb.grid() + self.window.update()