|
1 """An implementation of tabbed pages using only standard Tkinter. |
|
2 |
|
3 Originally developed for use in IDLE. Based on tabpage.py. |
|
4 |
|
5 Classes exported: |
|
6 TabbedPageSet -- A Tkinter implementation of a tabbed-page widget. |
|
7 TabSet -- A widget containing tabs (buttons) in one or more rows. |
|
8 |
|
9 """ |
|
10 from Tkinter import * |
|
11 |
|
12 class InvalidNameError(Exception): pass |
|
13 class AlreadyExistsError(Exception): pass |
|
14 |
|
15 |
|
16 class 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" % page_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" % 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 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 for tab_name in self._tabs.keys(): |
|
147 self._tabs.pop(tab_name).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 xrange(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 class TabbedPageSet(Frame): |
|
289 """A Tkinter tabbed-pane widget. |
|
290 |
|
291 Constains set of 'pages' (or 'panes') with tabs above for selecting which |
|
292 page is displayed. Only one page will be displayed at a time. |
|
293 |
|
294 Pages may be accessed through the 'pages' attribute, which is a dictionary |
|
295 of pages, using the name given as the key. A page is an instance of a |
|
296 subclass of Tk's Frame widget. |
|
297 |
|
298 The page widgets will be created (and destroyed when required) by the |
|
299 TabbedPageSet. Do not call the page's pack/place/grid/destroy methods. |
|
300 |
|
301 Pages may be added or removed at any time using the add_page() and |
|
302 remove_page() methods. |
|
303 |
|
304 """ |
|
305 class Page(object): |
|
306 """Abstract base class for TabbedPageSet's pages. |
|
307 |
|
308 Subclasses must override the _show() and _hide() methods. |
|
309 |
|
310 """ |
|
311 uses_grid = False |
|
312 |
|
313 def __init__(self, page_set): |
|
314 self.frame = Frame(page_set, borderwidth=2, relief=RAISED) |
|
315 |
|
316 def _show(self): |
|
317 raise NotImplementedError |
|
318 |
|
319 def _hide(self): |
|
320 raise NotImplementedError |
|
321 |
|
322 class PageRemove(Page): |
|
323 """Page class using the grid placement manager's "remove" mechanism.""" |
|
324 uses_grid = True |
|
325 |
|
326 def _show(self): |
|
327 self.frame.grid(row=0, column=0, sticky=NSEW) |
|
328 |
|
329 def _hide(self): |
|
330 self.frame.grid_remove() |
|
331 |
|
332 class PageLift(Page): |
|
333 """Page class using the grid placement manager's "lift" mechanism.""" |
|
334 uses_grid = True |
|
335 |
|
336 def __init__(self, page_set): |
|
337 super(TabbedPageSet.PageLift, self).__init__(page_set) |
|
338 self.frame.grid(row=0, column=0, sticky=NSEW) |
|
339 self.frame.lower() |
|
340 |
|
341 def _show(self): |
|
342 self.frame.lift() |
|
343 |
|
344 def _hide(self): |
|
345 self.frame.lower() |
|
346 |
|
347 class PagePackForget(Page): |
|
348 """Page class using the pack placement manager's "forget" mechanism.""" |
|
349 def _show(self): |
|
350 self.frame.pack(fill=BOTH, expand=True) |
|
351 |
|
352 def _hide(self): |
|
353 self.frame.pack_forget() |
|
354 |
|
355 def __init__(self, parent, page_names=None, page_class=PageLift, |
|
356 n_rows=1, max_tabs_per_row=5, expand_tabs=False, |
|
357 **kw): |
|
358 """Constructor arguments: |
|
359 |
|
360 page_names -- A list of strings, each will be the dictionary key to a |
|
361 page's widget, and the name displayed on the page's tab. Should be |
|
362 specified in the desired page order. The first page will be the default |
|
363 and first active page. If page_names is None or empty, the |
|
364 TabbedPageSet will be initialized empty. |
|
365 |
|
366 n_rows, max_tabs_per_row -- Parameters for the TabSet which will |
|
367 manage the tabs. See TabSet's docs for details. |
|
368 |
|
369 page_class -- Pages can be shown/hidden using three mechanisms: |
|
370 |
|
371 * PageLift - All pages will be rendered one on top of the other. When |
|
372 a page is selected, it will be brought to the top, thus hiding all |
|
373 other pages. Using this method, the TabbedPageSet will not be resized |
|
374 when pages are switched. (It may still be resized when pages are |
|
375 added/removed.) |
|
376 |
|
377 * PageRemove - When a page is selected, the currently showing page is |
|
378 hidden, and the new page shown in its place. Using this method, the |
|
379 TabbedPageSet may resize when pages are changed. |
|
380 |
|
381 * PagePackForget - This mechanism uses the pack placement manager. |
|
382 When a page is shown it is packed, and when it is hidden it is |
|
383 unpacked (i.e. pack_forget). This mechanism may also cause the |
|
384 TabbedPageSet to resize when the page is changed. |
|
385 |
|
386 """ |
|
387 Frame.__init__(self, parent, **kw) |
|
388 |
|
389 self.page_class = page_class |
|
390 self.pages = {} |
|
391 self._pages_order = [] |
|
392 self._current_page = None |
|
393 self._default_page = None |
|
394 |
|
395 self.columnconfigure(0, weight=1) |
|
396 self.rowconfigure(1, weight=1) |
|
397 |
|
398 self.pages_frame = Frame(self) |
|
399 self.pages_frame.grid(row=1, column=0, sticky=NSEW) |
|
400 if self.page_class.uses_grid: |
|
401 self.pages_frame.columnconfigure(0, weight=1) |
|
402 self.pages_frame.rowconfigure(0, weight=1) |
|
403 |
|
404 # the order of the following commands is important |
|
405 self._tab_set = TabSet(self, self.change_page, n_rows=n_rows, |
|
406 max_tabs_per_row=max_tabs_per_row, |
|
407 expand_tabs=expand_tabs) |
|
408 if page_names: |
|
409 for name in page_names: |
|
410 self.add_page(name) |
|
411 self._tab_set.grid(row=0, column=0, sticky=NSEW) |
|
412 |
|
413 self.change_page(self._default_page) |
|
414 |
|
415 def add_page(self, page_name): |
|
416 """Add a new page with the name given in page_name.""" |
|
417 if not page_name: |
|
418 raise InvalidNameError("Invalid TabPage name: '%s'" % page_name) |
|
419 if page_name in self.pages: |
|
420 raise AlreadyExistsError( |
|
421 "TabPage named '%s' already exists" % page_name) |
|
422 |
|
423 self.pages[page_name] = self.page_class(self.pages_frame) |
|
424 self._pages_order.append(page_name) |
|
425 self._tab_set.add_tab(page_name) |
|
426 |
|
427 if len(self.pages) == 1: # adding first page |
|
428 self._default_page = page_name |
|
429 self.change_page(page_name) |
|
430 |
|
431 def remove_page(self, page_name): |
|
432 """Destroy the page whose name is given in page_name.""" |
|
433 if not page_name in self.pages: |
|
434 raise KeyError("No such TabPage: '%s" % page_name) |
|
435 |
|
436 self._pages_order.remove(page_name) |
|
437 |
|
438 # handle removing last remaining, default, or currently shown page |
|
439 if len(self._pages_order) > 0: |
|
440 if page_name == self._default_page: |
|
441 # set a new default page |
|
442 self._default_page = self._pages_order[0] |
|
443 else: |
|
444 self._default_page = None |
|
445 |
|
446 if page_name == self._current_page: |
|
447 self.change_page(self._default_page) |
|
448 |
|
449 self._tab_set.remove_tab(page_name) |
|
450 page = self.pages.pop(page_name) |
|
451 page.frame.destroy() |
|
452 |
|
453 def change_page(self, page_name): |
|
454 """Show the page whose name is given in page_name.""" |
|
455 if self._current_page == page_name: |
|
456 return |
|
457 if page_name is not None and page_name not in self.pages: |
|
458 raise KeyError("No such TabPage: '%s'" % page_name) |
|
459 |
|
460 if self._current_page is not None: |
|
461 self.pages[self._current_page]._hide() |
|
462 self._current_page = None |
|
463 |
|
464 if page_name is not None: |
|
465 self._current_page = page_name |
|
466 self.pages[page_name]._show() |
|
467 |
|
468 self._tab_set.set_selected_tab(page_name) |
|
469 |
|
470 if __name__ == '__main__': |
|
471 # test dialog |
|
472 root=Tk() |
|
473 tabPage=TabbedPageSet(root, page_names=['Foobar','Baz'], n_rows=0, |
|
474 expand_tabs=False, |
|
475 ) |
|
476 tabPage.pack(side=TOP, expand=TRUE, fill=BOTH) |
|
477 Label(tabPage.pages['Foobar'].frame, text='Foo', pady=20).pack() |
|
478 Label(tabPage.pages['Foobar'].frame, text='Bar', pady=20).pack() |
|
479 Label(tabPage.pages['Baz'].frame, text='Baz').pack() |
|
480 entryPgName=Entry(root) |
|
481 buttonAdd=Button(root, text='Add Page', |
|
482 command=lambda:tabPage.add_page(entryPgName.get())) |
|
483 buttonRemove=Button(root, text='Remove Page', |
|
484 command=lambda:tabPage.remove_page(entryPgName.get())) |
|
485 labelPgName=Label(root, text='name of page to add/remove:') |
|
486 buttonAdd.pack(padx=5, pady=5) |
|
487 buttonRemove.pack(padx=5, pady=5) |
|
488 labelPgName.pack(padx=5) |
|
489 entryPgName.pack(padx=5) |
|
490 root.mainloop() |