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