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