blob: 98f6a273a3c76ee92cfd632a16312751b083c01f [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.
7TabBarSet -- 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 TabBarSet(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 TabBarSet 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 TabBarSet. 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 with the name given in tab_name."""
80 if not tab_name in self._tab_names:
81 raise KeyError("No such Tab: '%s" % page_name)
82
83 self._tab_names.remove(tab_name)
84 self._arrange_tabs()
85
86 def select_tab(self, tab_name):
87 """Select the tab with the name given in tab_name."""
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" % page_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 tab_row.tab_set = self
115 self._tab_rows.append(tab_row)
116
117 for tab_name in tab_names:
118 def tab_command(select_command=self.select_command,
119 tab_name=tab_name):
120 return select_command(tab_name)
121 tab = TabBarSet.TabButton(tab_row, tab_name, tab_command)
122 if expand_tabs:
123 tab.pack(side=LEFT, fill=X, expand=True)
124 else:
125 tab.pack(side=LEFT)
126 self._tabs[tab_name] = tab
127 self._tab2row[tab] = tab_row
128
129 tab.is_last_in_row = True
130
131 def _reset_tab_rows(self):
132 while self._tab_rows:
133 tab_row = self._tab_rows.pop()
134 tab_row.destroy()
135 self._tab2row = {}
136
137 def _arrange_tabs(self):
138 """
139 Arrange the tabs in rows, in the order in which they were added.
140
141 If n_rows >= 1, this will be the number of rows used. Otherwise the
142 number of rows will be calculated according to the number of tabs and
143 max_tabs_per_row. In this case, the number of rows may change when
144 adding/removing tabs.
145
146 """
147 # remove all tabs and rows
148 for tab_name in self._tabs.keys():
149 self._tabs.pop(tab_name).destroy()
150 self._reset_tab_rows()
151
152 if not self._tab_names:
153 return
154
155 if self.n_rows is not None and self.n_rows > 0:
156 n_rows = self.n_rows
157 else:
158 # calculate the required number of rows
159 n_rows = (len(self._tab_names) - 1) // self.max_tabs_per_row + 1
160
161 i = 0
162 expand_tabs = self.expand_tabs or n_rows > 1
163 for row_index in xrange(n_rows):
164 # calculate required number of tabs in this row
165 n_tabs = (len(self._tab_names) - i - 1) // (n_rows - row_index) + 1
166 tab_names = self._tab_names[i:i + n_tabs]
167 i += n_tabs
168 self._add_tab_row(tab_names, expand_tabs)
169
170 # re-select selected tab so it is properly displayed
171 selected = self._selected_tab
172 self.select_tab(None)
173 if selected in self._tab_names:
174 self.select_tab(selected)
175
176 class TabButton(Frame):
177 """A simple tab-like widget."""
178
179 bw = 2 # borderwidth
180
181 def __init__(self, tab_row, name, command):
182 """Constructor arguments:
183
184 name -- The tab's name, which will appear in its button.
185
186 command -- The command to be called upon selection of the tab. It
187 is called with the tab's name as an argument.
188
189 """
190 Frame.__init__(self, tab_row, borderwidth=self.bw)
191 self.button = Radiobutton(self, text=name, command=command,
192 padx=5, pady=1, takefocus=FALSE, indicatoron=FALSE,
193 highlightthickness=0, selectcolor='', borderwidth=0)
194 self.button.pack(side=LEFT, fill=X, expand=True)
195
196 self.tab_set = tab_row.tab_set
197
198 self.is_last_in_row = False
199
200 self._init_masks()
201 self.set_normal()
202
203 def set_selected(self):
204 """Assume selected look"""
205 for widget in self, self.mskl.ml, self.mskr.mr:
206 widget.config(relief=RAISED)
207 self._place_masks(selected=True)
208
209 def set_normal(self):
210 """Assume normal look"""
211 for widget in self, self.mskl.ml, self.mskr.mr:
212 widget.config(relief=RAISED)
213 self._place_masks(selected=False)
214
215 def _init_masks(self):
216 page_set = self.tab_set.page_set
217 background = page_set.pages_frame.cget('background')
218 # mask replaces the middle of the border with the background color
219 self.mask = Frame(page_set, borderwidth=0, relief=FLAT,
220 background=background)
221 # mskl replaces the bottom-left corner of the border with a normal
222 # left border
223 self.mskl = Frame(page_set, borderwidth=0, relief=FLAT,
224 background=background)
225 self.mskl.ml = Frame(self.mskl, borderwidth=self.bw,
226 relief=RAISED)
227 self.mskl.ml.place(x=0, y=-self.bw,
228 width=2*self.bw, height=self.bw*4)
229 # mskr replaces the bottom-right corner of the border with a normal
230 # right border
231 self.mskr = Frame(page_set, borderwidth=0, relief=FLAT,
232 background=background)
233 self.mskr.mr = Frame(self.mskr, borderwidth=self.bw,
234 relief=RAISED)
235
236 def _place_masks(self, selected=False):
237 height = self.bw
238 if selected:
239 height += self.bw
240
241 self.mask.place(in_=self,
242 relx=0.0, x=0,
243 rely=1.0, y=0,
244 relwidth=1.0, width=0,
245 relheight=0.0, height=height)
246
247 self.mskl.place(in_=self,
248 relx=0.0, x=-self.bw,
249 rely=1.0, y=0,
250 relwidth=0.0, width=self.bw,
251 relheight=0.0, height=height)
252
253 page_set = self.tab_set.page_set
254 if selected and ((not self.is_last_in_row) or
255 (self.winfo_rootx() + self.winfo_width() <
256 page_set.winfo_rootx() + page_set.winfo_width())
257 ):
258 # for a selected tab, if its rightmost edge isn't on the
259 # rightmost edge of the page set, the right mask should be one
260 # borderwidth shorter (vertically)
261 height -= self.bw
262
263 self.mskr.place(in_=self,
264 relx=1.0, x=0,
265 rely=1.0, y=0,
266 relwidth=0.0, width=self.bw,
267 relheight=0.0, height=height)
268
269 self.mskr.mr.place(x=-self.bw, y=-self.bw,
270 width=2*self.bw, height=height + self.bw*2)
271
272 # finally, lower the tab set so that all of the frames we just
273 # placed hide it
274 self.tab_set.lower()
275
276class TabbedPageSet(Frame):
277 """A Tkinter tabbed-pane widget.
278
279 Constains set of 'pages' (or 'panes') with tabs above for selecting which
280 page is displayed. Only one page will be displayed at a time.
281
282 Pages may be accessed through the 'pages' attribute, which is a dictionary
283 of pages, using the name given as the key. A page is an instance of a
284 subclass of Tk's Frame widget.
285
286 The page widgets will be created (and destroyed when required) by the
287 TabbedPageSet. Do not call the page's pack/place/grid/destroy methods.
288
289 Pages may be added or removed at any time using the add_page() and
290 remove_page() methods.
291
292 """
293 class Page(object):
294 """Abstract base class for TabbedPageSet's pages.
295
296 Subclasses must override the _show() and _hide() methods.
297
298 """
299 uses_grid = False
300
301 def __init__(self, page_set):
302 self.frame = Frame(page_set, borderwidth=2, relief=RAISED)
303
304 def _show(self):
305 raise NotImplementedError
306
307 def _hide(self):
308 raise NotImplementedError
309
310 class PageRemove(Page):
311 """Page class using the grid placement manager's "remove" mechanism."""
312 uses_grid = True
313
314 def _show(self):
315 self.frame.grid(row=0, column=0, sticky=NSEW)
316
317 def _hide(self):
318 self.frame.grid_remove()
319
320 class PageLift(Page):
321 """Page class using the grid placement manager's "lift" mechanism."""
322 uses_grid = True
323
324 def __init__(self, page_set):
325 super(TabbedPageSet.PageLift, self).__init__(page_set)
326 self.frame.grid(row=0, column=0, sticky=NSEW)
327 self.frame.lower()
328
329 def _show(self):
330 self.frame.lift()
331
332 def _hide(self):
333 self.frame.lower()
334
335 class PagePackForget(Page):
336 """Page class using the pack placement manager's "forget" mechanism."""
337 def _show(self):
338 self.frame.pack(fill=BOTH, expand=True)
339
340 def _hide(self):
341 self.frame.pack_forget()
342
343 def __init__(self, parent, page_names=None, page_class=PageLift,
344 n_rows=1, max_tabs_per_row=5, expand_tabs=False,
345 **kw):
346 """Constructor arguments:
347
348 page_names -- A list of strings, each will be the dictionary key to a
349 page's widget, and the name displayed on the page's tab. Should be
350 specified in the desired page order. The first page will be the default
351 and first active page. If page_names is None or empty, the
352 TabbedPageSet will be initialized empty.
353
354 n_rows, max_tabs_per_row -- Parameters for the TabBarSet which will
355 manage the tabs. See TabBarSet's docs for details.
356
357 page_class -- Pages can be shown/hidden using three mechanisms:
358
359 * PageLift - All pages will be rendered one on top of the other. When
360 a page is selected, it will be brought to the top, thus hiding all
361 other pages. Using this method, the TabbedPageSet will not be resized
362 when pages are switched. (It may still be resized when pages are
363 added/removed.)
364
365 * PageRemove - When a page is selected, the currently showing page is
366 hidden, and the new page shown in its place. Using this method, the
367 TabbedPageSet may resize when pages are changed.
368
369 * PagePackForget - This mechanism uses the pack placement manager.
370 When a page is shown it is packed, and when it is hidden it is
371 unpacked (i.e. pack_forget). This mechanism may also cause the
372 TabbedPageSet to resize when the page is changed.
373
374 """
375 Frame.__init__(self, parent, kw)
376
377 self.page_class = page_class
378 self.pages = {}
379 self._pages_order = []
380 self._current_page = None
381 self._default_page = None
382
383 self.columnconfigure(0, weight=1)
384 self.rowconfigure(1, weight=1)
385
386 self.pages_frame = Frame(self)
387 self.pages_frame.grid(row=1, column=0, sticky=NSEW)
388 if self.page_class.uses_grid:
389 self.pages_frame.columnconfigure(0, weight=1)
390 self.pages_frame.rowconfigure(0, weight=1)
391
392 # the order of the following commands is important
393 self._tab_set = TabBarSet(self, self.change_page, n_rows=n_rows,
394 max_tabs_per_row=max_tabs_per_row,
395 expand_tabs=expand_tabs)
396 if page_names:
397 for name in page_names:
398 self.add_page(name)
399 self._tab_set.grid(row=0, column=0, sticky=NSEW)
400
401 self.change_page(self._default_page)
402
403 def add_page(self, page_name):
404 """Add a new page with the name given in page_name."""
405 if not page_name:
406 raise InvalidNameError("Invalid TabPage name: '%s'" % page_name)
407 if page_name in self.pages:
408 raise AlreadyExistsError(
409 "TabPage named '%s' already exists" % page_name)
410
411 self.pages[page_name] = self.page_class(self.pages_frame)
412 self._pages_order.append(page_name)
413 self._tab_set.add_tab(page_name)
414
415 if len(self.pages) == 1: # adding first page
416 self._default_page = page_name
417 self.change_page(page_name)
418
419 def remove_page(self, page_name):
420 """Destroy the page whose name is given in page_name."""
421 if not page_name in self.pages:
422 raise KeyError("No such TabPage: '%s" % page_name)
423
424 self._pages_order.remove(page_name)
425
426 # handle removing last remaining, default, or currently shown page
427 if len(self._pages_order) > 0:
428 if page_name == self._default_page:
429 # set a new default page
430 self._default_page = self._pages_order[0]
431 else:
432 self._default_page = None
433
434 if page_name == self._current_page:
435 self.change_page(self._default_page)
436
437 self._tab_set.remove_tab(page_name)
438 page = self.pages.pop(page_name)
439 page.frame.destroy()
440
441 def change_page(self, page_name):
442 """Show the page whose name is given in page_name."""
443 if self._current_page == page_name:
444 return
445 if page_name is not None and page_name not in self.pages:
446 raise KeyError("No such TabPage: '%s'" % page_name)
447
448 if self._current_page is not None:
449 self.pages[self._current_page]._hide()
450 self._current_page = None
451
452 if page_name is not None:
453 self._current_page = page_name
454 self.pages[page_name]._show()
455
456 self._tab_set.select_tab(page_name)
457
458if __name__ == '__main__':
459 # test dialog
460 root=Tk()
461 tabPage=TabbedPageSet(root, page_names=['Foobar','Baz'], n_rows=0,
462 expand_tabs=False,
463 )
464 tabPage.pack(side=TOP, expand=TRUE, fill=BOTH)
465 Label(tabPage.pages['Foobar'].frame, text='Foo', pady=20).pack()
466 Label(tabPage.pages['Foobar'].frame, text='Bar', pady=20).pack()
467 Label(tabPage.pages['Baz'].frame, text='Baz').pack()
468 entryPgName=Entry(root)
469 buttonAdd=Button(root, text='Add Page',
470 command=lambda:tabPage.add_page(entryPgName.get()))
471 buttonRemove=Button(root, text='Remove Page',
472 command=lambda:tabPage.remove_page(entryPgName.get()))
473 labelPgName=Label(root, text='name of page to add/remove:')
474 buttonAdd.pack(padx=5, pady=5)
475 buttonRemove.pack(padx=5, pady=5)
476 labelPgName.pack(padx=5)
477 entryPgName.pack(padx=5)
478 root.mainloop()