1# Terminator by Chris Jones <cmsj@tenshu.net>
2# GPL v2 only
3"""terminator.py - class for the master Terminator singleton"""
4
5import copy
6import os
7import gi
8gi.require_version('Vte', '2.91')
9from gi.repository import Gtk, Gdk, Vte
10from gi.repository.GLib import GError
11
12from . import borg
13from .borg import Borg
14from .config import Config
15from .keybindings import Keybindings
16from .util import dbg, err, enumerate_descendants
17from .factory import Factory
18from .version import APP_NAME, APP_VERSION
19
20try:
21    from gi.repository import GdkX11
22except ImportError:
23    dbg("could not import X11 gir module")
24
25
26def eventkey2gdkevent(eventkey):  # FIXME FOR GTK3: is there a simpler way of casting from specific EventKey to generic (union) GdkEvent?
27    gdkevent = Gdk.Event.new(eventkey.type)
28    gdkevent.key.window = eventkey.window
29    gdkevent.key.send_event = eventkey.send_event
30    gdkevent.key.time = eventkey.time
31    gdkevent.key.state = eventkey.state
32    gdkevent.key.keyval = eventkey.keyval
33    gdkevent.key.length = eventkey.length
34    gdkevent.key.string = eventkey.string
35    gdkevent.key.hardware_keycode = eventkey.hardware_keycode
36    gdkevent.key.group = eventkey.group
37    gdkevent.key.is_modifier = eventkey.is_modifier
38    return gdkevent
39
40class Terminator(Borg):
41    """master object for the application"""
42
43    windows = None
44    launcher_windows = None
45    windowtitle = None
46    terminals = None
47    groups = None
48    config = None
49    keybindings = None
50    style_providers = None
51    last_focused_term = None
52
53    origcwd = None
54    dbus_path = None
55    dbus_name = None
56    debug_address = None
57    ibus_running = None
58
59    doing_layout = None
60    layoutname = None
61    last_active_window = None
62    prelayout_windows = None
63
64    groupsend = None
65    groupsend_type = {'all':0, 'group':1, 'off':2}
66
67    cur_gtk_theme_name = None
68    gtk_settings = None
69
70    def __init__(self):
71        """Class initialiser"""
72
73        Borg.__init__(self, self.__class__.__name__)
74        self.prepare_attributes()
75
76    def prepare_attributes(self):
77        """Initialise anything that isn't already"""
78
79        if not self.windows:
80            self.windows = []
81        if not self.launcher_windows:
82            self.launcher_windows = []
83        if not self.terminals:
84            self.terminals = []
85        if not self.groups:
86            self.groups = []
87        if not self.config:
88            self.config = Config()
89        if self.groupsend == None:
90            self.groupsend = self.groupsend_type[self.config['broadcast_default']]
91        if not self.keybindings:
92            self.keybindings = Keybindings()
93            self.keybindings.configure(self.config['keybindings'])
94        if not self.style_providers:
95            self.style_providers = []
96        if not self.doing_layout:
97            self.doing_layout = False
98        self.connect_signals()
99
100    def connect_signals(self):
101        """Connect all the gtk signals"""
102        self.gtk_settings=Gtk.Settings().get_default()
103        self.gtk_settings.connect('notify::gtk-theme-name', self.on_gtk_theme_name_notify)
104        self.cur_gtk_theme_name = self.gtk_settings.get_property('gtk-theme-name')
105
106    def set_origcwd(self, cwd):
107        """Store the original cwd our process inherits"""
108        if cwd == '/':
109            cwd = os.path.expanduser('~')
110            os.chdir(cwd)
111        self.origcwd = cwd
112
113    def set_dbus_data(self, dbus_service):
114        """Store the DBus bus details, if they are available"""
115        if dbus_service:
116            self.dbus_name = dbus_service.bus_name.get_name()
117            self.dbus_path = dbus_service.bus_path
118
119    def get_windows(self):
120        """Return a list of windows"""
121        return self.windows
122
123    def register_window(self, window):
124        """Register a new window widget"""
125        if window not in self.windows:
126            dbg('Terminator::register_window: registering %s:%s' % (id(window),
127                type(window)))
128            self.windows.append(window)
129
130    def deregister_window(self, window):
131        """de-register a window widget"""
132        dbg('Terminator::deregister_window: de-registering %s:%s' %
133                (id(window), type(window)))
134        if window in self.windows:
135            self.windows.remove(window)
136        else:
137            err('%s is not in registered window list' % window)
138
139        if len(self.windows) == 0:
140            # We have no windows left, we should exit
141            dbg('no windows remain, quitting')
142            Gtk.main_quit()
143
144    def register_launcher_window(self, window):
145        """Register a new launcher window widget"""
146        if window not in self.launcher_windows:
147            dbg('Terminator::register_launcher_window: registering %s:%s' % (id(window),
148                type(window)))
149            self.launcher_windows.append(window)
150
151    def deregister_launcher_window(self, window):
152        """de-register a launcher window widget"""
153        dbg('Terminator::deregister_launcher_window: de-registering %s:%s' %
154                (id(window), type(window)))
155        if window in self.launcher_windows:
156            self.launcher_windows.remove(window)
157        else:
158            err('%s is not in registered window list' % window)
159
160        if len(self.launcher_windows) == 0 and len(self.windows) == 0:
161            # We have no windows left, we should exit
162            dbg('no windows remain, quitting')
163            Gtk.main_quit()
164
165    def register_terminal(self, terminal):
166        """Register a new terminal widget"""
167        if terminal not in self.terminals:
168            dbg('Terminator::register_terminal: registering %s:%s' %
169                    (id(terminal), type(terminal)))
170            self.terminals.append(terminal)
171
172    def deregister_terminal(self, terminal):
173        """De-register a terminal widget"""
174        dbg('Terminator::deregister_terminal: de-registering %s:%s' %
175                (id(terminal), type(terminal)))
176        self.terminals.remove(terminal)
177
178        if len(self.terminals) == 0:
179            dbg('no terminals remain, destroying all windows')
180            for window in self.windows:
181                window.destroy()
182        else:
183            dbg('Terminator::deregister_terminal: %d terminals remain' %
184                    len(self.terminals))
185
186    def find_terminal_by_uuid(self, uuid):
187        """Search our terminals for one matching the supplied UUID"""
188        dbg('searching self.terminals for: %s' % uuid)
189        for terminal in self.terminals:
190            dbg('checking: %s (%s)' % (terminal.uuid.urn, terminal))
191            if terminal.uuid.urn == uuid:
192                return terminal
193        return None
194
195    def find_window_by_uuid(self, uuid):
196        """Search our terminals for one matching the supplied UUID"""
197        dbg('searching self.terminals for: %s' % uuid)
198        for window in self.windows:
199            dbg('checking: %s (%s)' % (window.uuid.urn, window))
200            if window.uuid.urn == uuid:
201                return window
202        return None
203
204    def new_window(self, cwd=None, profile=None):
205        """Create a window with a Terminal in it"""
206        maker = Factory()
207        window = maker.make('Window')
208        terminal = maker.make('Terminal')
209        if cwd:
210            terminal.set_cwd(cwd)
211        if profile and self.config['always_split_with_profile']:
212            terminal.force_set_profile(None, profile)
213        window.add(terminal)
214        window.show(True)
215        terminal.spawn_child()
216
217        return(window, terminal)
218
219    def create_layout(self, layoutname):
220        """Create all the parts necessary to satisfy the specified layout"""
221        layout = None
222        objects = {}
223
224        self.doing_layout = True
225        self.last_active_window = None
226        self.prelayout_windows = self.windows[:]
227
228        layout = copy.deepcopy(self.config.layout_get_config(layoutname))
229        if not layout:
230            # User specified a non-existent layout. default to one Terminal
231            err('layout %s not defined' % layout)
232            self.new_window()
233            return
234
235        # Wind the flat objects into a hierarchy
236        hierarchy = {}
237        count = 0
238        # Loop over the layout until we have consumed it, or hit 1000 loops.
239        # This is a stupid artificial limit, but it's safe.
240        while len(layout) > 0 and count < 1000:
241            count = count + 1
242            if count == 1000:
243                err('hit maximum loop boundary. THIS IS VERY LIKELY A BUG')
244            for obj in list(layout.keys()):
245                if layout[obj]['type'].lower() == 'window':
246                    hierarchy[obj] = {}
247                    hierarchy[obj]['type'] = 'Window'
248                    hierarchy[obj]['children'] = {}
249
250                    # Copy any additional keys
251                    for objkey in list(layout[obj].keys()):
252                        if layout[obj][objkey] != '' and objkey not in hierarchy[obj]:
253                            hierarchy[obj][objkey] = layout[obj][objkey]
254
255                    objects[obj] = hierarchy[obj]
256                    del(layout[obj])
257                else:
258                    # Now examine children to see if their parents exist yet
259                    if 'parent' not in layout[obj]:
260                        err('Invalid object: %s' % obj)
261                        del(layout[obj])
262                        continue
263                    if layout[obj]['parent'] in objects:
264                        # Our parent has been created, add ourselves
265                        childobj = {}
266                        childobj['type'] = layout[obj]['type']
267                        childobj['children'] = {}
268
269                        # Copy over any additional object keys
270                        for objkey in list(layout[obj].keys()):
271                            if objkey not in childobj:
272                                childobj[objkey] = layout[obj][objkey]
273
274                        objects[layout[obj]['parent']]['children'][obj] = childobj
275                        objects[obj] = childobj
276                        del(layout[obj])
277
278        layout = hierarchy
279
280        for windef in layout:
281            if layout[windef]['type'] != 'Window':
282                err('invalid layout format. %s' % layout)
283                raise(ValueError)
284            dbg('Creating a window')
285            window, terminal = self.new_window()
286            if 'position' in layout[windef]:
287                parts = layout[windef]['position'].split(':')
288                if len(parts) == 2:
289                    window.move(int(parts[0]), int(parts[1]))
290            if 'size' in layout[windef]:
291                parts = layout[windef]['size']
292                winx = int(parts[0])
293                winy = int(parts[1])
294                if winx > 1 and winy > 1:
295                    window.resize(winx, winy)
296            if 'title' in layout[windef]:
297                window.title.force_title(layout[windef]['title'])
298            if 'maximised' in layout[windef]:
299                if layout[windef]['maximised'] == 'True':
300                    window.ismaximised = True
301                else:
302                    window.ismaximised = False
303                window.set_maximised(window.ismaximised)
304            if 'fullscreen' in layout[windef]:
305                if layout[windef]['fullscreen'] == 'True':
306                    window.isfullscreen = True
307                else:
308                    window.isfullscreen = False
309                window.set_fullscreen(window.isfullscreen)
310            window.create_layout(layout[windef])
311
312        self.layoutname = layoutname
313
314    def layout_done(self):
315        """Layout operations have finished, record that fact"""
316        self.doing_layout = False
317        maker = Factory()
318
319        window_last_active_term_mapping = {}
320        for window in self.windows:
321            if window.is_child_notebook():
322                source = window.get_toplevel().get_children()[0]
323            else:
324                source = window
325            window_last_active_term_mapping[window] = copy.copy(source.last_active_term)
326
327        for terminal in self.terminals:
328            if not terminal.pid:
329                terminal.spawn_child()
330
331        for window in self.windows:
332            if not window.is_child_notebook():
333                # For windows without a notebook ensure Terminal is visible and focussed
334                if window_last_active_term_mapping[window]:
335                    term = self.find_terminal_by_uuid(window_last_active_term_mapping[window].urn)
336                    term.ensure_visible_and_focussed()
337
338        # Build list of new windows using prelayout list
339        new_win_list = []
340        if self.prelayout_windows:
341            for window in self.windows:
342                if window not in self.prelayout_windows:
343                    new_win_list.append(window)
344
345        # Make sure all new windows get bumped to the top
346        for window in new_win_list:
347            window.show()
348            window.grab_focus()
349            try:
350                t = GdkX11.x11_get_server_time(window.get_window())
351            except (NameError,TypeError, AttributeError):
352                t = 0
353            window.get_window().focus(t)
354
355        # Awful workaround to be sure that the last focused window is actually the one focused.
356        # Don't ask, don't tell policy on this. Even this is not 100%
357        if self.last_active_window:
358            window = self.find_window_by_uuid(self.last_active_window.urn)
359            count = 0
360            while count < 1000 and Gtk.events_pending():
361                count += 1
362                Gtk.main_iteration_do(False)
363                window.show()
364                window.grab_focus()
365                try:
366                    t = GdkX11.x11_get_server_time(window.get_window())
367                except (NameError,TypeError, AttributeError):
368                    t = 0
369                window.get_window().focus(t)
370
371        self.prelayout_windows = None
372
373    def on_gtk_theme_name_notify(self, settings, prop):
374        """Reconfigure if the gtk theme name changes"""
375        new_gtk_theme_name = settings.get_property(prop.name)
376        if new_gtk_theme_name != self.cur_gtk_theme_name:
377            self.cur_gtk_theme_name = new_gtk_theme_name
378            self.reconfigure()
379
380    def reconfigure(self):
381        """Update configuration for the whole application"""
382
383        if self.style_providers != []:
384            for style_provider in self.style_providers:
385                Gtk.StyleContext.remove_provider_for_screen(
386                    Gdk.Screen.get_default(),
387                    style_provider)
388        self.style_providers = []
389
390        # Force the window background to be transparent for newer versions of
391        # GTK3. We then have to fix all the widget backgrounds because the
392        # widgets theming may not render it's own background.
393        css = """
394            .terminator-terminal-window {
395                background-color: alpha(@theme_bg_color,0); }
396
397            .terminator-terminal-window .notebook.header,
398            .terminator-terminal-window notebook header {
399                background-color: @theme_bg_color; }
400
401            .terminator-terminal-window .pane-separator {
402                background-color: @theme_bg_color; }
403
404            .terminator-terminal-window .terminator-terminal-searchbar {
405                background-color: @theme_bg_color; }
406            """
407
408        # Fix several themes that put a borders, corners, or backgrounds around
409        # viewports, making the titlebar look bad.
410        css += """
411            .terminator-terminal-window GtkViewport,
412            .terminator-terminal-window viewport {
413                border-width: 0px;
414                border-radius: 0px;
415                background-color: transparent; }
416            """
417
418        # Add per profile snippets for setting the background of the HBox
419        template = """
420            .terminator-profile-%s {
421                background-color: alpha(%s, %s); }
422            """
423        profiles = self.config.base.profiles
424        for profile in list(profiles.keys()):
425            if profiles[profile]['use_theme_colors']:
426                # Create a dummy window/vte and realise it so it has correct
427                # values to read from
428                tmp_win = Gtk.Window()
429                tmp_vte = Vte.Terminal()
430                tmp_win.add(tmp_vte)
431                tmp_win.realize()
432                bgcolor = tmp_vte.get_style_context().get_background_color(Gtk.StateType.NORMAL)
433                bgcolor = "#{0:02x}{1:02x}{2:02x}".format(int(bgcolor.red  * 255),
434                                                          int(bgcolor.green * 255),
435                                                          int(bgcolor.blue * 255))
436                tmp_win.remove(tmp_vte)
437                del(tmp_vte)
438                del(tmp_win)
439            else:
440                bgcolor = Gdk.RGBA()
441                bgcolor = profiles[profile]['background_color']
442            if profiles[profile]['background_type'] == 'image':
443                backgound_image = profiles[profile]['background_image']
444            if profiles[profile]['background_type'] == 'transparent' or profiles[profile]['background_type'] == 'image':
445                bgalpha = profiles[profile]['background_darkness']
446            else:
447                bgalpha = "1"
448
449            munged_profile = "".join([c if c.isalnum() else "-" for c in profile])
450            css += template % (munged_profile, bgcolor, bgalpha)
451
452        style_provider = Gtk.CssProvider()
453        style_provider.load_from_data(css.encode('utf-8'))
454        self.style_providers.append(style_provider)
455
456        # Attempt to load some theme specific stylistic tweaks for appearances
457        usr_theme_dir = os.path.expanduser('~/.local/share/themes')
458        (head, _tail) = os.path.split(borg.__file__)
459        app_theme_dir = os.path.join(head, 'themes')
460
461        theme_name = self.gtk_settings.get_property('gtk-theme-name')
462
463        theme_part_list = ['terminator.css']
464        if self.config['extra_styling']:    # checkbox_style - needs adding to prefs
465            theme_part_list.append('terminator_styling.css')
466        for theme_part_file in theme_part_list:
467            for theme_dir in [usr_theme_dir, app_theme_dir]:
468                path_to_theme_specific_css = os.path.join(theme_dir,
469                                                          theme_name,
470                                                          'gtk-3.0/apps',
471                                                          theme_part_file)
472                if os.path.isfile(path_to_theme_specific_css):
473                    style_provider = Gtk.CssProvider()
474                    style_provider.connect('parsing-error', self.on_css_parsing_error)
475                    try:
476                        style_provider.load_from_path(path_to_theme_specific_css)
477                    except GError:
478                        # Hmmm. Should we try to provide GTK version specific files here on failure?
479                        gtk_version_string = '.'.join([str(Gtk.get_major_version()),
480                                                       str(Gtk.get_minor_version()),
481                                                       str(Gtk.get_micro_version())])
482                        err('Error(s) loading css from %s into Gtk %s' % (path_to_theme_specific_css,
483                                                                          gtk_version_string))
484                    self.style_providers.append(style_provider)
485                    break
486
487        # Size the GtkPaned splitter handle size.
488        css = ""
489        if self.config['handle_size'] in range(0, 21):
490            css += """
491                .terminator-terminal-window separator {
492                    min-height: %spx;
493                    min-width: %spx;
494                }
495                """ % (self.config['handle_size'],self.config['handle_size'])
496        style_provider = Gtk.CssProvider()
497        style_provider.load_from_data(css.encode('utf-8'))
498        self.style_providers.append(style_provider)
499
500        # Apply the providers, incrementing priority so they don't cancel out
501        # each other
502        for idx in range(0, len(self.style_providers)):
503            Gtk.StyleContext.add_provider_for_screen(
504                Gdk.Screen.get_default(),
505                self.style_providers[idx],
506                Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION+idx)
507
508        # Cause all the terminals to reconfigure
509        for terminal in self.terminals:
510            terminal.reconfigure()
511
512        # Reparse our keybindings
513        self.keybindings.configure(self.config['keybindings'])
514
515        # Update tab position if appropriate
516        maker = Factory()
517        for window in self.windows:
518            child = window.get_child()
519            if maker.isinstance(child, 'Notebook'):
520                child.configure()
521
522    def on_css_parsing_error(self, provider, section, error, user_data=None):
523        """Report CSS parsing issues"""
524        file_path = section.get_file().get_path()
525        line_no = section.get_end_line() +1
526        col_no = section.get_end_position() + 1
527        err('%s, at line %d, column %d, of file %s' % (error.message,
528                                                       line_no, col_no,
529                                                       file_path))
530
531    def create_group(self, name):
532        """Create a new group"""
533        if name not in self.groups:
534            dbg('Terminator::create_group: registering group %s' % name)
535            self.groups.append(name)
536
537    def closegroupedterms(self, group):
538        """Close all terminals in a group"""
539        for terminal in self.terminals[:]:
540            if terminal.group == group:
541                terminal.close()
542
543    def group_hoover(self):
544        """Clean out unused groups"""
545
546        if self.config['autoclean_groups']:
547            inuse = []
548            todestroy = []
549
550            for terminal in self.terminals:
551                if terminal.group:
552                    if not terminal.group in inuse:
553                        inuse.append(terminal.group)
554
555            for group in self.groups:
556                if not group in inuse:
557                    todestroy.append(group)
558
559            dbg('Terminator::group_hoover: %d groups, hoovering %d' %
560                    (len(self.groups), len(todestroy)))
561            for group in todestroy:
562                self.groups.remove(group)
563
564    def group_emit(self, terminal, group, type, event):
565        """Emit to each terminal in a group"""
566        dbg('Terminator::group_emit: emitting a keystroke for group %s' %
567                group)
568        for term in self.terminals:
569            if term != terminal and term.group == group:
570                term.vte.emit(type, eventkey2gdkevent(event))
571
572    def all_emit(self, terminal, type, event):
573        """Emit to all terminals"""
574        for term in self.terminals:
575            if term != terminal:
576                term.vte.emit(type, eventkey2gdkevent(event))
577
578    def do_enumerate(self, widget, pad):
579        """Insert the number of each terminal in a group, into that terminal"""
580        if pad:
581            numstr = '%0'+str(len(str(len(self.terminals))))+'d'
582        else:
583            numstr = '%d'
584
585        terminals = []
586        for window in self.windows:
587            containers, win_terminals = enumerate_descendants(window)
588            terminals.extend(win_terminals)
589
590        for term in self.get_target_terms(widget):
591            idx = terminals.index(term)
592            term.feed(numstr % (idx + 1))
593
594    def get_sibling_terms(self, widget):
595        termset = []
596        for term in self.terminals:
597            if term.group == widget.group:
598                termset.append(term)
599        return(termset)
600
601    def get_target_terms(self, widget):
602        """Get the terminals we should currently be broadcasting to"""
603        if self.groupsend == self.groupsend_type['all']:
604            return(self.terminals)
605        elif self.groupsend == self.groupsend_type['group']:
606            if widget.group != None:
607                return(self.get_sibling_terms(widget))
608        return([widget])
609
610    def get_focussed_terminal(self):
611        """iterate over all the terminals to find which, if any, has focus"""
612        for terminal in self.terminals:
613            if terminal.has_focus():
614                return(terminal)
615        return(None)
616
617    def focus_changed(self, widget):
618        """We just moved focus to a new terminal"""
619        for terminal in self.terminals:
620            terminal.titlebar.update(widget)
621        return
622
623    def focus_left(self, widget):
624        self.last_focused_term=widget
625
626    def describe_layout(self):
627        """Describe our current layout"""
628        layout = {}
629        count = 0
630        for window in self.windows:
631            parent = ''
632            count = window.describe_layout(count, parent, layout, 0)
633
634        return(layout)
635
636    def zoom_in_all(self):
637        for term in self.terminals:
638            term.zoom_in()
639
640    def zoom_out_all(self):
641        for term in self.terminals:
642            term.zoom_out()
643
644    def zoom_orig_all(self):
645        for term in self.terminals:
646            term.zoom_orig()
647# vim: set expandtab ts=4 sw=4:
648