1#!/usr/local/bin/python3.8 2 3# Todo: 4# - TextTag.invisible does not work nicely with scrollheight, find out why 5# - (Sometimes scrollbars think there is more or less to scroll than there actually is after 6# showing/hiding entries in page_log.py) 7# - Add insert button to "simple types" inspect dialog ? is there actual use for these types 8# inserted as results ? 9# - Load all enabled log categories and window height from gsettings 10# - Make CommandLine entry & history work more like a normal terminal 11# - When navigating through history and modifying a line 12# - When pressing ctrl + r, search history 13# - auto-completion ? 14 15import os 16import signal 17import sys 18import dbus 19import dbus.service 20from dbus.mainloop.glib import DBusGMainLoop 21import pyinotify 22import gi 23gi.require_version('Gtk', '3.0') 24from gi.repository import Gio, Gtk, GObject, Gdk, GLib 25from setproctitle import setproctitle 26 27import pageutils 28from lookingglass_proxy import LookingGlassProxy 29 30signal.signal(signal.SIGINT, signal.SIG_DFL) 31 32MELANGE_DBUS_NAME = "org.Cinnamon.Melange" 33MELANGE_DBUS_PATH = "/org/Cinnamon/Melange" 34 35class MenuButton(Gtk.Button): 36 def __init__(self, text): 37 Gtk.Button.__init__(self, text) 38 self.menu = None 39 self.connect("clicked", self.on_clicked) 40 41 def set_popup(self, menu): 42 self.menu = menu 43 44 def on_clicked(self, widget): 45 x, y, w, h = self.get_screen_coordinates() 46 self.menu.popup(None, None, lambda menu, data: (x, y+h, True), None, 1, 0) 47 48 def get_screen_coordinates(self): 49 parent = self.get_parent_window() 50 x, y = parent.get_root_origin() 51 w = parent.get_width() 52 h = parent.get_height() 53 extents = parent.get_frame_extents() 54 allocation = self.get_allocation() 55 return (x + (extents.width-w)//2 + allocation.x, 56 y + (extents.height-h)-(extents.width-w)//2 + allocation.y, 57 allocation.width, 58 allocation.height) 59 60class CommandLine(Gtk.Entry): 61 def __init__(self, exec_cb): 62 Gtk.Entry.__init__(self) 63 self.exec_cb = exec_cb 64 self.settings = Gio.Settings.new("org.cinnamon") 65 self.history = self.settings.get_strv("looking-glass-history") 66 self.history_position = -1 67 self.last_text = "" 68 self.connect('key-press-event', self.on_key_press) 69 self.connect("populate-popup", self.populate_popup) 70 71 def populate_popup(self, view, menu): 72 menu.append(Gtk.SeparatorMenuItem()) 73 clear = Gtk.MenuItem("Clear History") 74 clear.connect('activate', self.history_clear) 75 menu.append(clear) 76 menu.show_all() 77 return False 78 79 def on_key_press(self, widget, event): 80 if event.keyval == Gdk.KEY_Up: 81 self.history_prev() 82 return True 83 if event.keyval == Gdk.KEY_Down: 84 self.history_next() 85 return True 86 if event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter: 87 self.execute() 88 return True 89 90 def history_clear(self, menu_item): 91 self.history = [] 92 self.history_position = -1 93 self.last_text = "" 94 self.settings.set_strv("looking-glass-history", self.history) 95 96 def history_prev(self): 97 num = len(self.history) 98 if self.history_position == 0 or num == 0: 99 return 100 if self.history_position == -1: 101 self.history_position = num - 1 102 self.last_text = self.get_text() 103 else: 104 self.history_position -= 1 105 self.set_text(self.history[self.history_position]) 106 self.select_region(-1, -1) 107 108 def history_next(self): 109 if self.history_position == -1: 110 return 111 num = len(self.history) 112 if self.history_position == num-1: 113 self.history_position = -1 114 self.set_text(self.last_text) 115 else: 116 self.history_position += 1 117 self.set_text(self.history[self.history_position]) 118 self.select_region(-1, -1) 119 120 def execute(self): 121 self.history_position = -1 122 command = self.get_text() 123 if command != "": 124 num = len(self.history) 125 if num == 0 or self.history[num-1] != command: 126 self.history.append(command) 127 self.set_text("") 128 self.settings.set_strv("looking-glass-history", self.history) 129 130 self.exec_cb(command) 131 132 133class NewLogDialog(Gtk.Dialog): 134 def __init__(self, parent): 135 Gtk.Dialog.__init__(self, "Add a new file watcher", parent, 0, 136 (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, 137 Gtk.STOCK_OK, Gtk.ResponseType.OK)) 138 139 self.set_default_size(150, 100) 140 141 label = Gtk.Label("") 142 label.set_markup("<span size='large'>Add File Watch:</span>\n\n" + 143 "Please select a file to watch and a name for the tab\n") 144 145 box = self.get_content_area() 146 box.add(label) 147 148 self.store = Gtk.ListStore(str, str) 149 self.store.append(["glass.log", "~/.cinnamon/glass.log"]) 150 self.store.append(["custom", "<Select file>"]) 151 152 self.combo = Gtk.ComboBox.new_with_model(self.store) 153 self.combo.connect("changed", self.on_combo_changed) 154 renderer_text = Gtk.CellRendererText() 155 self.combo.pack_start(renderer_text, True) 156 self.combo.add_attribute(renderer_text, "text", 1) 157 158 table = Gtk.Table(2, 2, False) 159 table.attach(Gtk.Label(label="File: ", halign=Gtk.Align.START), 0, 1, 0, 1) 160 table.attach(self.combo, 1, 2, 0, 1) 161 table.attach(Gtk.Label(label="Name: ", halign=Gtk.Align.START), 0, 1, 1, 2) 162 self.entry = Gtk.Entry() 163 table.attach(self.entry, 1, 2, 1, 2) 164 165 self.filename = None 166 box.add(table) 167 self.show_all() 168 169 def on_combo_changed(self, combo): 170 tree_iter = combo.get_active_iter() 171 if tree_iter is not None: 172 model = combo.get_model() 173 name, self.filename = model[tree_iter][:2] 174 self.entry.set_text(name) 175 if name == "custom": 176 new_file = self.select_file() 177 if new_file is not None: 178 combo.set_active_iter(self.store.insert(1, ["user", new_file])) 179 else: 180 combo.set_active(-1) 181 return False 182 183 def is_valid(self): 184 return (self.entry.get_text() != "" and 185 self.filename is not None and 186 os.path.isfile(os.path.expanduser(self.filename))) 187 188 def get_file(self): 189 return os.path.expanduser(self.filename) 190 191 def get_text(self): 192 return self.entry.get_text() 193 194 def select_file(self): 195 dialog = Gtk.FileChooserDialog("Please select a log file", self, 196 Gtk.FileChooserAction.OPEN, 197 (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, 198 Gtk.STOCK_OPEN, Gtk.ResponseType.OK)) 199 200 filter_text = Gtk.FileFilter() 201 filter_text.set_name("Text files") 202 filter_text.add_mime_type("text/plain") 203 dialog.add_filter(filter_text) 204 205 filter_any = Gtk.FileFilter() 206 filter_any.set_name("Any files") 207 filter_any.add_pattern("*") 208 dialog.add_filter(filter_any) 209 210 response = dialog.run() 211 result = None 212 if response == Gtk.ResponseType.OK: 213 result = dialog.get_filename() 214 dialog.destroy() 215 216 return result 217 218class FileWatchHandler(pyinotify.ProcessEvent): 219 def my_init(self, view): 220 self.view = view 221 222 def process_IN_CLOSE_WRITE(self, event): 223 self.view.get_updates() 224 225 def process_IN_CREATE(self, event): 226 self.view.get_updates() 227 228 def process_IN_DELETE(self, event): 229 self.view.get_updates() 230 231 def process_IN_MODIFY(self, event): 232 self.view.get_updates() 233 234class FileWatcherView(Gtk.ScrolledWindow): 235 def __init__(self, filename): 236 Gtk.ScrolledWindow.__init__(self) 237 238 self.filename = filename 239 self.changed = 0 240 self.update_id = 0 241 self.set_shadow_type(Gtk.ShadowType.ETCHED_IN) 242 self.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) 243 244 self.textview = Gtk.TextView() 245 self.textview.set_editable(False) 246 self.add(self.textview) 247 248 self.textbuffer = self.textview.get_buffer() 249 250 self.show_all() 251 self.get_updates() 252 253 handler = FileWatchHandler(view=self) 254 watch_manager = pyinotify.WatchManager() 255 self.notifier = pyinotify.ThreadedNotifier(watch_manager, handler) 256 watch_manager.add_watch(filename, (pyinotify.IN_CLOSE_WRITE | 257 pyinotify.IN_CREATE | 258 pyinotify.IN_DELETE | 259 pyinotify.IN_MODIFY)) 260 self.notifier.start() 261 self.connect("destroy", self.on_destroy) 262 self.connect("size-allocate", self.on_size_changed) 263 264 def on_destroy(self, widget): 265 if self.notifier: 266 self.notifier.stop() 267 self.notifier = None 268 269 def on_size_changed(self, widget, bla): 270 if self.changed > 0: 271 end_iter = self.textbuffer.get_end_iter() 272 self.textview.scroll_to_iter(end_iter, 0, False, 0, 0) 273 self.changed -= 1 274 275 def get_updates(self): 276 # only update 2 times per second max 277 # without this rate limiting, certain file modifications can cause 278 # a crash at Gtk.TextBuffer.set_text() 279 if self.update_id == 0: 280 self.update_id = GLib.timeout_add(500, self.update) 281 282 def update(self): 283 self.changed = 2 # on_size_changed will be called twice, but only the second time is final 284 self.textbuffer.set_text(open(self.filename, 'r').read()) 285 self.update_id = 0 286 return False 287 288class ClosableTabLabel(Gtk.Box): 289 __gsignals__ = { 290 "close-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()), 291 } 292 def __init__(self, label_text): 293 Gtk.Box.__init__(self) 294 self.set_orientation(Gtk.Orientation.HORIZONTAL) 295 self.set_spacing(5) 296 297 label = Gtk.Label(label_text) 298 self.pack_start(label, True, True, 0) 299 300 button = Gtk.Button() 301 button.set_relief(Gtk.ReliefStyle.NONE) 302 button.set_focus_on_click(False) 303 button.add(Gtk.Image.new_from_stock(Gtk.STOCK_CLOSE, Gtk.IconSize.MENU)) 304 button.connect("clicked", self.button_clicked) 305 self.pack_start(button, False, False, 0) 306 307 self.show_all() 308 309 def button_clicked(self, button, data=None): 310 self.emit("close-clicked") 311 312class MelangeApp(dbus.service.Object): 313 def __init__(self): 314 self.lg_proxy = LookingGlassProxy() 315 # The status label is shown iff we are not okay 316 self.lg_proxy.add_status_change_callback(lambda x: self.status_label.set_visible(not x)) 317 318 self.window = None 319 self._minimized = False 320 self.run() 321 322 dbus.service.Object.__init__(self, dbus.SessionBus(), MELANGE_DBUS_PATH, MELANGE_DBUS_NAME) 323 324 @dbus.service.method(MELANGE_DBUS_NAME, in_signature='', out_signature='') 325 def show(self): 326 if self.window.get_visible(): 327 if self._minimized: 328 self.window.present() 329 else: 330 self.window.hide() 331 else: 332 self.show_and_focus() 333 334 @dbus.service.method(MELANGE_DBUS_NAME, in_signature='', out_signature='') 335 def hide(self): 336 self.window.hide() 337 338 @dbus.service.method(MELANGE_DBUS_NAME, in_signature='', out_signature='b') 339 def getVisible(self): 340 return self.window.get_visible() 341 342 @dbus.service.method(MELANGE_DBUS_NAME, in_signature='', out_signature='') 343 def doInspect(self): 344 if self.lg_proxy: 345 self.lg_proxy.StartInspector() 346 self.window.hide() 347 348 def show_and_focus(self): 349 self.window.show_all() 350 self.lg_proxy.refresh_status() 351 self.command_line.grab_focus() 352 353 def run(self): 354 self.window = Gtk.Window(type=Gtk.WindowType.TOPLEVEL) 355 self.window.set_title("Melange") 356 self.window.set_icon_name("system-search") 357 self.window.set_default_size(1000, 400) 358 self.window.set_position(Gtk.WindowPosition.MOUSE) 359 360 # I can't think of a way to reliably detect if the window 361 # is active to determine if we need to present or hide 362 # in show(). Since the window briefly loses focus during 363 # shortcut press we'd be unable to detect it at that time. 364 # Keeping the window on top ensures the window is never 365 # obscured so we can just hide if visible. 366 self.window.set_keep_above(True) 367 368 self.window.connect("delete_event", self.on_delete) 369 self.window.connect("key-press-event", self.on_key_press) 370 self._minimized = False 371 self.window.connect("window-state-event", self.on_window_state) 372 373 num_rows = 3 374 num_columns = 6 375 table = Gtk.Table(n_rows=num_rows, n_columns=num_columns, homogeneous=False) 376 table.set_margin_start(6) 377 table.set_margin_end(6) 378 table.set_margin_top(6) 379 table.set_margin_bottom(6) 380 self.window.add(table) 381 382 self.notebook = Gtk.Notebook() 383 self.notebook.set_tab_pos(Gtk.PositionType.BOTTOM) 384 self.notebook.show() 385 self.notebook.set_show_border(True) 386 self.notebook.set_show_tabs(True) 387 388 label = Gtk.Label(label="Melange") 389 label.set_markup("<u>Melange - Cinnamon Debugger</u> ") 390 label.show() 391 self.notebook.set_action_widget(label, Gtk.PackType.END) 392 393 self.pages = {} 394 self.custom_pages = {} 395 self.create_page("Results", "results") 396 self.create_page("Inspect", "inspect") 397 self.create_page("Windows", "windows") 398 self.create_page("Extensions", "extensions") 399 self.create_page("Log", "log") 400 401 table.attach(self.notebook, 0, num_columns, 0, 1) 402 403 column = 0 404 picker_button = pageutils.ImageButton("color-select-symbolic") 405 picker_button.set_tooltip_text("Select an actor to inspect") 406 picker_button.connect("clicked", self.on_picker_clicked) 407 table.attach(picker_button, column, column+1, 1, 2, 0, 0, 2) 408 column += 1 409 410 full_gc = pageutils.ImageButton("user-trash-full-symbolic") 411 full_gc.set_tooltip_text("Invoke garbage collection") 412 # ignore signal arg 413 full_gc.connect('clicked', lambda source: self.lg_proxy.FullGc()) 414 table.attach(full_gc, column, column+1, 1, 2, 0, 0, 2) 415 column += 1 416 417 self.command_line = CommandLine(self.lg_proxy.Eval) 418 self.command_line.set_tooltip_text("Evaluate javascript") 419 table.attach(self.command_line, 420 column, 421 column + 1, 422 1, 423 2, 424 Gtk.AttachOptions.EXPAND | Gtk.AttachOptions.FILL, 425 0, 426 3, 427 2) 428 column += 1 429 430 self.status_label = Gtk.Label(label="Status") 431 self.status_label.set_markup(" <span foreground='red'>[ Cinnamon is OFFLINE! ]</span> ") 432 self.status_label.set_tooltip_text("The connection to cinnamon is broken") 433 self.status_label.set_no_show_all(True) 434 table.attach(self.status_label, column, column+1, 1, 2, 0, 0, 1) 435 column += 1 436 437 box = Gtk.HBox() 438 settings = Gio.Settings(schema="org.cinnamon.desktop.keybindings") 439 arr = settings.get_strv("looking-glass-keybinding") 440 if len(arr) > 0: 441 # only the first mapped keybinding 442 [accel_key, mask] = Gtk.accelerator_parse(arr[0]) 443 if accel_key == 0 and mask == 0: 444 # failed to parse, fallback to plain accel string 445 label = Gtk.Label(label=arr[0]) 446 else: 447 label = Gtk.Label(label=Gtk.accelerator_get_label(accel_key, mask)) 448 label.set_tooltip_text("Toggle shortcut") 449 box.pack_start(label, False, False, 3) 450 451 action_button = self.create_action_button() 452 box.pack_start(action_button, False, False, 3) 453 454 table.attach(box, column, column+1, 1, 2, 0, 0, 1) 455 456 self.activate_page("results") 457 self.status_label.hide() 458 self.window.set_focus(self.command_line) 459 460 def create_menu_item(self, text, callback): 461 item = Gtk.MenuItem(label=text) 462 item.connect("activate", callback) 463 return item 464 465 def create_action_button(self): 466 restart_func = lambda junk: os.system("nohup cinnamon --replace > /dev/null 2>&1 &") 467 crash_func = lambda junk: self.lg_proxy.Eval("global.segfault()") 468 469 menu = Gtk.Menu() 470 menu.append(self.create_menu_item('Add File Watcher', self.on_add_file_watcher)) 471 menu.append(Gtk.SeparatorMenuItem()) 472 menu.append(self.create_menu_item('Restart Cinnamon', restart_func)) 473 menu.append(self.create_menu_item('Crash Cinnamon', crash_func)) 474 menu.append(self.create_menu_item('Reset Cinnamon Settings', self.on_reset_clicked)) 475 menu.append(Gtk.SeparatorMenuItem()) 476 menu.append(self.create_menu_item('About Melange', self.on_about_clicked)) 477 menu.append(self.create_menu_item('Quit', self.on_delete)) 478 menu.show_all() 479 480 button = Gtk.MenuButton(label="Actions \u25BE") 481 button.set_popup(menu) 482 return button 483 484 def on_add_file_watcher(self, menu_item): 485 dialog = NewLogDialog(self.window) 486 response = dialog.run() 487 488 if response == Gtk.ResponseType.OK and dialog.is_valid(): 489 label = ClosableTabLabel(dialog.get_text()) 490 content = FileWatcherView(dialog.get_file()) 491 content.show() 492 label.connect("close-clicked", self.on_close_tab, content) 493 self.custom_pages[label] = content 494 self.notebook.append_page(content, label) 495 self.notebook.set_current_page(self.notebook.get_n_pages()-1) 496 497 dialog.destroy() 498 499 def on_close_tab(self, label, content): 500 self.notebook.remove_page(self.notebook.page_num(content)) 501 content.destroy() 502 del self.custom_pages[label] 503 504 def on_about_clicked(self, menu_item): 505 dialog = Gtk.MessageDialog(self.window, 0, 506 Gtk.MessageType.QUESTION, Gtk.ButtonsType.CLOSE) 507 508 dialog.set_title("About Melange") 509 dialog.set_markup("""\ 510<b>Melange</b> is a GTK3 alternative to the built-in javascript debugger <i>Looking Glass</i> 511 512Pressing <i>Escape</i> while Melange has focus will hide the window. 513If you want to exit Melange, use ALT+F4 or the <u>Actions</u> menu button. 514 515If you defined a hotkey for Melange, pressing it while Melange is visible it will be hidden.""") 516 517 dialog.run() 518 dialog.destroy() 519 520 def on_reset_clicked(self, menu_item): 521 dialog = Gtk.MessageDialog(self.window, 0, 522 Gtk.MessageType.WARNING, Gtk.ButtonsType.YES_NO, 523 "Reset all cinnamon settings to default?") 524 dialog.set_title("Warning: Trying to reset all cinnamon settings!") 525 526 response = dialog.run() 527 dialog.destroy() 528 if response == Gtk.ResponseType.YES: 529 os.system("gsettings reset-recursively org.cinnamon &") 530 531 def on_key_press(self, widget, event=None): 532 if event.keyval == Gdk.KEY_Escape: 533 self.window.hide() 534 535 def on_delete(self, widget=None, event=None): 536 tmp_pages = self.custom_pages.copy() 537 for label, content in tmp_pages.items(): 538 self.on_close_tab(label, content) 539 Gtk.main_quit() 540 return False 541 542 def on_window_state(self, widget, event): 543 if event.new_window_state & Gdk.WindowState.ICONIFIED: 544 self._minimized = True 545 else: 546 self._minimized = False 547 548 def on_picker_clicked(self, widget): 549 self.lg_proxy.StartInspector() 550 self.window.hide() 551 552 def create_dummy_page(self, text, description): 553 label = Gtk.Label(label=text) 554 self.notebook.append_page(Gtk.Label(label=description), label) 555 556 def create_page(self, text, module_name): 557 module = __import__("page_%s" % module_name) 558 module.lg_proxy = self.lg_proxy 559 module.melangeApp = self 560 label = Gtk.Label(label=text) 561 page = module.ModulePage(self) 562 self.pages[module_name] = page 563 self.notebook.append_page(page, label) 564 565 def activate_page(self, module_name): 566 page = self.notebook.page_num(self.pages[module_name]) 567 self.notebook.set_current_page(page) 568 569def main(): 570 setproctitle("cinnamon-looking-glass") 571 DBusGMainLoop(set_as_default=True) 572 573 session_bus = dbus.SessionBus() 574 request = session_bus.request_name(MELANGE_DBUS_NAME, dbus.bus.NAME_FLAG_DO_NOT_QUEUE) 575 if request != dbus.bus.REQUEST_NAME_REPLY_EXISTS: 576 app = MelangeApp() 577 else: 578 dbus_obj = session_bus.get_object(MELANGE_DBUS_NAME, MELANGE_DBUS_PATH) 579 app = dbus.Interface(dbus_obj, MELANGE_DBUS_NAME) 580 581 daemon = len(sys.argv) == 2 and sys.argv[1] == "daemon" 582 inspect = len(sys.argv) == 2 and sys.argv[1] == "inspect" 583 584 if inspect: 585 app.doInspect() 586 elif not daemon: 587 app.show() 588 589 Gtk.main() 590 591if __name__ == "__main__": 592 main() 593