blob: 4186fa201317ae8836a6b134d41a23f284eb8c1d [file] [log] [blame]
Guido van Rossum8ce8a782007-11-01 19:42:39 +00001"""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.
Christian Heimes38053212007-12-14 01:24:44 +00007TabSet -- A widget containing tabs (buttons) in one or more rows.
Guido van Rossum8ce8a782007-11-01 19:42:39 +00008
9"""
Georg Brandl14fc4272008-05-17 18:39:55 +000010from tkinter import *
Guido van Rossum8ce8a782007-11-01 19:42:39 +000011
12class InvalidNameError(Exception): pass
13class AlreadyExistsError(Exception): pass
14
15
Christian Heimes38053212007-12-14 01:24:44 +000016class TabSet(Frame):
Guido van Rossum8ce8a782007-11-01 19:42:39 +000017 """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
Christian Heimes38053212007-12-14 01:24:44 +000033 active tab. If tabs is None or empty, the TabSet will be initialized
Guido van Rossum8ce8a782007-11-01 19:42:39 +000034 empty.
35
36 n_rows -- Number of rows of tabs to be shown. If n_rows <= 0 or is
Christian Heimes38053212007-12-14 01:24:44 +000037 None, then the number of rows will be decided by TabSet. See
Guido van Rossum8ce8a782007-11-01 19:42:39 +000038 _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):
Christian Heimes38053212007-12-14 01:24:44 +000079 """Remove the tab named <tab_name>"""
Guido van Rossum8ce8a782007-11-01 19:42:39 +000080 if not tab_name in self._tab_names:
Andrew Svetlov10245412012-04-05 16:58:05 +030081 raise KeyError("No such Tab: '%s" % tab_name)
Guido van Rossum8ce8a782007-11-01 19:42:39 +000082
83 self._tab_names.remove(tab_name)
84 self._arrange_tabs()
85
Christian Heimes38053212007-12-14 01:24:44 +000086 def set_selected_tab(self, tab_name):
87 """Show the tab named <tab_name> as the selected one"""
Guido van Rossum8ce8a782007-11-01 19:42:39 +000088 if tab_name == self._selected_tab:
89 return
90 if tab_name is not None and tab_name not in self._tabs:
Andrew Svetlov10245412012-04-05 16:58:05 +030091 raise KeyError("No such Tab: '%s" % tab_name)
Guido van Rossum8ce8a782007-11-01 19:42:39 +000092
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)
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000114 self._tab_rows.append(tab_row)
115
116 for tab_name in tab_names:
Christian Heimes38053212007-12-14 01:24:44 +0000117 tab = TabSet.TabButton(tab_name, self.select_command,
118 tab_row, self)
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000119 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
Christian Heimes38053212007-12-14 01:24:44 +0000126 # tab is the last one created in the above loop
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000127 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
Amaury Forgeot d'Arcb9f7f242007-12-10 22:09:44 +0000146 while self._tabs:
147 self._tabs.popitem()[1].destroy()
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000148 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
Christian Heimes38053212007-12-14 01:24:44 +0000159 # not expanding the tabs with more than one row is very ugly
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000160 expand_tabs = self.expand_tabs or n_rows > 1
Christian Heimes38053212007-12-14 01:24:44 +0000161 i = 0 # index in self._tab_names
Amaury Forgeot d'Arcb9f7f242007-12-10 22:09:44 +0000162 for row_index in range(n_rows):
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000163 # 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
Christian Heimes38053212007-12-14 01:24:44 +0000171 self.set_selected_tab(None)
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000172 if selected in self._tab_names:
Christian Heimes38053212007-12-14 01:24:44 +0000173 self.set_selected_tab(selected)
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000174
175 class TabButton(Frame):
176 """A simple tab-like widget."""
177
178 bw = 2 # borderwidth
179
Christian Heimes38053212007-12-14 01:24:44 +0000180 def __init__(self, name, select_command, tab_row, tab_set):
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000181 """Constructor arguments:
182
183 name -- The tab's name, which will appear in its button.
184
Christian Heimes38053212007-12-14 01:24:44 +0000185 select_command -- The command to be called upon selection of the
186 tab. It is called with the tab's name as an argument.
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000187
188 """
Christian Heimes38053212007-12-14 01:24:44 +0000189 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,
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000198 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
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000202 self._init_masks()
203 self.set_normal()
204
Christian Heimes38053212007-12-14 01:24:44 +0000205 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
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000219 def set_selected(self):
220 """Assume selected look"""
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000221 self._place_masks(selected=True)
222
223 def set_normal(self):
224 """Assume normal look"""
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000225 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
Terry Jan Reedybfbaa6b2016-08-31 00:50:55 -0400288
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000289class 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 """
Terry Jan Reedybfbaa6b2016-08-31 00:50:55 -0400306
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000307 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
Christian Heimes38053212007-12-14 01:24:44 +0000368 n_rows, max_tabs_per_row -- Parameters for the TabSet which will
369 manage the tabs. See TabSet's docs for details.
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000370
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 """
Christian Heimes38053212007-12-14 01:24:44 +0000389 Frame.__init__(self, parent, **kw)
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000390
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
Christian Heimes38053212007-12-14 01:24:44 +0000407 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)
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000410 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
Christian Heimes38053212007-12-14 01:24:44 +0000470 self._tab_set.set_selected_tab(page_name)
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000471
Terry Jan Reedybfbaa6b2016-08-31 00:50:55 -0400472
Terry Jan Reedyb60adc52016-06-21 18:41:38 -0400473def _tabbed_pages(parent): # htest #
Terry Jan Reedyb60adc52016-06-21 18:41:38 -0400474 top=Toplevel(parent)
Terry Jan Reedya7480322016-07-10 17:28:10 -0400475 x, y = map(int, parent.geometry().split('+')[1:])
476 top.geometry("+%d+%d" % (x, y + 175))
Terry Jan Reedyb60adc52016-06-21 18:41:38 -0400477 top.title("Test tabbed pages")
478 tabPage=TabbedPageSet(top, page_names=['Foobar','Baz'], n_rows=0,
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000479 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()
Terry Jan Reedyb60adc52016-06-21 18:41:38 -0400485 entryPgName=Entry(top)
486 buttonAdd=Button(top, text='Add Page',
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000487 command=lambda:tabPage.add_page(entryPgName.get()))
Terry Jan Reedyb60adc52016-06-21 18:41:38 -0400488 buttonRemove=Button(top, text='Remove Page',
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000489 command=lambda:tabPage.remove_page(entryPgName.get()))
Terry Jan Reedyb60adc52016-06-21 18:41:38 -0400490 labelPgName=Label(top, text='name of page to add/remove:')
Guido van Rossum8ce8a782007-11-01 19:42:39 +0000491 buttonAdd.pack(padx=5, pady=5)
492 buttonRemove.pack(padx=5, pady=5)
493 labelPgName.pack(padx=5)
494 entryPgName.pack(padx=5)
Terry Jan Reedy1b392ff2014-05-24 18:48:18 -0400495
496if __name__ == '__main__':
497 from idlelib.idle_test.htest import run
498 run(_tabbed_pages)