diff --git a/AUTHORS.md b/AUTHORS.md index f7d74299..52fba3a5 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -11,6 +11,7 @@ This file contains a list of all the authors of widgets in this repository. Plea * `Balloon` * `ItemsCanvas` * `TimeLine` + * `VNotebook` - The Python Team * `Calendar`, found [here](http://svn.python.org/projects/sandbox/trunk/ttk-gsoc/samples/ttkcalendar.py) - Mitja Martini diff --git a/docs/source/authors.rst b/docs/source/authors.rst index 3e53e915..1ea86b04 100644 --- a/docs/source/authors.rst +++ b/docs/source/authors.rst @@ -14,6 +14,7 @@ List of all the authors of widgets in this repository. Please note that this lis * :class:`~ttkwidgets.frames.Balloon` * :class:`~ttkwidgets.ItemsCanvas` * :class:`~ttkwidgets.TimeLine` + * :class:`~ttkwidgets.VNotebook` - The Python Team diff --git a/docs/source/ttkwidgets/ttkwidgets.frames.rst b/docs/source/ttkwidgets/ttkwidgets.frames.rst index af677973..31d348d9 100644 --- a/docs/source/ttkwidgets/ttkwidgets.frames.rst +++ b/docs/source/ttkwidgets/ttkwidgets.frames.rst @@ -14,3 +14,4 @@ ttkwidgets.frames Balloon ScrolledFrame ToggledFrame + VNotebook diff --git a/docs/source/ttkwidgets/ttkwidgets.frames/ttkwidgets.frames.VNotebook.rst b/docs/source/ttkwidgets/ttkwidgets.frames/ttkwidgets.frames.VNotebook.rst new file mode 100644 index 00000000..170532e8 --- /dev/null +++ b/docs/source/ttkwidgets/ttkwidgets.frames/ttkwidgets.frames.VNotebook.rst @@ -0,0 +1,10 @@ +VNotebook +========= + +.. currentmodule:: ttkwidgets.frames + +.. autoclass:: VNotebook + :show-inheritance: + :members: + + .. automethod:: __init__ \ No newline at end of file diff --git a/examples/example_vnotebook.py b/examples/example_vnotebook.py new file mode 100644 index 00000000..92462192 --- /dev/null +++ b/examples/example_vnotebook.py @@ -0,0 +1,19 @@ +from ttkwidgets import VNotebook +import tkinter.ttk as ttk +import tkinter as tk + + +def callback(): + notebook.hide(id_) + + +root = tk.Tk() +notebook = VNotebook(root, compound=tk.RIGHT) +notebook.add(ttk.Scale(notebook), text="Scale") +notebook.add(ttk.Button(notebook, text="Destroy", command=root.destroy), text="Button") +frame = ttk.Frame(notebook) +id_ = notebook.add(frame, text="Hidden") +ttk.Button(frame, command=callback, text="Hide").grid() +notebook.enable_traversal() +notebook.grid(row=1) +root.mainloop() diff --git a/setup.py b/setup.py index 091603b0..9befcd9e 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ License ------- -ttkwidgets: A collection of widgets for Tkinter's ttk extensions by various authors +ttkwidgets: A collection of widgets for Tkinter's ttk extensions by various authors Copyright (C) RedFantom 2017 Copyright (C) The Python Team Copyright (C) Mitja Martini 2008 @@ -47,15 +47,15 @@ .. |Codecov| image:: https://codecov.io/gh/RedFantom/ttkwidgets/branch/master/graph/badge.svg :alt: Code Coverage :target: https://codecov.io/gh/RedFantom/ttkwidgets - + .. |Pypi| image:: https://badge.fury.io/py/ttkwidgets.svg :alt: PyPI version :target: https://badge.fury.io/py/ttkwidgets - + .. |License| image:: https://img.shields.io/badge/License-GPL%20v3-blue.svg :alt: License: GPL v3 :target: http://www.gnu.org/licenses/gpl-3.0 - + """ setup( diff --git a/tests/test_vnotebook.py b/tests/test_vnotebook.py new file mode 100644 index 00000000..ce4af5be --- /dev/null +++ b/tests/test_vnotebook.py @@ -0,0 +1,71 @@ +""" +Author: RedFantom +License: GNU GPLv3, as in LICENSE.md +Copyright (C) 2018 RedFantom +""" +from unittest import TestCase +# Basic UI imports +import tkinter as tk +from tkinter import ttk +# Module to test +from ttkwidgets.frames import VNotebook + + +class TestVNotebook(TestCase): + def setUp(self): + self.window = tk.Tk() + + def tearDown(self): + self.window.destroy() + + def test_init(self): + VNotebook(self.window).grid() + self.window.update() + + def _add_test_frame(self, notebook, **kwargs): + notebook.grid() + frame = ttk.Frame(notebook) + ttk.Scale(frame).grid() + frame.grid() + return notebook.add(frame, text="Test", **kwargs) + + def test_add(self): + notebook = VNotebook(self.window) + self._add_test_frame(notebook) + self.window.update() + + def test_compound(self): + for compound in (tk.BOTTOM, tk.TOP, tk.RIGHT, tk.LEFT): + VNotebook(self.window, compound=compound).grid() + self.window.update() + + def test_index(self): + notebook = VNotebook(self.window) + self._add_test_frame(notebook) + frame = ttk.Frame(notebook) + notebook.insert(0, frame) + self.assertEqual(notebook.tabs[0], notebook.get_id_for_tab(frame)) + self.assertEqual(0, notebook.index(frame)) + + def test_enable_traversal(self): + notebook = VNotebook(self.window) + self._add_test_frame(notebook) + self._add_test_frame(notebook) + notebook.enable_traversal() + active = notebook.active + notebook._switch_tab(None) + self.assertNotEqual(active, notebook.active) + + def test_tab_config(self): + notebook = VNotebook(self.window) + id = self._add_test_frame(notebook) + notebook.tab_configure(id, text="Hello") + self.assertEqual(notebook.tab_cget(id, "text"), "Hello") + + def test_activate(self): + notebook = VNotebook(self.window) + self._add_test_frame(notebook) + self._add_test_frame(notebook) + self.assertEqual(notebook.tabs[0], notebook.active) + notebook.activate_index(1) + self.assertEqual(notebook.tabs[1], notebook.active) diff --git a/ttkwidgets/__init__.py b/ttkwidgets/__init__.py index 9788fb40..9833c75a 100644 --- a/ttkwidgets/__init__.py +++ b/ttkwidgets/__init__.py @@ -10,4 +10,7 @@ from ttkwidgets.scaleentry import ScaleEntry from ttkwidgets.timeline import TimeLine from ttkwidgets.tickscale import TickScale + +from ttkwidgets.frames import VNotebook from ttkwidgets.table import Table + diff --git a/ttkwidgets/debugwindow.py b/ttkwidgets/debugwindow.py index 187a6577..fd584d68 100644 --- a/ttkwidgets/debugwindow.py +++ b/ttkwidgets/debugwindow.py @@ -14,11 +14,11 @@ class DebugWindow(tk.Toplevel): """ A Toplevel that shows sys.stdout and sys.stderr for Tkinter applications """ - def __init__(self, master=None, title="Debug window", stdout=True, + def __init__(self, master=None, title="Debug window", stdout=True, stderr=False, width=70, autohidescrollbar=True, **kwargs): """ Create a Debug window. - + :param master: master widget :type master: widget :param stdout: whether to redirect stdout to the widget @@ -61,7 +61,7 @@ def __init__(self, master=None, title="Debug window", stdout=True, def save(self): """Save widget content.""" file_name = fd.asksaveasfilename() - if file_name is "" or file_name is None: + if file_name == "" or file_name is None: return with open(file_name, "w") as f: f.write(self.text.get("1.0", tk.END)) @@ -73,7 +73,7 @@ def _grid_widgets(self): def write(self, line): """ Write line at the end of the widget. - + :param line: text to insert in the widget :type line: str """ diff --git a/ttkwidgets/frames/__init__.py b/ttkwidgets/frames/__init__.py index befc0fd6..1ad33fcd 100644 --- a/ttkwidgets/frames/__init__.py +++ b/ttkwidgets/frames/__init__.py @@ -3,3 +3,4 @@ from .scrolledframe import ScrolledFrame from .toggledframe import ToggledFrame from .balloon import Balloon +from .vnotebook import VNotebook diff --git a/ttkwidgets/frames/vnotebook.py b/ttkwidgets/frames/vnotebook.py new file mode 100644 index 00000000..e8527a98 --- /dev/null +++ b/ttkwidgets/frames/vnotebook.py @@ -0,0 +1,321 @@ +""" +Author: RedFantom +License: GNU GPLv3, as in LICENSE.md +Copyright (C) 2018 RedFantom +""" +# Basic UI imports +import tkinter as tk +from tkinter import ttk +from ttkwidgets.utilities import get_widget_options + + +class VNotebook(ttk.Frame): + """ + Notebook with vertical tabs. Does not actually use the + :class:`ttk.Notebook` widget, but a set of Toolbutton-styles + Radiobuttons to select a Frame of a set. Provides an interface that + behaves like a normal :class:`ttk.Notebook` widget. + """ + + options = [ + # Notebook options + "cursor", + "padding", + "style", + "takefocus", + # VNotebook options + "compound", + "callback", + ] + + tab_options = [ + "compound", + "padding", + "sticky", + "image", + "text", + "underline", + "font", + "id", + ] + + def __init__(self, master, **kwargs): + """ + Create a VNotebook. + + :param cursor: Cursor set upon hovering Buttons + :type cursor: str + :param padding: Amount of pixels between the Buttons + :type padding: int + :param compound: Location of the Buttons + :type compound: str + :param kwargs: Passed on to :class:`ttk.Frame` initializer + """ + # Argument processing + self._cursor = kwargs.pop("cursor", "default") + self._height = kwargs.pop("cursor", 400) + self._width = kwargs.pop("width", 400) + self._padding = kwargs.pop("padding", 0) + self._style = kwargs.pop("style", "Toolbutton") + self._compound = kwargs.pop("compound", tk.LEFT) + + kwargs["width"] = self._width + kwargs["height"] = self._height + + # Initialize + ttk.Frame.__init__(self, master, **kwargs) + self.grid_propagate(False) + + # Attributes + self._tab_buttons = dict() + self._tab_frames = dict() + self._tab_ids = list() + self._frame_padding = dict() + self._hidden = dict() + self._variable = tk.StringVar() + self.__current_tab = None + self._buttons_frame = None + self._separator = None + + # Initialize widgets + self.init_widgets() + self.grid_widgets() + + def init_widgets(self): + """Initialize child widgets.""" + self._buttons_frame = ttk.Frame(self) + self._separator = ttk.Separator(self) + + def grid_widgets(self): + """Put child widgets in place.""" + horizontal = self._compound in (tk.BOTTOM, tk.TOP) + if horizontal is True: + sticky = "sw" if self._compound == tk.BOTTOM else "nw" + self.columnconfigure(2, weight=1) + else: + sticky = "nw" if self._compound == tk.RIGHT else "ne" + self.rowconfigure(2, weight=1) + + self._buttons_frame.grid(row=2, column=2, sticky=sticky) + self._separator.config( + orient=tk.HORIZONTAL if horizontal is True else tk.VERTICAL) + if self.active is None: + return + + # Grid the position dependent widgets + pad, sticky = self._frame_padding[self.active] + padding = {"padx": pad, "pady": pad} + if self._compound == tk.BOTTOM: + self._separator.grid(row=3, column=2, pady=4, sticky="swe") + self.__current_tab.grid(row=4, column=2, sticky=sticky, **padding) + elif self._compound == tk.TOP: + self._separator.grid(row=1, column=2, pady=4, sticky="nwe") + self.__current_tab.grid(row=0, column=2, sticky=sticky, **padding) + elif self._compound == tk.RIGHT: + self._separator.grid(row=2, column=3, padx=4, sticky="nsw") + self.__current_tab.grid(row=2, column=4, sticky=sticky, **padding) + elif self._compound == tk.LEFT: + self._separator.grid(row=2, column=1, padx=4, sticky="nse") + self.__current_tab.grid(row=2, column=0, sticky=sticky, **padding) + else: + raise ValueError("Invalid compound value: {}".format(self._compound)) + self._grid_tabs() + + def grid_forget_widgets(self): + """Remove child widgets from grid.""" + self._buttons_frame.grid_forget() + if self.__current_tab is not None: + self.__current_tab.grid_forget() + for button in self._tab_buttons.values(): + button.grid_forget() + + def _grid_tabs(self): + """Organize tab buttons.""" + for button in self._tab_buttons.values(): + button.grid_forget() + for index, tab_id in enumerate(self._tab_ids): + if tab_id in self._hidden and self._hidden[tab_id] is True: + continue + horizontal = self._compound in (tk.BOTTOM, tk.TOP) + row = index if horizontal is False else 0 + column = index if horizontal is True else 0 + self._tab_buttons[tab_id].grid( + row=row, column=column, pady=self._padding, padx=self._padding, sticky="nswe") + if self.active is None and len(self._tab_ids) != 0: + self.activate(self._tab_ids[0]) + return + + def add(self, child, **kwargs): + """ + Create new tab in the notebook and append it to the end. + If the child is already managed by the VNotebook widget, then update the child with its settings. + + :param child: Child widget, such as a :class:`ttk.Frame` + :param kwargs: Keyword arguments to create tab with. + Supports all arguments supported by :meth:`VNotebook.tab` + function, and in addition supports: + + :param id: ID for the newly added Tab. If the ID is not given, one is generated automatically. + :param index: Position of the new Tab. + + :return: ID for the new Tab + :rtype: int + """ + tab_id = kwargs.pop("id", hash(child)) + updating = child in self._tab_frames.values() + if not updating: + self._tab_buttons[tab_id] = ttk.Radiobutton( + self._buttons_frame, variable=self._variable, value=tab_id) + self._tab_frames[tab_id] = child + else: + self._tab_frames[tab_id].config(**get_widget_options(child)) + + # Process where argument + where = kwargs.get("index", tk.END) + if updating and 'index' in kwargs: + self._tab_ids.pop(where) + self._tab_ids.insert(where, tab_id) + else: + if where == tk.END: + self._tab_ids.append(tab_id) + else: + self._tab_ids.insert(where, tab_id) + kwargs.pop('index', None) + self.tab(tab_id, **kwargs) + return tab_id + + def insert(self, index, child, **kwargs): + """:meth:`VNotebook.add` alias with non-optional index argument.""" + kwargs.update({"index": index}) + return self.add(child, **kwargs) + + def enable_traversal(self, enable=True): + """Setup keybinds for CTRL-TAB to switch tabs.""" + if enable is True: + func = "bind" + args = ("", self._switch_tab,) + else: + func = "unbind" + args = ("",) + for widget in (self, self._buttons_frame, self._separator) + tuple(self._tab_frames.values()): + getattr(widget, func)(*args) + return enable + + def disable_traversal(self): + """Alias of :obj:`VNotebook.enable_traversal(enable=False)`.""" + return self.enable_traversal(enable=False) + + def forget(self, child): + """Remove a child by widget or tab_id.""" + tab_id = self.get_id_for_tab(child) + self._tab_buttons[tab_id].destroy() + del self._tab_buttons[tab_id] + del self._tab_frames[tab_id] + self._tab_ids.remove(tab_id) + self._grid_tabs() + + def hide(self, child, hide=True): + """Hide or unhide a Tab.""" + tab_id = self.get_id_for_tab(child) + self._hidden[tab_id] = hide + if tab_id == self.active and len(self._tab_ids) != 1: + self.activate(self._tab_ids[0]) + self.grid_widgets() + + def show(self, child): + """Alias for :obj:`VNotebook.hide(hide=False)`""" + return self.hide(child, hide=False) + + def index(self, child): + """Return zero-indexed index value of a child or tab_id.""" + return self._tab_ids.index(self.get_id_for_tab(child)) + + def tab(self, tab_id, option=None, **kwargs): + """ + Configure a tab with options given in kwargs. + + :param tab_id: Non-optional tab ID of the tab to be configured + :param option: If not None, function returns value for option + key given in this argument + """ + if option is not None: + return self._tab_buttons[tab_id].cget(option) + # Argument processing + self._frame_padding[tab_id] = \ + (kwargs.pop("padding", self._padding), kwargs.pop("sticky", tk.N)) + kwargs["command"] = lambda tab_id=tab_id: self.activate(tab_id) + kwargs["style"] = "Toolbutton" + # Configure Buttons + self._tab_buttons[tab_id].configure(**kwargs) + self._grid_tabs() + + def tab_configure(self, tab_id, **kwargs): + """Configure alias for :meth:`VNotebook.tab`""" + return self.tab(tab_id, **kwargs) + + def tab_cget(self, tab_id, key): + """cget alias for :meth:`VNotebook.tab`""" + return self.tab(tab_id, option=key) + + @property + def tabs(self): + """Return list of tab IDs.""" + return self._tab_ids.copy() + + def config(self, **kwargs): + """Alias for :meth:`VNotebook.configure`""" + return self.configure(**kwargs) + + def configure(self, **kwargs): + """Change settings for the widget.""" + for option in self.options: + attr = "_{}".format(option) + setattr(self, attr, kwargs.pop(option, getattr(self, attr))) + return super().configure(**kwargs) + + def cget(self, key): + """Return current value for a setting.""" + if key in self.options: + return getattr(self, "_{}".format(key)) + return ttk.Frame.cget(self, key) + + def __getitem__(self, item): + return self.cget(item) + + def __setitem__(self, key, value): + return self.configure(**{key: value}) + + def activate(self, tab_id): + """Activate a new Tab in the notebook.""" + if self.active is not None: + self.__current_tab.grid_forget() + self.__current_tab = self._tab_frames[tab_id] + self.grid_widgets() + self._variable.set(tab_id) + + def activate_index(self, index): + """Activate Tab by zero-indexed value.""" + return self.activate(self._tab_ids[index]) + + @property + def active(self): + """Return tab_id for currently active Tab.""" + if self.__current_tab is None: + return None + return self.get_id_for_tab(self.__current_tab) + + def get_id_for_tab(self, child): + """Return tab_id for child, which can be tab_id or widget.""" + if child in self._tab_ids: + return child + return {widget: tab_id for tab_id, widget in self._tab_frames.items()}[child] + + def _switch_tab(self, event): + """Callback for CTRL-TAB.""" + if self.active is None: + self.activate(self._tab_ids[0]) + return + to_activate = self._tab_ids.index(self.active) + 1 + if to_activate == len(self._tab_ids): + to_activate = 0 + self.activate(self._tab_ids[to_activate]) diff --git a/ttkwidgets/itemscanvas.py b/ttkwidgets/itemscanvas.py index 2f9cd2e8..e4c42165 100644 --- a/ttkwidgets/itemscanvas.py +++ b/ttkwidgets/itemscanvas.py @@ -11,15 +11,15 @@ class ItemsCanvas(ttk.Frame): """ - A :class:`ttk.Frame` containing a Canvas upon which text items can be placed with a coloured background. - + A :class:`ttk.Frame` containing a Canvas upon which text items can be placed with a coloured background. + The items can be moved around and deleted. A background can also be set. """ def __init__(self, *args, **kwargs): """ Create an ItemsCanvas. - + :param canvaswidth: width of the canvas in pixels :type canvaswidth: int :param canvasheight: height of the canvas in pixels @@ -73,7 +73,7 @@ def left_press(self, event): Callback for the press of the left mouse button. Selects a new item and sets its highlightcolor. - + :param event: Tkinter event """ self.current_coords = self.canvas.canvasx(event.x), self.canvas.canvasy(event.y) @@ -128,7 +128,7 @@ def left_motion(self, event): def right_press(self, event): """ Callback for the right mouse button event to pop up the correct menu. - + :param event: Tkinter event """ self.set_current() @@ -149,7 +149,7 @@ def add_item(self, text, font=("default", 12, "bold"), backgroundcolor="yellow", highlightcolor="blue"): """ Add a new item on the Canvas. - + :param text: text to display :type text: str :param font: font of the text @@ -185,7 +185,7 @@ def _new_item(self): def set_background(self, image=None, path=None, resize=True): """ Set the background image of the Canvas. - + :param image: background image :type image: PhotoImage :param path: background image path @@ -223,17 +223,17 @@ def cget(self, key): To get the list of options for this widget, call the method :meth:`~ItemsCanvas.keys`. """ - if key is "canvaswidth": + if key == "canvaswidth": return self._canvaswidth - elif key is "canvasheight": + elif key == "canvasheight": return self._canvasheight - elif key is "function_new": + elif key == "function_new": return self._function_new - elif key is "callback_add": + elif key == "callback_add": return self._callback_add - elif key is "callback_del": + elif key == "callback_del": return self._callback_del - elif key is "callback_move": + elif key == "callback_move": return self._callback_move else: ttk.Frame.cget(self, key) diff --git a/ttkwidgets/linklabel.py b/ttkwidgets/linklabel.py index f1f4f730..c901a077 100644 --- a/ttkwidgets/linklabel.py +++ b/ttkwidgets/linklabel.py @@ -17,7 +17,7 @@ class LinkLabel(ttk.Label): def __init__(self, master=None, **kwargs): """ Create a LinkLabel. - + :param master: master widget :param link: link to be opened :type link: str @@ -81,13 +81,13 @@ def cget(self, key): To get the list of options for this widget, call the method :meth:`~LinkLabel.keys`. """ - if key is "link": + if key == "link": return self._link - elif key is "hover_color": + elif key == "hover_color": return self._hover_color - elif key is "normal_color": + elif key == "normal_color": return self._normal_color - elif key is "clicked_color": + elif key == "clicked_color": return self._clicked_color else: return ttk.Label.cget(self, key) @@ -111,4 +111,3 @@ def keys(self): keys = ttk.Label.keys(self) keys.extend(["link", "normal_color", "hover_color", "clicked_color"]) return keys - diff --git a/ttkwidgets/utilities.py b/ttkwidgets/utilities.py index ad32dbe3..f6b8f4b4 100644 --- a/ttkwidgets/utilities.py +++ b/ttkwidgets/utilities.py @@ -9,3 +9,18 @@ def get_assets_directory(): def open_icon(icon_name): return ImageTk.PhotoImage(Image.open(os.path.join(get_assets_directory(), icon_name))) + + +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