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