1"""An implementation of tabbed pages using only standard Tkinter.
2
3Originally developed for use in IDLE. Based on tabpage.py.
4
5Classes exported:
6TabbedPageSet -- A Tkinter implementation of a tabbed-page widget.
7TabSet -- A widget containing tabs (buttons) in one or more rows.
8
9"""
10from tkinter import *
11
12class InvalidNameError(Exception): pass
13class AlreadyExistsError(Exception): pass
14
15
16class TabSet(Frame):
17    """A widget containing tabs (buttons) in one or more rows.
18
19    Only one tab may be selected at a time.
20
21    """
22    def __init__(self, page_set, select_command,
23                 tabs=None, n_rows=1, max_tabs_per_row=5,
24                 expand_tabs=False, **kw):
25        """Constructor arguments:
26
27        select_command -- A callable which will be called when a tab is
28        selected. It is called with the name of the selected tab as an
29        argument.
30
31        tabs -- A list of strings, the names of the tabs. Should be specified in
32        the desired tab order. The first tab will be the default and first
33        active tab. If tabs is None or empty, the TabSet will be initialized
34        empty.
35
36        n_rows -- Number of rows of tabs to be shown. If n_rows <= 0 or is
37        None, then the number of rows will be decided by TabSet. See
38        _arrange_tabs() for details.
39
40        max_tabs_per_row -- Used for deciding how many rows of tabs are needed,
41        when the number of rows is not constant. See _arrange_tabs() for
42        details.
43
44        """
45        Frame.__init__(self, page_set, **kw)
46        self.select_command = select_command
47        self.n_rows = n_rows
48        self.max_tabs_per_row = max_tabs_per_row
49        self.expand_tabs = expand_tabs
50        self.page_set = page_set
51
52        self._tabs = {}
53        self._tab2row = {}
54        if tabs:
55            self._tab_names = list(tabs)
56        else:
57            self._tab_names = []
58        self._selected_tab = None
59        self._tab_rows = []
60
61        self.padding_frame = Frame(self, height=2,
62                                   borderwidth=0, relief=FLAT,
63                                   background=self.cget('background'))
64        self.padding_frame.pack(side=TOP, fill=X, expand=False)
65
66        self._arrange_tabs()
67
68    def add_tab(self, tab_name):
69        """Add a new tab with the name given in tab_name."""
70        if not tab_name:
71            raise InvalidNameError("Invalid Tab name: '%s'" % tab_name)
72        if tab_name in self._tab_names:
73            raise AlreadyExistsError("Tab named '%s' already exists" %tab_name)
74
75        self._tab_names.append(tab_name)
76        self._arrange_tabs()
77
78    def remove_tab(self, tab_name):
79        """Remove the tab named <tab_name>"""
80        if not tab_name in self._tab_names:
81            raise KeyError("No such Tab: '%s" % tab_name)
82
83        self._tab_names.remove(tab_name)
84        self._arrange_tabs()
85
86    def set_selected_tab(self, tab_name):
87        """Show the tab named <tab_name> as the selected one"""
88        if tab_name == self._selected_tab:
89            return
90        if tab_name is not None and tab_name not in self._tabs:
91            raise KeyError("No such Tab: '%s" % tab_name)
92
93        # deselect the current selected tab
94        if self._selected_tab is not None:
95            self._tabs[self._selected_tab].set_normal()
96        self._selected_tab = None
97
98        if tab_name is not None:
99            # activate the tab named tab_name
100            self._selected_tab = tab_name
101            tab = self._tabs[tab_name]
102            tab.set_selected()
103            # move the tab row with the selected tab to the bottom
104            tab_row = self._tab2row[tab]
105            tab_row.pack_forget()
106            tab_row.pack(side=TOP, fill=X, expand=0)
107
108    def _add_tab_row(self, tab_names, expand_tabs):
109        if not tab_names:
110            return
111
112        tab_row = Frame(self)
113        tab_row.pack(side=TOP, fill=X, expand=0)
114        self._tab_rows.append(tab_row)
115
116        for tab_name in tab_names:
117            tab = TabSet.TabButton(tab_name, self.select_command,
118                                   tab_row, self)
119            if expand_tabs:
120                tab.pack(side=LEFT, fill=X, expand=True)
121            else:
122                tab.pack(side=LEFT)
123            self._tabs[tab_name] = tab
124            self._tab2row[tab] = tab_row
125
126        # tab is the last one created in the above loop
127        tab.is_last_in_row = True
128
129    def _reset_tab_rows(self):
130        while self._tab_rows:
131            tab_row = self._tab_rows.pop()
132            tab_row.destroy()
133        self._tab2row = {}
134
135    def _arrange_tabs(self):
136        """
137        Arrange the tabs in rows, in the order in which they were added.
138
139        If n_rows >= 1, this will be the number of rows used. Otherwise the
140        number of rows will be calculated according to the number of tabs and
141        max_tabs_per_row. In this case, the number of rows may change when
142        adding/removing tabs.
143
144        """
145        # remove all tabs and rows
146        while self._tabs:
147            self._tabs.popitem()[1].destroy()
148        self._reset_tab_rows()
149
150        if not self._tab_names:
151            return
152
153        if self.n_rows is not None and self.n_rows > 0:
154            n_rows = self.n_rows
155        else:
156            # calculate the required number of rows
157            n_rows = (len(self._tab_names) - 1) // self.max_tabs_per_row + 1
158
159        # not expanding the tabs with more than one row is very ugly
160        expand_tabs = self.expand_tabs or n_rows > 1
161        i = 0 # index in self._tab_names
162        for row_index in range(n_rows):
163            # calculate required number of tabs in this row
164            n_tabs = (len(self._tab_names) - i - 1) // (n_rows - row_index) + 1
165            tab_names = self._tab_names[i:i + n_tabs]
166            i += n_tabs
167            self._add_tab_row(tab_names, expand_tabs)
168
169        # re-select selected tab so it is properly displayed
170        selected = self._selected_tab
171        self.set_selected_tab(None)
172        if selected in self._tab_names:
173            self.set_selected_tab(selected)
174
175    class TabButton(Frame):
176        """A simple tab-like widget."""
177
178        bw = 2 # borderwidth
179
180        def __init__(self, name, select_command, tab_row, tab_set):
181            """Constructor arguments:
182
183            name -- The tab's name, which will appear in its button.
184
185            select_command -- The command to be called upon selection of the
186            tab. It is called with the tab's name as an argument.
187
188            """
189            Frame.__init__(self, tab_row, borderwidth=self.bw, relief=RAISED)
190
191            self.name = name
192            self.select_command = select_command
193            self.tab_set = tab_set
194            self.is_last_in_row = False
195
196            self.button = Radiobutton(
197                self, text=name, command=self._select_event,
198                padx=5, pady=1, takefocus=FALSE, indicatoron=FALSE,
199                highlightthickness=0, selectcolor='', borderwidth=0)
200            self.button.pack(side=LEFT, fill=X, expand=True)
201
202            self._init_masks()
203            self.set_normal()
204
205        def _select_event(self, *args):
206            """Event handler for tab selection.
207
208            With TabbedPageSet, this calls TabbedPageSet.change_page, so that
209            selecting a tab changes the page.
210
211            Note that this does -not- call set_selected -- it will be called by
212            TabSet.set_selected_tab, which should be called when whatever the
213            tabs are related to changes.
214
215            """
216            self.select_command(self.name)
217            return
218
219        def set_selected(self):
220            """Assume selected look"""
221            self._place_masks(selected=True)
222
223        def set_normal(self):
224            """Assume normal look"""
225            self._place_masks(selected=False)
226
227        def _init_masks(self):
228            page_set = self.tab_set.page_set
229            background = page_set.pages_frame.cget('background')
230            # mask replaces the middle of the border with the background color
231            self.mask = Frame(page_set, borderwidth=0, relief=FLAT,
232                              background=background)
233            # mskl replaces the bottom-left corner of the border with a normal
234            # left border
235            self.mskl = Frame(page_set, borderwidth=0, relief=FLAT,
236                              background=background)
237            self.mskl.ml = Frame(self.mskl, borderwidth=self.bw,
238                                 relief=RAISED)
239            self.mskl.ml.place(x=0, y=-self.bw,
240                               width=2*self.bw, height=self.bw*4)
241            # mskr replaces the bottom-right corner of the border with a normal
242            # right border
243            self.mskr = Frame(page_set, borderwidth=0, relief=FLAT,
244                              background=background)
245            self.mskr.mr = Frame(self.mskr, borderwidth=self.bw,
246                                 relief=RAISED)
247
248        def _place_masks(self, selected=False):
249            height = self.bw
250            if selected:
251                height += self.bw
252
253            self.mask.place(in_=self,
254                            relx=0.0, x=0,
255                            rely=1.0, y=0,
256                            relwidth=1.0, width=0,
257                            relheight=0.0, height=height)
258
259            self.mskl.place(in_=self,
260                            relx=0.0, x=-self.bw,
261                            rely=1.0, y=0,
262                            relwidth=0.0, width=self.bw,
263                            relheight=0.0, height=height)
264
265            page_set = self.tab_set.page_set
266            if selected and ((not self.is_last_in_row) or
267                             (self.winfo_rootx() + self.winfo_width() <
268                              page_set.winfo_rootx() + page_set.winfo_width())
269                             ):
270                # for a selected tab, if its rightmost edge isn't on the
271                # rightmost edge of the page set, the right mask should be one
272                # borderwidth shorter (vertically)
273                height -= self.bw
274
275            self.mskr.place(in_=self,
276                            relx=1.0, x=0,
277                            rely=1.0, y=0,
278                            relwidth=0.0, width=self.bw,
279                            relheight=0.0, height=height)
280
281            self.mskr.mr.place(x=-self.bw, y=-self.bw,
282                               width=2*self.bw, height=height + self.bw*2)
283
284            # finally, lower the tab set so that all of the frames we just
285            # placed hide it
286            self.tab_set.lower()
287
288
289class TabbedPageSet(Frame):
290    """A Tkinter tabbed-pane widget.
291
292    Constains set of 'pages' (or 'panes') with tabs above for selecting which
293    page is displayed. Only one page will be displayed at a time.
294
295    Pages may be accessed through the 'pages' attribute, which is a dictionary
296    of pages, using the name given as the key. A page is an instance of a
297    subclass of Tk's Frame widget.
298
299    The page widgets will be created (and destroyed when required) by the
300    TabbedPageSet. Do not call the page's pack/place/grid/destroy methods.
301
302    Pages may be added or removed at any time using the add_page() and
303    remove_page() methods.
304
305    """
306
307    class Page(object):
308        """Abstract base class for TabbedPageSet's pages.
309
310        Subclasses must override the _show() and _hide() methods.
311
312        """
313        uses_grid = False
314
315        def __init__(self, page_set):
316            self.frame = Frame(page_set, borderwidth=2, relief=RAISED)
317
318        def _show(self):
319            raise NotImplementedError
320
321        def _hide(self):
322            raise NotImplementedError
323
324    class PageRemove(Page):
325        """Page class using the grid placement manager's "remove" mechanism."""
326        uses_grid = True
327
328        def _show(self):
329            self.frame.grid(row=0, column=0, sticky=NSEW)
330
331        def _hide(self):
332            self.frame.grid_remove()
333
334    class PageLift(Page):
335        """Page class using the grid placement manager's "lift" mechanism."""
336        uses_grid = True
337
338        def __init__(self, page_set):
339            super(TabbedPageSet.PageLift, self).__init__(page_set)
340            self.frame.grid(row=0, column=0, sticky=NSEW)
341            self.frame.lower()
342
343        def _show(self):
344            self.frame.lift()
345
346        def _hide(self):
347            self.frame.lower()
348
349    class PagePackForget(Page):
350        """Page class using the pack placement manager's "forget" mechanism."""
351        def _show(self):
352            self.frame.pack(fill=BOTH, expand=True)
353
354        def _hide(self):
355            self.frame.pack_forget()
356
357    def __init__(self, parent, page_names=None, page_class=PageLift,
358                 n_rows=1, max_tabs_per_row=5, expand_tabs=False,
359                 **kw):
360        """Constructor arguments:
361
362        page_names -- A list of strings, each will be the dictionary key to a
363        page's widget, and the name displayed on the page's tab. Should be
364        specified in the desired page order. The first page will be the default
365        and first active page. If page_names is None or empty, the
366        TabbedPageSet will be initialized empty.
367
368        n_rows, max_tabs_per_row -- Parameters for the TabSet which will
369        manage the tabs. See TabSet's docs for details.
370
371        page_class -- Pages can be shown/hidden using three mechanisms:
372
373        * PageLift - All pages will be rendered one on top of the other. When
374          a page is selected, it will be brought to the top, thus hiding all
375          other pages. Using this method, the TabbedPageSet will not be resized
376          when pages are switched. (It may still be resized when pages are
377          added/removed.)
378
379        * PageRemove - When a page is selected, the currently showing page is
380          hidden, and the new page shown in its place. Using this method, the
381          TabbedPageSet may resize when pages are changed.
382
383        * PagePackForget - This mechanism uses the pack placement manager.
384          When a page is shown it is packed, and when it is hidden it is
385          unpacked (i.e. pack_forget). This mechanism may also cause the
386          TabbedPageSet to resize when the page is changed.
387
388        """
389        Frame.__init__(self, parent, **kw)
390
391        self.page_class = page_class
392        self.pages = {}
393        self._pages_order = []
394        self._current_page = None
395        self._default_page = None
396
397        self.columnconfigure(0, weight=1)
398        self.rowconfigure(1, weight=1)
399
400        self.pages_frame = Frame(self)
401        self.pages_frame.grid(row=1, column=0, sticky=NSEW)
402        if self.page_class.uses_grid:
403            self.pages_frame.columnconfigure(0, weight=1)
404            self.pages_frame.rowconfigure(0, weight=1)
405
406        # the order of the following commands is important
407        self._tab_set = TabSet(self, self.change_page, n_rows=n_rows,
408                               max_tabs_per_row=max_tabs_per_row,
409                               expand_tabs=expand_tabs)
410        if page_names:
411            for name in page_names:
412                self.add_page(name)
413        self._tab_set.grid(row=0, column=0, sticky=NSEW)
414
415        self.change_page(self._default_page)
416
417    def add_page(self, page_name):
418        """Add a new page with the name given in page_name."""
419        if not page_name:
420            raise InvalidNameError("Invalid TabPage name: '%s'" % page_name)
421        if page_name in self.pages:
422            raise AlreadyExistsError(
423                "TabPage named '%s' already exists" % page_name)
424
425        self.pages[page_name] = self.page_class(self.pages_frame)
426        self._pages_order.append(page_name)
427        self._tab_set.add_tab(page_name)
428
429        if len(self.pages) == 1: # adding first page
430            self._default_page = page_name
431            self.change_page(page_name)
432
433    def remove_page(self, page_name):
434        """Destroy the page whose name is given in page_name."""
435        if not page_name in self.pages:
436            raise KeyError("No such TabPage: '%s" % page_name)
437
438        self._pages_order.remove(page_name)
439
440        # handle removing last remaining, default, or currently shown page
441        if len(self._pages_order) > 0:
442            if page_name == self._default_page:
443                # set a new default page
444                self._default_page = self._pages_order[0]
445        else:
446            self._default_page = None
447
448        if page_name == self._current_page:
449            self.change_page(self._default_page)
450
451        self._tab_set.remove_tab(page_name)
452        page = self.pages.pop(page_name)
453        page.frame.destroy()
454
455    def change_page(self, page_name):
456        """Show the page whose name is given in page_name."""
457        if self._current_page == page_name:
458            return
459        if page_name is not None and page_name not in self.pages:
460            raise KeyError("No such TabPage: '%s'" % page_name)
461
462        if self._current_page is not None:
463            self.pages[self._current_page]._hide()
464        self._current_page = None
465
466        if page_name is not None:
467            self._current_page = page_name
468            self.pages[page_name]._show()
469
470        self._tab_set.set_selected_tab(page_name)
471
472
473def _tabbed_pages(parent):  # htest #
474    top=Toplevel(parent)
475    x, y = map(int, parent.geometry().split('+')[1:])
476    top.geometry("+%d+%d" % (x, y + 175))
477    top.title("Test tabbed pages")
478    tabPage=TabbedPageSet(top, page_names=['Foobar','Baz'], n_rows=0,
479                          expand_tabs=False,
480                          )
481    tabPage.pack(side=TOP, expand=TRUE, fill=BOTH)
482    Label(tabPage.pages['Foobar'].frame, text='Foo', pady=20).pack()
483    Label(tabPage.pages['Foobar'].frame, text='Bar', pady=20).pack()
484    Label(tabPage.pages['Baz'].frame, text='Baz').pack()
485    entryPgName=Entry(top)
486    buttonAdd=Button(top, text='Add Page',
487            command=lambda:tabPage.add_page(entryPgName.get()))
488    buttonRemove=Button(top, text='Remove Page',
489            command=lambda:tabPage.remove_page(entryPgName.get()))
490    labelPgName=Label(top, text='name of page to add/remove:')
491    buttonAdd.pack(padx=5, pady=5)
492    buttonRemove.pack(padx=5, pady=5)
493    labelPgName.pack(padx=5)
494    entryPgName.pack(padx=5)
495
496if __name__ == '__main__':
497    from idlelib.idle_test.htest import run
498    run(_tabbed_pages)
499