1# Terminator by Chris Jones <cmsj@tenshu.net> 2# GPL v2 only 3"""notebook.py - classes for the notebook widget""" 4 5from functools import cmp_to_key 6from gi.repository import GObject 7from gi.repository import Gtk 8from gi.repository import Gdk 9from gi.repository import Gio 10 11from .terminator import Terminator 12from .config import Config 13from .factory import Factory 14from .container import Container 15from .editablelabel import EditableLabel 16from .translation import _ 17from .util import err, dbg, enumerate_descendants, make_uuid 18 19class Notebook(Container, Gtk.Notebook): 20 """Class implementing a Gtk.Notebook container""" 21 window = None 22 last_active_term = None 23 pending_on_tab_switch = None 24 pending_on_tab_switch_args = None 25 26 def __init__(self, window): 27 """Class initialiser""" 28 if isinstance(window.get_child(), Gtk.Notebook): 29 err('There is already a Notebook at the top of this window') 30 raise(ValueError) 31 32 Container.__init__(self) 33 GObject.GObject.__init__(self) 34 self.terminator = Terminator() 35 self.window = window 36 GObject.type_register(Notebook) 37 self.register_signals(Notebook) 38 self.connect('switch-page', self.deferred_on_tab_switch) 39 self.connect('scroll-event', self.on_scroll_event) 40 self.configure() 41 42 child = window.get_child() 43 window.remove(child) 44 window.add(self) 45 window_last_active_term = window.last_active_term 46 self.newtab(widget=child) 47 if window_last_active_term: 48 self.set_last_active_term(window_last_active_term) 49 window.last_active_term = None 50 51 self.show_all() 52 53 def configure(self): 54 """Apply widget-wide settings""" 55 # FIXME: The old reordered handler updated Terminator.terminals with 56 # the new order of terminals. We probably need to preserve this for 57 # navigation to next/prev terminals. 58 #self.connect('page-reordered', self.on_page_reordered) 59 self.set_scrollable(self.config['scroll_tabbar']) 60 61 if self.config['tab_position'] == 'hidden' or self.config['hide_tabbar']: 62 self.set_show_tabs(False) 63 else: 64 self.set_show_tabs(True) 65 pos = getattr(Gtk.PositionType, self.config['tab_position'].upper()) 66 self.set_tab_pos(pos) 67 68 for tab in range(0, self.get_n_pages()): 69 label = self.get_tab_label(self.get_nth_page(tab)) 70 label.update_angle() 71 72# style = Gtk.RcStyle() # FIXME FOR GTK3 how to do it there? actually do we really want to override the theme? 73# style.xthickness = 0 74# style.ythickness = 0 75# self.modify_style(style) 76 self.last_active_term = {} 77 78 def create_layout(self, layout): 79 """Apply layout configuration""" 80 def child_compare(a, b): 81 order_a = children[a]['order'] 82 order_b = children[b]['order'] 83 84 if (order_a == order_b): 85 return 0 86 if (order_a < order_b): 87 return -1 88 if (order_a > order_b): 89 return 1 90 91 if 'children' not in layout: 92 err('layout specifies no children: %s' % layout) 93 return 94 95 children = layout['children'] 96 if len(children) <= 1: 97 #Notebooks should have two or more children 98 err('incorrect number of children for Notebook: %s' % layout) 99 return 100 101 num = 0 102 keys = list(children.keys()) 103 keys = sorted(keys, key=cmp_to_key(child_compare)) 104 105 for child_key in keys: 106 child = children[child_key] 107 dbg('Making a child of type: %s' % child['type']) 108 if child['type'] == 'Terminal': 109 pass 110 elif child['type'] == 'VPaned': 111 page = self.get_nth_page(num) 112 self.split_axis(page, True) 113 elif child['type'] == 'HPaned': 114 page = self.get_nth_page(num) 115 self.split_axis(page, False) 116 num = num + 1 117 118 num = 0 119 for child_key in keys: 120 page = self.get_nth_page(num) 121 if not page: 122 # This page does not yet exist, so make it 123 self.newtab(children[child_key]) 124 page = self.get_nth_page(num) 125 if 'labels' in layout: 126 labeltext = layout['labels'][num] 127 if labeltext and labeltext != "None": 128 label = self.get_tab_label(page) 129 label.set_custom_label(labeltext) 130 page.create_layout(children[child_key]) 131 132 if layout.get('last_active_term', None): 133 self.last_active_term[page] = make_uuid(layout['last_active_term'][num]) 134 num = num + 1 135 136 if 'active_page' in layout: 137 # Need to do it later, or layout changes result 138 GObject.idle_add(self.set_current_page, int(layout['active_page'])) 139 else: 140 self.set_current_page(0) 141 142 def split_axis(self, widget, vertical=True, cwd=None, sibling=None, widgetfirst=True): 143 """Split the axis of a terminal inside us""" 144 dbg('called for widget: %s' % widget) 145 order = None 146 page_num = self.page_num(widget) 147 if page_num == -1: 148 err('Notebook::split_axis: %s not found in Notebook' % widget) 149 return 150 151 label = self.get_tab_label(widget) 152 self.remove(widget) 153 154 maker = Factory() 155 if vertical: 156 container = maker.make('vpaned') 157 else: 158 container = maker.make('hpaned') 159 160 self.get_toplevel().set_pos_by_ratio = True 161 162 if not sibling: 163 sibling = maker.make('terminal') 164 sibling.set_cwd(cwd) 165 if self.config['always_split_with_profile']: 166 sibling.force_set_profile(None, widget.get_profile()) 167 sibling.spawn_child() 168 if widget.group and self.config['split_to_group']: 169 sibling.set_group(None, widget.group) 170 elif self.config['always_split_with_profile']: 171 sibling.force_set_profile(None, widget.get_profile()) 172 173 self.insert_page(container, None, page_num) 174 self.child_set_property(container, 'tab-expand', True) 175 self.child_set_property(container, 'tab-fill', True) 176 self.set_tab_reorderable(container, True) 177 self.set_tab_label(container, label) 178 self.show_all() 179 180 order = [widget, sibling] 181 if widgetfirst is False: 182 order.reverse() 183 184 for terminal in order: 185 container.add(terminal) 186 self.set_current_page(page_num) 187 188 self.show_all() 189 190 while Gtk.events_pending(): 191 Gtk.main_iteration_do(False) 192 self.get_toplevel().set_pos_by_ratio = False 193 194 GObject.idle_add(terminal.ensure_visible_and_focussed) 195 196 def add(self, widget, metadata=None): 197 """Add a widget to the container""" 198 dbg('adding a new tab') 199 self.newtab(widget=widget, metadata=metadata) 200 201 def remove(self, widget): 202 """Remove a widget from the container""" 203 page_num = self.page_num(widget) 204 if page_num == -1: 205 err('%s not found in Notebook. Actual parent is: %s' % 206 (widget, widget.get_parent())) 207 return(False) 208 self.remove_page(page_num) 209 self.disconnect_child(widget) 210 return(True) 211 212 def replace(self, oldwidget, newwidget): 213 """Replace a tab's contents with a new widget""" 214 page_num = self.page_num(oldwidget) 215 self.remove(oldwidget) 216 self.add(newwidget) 217 self.reorder_child(newwidget, page_num) 218 219 def get_child_metadata(self, widget): 220 """Fetch the relevant metadata for a widget which we'd need 221 to recreate it when it's readded""" 222 metadata = {} 223 metadata['tabnum'] = self.page_num(widget) 224 label = self.get_tab_label(widget) 225 if not label: 226 dbg('unable to find label for widget: %s' % widget) 227 elif label.get_custom_label(): 228 metadata['label'] = label.get_custom_label() 229 else: 230 dbg('don\'t grab the label as it was not customised') 231 return metadata 232 233 def get_children(self): 234 """Return an ordered list of our children""" 235 children = [] 236 for page in range(0,self.get_n_pages()): 237 children.append(self.get_nth_page(page)) 238 return(children) 239 240 def newtab(self, debugtab=False, widget=None, cwd=None, metadata=None, profile=None): 241 """Add a new tab, optionally supplying a child widget""" 242 dbg('making a new tab') 243 maker = Factory() 244 top_window = self.get_toplevel() 245 246 if not widget: 247 widget = maker.make('Terminal') 248 if cwd: 249 widget.set_cwd(cwd) 250 if profile and self.config['always_split_with_profile']: 251 widget.force_set_profile(None, profile) 252 widget.spawn_child(debugserver=debugtab) 253 elif profile and self.config['always_split_with_profile']: 254 widget.force_set_profile(None, profile) 255 256 signals = {'close-term': self.wrapcloseterm, 257 'split-horiz': self.split_horiz, 258 'split-vert': self.split_vert, 259 'title-change': self.propagate_title_change, 260 'unzoom': self.unzoom, 261 'tab-change': top_window.tab_change, 262 'group-all': top_window.group_all, 263 'group-all-toggle': top_window.group_all_toggle, 264 'ungroup-all': top_window.ungroup_all, 265 'group-tab': top_window.group_tab, 266 'group-tab-toggle': top_window.group_tab_toggle, 267 'ungroup-tab': top_window.ungroup_tab, 268 'move-tab': top_window.move_tab, 269 'tab-new': [top_window.tab_new, widget], 270 'navigate': top_window.navigate_terminal} 271 272 if maker.isinstance(widget, 'Terminal'): 273 for signal in signals: 274 args = [] 275 handler = signals[signal] 276 if isinstance(handler, list): 277 args = handler[1:] 278 handler = handler[0] 279 self.connect_child(widget, signal, handler, *args) 280 281 if metadata and 'tabnum' in metadata: 282 tabpos = metadata['tabnum'] 283 else: 284 tabpos = -1 285 286 label = TabLabel(self.window.get_title(), self) 287 if metadata and 'label' in metadata: 288 dbg('creating TabLabel with text: %s' % metadata['label']) 289 label.set_custom_label(metadata['label']) 290 label.connect('close-clicked', self.closetab) 291 292 label.show_all() 293 widget.show_all() 294 295 dbg('inserting page at position: %s' % tabpos) 296 self.insert_page(widget, None, tabpos) 297 298 if maker.isinstance(widget, 'Terminal'): 299 containers, objects = ([], [widget]) 300 else: 301 containers, objects = enumerate_descendants(widget) 302 303 term_widget = None 304 for term_widget in objects: 305 if maker.isinstance(term_widget, 'Terminal'): 306 self.set_last_active_term(term_widget.uuid) 307 break 308 309 self.set_tab_label(widget, label) 310 self.child_set_property(widget, 'tab-expand', True) 311 self.child_set_property(widget, 'tab-fill', True) 312 313 self.set_tab_reorderable(widget, True) 314 self.set_current_page(tabpos) 315 self.show_all() 316 if maker.isinstance(term_widget, 'Terminal'): 317 widget.grab_focus() 318 319 def wrapcloseterm(self, widget): 320 """A child terminal has closed""" 321 dbg('Notebook::wrapcloseterm: called on %s' % widget) 322 if self.closeterm(widget): 323 dbg('Notebook::wrapcloseterm: closeterm succeeded') 324 self.hoover() 325 else: 326 dbg('Notebook::wrapcloseterm: closeterm failed') 327 328 def closetab(self, widget, label): 329 """Close a tab""" 330 tabnum = None 331 try: 332 nb = widget.notebook 333 except AttributeError: 334 err('TabLabel::closetab: called on non-Notebook: %s' % widget) 335 return 336 337 for i in range(0, nb.get_n_pages() + 1): 338 if label == nb.get_tab_label(nb.get_nth_page(i)): 339 tabnum = i 340 break 341 342 if tabnum is None: 343 err('TabLabel::closetab: %s not in %s. Bailing.' % (label, nb)) 344 return 345 346 maker = Factory() 347 child = nb.get_nth_page(tabnum) 348 349 if maker.isinstance(child, 'Terminal'): 350 dbg('Notebook::closetab: child is a single Terminal') 351 del nb.last_active_term[child] 352 child.close() 353 # FIXME: We only do this del and return here to avoid removing the 354 # page below, which child.close() implicitly does 355 del(label) 356 return 357 elif maker.isinstance(child, 'Container'): 358 dbg('Notebook::closetab: child is a Container') 359 result = self.construct_confirm_close(self.window, _('tab')) 360 361 if result == Gtk.ResponseType.ACCEPT: 362 containers = None 363 objects = None 364 containers, objects = enumerate_descendants(child) 365 366 while len(objects) > 0: 367 descendant = objects.pop() 368 descendant.close() 369 while Gtk.events_pending(): 370 Gtk.main_iteration() 371 return 372 else: 373 dbg('Notebook::closetab: user cancelled request') 374 return 375 else: 376 err('Notebook::closetab: child is unknown type %s' % child) 377 return 378 379 def resizeterm(self, widget, keyname): 380 """Handle a keyboard event requesting a terminal resize""" 381 raise NotImplementedError('resizeterm') 382 383 def zoom(self, widget, fontscale = False): 384 """Zoom a terminal""" 385 raise NotImplementedError('zoom') 386 387 def unzoom(self, widget): 388 """Unzoom a terminal""" 389 raise NotImplementedError('unzoom') 390 391 def find_tab_root(self, widget): 392 """Look for the tab child which is or ultimately contains the supplied 393 widget""" 394 parent = widget.get_parent() 395 previous = parent 396 397 while parent is not None and parent is not self: 398 previous = parent 399 parent = parent.get_parent() 400 401 if previous == self: 402 return(widget) 403 else: 404 return(previous) 405 406 def update_tab_label_text(self, widget, text): 407 """Update the text of a tab label""" 408 notebook = self.find_tab_root(widget) 409 label = self.get_tab_label(notebook) 410 if not label: 411 err('Notebook::update_tab_label_text: %s not found' % widget) 412 return 413 414 label.set_label(text) 415 416 def hoover(self): 417 """Clean up any empty tabs and if we only have one tab left, die""" 418 numpages = self.get_n_pages() 419 while numpages > 0: 420 numpages = numpages - 1 421 page = self.get_nth_page(numpages) 422 if not page: 423 dbg('Removing empty page: %d' % numpages) 424 self.remove_page(numpages) 425 426 if self.get_n_pages() == 1: 427 dbg('Last page, removing self') 428 child = self.get_nth_page(0) 429 self.remove_page(0) 430 parent = self.get_parent() 431 parent.remove(self) 432 self.cnxids.remove_all() 433 parent.add(child) 434 del(self) 435 # Find the last terminal in the new parent and give it focus 436 terms = parent.get_visible_terminals() 437 list(terms.keys())[-1].grab_focus() 438 439 def page_num_descendant(self, widget): 440 """Find the tabnum of the tab containing a widget at any level""" 441 tabnum = self.page_num(widget) 442 dbg("widget is direct child if not equal -1 - tabnum: %d" % tabnum) 443 while tabnum == -1 and widget.get_parent(): 444 widget = widget.get_parent() 445 tabnum = self.page_num(widget) 446 dbg("found tabnum containing widget: %d" % tabnum) 447 return tabnum 448 449 def set_last_active_term(self, uuid): 450 """Set the last active term for uuid""" 451 widget = self.terminator.find_terminal_by_uuid(uuid.urn) 452 if not widget: 453 err("Cannot find terminal with uuid: %s, so cannot make it active" % (uuid.urn)) 454 return 455 tabnum = self.page_num_descendant(widget) 456 if tabnum == -1: 457 err("No tabnum found for terminal with uuid: %s" % (uuid.urn)) 458 return 459 nth_page = self.get_nth_page(tabnum) 460 self.last_active_term[nth_page] = uuid 461 462 def clean_last_active_term(self): 463 """Clean up old entries in last_active_term""" 464 if self.terminator.doing_layout == True: 465 return 466 last_active_term = {} 467 for tabnum in range(0, self.get_n_pages()): 468 nth_page = self.get_nth_page(tabnum) 469 if nth_page in self.last_active_term: 470 last_active_term[nth_page] = self.last_active_term[nth_page] 471 self.last_active_term = last_active_term 472 473 def deferred_on_tab_switch(self, notebook, page, page_num, data=None): 474 """Prime a single idle tab switch signal, using the most recent set of params""" 475 tabs_last_active_term = self.last_active_term.get(self.get_nth_page(page_num), None) 476 data = {'tabs_last_active_term':tabs_last_active_term} 477 478 self.pending_on_tab_switch_args = (notebook, page, page_num, data) 479 if self.pending_on_tab_switch == True: 480 return 481 GObject.idle_add(self.do_deferred_on_tab_switch) 482 self.pending_on_tab_switch = True 483 484 def do_deferred_on_tab_switch(self): 485 """Perform the latest tab switch signal, and resetting the pending flag""" 486 self.on_tab_switch(*self.pending_on_tab_switch_args) 487 self.pending_on_tab_switch = False 488 self.pending_on_tab_switch_args = None 489 490 def on_tab_switch(self, notebook, page, page_num, data=None): 491 """Do the real work for a tab switch""" 492 tabs_last_active_term = data['tabs_last_active_term'] 493 if tabs_last_active_term: 494 term = self.terminator.find_terminal_by_uuid(tabs_last_active_term.urn) 495 # if we can't find a last active term we must be starting up 496 if term is not None: 497 GObject.idle_add(term.ensure_visible_and_focussed) 498 return True 499 500 def on_scroll_event(self, notebook, event): 501 '''Handle scroll events for scrolling through tabs''' 502 #print "self: %s" % self 503 #print "event: %s" % event 504 child = self.get_nth_page(self.get_current_page()) 505 if child == None: 506 print("Child = None, return false") 507 return False 508 509 event_widget = Gtk.get_event_widget(event) 510 511 if event_widget == None or \ 512 event_widget == child or \ 513 event_widget.is_ancestor(child): 514 print("event_widget is wrong one, return false") 515 return False 516 517 # Not sure if we need these. I don't think wehave any action widgets 518 # at this point. 519 action_widget = self.get_action_widget(Gtk.PackType.START) 520 if event_widget == action_widget or \ 521 (action_widget != None and event_widget.is_ancestor(action_widget)): 522 return False 523 action_widget = self.get_action_widget(Gtk.PackType.END) 524 if event_widget == action_widget or \ 525 (action_widget != None and event_widget.is_ancestor(action_widget)): 526 return False 527 528 if event.direction in [Gdk.ScrollDirection.RIGHT, 529 Gdk.ScrollDirection.DOWN]: 530 self.next_page() 531 elif event.direction in [Gdk.ScrollDirection.LEFT, 532 Gdk.ScrollDirection.UP]: 533 self.prev_page() 534 elif event.direction == Gdk.ScrollDirection.SMOOTH: 535 if self.get_tab_pos() in [Gtk.PositionType.LEFT, 536 Gtk.PositionType.RIGHT]: 537 if event.delta_y > 0: 538 self.next_page() 539 elif event.delta_y < 0: 540 self.prev_page() 541 elif self.get_tab_pos() in [Gtk.PositionType.TOP, 542 Gtk.PositionType.BOTTOM]: 543 if event.delta_x > 0: 544 self.next_page() 545 elif event.delta_x < 0: 546 self.prev_page() 547 return True 548 549class TabLabel(Gtk.HBox): 550 """Class implementing a label widget for Notebook tabs""" 551 notebook = None 552 terminator = None 553 config = None 554 label = None 555 icon = None 556 button = None 557 558 __gsignals__ = { 559 'close-clicked': (GObject.SignalFlags.RUN_LAST, None, 560 (GObject.TYPE_OBJECT,)), 561 } 562 563 def __init__(self, title, notebook): 564 """Class initialiser""" 565 GObject.GObject.__init__(self) 566 567 self.notebook = notebook 568 self.terminator = Terminator() 569 self.config = Config() 570 571 self.label = EditableLabel(title) 572 self.update_angle() 573 574 self.pack_start(self.label, True, True, 0) 575 576 self.update_button() 577 self.show_all() 578 579 def set_label(self, text): 580 """Update the text of our label""" 581 self.label.set_text(text) 582 583 def get_label(self): 584 return self.label.get_text() 585 586 def set_custom_label(self, text): 587 """Set a permanent label as if the user had edited it""" 588 self.label.set_text(text) 589 self.label.set_custom() 590 591 def get_custom_label(self): 592 """Return a custom label if we have one, otherwise None""" 593 if self.label.is_custom(): 594 return(self.label.get_text()) 595 else: 596 return(None) 597 598 def edit(self): 599 self.label.edit() 600 601 def update_button(self): 602 """Update the state of our close button""" 603 if not self.config['close_button_on_tab']: 604 if self.button: 605 self.button.remove(self.icon) 606 self.remove(self.button) 607 del(self.button) 608 del(self.icon) 609 self.button = None 610 self.icon = None 611 return 612 613 if not self.button: 614 self.button = Gtk.Button() 615 if not self.icon: 616 self.icon = Gio.ThemedIcon.new_with_default_fallbacks("window-close-symbolic") 617 self.icon = Gtk.Image.new_from_gicon(self.icon, Gtk.IconSize.MENU) 618 619 self.button.set_focus_on_click(False) 620 self.button.set_relief(Gtk.ReliefStyle.NONE) 621# style = Gtk.RcStyle() # FIXME FOR GTK3 how to do it there? actually do we really want to override the theme? 622# style.xthickness = 0 623# style.ythickness = 0 624# self.button.modify_style(style) 625 self.button.add(self.icon) 626 self.button.connect('clicked', self.on_close) 627 self.button.set_name('terminator-tab-close-button') 628 if hasattr(self.button, 'set_tooltip_text'): 629 self.button.set_tooltip_text(_('Close Tab')) 630 self.pack_start(self.button, False, False, 0) 631 self.show_all() 632 633 def update_angle(self): 634 """Update the angle of a label""" 635 position = self.notebook.get_tab_pos() 636 if position == Gtk.PositionType.LEFT: 637 if hasattr(self, 'set_orientation'): 638 self.set_orientation(Gtk.Orientation.VERTICAL) 639 self.label.set_angle(90) 640 elif position == Gtk.PositionType.RIGHT: 641 if hasattr(self, 'set_orientation'): 642 self.set_orientation(Gtk.Orientation.VERTICAL) 643 self.label.set_angle(270) 644 else: 645 if hasattr(self, 'set_orientation'): 646 self.set_orientation(Gtk.Orientation.HORIZONTAL) 647 self.label.set_angle(0) 648 649 def on_close(self, _widget): 650 """The close button has been clicked. Destroy the tab""" 651 self.emit('close-clicked', self) 652 653# vim: set expandtab ts=4 sw=4: 654