1#!/usr/bin/python3 2# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- 3# MenuLibre - Advanced fd.o Compliant Menu Editor 4# Copyright (C) 2012-2021 Sean Davis <sean@bluesabre.org> 5# Copyright (C) 2016-2018 OmegaPhil <OmegaPhil@startmail.com> 6# 7# This program is free software: you can redistribute it and/or modify it 8# under the terms of the GNU General Public License version 3, as published 9# by the Free Software Foundation. 10# 11# This program is distributed in the hope that it will be useful, but 12# WITHOUT ANY WARRANTY; without even the implied warranties of 13# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR 14# PURPOSE. See the GNU General Public License for more details. 15# 16# You should have received a copy of the GNU General Public License along 17# with this program. If not, see <http://www.gnu.org/licenses/>. 18 19import os 20import re 21import shlex 22import sys 23 24import subprocess 25import tempfile 26 27from locale import gettext as _ 28 29from gi import require_version 30require_version('Gtk', '3.0') 31from gi.repository import Gio, GLib, GObject, Gtk, Gdk, GdkPixbuf 32 33from . import MenulibreStackSwitcher, MenulibreIconSelection 34from . import MenulibreTreeview, MenulibreHistory, Dialogs 35from . import MenulibreXdg, util, MenulibreLog 36from . import MenuEditor 37from .util import MenuItemTypes, check_keypress, getBasename, getRelatedKeys 38from .util import escapeText, getCurrentDesktop, find_program 39import menulibre_lib 40 41import logging 42 43logger = logging.getLogger('menulibre') 44 45session = os.getenv("DESKTOP_SESSION", "") 46root = os.getuid() == 0 47 48current_desktop = getCurrentDesktop() 49 50category_descriptions = { 51 # Translators: Launcher category description 52 'AudioVideo': _('Multimedia'), 53 # Translators: Launcher category description 54 'Development': _('Development'), 55 # Translators: Launcher category description 56 'Education': _('Education'), 57 # Translators: Launcher category description 58 'Game': _('Games'), 59 # Translators: Launcher category description 60 'Graphics': _('Graphics'), 61 # Translators: Launcher category description 62 'Network': _('Internet'), 63 # Translators: Launcher category description 64 'Office': _('Office'), 65 # Translators: Launcher category description 66 'Settings': _('Settings'), 67 # Translators: Launcher category description 68 'System': _('System'), 69 # Translators: Launcher category description 70 'Utility': _('Accessories'), 71 # Translators: Launcher category description 72 'WINE': _('WINE'), 73 # Translators: Launcher category description 74 'DesktopSettings': _('Desktop configuration'), 75 # Translators: Launcher category description 76 'PersonalSettings': _('User configuration'), 77 # Translators: Launcher category description 78 'HardwareSettings': _('Hardware configuration'), 79 # Translators: Launcher category description 80 'GNOME': _('GNOME application'), 81 # Translators: Launcher category description 82 'GTK': _('GTK+ application'), 83 # Translators: Launcher category description 84 'X-GNOME-PersonalSettings': _('GNOME user configuration'), 85 # Translators: Launcher category description 86 'X-GNOME-HardwareSettings': _('GNOME hardware configuration'), 87 # Translators: Launcher category description 88 'X-GNOME-SystemSettings': _('GNOME system configuration'), 89 # Translators: Launcher category description 90 'X-GNOME-Settings-Panel': _('GNOME system configuration'), 91 # Translators: Launcher category description 92 'XFCE': _('Xfce menu item'), 93 # Translators: Launcher category description 94 'X-XFCE': _('Xfce menu item'), 95 # Translators: Launcher category description 96 'X-Xfce-Toplevel': _('Xfce toplevel menu item'), 97 # Translators: Launcher category description 98 'X-XFCE-PersonalSettings': _('Xfce user configuration'), 99 # Translators: Launcher category description 100 'X-XFCE-HardwareSettings': _('Xfce hardware configuration'), 101 # Translators: Launcher category description 102 'X-XFCE-SettingsDialog': _('Xfce system configuration'), 103 # Translators: Launcher category description 104 'X-XFCE-SystemSettings': _('Xfce system configuration'), 105} 106 107# Sourced from https://specifications.freedesktop.org/menu-spec/latest/apa.html 108# and https://specifications.freedesktop.org/menu-spec/latest/apas02.html , 109# in addition category group names have been added to the list where launchers 110# typically use them (e.g. plain 'Utility' to add to Accessories), to allow the 111# user to restore default categories that have been manually removed 112category_groups = { 113 'Utility': ( 114 'Accessibility', 'Archiving', 'Calculator', 'Clock', 115 'Compression', 'FileTools', 'TextEditor', 'TextTools', 'Utility' 116 ), 117 'Development': ( 118 'Building', 'Debugger', 'Development', 'IDE', 'GUIDesigner', 119 'Profiling', 'RevisionControl', 'Translation', 'WebDevelopment' 120 ), 121 'Education': ( 122 'Art', 'ArtificialIntelligence', 'Astronomy', 'Biology', 'Chemistry', 123 'ComputerScience', 'Construction', 'DataVisualization', 'Economy', 124 'Education', 'Electricity', 'Geography', 'Geology', 'Geoscience', 125 'History', 'Humanities', 'ImageProcessing', 'Languages', 'Literature', 126 'Maps', 'Math', 'MedicalSoftware', 'Music', 'NumericalAnalysis', 127 'ParallelComputing', 'Physics', 'Robotics', 'Spirituality', 'Sports' 128 ), 129 'Game': ( 130 'ActionGame', 'AdventureGame', 'ArcadeGame', 'BoardGame', 131 'BlocksGame', 'CardGame', 'Emulator', 'Game', 'KidsGame', 'LogicGame', 132 'RolePlaying', 'Shooter', 'Simulation', 'SportsGame', 133 'StrategyGame' 134 ), 135 'Graphics': ( 136 '2DGraphics', '3DGraphics', 'Graphics', 'OCR', 'Photography', 137 'Publishing', 'RasterGraphics', 'Scanning', 'VectorGraphics', 'Viewer' 138 ), 139 'Network': ( 140 'Chat', 'Dialup', 'Feed', 'FileTransfer', 'HamRadio', 141 'InstantMessaging', 'IRCClient', 'Monitor', 'News', 'Network', 'P2P', 142 'RemoteAccess', 'Telephony', 'TelephonyTools', 'WebBrowser', 143 'WebDevelopment' 144 ), 145 'AudioVideo': ( 146 'Audio', 'AudioVideoEditing', 'DiscBurning', 'Midi', 'Mixer', 'Player', 147 'Recorder', 'Sequencer', 'Tuner', 'TV', 'Video' 148 ), 149 'Office': ( 150 'Calendar', 'ContactManagement', 'Database', 'Dictionary', 151 'Chart', 'Email', 'Finance', 'FlowChart', 'Office', 'PDA', 152 'Photography', 'ProjectManagement', 'Presentation', 'Publishing', 153 'Spreadsheet', 'WordProcessor' 154 ), 155 # Translators: "Other" category group. This item is only displayed for 156 # unknown or non-standard categories. 157 _('Other'): ( 158 'Amusement', 'ConsoleOnly', 'Core', 'Documentation', 159 'Electronics', 'Engineering', 'GNOME', 'GTK', 'Java', 'KDE', 160 'Motif', 'Qt', 'XFCE' 161 ), 162 'Settings': ( 163 'Accessibility', 'DesktopSettings', 'HardwareSettings', 164 'PackageManager', 'Printing', 'Security', 'Settings' 165 ), 166 'System': ( 167 'Emulator', 'FileManager', 'Filesystem', 'FileTools', 'Monitor', 168 'Security', 'System', 'TerminalEmulator' 169 ) 170} 171 172# DE-specific categories 173if util.getDefaultMenuPrefix() == 'xfce-': 174 category_groups['Xfce'] = ( 175 'X-XFCE', 'X-Xfce-Toplevel', 'X-XFCE-PersonalSettings', 'X-XFCE-HardwareSettings', 176 'X-XFCE-SettingsDialog', 'X-XFCE-SystemSettings' 177 ) 178elif util.getDefaultMenuPrefix() == 'gnome-': 179 category_groups['GNOME'] = ( 180 'X-GNOME-NetworkSettings', 'X-GNOME-PersonalSettings', 'X-GNOME-Settings-Panel', 181 'X-GNOME-Utilities' 182 ) 183 184# Create a reverse-lookup 185category_lookup = dict() 186for key in list(category_groups.keys()): 187 for item in category_groups[key]: 188 category_lookup[item] = key 189 190 191def lookup_category_description(spec_name): 192 """Return a valid description string for a spec entry.""" 193 # if spec_name.startswith("menulibre-"): 194 # return _("User Category") 195 try: 196 return category_descriptions[spec_name] 197 except KeyError: 198 pass 199 200 try: 201 group = category_lookup[spec_name] 202 return lookup_category_description(group) 203 except KeyError: 204 pass 205 206 # Regex <3 Split CamelCase into separate words. 207 try: 208 description = re.sub('(?!^)([A-Z]+)', r' \1', spec_name) 209 except TypeError: 210 # Translators: "Other" category group. This item is only displayed for 211 # unknown or non-standard categories. 212 description = _("Other") 213 return description 214 215 216class MenulibreWindow(Gtk.ApplicationWindow): 217 """The Menulibre application window.""" 218 219 __gsignals__ = { 220 'about': (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, 221 (GObject.TYPE_BOOLEAN,)), 222 'help': (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, 223 (GObject.TYPE_BOOLEAN,)), 224 'quit': (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE, 225 (GObject.TYPE_BOOLEAN,)) 226 } 227 228 def __init__(self, app, headerbar_pref=True): 229 """Initialize the Menulibre application.""" 230 self.root_lockout() 231 232 # Initialize the GtkBuilder to get our widgets from Glade. 233 builder = menulibre_lib.get_builder('MenulibreWindow') 234 235 # Set up History 236 self.history = MenulibreHistory.History() 237 self.history.connect('undo-changed', self.on_undo_changed) 238 self.history.connect('redo-changed', self.on_redo_changed) 239 self.history.connect('revert-changed', self.on_revert_changed) 240 241 # Steal the window contents for the GtkApplication. 242 self.configure_application_window(builder, app) 243 244 self.values = dict() 245 246 # Set up the actions, menubar, and toolbar 247 self.configure_application_actions(builder) 248 self.configure_application_menubar(builder) 249 250 if headerbar_pref: 251 self.configure_headerbar(builder) 252 else: 253 self.configure_application_toolbar(builder) 254 255 self.configure_css() 256 257 # Set up the application editor 258 self.configure_application_editor(builder) 259 260 # Set up the application browser 261 self.configure_application_treeview(builder) 262 263 # Determining paths of bad desktop files GMenu can't load - if some are 264 # detected, alerting user via InfoBar 265 self.bad_desktop_files = util.determine_bad_desktop_files() 266 if self.bad_desktop_files: 267 self.configure_application_bad_desktop_files_infobar(builder) 268 269 def root_lockout(self): 270 if root: 271 # Translators: This error is displayed when the application is run 272 # as a root user. The application exits once the dialog is 273 # dismissed. 274 primary = _("MenuLibre cannot be run as root.") 275 276 docs_url = "https://github.com/bluesabre/menulibre/wiki/Frequently-Asked-Questions" 277 278 # Translators: This link goes to the online documentation with more 279 # information. 280 secondary = _("Please see the " 281 "<a href='%s'>online documentation</a> " 282 "for more information.") % docs_url 283 284 dialog = Gtk.MessageDialog(None, 0, Gtk.MessageType.ERROR, 285 Gtk.ButtonsType.CLOSE, primary) 286 dialog.format_secondary_markup(secondary) 287 dialog.run() 288 sys.exit(1) 289 290 def menu_load_failure(self): 291 primary = _("MenuLibre failed to load.") 292 293 docs_url = "https://github.com/bluesabre/menulibre/wiki/Frequently-Asked-Questions" 294 295 # Translators: This link goes to the online documentation with more 296 # information. 297 secondary = _("The default menu could not be found. Please see the " 298 "<a href='%s'>online documentation</a> " 299 "for more information.") % docs_url 300 301 secondary += "\n\n<big><b>%s</b></big>" % _("Diagnostics") 302 303 diagnostics = util.getMenuDiagnostics() 304 for k, v in diagnostics.items(): 305 secondary += "\n<b>%s</b>: %s" % (k, v) 306 307 dialog = Gtk.MessageDialog(None, 0, Gtk.MessageType.ERROR, 308 Gtk.ButtonsType.CLOSE, primary) 309 dialog.format_secondary_markup(secondary) 310 311 label = self.find_secondary_label(dialog) 312 if label is not None: 313 label.set_selectable(True) 314 315 dialog.run() 316 sys.exit(1) 317 318 def find_secondary_label(self, container = None): 319 try: 320 children = container.get_children() 321 if len(children) == 0: 322 return None 323 if isinstance(children[0], Gtk.Label): 324 return children[1] 325 for child in children: 326 label = self.find_secondary_label(child) 327 if label is not None: 328 return label 329 except AttributeError: 330 pass 331 except IndexError: 332 pass 333 return None 334 335 def configure_application_window(self, builder, app): 336 """Glade is currently unable to create a GtkApplicationWindow. This 337 function takes the GtkWindow from the UI file and reparents the 338 contents into the Menulibre GtkApplication window, preserving the 339 window's properties.'""" 340 # Get the GtkWindow. 341 window = builder.get_object('menulibre_window') 342 343 # Back up the window properties. 344 window_title = window.get_title() 345 window_icon = window.get_icon_name() 346 window_contents = window.get_children()[0] 347 size = window.get_default_size() 348 size_request = window.get_size_request() 349 position = window.get_property("window-position") 350 351 # Initialize the GtkApplicationWindow. 352 Gtk.Window.__init__(self, title=window_title, application=app) 353 self.set_wmclass("MenuLibre", "MenuLibre") 354 355 # Restore the window properties. 356 self.set_title(window_title) 357 self.set_icon_name(window_icon) 358 self.set_default_size(size[0], size[1]) 359 self.set_size_request(size_request[0], size_request[1]) 360 self.set_position(position) 361 362 # Reparent the widgets. 363 window_contents.reparent(self) 364 365 # Connect any window-specific events. 366 self.connect('key-press-event', self.on_window_keypress_event) 367 self.connect('delete-event', self.on_window_delete_event) 368 369 def configure_css(self): 370 css = """ 371 #MenulibreSidebar GtkToolbar.inline-toolbar, 372 #MenulibreSidebar GtkScrolledWindow.frame { 373 border-radius: 0px; 374 border-width: 0px; 375 border-right-width: 1px; 376 } 377 #MenulibreSidebar GtkScrolledWindow.frame { 378 border-bottom-width: 1px; 379 } 380 """ 381 style_provider = Gtk.CssProvider.new() 382 style_provider.load_from_data(bytes(css.encode())) 383 384 Gtk.StyleContext.add_provider_for_screen( 385 Gdk.Screen.get_default(), style_provider, 386 Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION 387 ) 388 389 def configure_headerbar(self, builder): 390 # Configure the Add, Save, Undo, Redo, Revert, Delete widgets. 391 for action_name in ['save_launcher', 'undo', 'redo', 392 'revert', 'execute', 'delete']: 393 widget = builder.get_object("headerbar_%s" % action_name) 394 widget.connect("clicked", self.activate_action_cb, action_name) 395 396 self.action_items = dict() 397 self.action_items['add_button'] = builder.get_object('headerbar_add') 398 399 for action_name in ['add_launcher', 'add_directory', 'add_separator']: 400 self.action_items[action_name] = [] 401 widget = builder.get_object('menubar_%s' % action_name) 402 widget.connect('activate', self.activate_action_cb, action_name) 403 self.action_items[action_name].append(widget) 404 widget = builder.get_object('popup_%s' % action_name) 405 widget.connect('activate', self.activate_action_cb, action_name) 406 self.action_items[action_name].append(widget) 407 408 # Save 409 self.save_button = builder.get_object('headerbar_save_launcher') 410 411 # Undo/Redo/Revert 412 self.undo_button = builder.get_object('headerbar_undo') 413 self.redo_button = builder.get_object('headerbar_redo') 414 self.revert_button = builder.get_object('headerbar_revert') 415 416 # Configure the Delete widget. 417 self.delete_button = builder.get_object('headerbar_delete') 418 419 # Configure the Test Launcher widget. 420 self.execute_button = builder.get_object('headerbar_execute') 421 422 # Configure the search widget. 423 self.search_box = builder.get_object('search') 424 self.search_box.connect('icon-press', self.on_search_cleared) 425 426 self.search_box.reparent(builder.get_object('headerbar_search')) 427 428 headerbar = builder.get_object('headerbar') 429 headerbar.set_title("MenuLibre") 430 headerbar.set_custom_title(Gtk.Label.new()) 431 432 builder.get_object("toolbar").hide() 433 434 self.set_titlebar(headerbar) 435 headerbar.show_all() 436 437 def configure_application_actions(self, builder): 438 """Configure the GtkActions that are used in the Menulibre 439 application.""" 440 self.actions = {} 441 442 # Add Launcher 443 self.actions['add_launcher'] = Gtk.Action( 444 name='add_launcher', 445 # Translators: Add Launcher action label 446 label=_('Add _Launcher…'), 447 # Translators: Add Launcher action tooltip 448 tooltip=_('Add Launcher…'), 449 stock_id=Gtk.STOCK_NEW) 450 451 # Add Directory 452 self.actions['add_directory'] = Gtk.Action( 453 name='add_directory', 454 # Translators: Add Directory action label 455 label=_('Add _Directory…'), 456 # Translators: Add Directory action tooltip 457 tooltip=_('Add Directory…'), 458 stock_id=Gtk.STOCK_NEW) 459 460 # Add Separator 461 self.actions['add_separator'] = Gtk.Action( 462 name='add_separator', 463 # Translators: Add Separator action label 464 label=_('_Add Separator…'), 465 # Translators: Add Separator action tooltip 466 tooltip=_('Add Separator…'), 467 stock_id=Gtk.STOCK_NEW) 468 469 # Save Launcher 470 self.actions['save_launcher'] = Gtk.Action( 471 name='save_launcher', 472 # Translators: Save Launcher action label 473 label=_('_Save'), 474 # Translators: Save Launcher action tooltip 475 tooltip=_('Save'), 476 stock_id=Gtk.STOCK_SAVE) 477 478 # Undo 479 self.actions['undo'] = Gtk.Action( 480 name='undo', 481 # Translators: Undo action label 482 label=_('_Undo'), 483 # Translators: Undo action tooltip 484 tooltip=_('Undo'), 485 stock_id=Gtk.STOCK_UNDO) 486 487 # Redo 488 self.actions['redo'] = Gtk.Action( 489 name='redo', 490 # Translators: Redo action label 491 label=_('_Redo'), 492 # Translators: Redo action tooltip 493 tooltip=_('Redo'), 494 stock_id=Gtk.STOCK_REDO) 495 496 # Revert 497 self.actions['revert'] = Gtk.Action( 498 name='revert', 499 # Translators: Revert action label 500 label=_('_Revert'), 501 # Translators: Revert action tooltip 502 tooltip=_('Revert'), 503 stock_id=Gtk.STOCK_REVERT_TO_SAVED) 504 505 # Execute 506 self.actions['execute'] = Gtk.Action( 507 name='execute', 508 # Translators: Execute action label 509 label=_('_Execute'), 510 # Translators: Execute action tooltip 511 tooltip=_('Execute Launcher'), 512 stock_id=Gtk.STOCK_MEDIA_PLAY) 513 514 # Delete 515 self.actions['delete'] = Gtk.Action( 516 name='delete', 517 # Translators: Delete action label 518 label=_('_Delete'), 519 # Translators: Delete action tooltip 520 tooltip=_('Delete'), 521 stock_id=Gtk.STOCK_DELETE) 522 523 # Quit 524 self.actions['quit'] = Gtk.Action( 525 name='quit', 526 # Translators: Quit action label 527 label=_('_Quit'), 528 # Translators: Quit action tooltip 529 tooltip=_('Quit'), 530 stock_id=Gtk.STOCK_QUIT) 531 532 # Help 533 self.actions['help'] = Gtk.Action( 534 name='help', 535 # Translators: Help action label 536 label=_('_Contents'), 537 # Translators: Help action tooltip 538 tooltip=_('Help'), 539 stock_id=Gtk.STOCK_HELP) 540 541 # About 542 self.actions['about'] = Gtk.Action( 543 name='about', 544 # Translators: About action label 545 label=_('_About'), 546 # Translators: About action tooltip 547 tooltip=_('About'), 548 stock_id=Gtk.STOCK_ABOUT) 549 550 # Connect the GtkAction events. 551 self.actions['add_launcher'].connect('activate', 552 self.on_add_launcher_cb) 553 self.actions['add_directory'].connect('activate', 554 self.on_add_directory_cb) 555 self.actions['add_separator'].connect('activate', 556 self.on_add_separator_cb) 557 self.actions['save_launcher'].connect('activate', 558 self.on_save_launcher_cb, 559 builder) 560 self.actions['undo'].connect('activate', self.on_undo_cb) 561 self.actions['redo'].connect('activate', self.on_redo_cb) 562 self.actions['revert'].connect('activate', self.on_revert_cb) 563 self.actions['execute'].connect('activate', self.on_execute_cb, 564 builder) 565 self.actions['delete'].connect('activate', self.on_delete_cb) 566 self.actions['quit'].connect('activate', self.on_quit_cb) 567 self.actions['help'].connect('activate', self.on_help_cb) 568 self.actions['about'].connect('activate', self.on_about_cb) 569 570 def configure_application_bad_desktop_files_infobar(self, builder): 571 """Configure InfoBar to alert user to bad desktop files.""" 572 573 # Fetching UI widgets 574 self.infobar = builder.get_object('bad_desktop_files_infobar') 575 576 # Configuring buttons for the InfoBar - looks like you can't set a 577 # response ID via a button defined in glade? 578 # Can't get a stock button then change its icon, so leaving with no 579 # icon 580 self.infobar.add_button('Details', Gtk.ResponseType.YES) 581 582 self.infobar.show() 583 584 # Hook up events 585 self.infobar.set_default_response(Gtk.ResponseType.CLOSE) 586 self.infobar.connect('response', 587 self.on_bad_desktop_files_infobar_response) 588 589 def configure_application_menubar(self, builder): 590 """Configure the application GlobalMenu (in Unity) and AppMenu.""" 591 self.app_menu_button = None 592 builder.get_object('app_menu_holder') 593 594 # Show the menubar if using a Unity session. 595 if session in ['ubuntu', 'ubuntu-2d']: 596 builder.get_object('menubar').set_visible(True) 597 598 # Connect the menubar events. 599 for action_name in ['add_launcher', 'save_launcher', 'undo', 600 'redo', 'revert', 'quit', 'help', 'about']: 601 widget = builder.get_object("menubar_%s" % action_name) 602 widget.set_related_action(self.actions[action_name]) 603 widget.set_use_action_appearance(True) 604 605 def configure_application_toolbar(self, builder): 606 """Configure the application toolbar.""" 607 # Configure the Add, Save, Undo, Redo, Revert, Delete widgets. 608 for action_name in ['save_launcher', 'undo', 'redo', 609 'revert', 'execute', 'delete']: 610 widget = builder.get_object("toolbar_%s" % action_name) 611 widget.connect("clicked", self.activate_action_cb, action_name) 612 613 self.action_items = dict() 614 self.action_items['add_button'] = builder.get_object('toolbar_add') 615 616 for action_name in ['add_launcher', 'add_directory', 'add_separator']: 617 self.action_items[action_name] = [] 618 widget = builder.get_object('menubar_%s' % action_name) 619 widget.connect('activate', self.activate_action_cb, action_name) 620 self.action_items[action_name].append(widget) 621 widget = builder.get_object('popup_%s' % action_name) 622 widget.connect('activate', self.activate_action_cb, action_name) 623 self.action_items[action_name].append(widget) 624 625 # Save 626 self.save_button = builder.get_object('toolbar_save_launcher') 627 628 # Undo/Redo/Revert 629 self.undo_button = builder.get_object('toolbar_undo') 630 self.redo_button = builder.get_object('toolbar_redo') 631 self.revert_button = builder.get_object('toolbar_revert') 632 633 # Configure the Delete widget. 634 self.delete_button = builder.get_object('toolbar_delete') 635 636 # Configure the Test Launcher widget. 637 self.execute_button = builder.get_object('toolbar_execute') 638 639 # Configure the search widget. 640 self.search_box = builder.get_object('search') 641 self.search_box.connect('icon-press', self.on_search_cleared) 642 643 def configure_application_treeview(self, builder): 644 """Configure the menu-browsing GtkTreeView.""" 645 self.treeview = MenulibreTreeview.Treeview(self, builder) 646 if not self.treeview.loaded: 647 self.menu_load_failure() 648 treeview = self.treeview.get_treeview() 649 treeview.set_search_entry(self.search_box) 650 self.search_box.connect('changed', self.on_app_search_changed, 651 treeview, True) 652 self.treeview.set_can_select_function(self.get_can_select) 653 self.treeview.connect("cursor-changed", 654 self.on_apps_browser_cursor_changed, builder) 655 self.treeview.connect("add-directory-enabled", 656 self.on_apps_browser_add_directory_enabled, 657 builder) 658 treeview.set_cursor(Gtk.TreePath.new_from_string("1")) 659 treeview.set_cursor(Gtk.TreePath.new_from_string("0")) 660 661 def get_can_select(self): 662 if self.save_button.get_sensitive(): 663 dialog = Dialogs.SaveOnLeaveDialog(self) 664 665 response = dialog.run() 666 dialog.destroy() 667 # Cancel prevents leaving this launcher. 668 if response == Gtk.ResponseType.CANCEL: 669 return False 670 # Don't Save allows leaving this launcher, deleting 'new'. 671 elif response == Gtk.ResponseType.NO: 672 filename = self.treeview.get_selected_filename() 673 if filename is None: 674 self.delete_launcher() 675 return False 676 return True 677 # Save and move on. 678 else: 679 self.save_launcher() 680 return True 681 return False 682 else: 683 return True 684 685 def configure_application_editor(self, builder): 686 """Configure the editor frame.""" 687 placeholder = builder.get_object('settings_placeholder') 688 self.switcher = MenulibreStackSwitcher.StackSwitcherBox() 689 placeholder.add(self.switcher) 690 self.switcher.add_child(builder.get_object('page_categories'), 691 # Translators: "Categories" launcher section 692 'categories', _('Categories')) 693 self.switcher.add_child(builder.get_object('page_actions'), 694 # Translators: "Actions" launcher section 695 'actions', _('Actions')) 696 self.switcher.add_child(builder.get_object('page_advanced'), 697 # Translators: "Advanced" launcher section 698 'advanced', _('Advanced')) 699 700 # Store the editor. 701 self.editor = builder.get_object('application_editor') 702 703 # Keep a dictionary of the widgets for easy lookup and updates. 704 # The keys are the DesktopSpec keys. 705 self.widgets = { 706 'Name': ( # GtkButton, GtkLabel, GtkEntry 707 builder.get_object('button_Name'), 708 builder.get_object('label_Name'), 709 builder.get_object('entry_Name')), 710 'Comment': ( # GtkButton, GtkLabel, GtkEntry 711 builder.get_object('button_Comment'), 712 builder.get_object('label_Comment'), 713 builder.get_object('entry_Comment')), 714 'Icon': ( # GtkButton, GtkImage 715 builder.get_object('button_Icon'), 716 builder.get_object('image_Icon')), 717 'Filename': builder.get_object('label_Filename'), 718 'Exec': builder.get_object('entry_Exec'), 719 'Path': builder.get_object('entry_Path'), 720 'Terminal': builder.get_object('switch_Terminal'), 721 'StartupNotify': builder.get_object('switch_StartupNotify'), 722 'NoDisplay': builder.get_object('switch_NoDisplay'), 723 'GenericName': builder.get_object('entry_GenericName'), 724 'TryExec': builder.get_object('entry_TryExec'), 725 'OnlyShowIn': builder.get_object('entry_OnlyShowIn'), 726 'NotShowIn': builder.get_object('entry_NotShowIn'), 727 'MimeType': builder.get_object('entry_Mimetype'), 728 'Keywords': builder.get_object('entry_Keywords'), 729 'StartupWMClass': builder.get_object('entry_StartupWMClass'), 730 'Implements': builder.get_object('entry_Implements'), 731 'Hidden': builder.get_object('switch_Hidden'), 732 'DBusActivatable': builder.get_object('switch_DBusActivatable'), 733 'PrefersNonDefaultGPU': builder.get_object('switch_PrefersNonDefaultGPU'), 734 'X-GNOME-UsesNotifications': builder.get_object('switch_UsesNotifications') 735 } 736 737 # Configure the switches 738 for widget_name in ['Terminal', 'StartupNotify', 'NoDisplay', 'Hidden', 739 'DBusActivatable', 'PrefersNonDefaultGPU']: 740 widget = self.widgets[widget_name] 741 widget.connect('notify::active', self.on_switch_toggle, 742 widget_name) 743 744 # These widgets are hidden when the selected item is a Directory. 745 self.directory_hide_widgets = [] 746 for widget_name in ['details_frame', 'settings_placeholder', 747 'terminal_label', 'switch_Terminal', 748 'notify_label', 'switch_StartupNotify']: 749 self.directory_hide_widgets.append(builder.get_object(widget_name)) 750 751 # Configure the Name/Comment widgets. 752 for widget_name in ['Name', 'Comment']: 753 button = builder.get_object('button_%s' % widget_name) 754 builder.get_object('cancel_%s' % widget_name) 755 builder.get_object('apply_%s' % widget_name) 756 entry = builder.get_object('entry_%s' % widget_name) 757 button.connect('clicked', self.on_NameComment_clicked, 758 widget_name, builder) 759 entry.connect('key-press-event', 760 self.on_NameComment_key_press_event, 761 widget_name, builder) 762 entry.connect('activate', self.on_NameComment_activate, 763 widget_name, builder) 764 entry.connect('icon-press', self.on_NameComment_apply, 765 widget_name, builder) 766 767 # Button Focus events 768 for widget_name in ['Name', 'Comment', 'Icon']: 769 button = builder.get_object('button_%s' % widget_name) 770 button.connect('focus-in-event', 771 self.on_NameCommentIcon_focus_in_event) 772 button.connect('focus-out-event', 773 self.on_NameCommentIcon_focus_out_event) 774 775 for widget_name in ['Name', 'Comment']: 776 entry = builder.get_object('entry_%s' % widget_name) 777 778 # Commit changes to entries when focusing out. 779 entry.connect('focus-out-event', 780 self.on_entry_focus_out_event, 781 widget_name) 782 783 # Enable saving on any edit with an Entry. 784 entry.connect("changed", 785 self.on_entry_changed, 786 widget_name) 787 788 for widget_name in ['Exec', 'Path', 'GenericName', 'TryExec', 789 'OnlyShowIn', 'NotShowIn', 'MimeType', 'Keywords', 790 'StartupWMClass', 'Implements']: 791 792 # Commit changes to entries when focusing out. 793 self.widgets[widget_name].connect('focus-out-event', 794 self.on_entry_focus_out_event, 795 widget_name) 796 797 # Enable saving on any edit with an Entry. 798 self.widgets[widget_name].connect("changed", 799 self.on_entry_changed, 800 widget_name) 801 802 # Configure the Exec/Path widgets. 803 for widget_name in ['Exec', 'Path']: 804 button = builder.get_object('entry_%s' % widget_name) 805 button.connect('icon-press', self.on_ExecPath_clicked, widget_name, 806 builder) 807 808 xprop = find_program('xprop') 809 if xprop is None: 810 self.widgets['StartupWMClass'].set_icon_from_icon_name( 811 Gtk.EntryIconPosition.SECONDARY, None) 812 else: 813 self.widgets['StartupWMClass'].connect( 814 'icon-press', self.on_StartupWmClass_clicked) 815 816 # Icon Selector 817 self.icon_selector = MenulibreIconSelection.IconSelector(parent=self) 818 819 # Connect the Icon menu. 820 select_icon_name = builder.get_object("icon_select_by_icon_name") 821 select_icon_name.connect("activate", 822 self.on_IconSelectFromIcons_clicked, 823 builder) 824 select_icon_file = builder.get_object("icon_select_by_filename") 825 select_icon_file.connect("activate", 826 self.on_IconSelectFromFilename_clicked) 827 828 # Categories Treeview and Inline Toolbar 829 self.categories_treeview = builder.get_object('categories_treeview') 830 add_button = builder.get_object('categories_add') 831 add_button.connect("clicked", self.on_categories_add) 832 remove_button = builder.get_object('categories_remove') 833 remove_button.connect("clicked", self.on_categories_remove) 834 clear_button = builder.get_object('categories_clear') 835 clear_button.connect("clicked", self.on_categories_clear) 836 self.configure_categories_treeview(builder) 837 838 # Actions Treeview and Inline Toolbar 839 self.actions_treeview = builder.get_object('actions_treeview') 840 model = self.actions_treeview.get_model() 841 add_button = builder.get_object('actions_add') 842 add_button.connect("clicked", self.on_actions_add) 843 remove_button = builder.get_object('actions_remove') 844 remove_button.connect("clicked", self.on_actions_remove) 845 clear_button = builder.get_object('actions_clear') 846 clear_button.connect("clicked", self.on_actions_clear) 847 move_up = builder.get_object('actions_move_up') 848 move_up.connect('clicked', self.move_action, (self.actions_treeview, 849 - 1)) 850 move_down = builder.get_object('actions_move_down') 851 move_down.connect('clicked', self.move_action, (self.actions_treeview, 852 1)) 853 renderer = builder.get_object('actions_show_renderer') 854 renderer.connect('toggled', self.on_actions_show_toggled, model) 855 renderer = builder.get_object('actions_name_renderer') 856 renderer.connect('edited', self.on_actions_text_edited, model, 2) 857 renderer = builder.get_object('actions_command_renderer') 858 renderer.connect('edited', self.on_actions_text_edited, model, 3) 859 860 def configure_categories_treeview(self, builder): 861 """Set the up combobox in the categories treeview editor.""" 862 # Populate the ListStore. 863 self.categories_treestore = Gtk.TreeStore(str) 864 self.categories_treefilter = self.categories_treestore.filter_new() 865 self.categories_treefilter.set_visible_func( 866 self.categories_treefilter_func) 867 868 keys = list(category_groups.keys()) 869 keys.sort() 870 871 # Translators: Launcher-specific categories, camelcase "This Entry" 872 keys.append(_('ThisEntry')) 873 874 for key in keys: 875 parent = self.categories_treestore.append(None, [key]) 876 try: 877 for category in category_groups[key]: 878 self.categories_treestore.append(parent, [category]) 879 except KeyError: 880 pass 881 882 # Create the TreeView... 883 treeview = builder.get_object('categories_treeview') 884 885 renderer_combo = Gtk.CellRendererCombo() 886 renderer_combo.set_property("editable", True) 887 renderer_combo.set_property("model", self.categories_treefilter) 888 renderer_combo.set_property("text-column", 0) 889 renderer_combo.set_property("has-entry", False) 890 891 # Translators: Placeholder text for the launcher-specific category 892 # selection. 893 renderer_combo.set_property("placeholder-text", _("Select a category")) 894 renderer_combo.connect("edited", self.on_category_combo_changed) 895 896 # Translators: "Category Name" tree column header 897 column_combo = Gtk.TreeViewColumn(_("Category Name"), 898 renderer_combo, text=0) 899 treeview.append_column(column_combo) 900 901 renderer_text = Gtk.CellRendererText() 902 903 # Translators: "Description" tree column header 904 column_text = Gtk.TreeViewColumn(_("Description"), 905 renderer_text, text=1) 906 treeview.append_column(column_text) 907 908 self.categories_treefilter.refilter() 909 910 # Allow to keep track of categories a user has explicitly removed for a 911 # desktop file 912 self.categories_removed = set() 913 914 def activate_action_cb(self, widget, action_name): 915 """Activate the specified GtkAction.""" 916 self.actions[action_name].activate() 917 918 def on_switch_toggle(self, widget, status, widget_name): 919 """Connect switch toggle event for storing in history.""" 920 self.set_value(widget_name, widget.get_active()) 921 922# History Signals 923 def on_undo_changed(self, history, enabled): 924 """Toggle undo functionality when history is changed.""" 925 self.undo_button.set_sensitive(enabled) 926 927 def on_redo_changed(self, history, enabled): 928 """Toggle redo functionality when history is changed.""" 929 self.redo_button.set_sensitive(enabled) 930 931 def on_revert_changed(self, history, enabled): 932 """Toggle revert functionality when history is changed.""" 933 self.revert_button.set_sensitive(enabled) 934 self.save_button.set_sensitive(enabled) 935 self.actions['save_launcher'].set_sensitive(enabled) 936 937# Generic Treeview functions 938 def treeview_add(self, treeview, row_data): 939 """Append the specified row_data to the treeview.""" 940 model = treeview.get_model() 941 model.append(row_data) 942 943 def treeview_remove(self, treeview): 944 """Remove the selected row from the treeview.""" 945 model, treeiter = treeview.get_selection().get_selected() 946 if model is not None and treeiter is not None: 947 model.remove(treeiter) 948 949 def treeview_clear(self, treeview): 950 """Remove all items from the treeview.""" 951 model = treeview.get_model() 952 model.clear() 953 954 def treeview_get_selected_text(self, treeview, column): 955 """Return selected item's text value stored at the given column (text 956 is the expected data type).""" 957 958 # Note that the categories treeview is configured to only allow one row 959 # to be selected 960 model, treeiter = treeview.get_selection().get_selected() 961 if model is not None and treeiter is not None: 962 return model[treeiter][column] 963 else: 964 return '' 965 966 def cleanup_treeview(self, treeview, key_columns, sort=False): 967 """Cleanup a treeview""" 968 rows = [] 969 970 model = treeview.get_model() 971 for row in model: 972 row_data = row[:] 973 append_row = True 974 for key_column in key_columns: 975 text = row_data[key_column].lower() 976 if len(text) == 0: 977 append_row = False 978 if append_row: 979 rows.append(row_data) 980 981 if sort: 982 rows = sorted(rows, key=lambda row_data: row_data[key_columns[MenuEditor.COL_NAME]]) 983 984 model.clear() 985 for row in rows: 986 model.append(row) 987 988# Categories 989 def cleanup_categories(self): 990 """Cleanup the Categories treeview. Remove any rows where category 991 has not been set and sort alphabetically.""" 992 self.cleanup_treeview(self.categories_treeview, [0], sort=True) 993 994 def categories_treefilter_func(self, model, treeiter, data=None): 995 """Only show ThisEntry when there are child items.""" 996 row = model[treeiter] 997 if row.get_parent() is not None: 998 return True 999 # Translators: "This Entry" launcher-specific category group 1000 if row[0] == _('This Entry'): 1001 return model.iter_n_children(treeiter) != 0 1002 return True 1003 1004 def on_category_combo_changed(self, widget, path, text): 1005 """Set the active iter to the new text.""" 1006 model = self.categories_treeview.get_model() 1007 model[path][0] = text 1008 description = lookup_category_description(text) 1009 model[path][1] = description 1010 self.set_value('Categories', self.get_editor_categories(), False) 1011 1012 def on_categories_add(self, widget): 1013 """Add a new row to the Categories TreeView.""" 1014 self.treeview_add(self.categories_treeview, ['', '']) 1015 self.set_value('Categories', self.get_editor_categories(), False) 1016 1017 def on_categories_remove(self, widget): 1018 """Remove the currently selected row from the Categories TreeView.""" 1019 1020 # Keep track of category names user has explicitly removed 1021 name = self.treeview_get_selected_text(self.categories_treeview, 0) 1022 self.categories_removed.add(name) 1023 1024 self.treeview_remove(self.categories_treeview) 1025 self.set_value('Categories', self.get_editor_categories(), False) 1026 1027 def on_categories_clear(self, widget): 1028 """Clear all rows from the Categories TreeView.""" 1029 self.treeview_clear(self.categories_treeview) 1030 self.set_value('Categories', self.get_editor_categories(), False) 1031 1032 def cleanup_actions(self): 1033 """Cleanup the Actions treeview. Remove any rows where name or command 1034 have not been set.""" 1035 self.cleanup_treeview(self.actions_treeview, [2, 3]) 1036 1037# Actions 1038 def on_actions_text_edited(self, w, row, new_text, model, col): 1039 """Edited callback function to enable modifications to a cell.""" 1040 model[row][col] = new_text 1041 self.set_value('Actions', self.get_editor_actions(), False) 1042 1043 def on_actions_show_toggled(self, cell, path, model=None): 1044 """Toggled callback function to enable modifications to a cell.""" 1045 treeiter = model.get_iter(path) 1046 model.set_value(treeiter, 0, not cell.get_active()) 1047 self.set_value('Actions', self.get_editor_actions(), False) 1048 1049 def on_actions_add(self, widget): 1050 """Add a new row to the Actions TreeView.""" 1051 model = self.actions_treeview.get_model() 1052 existing = list() 1053 for row in model: 1054 existing.append(row[1]) 1055 name = 'NewShortcut' 1056 n = 1 1057 while name in existing: 1058 name = 'NewShortcut%i' % n 1059 n += 1 1060 # Translators: Placeholder text for a newly created action 1061 displayed = _("New Shortcut") 1062 self.treeview_add(self.actions_treeview, [True, name, displayed, '']) 1063 self.set_value('Actions', self.get_editor_actions(), False) 1064 1065 def on_actions_remove(self, widget): 1066 """Remove the currently selected row from the Actions TreeView.""" 1067 self.treeview_remove(self.actions_treeview) 1068 self.set_value('Actions', self.get_editor_actions(), False) 1069 1070 def on_actions_clear(self, widget): 1071 """Clear all rows from the Actions TreeView.""" 1072 self.treeview_clear(self.actions_treeview) 1073 self.set_value('Actions', self.get_editor_actions(), False) 1074 1075 def move_action(self, widget, user_data): 1076 """Move row in Actions treeview.""" 1077 # Unpack the user data 1078 treeview, relative_position = user_data 1079 1080 sel = treeview.get_selection().get_selected() 1081 if sel: 1082 model, selected_iter = sel 1083 1084 # Move the row up if relative_position < 0 1085 if relative_position < 0: 1086 sibling = model.iter_previous(selected_iter) 1087 model.move_before(selected_iter, sibling) 1088 else: 1089 sibling = model.iter_next(selected_iter) 1090 model.move_after(selected_iter, sibling) 1091 1092 self.set_value('Actions', self.get_editor_actions(), False) 1093 1094# Window events 1095 def on_window_keypress_event(self, widget, event, user_data=None): 1096 """Handle window keypress events.""" 1097 # Ctrl-F (Find) 1098 if check_keypress(event, ['Control', 'f']): 1099 self.search_box.grab_focus() 1100 return True 1101 # Ctrl-S (Save) 1102 if check_keypress(event, ['Control', 's']): 1103 self.actions['save_launcher'].activate() 1104 return True 1105 # Ctrl-Q (Quit) 1106 if check_keypress(event, ['Control', 'q']): 1107 self.actions['quit'].activate() 1108 return True 1109 return False 1110 1111 def on_window_delete_event(self, widget, event): 1112 """Save changes on close.""" 1113 if self.save_button.get_sensitive(): 1114 # Unsaved changes 1115 dialog = Dialogs.SaveOnCloseDialog(self) 1116 response = dialog.run() 1117 dialog.destroy() 1118 # Cancel prevents the application from closing. 1119 if response == Gtk.ResponseType.CANCEL: 1120 return True 1121 # Don't Save allows the application to close. 1122 elif response == Gtk.ResponseType.NO: 1123 return False 1124 # Save and close. 1125 else: 1126 self.save_launcher() 1127 return False 1128 return False 1129 1130# Improved navigation of the Name, Comment, and Icon widgets 1131 def on_NameCommentIcon_focus_in_event(self, button, event): 1132 """Make the selected focused widget more noticeable.""" 1133 button.set_relief(Gtk.ReliefStyle.NORMAL) 1134 1135 def on_NameCommentIcon_focus_out_event(self, button, event): 1136 """Make the selected focused widget less noticeable.""" 1137 button.set_relief(Gtk.ReliefStyle.NONE) 1138 1139# Icon Selection 1140 def on_IconSelectFromIcons_clicked(self, widget, builder): 1141 icon_name = self.icon_selector.select_by_icon_name() 1142 if icon_name is not None: 1143 self.set_value('Icon', icon_name) 1144 1145 def on_IconSelectFromFilename_clicked(self, widget): 1146 filename = self.icon_selector.select_by_filename() 1147 if filename is not None: 1148 self.set_value('Icon', filename) 1149 1150# Name and Comment Widgets 1151 def on_NameComment_key_press_event(self, widget, ev, widget_name, builder): 1152 """Handle cancelling the Name/Comment dialogs with Escape.""" 1153 if check_keypress(ev, ['Escape']): 1154 self.on_NameComment_cancel(widget, widget_name, builder) 1155 1156 def on_NameComment_activate(self, widget, widget_name, builder): 1157 """Activate apply button on Enter press.""" 1158 self.on_NameComment_apply(widget, widget_name, builder) 1159 1160 def on_NameComment_clicked(self, widget, widget_name, builder): 1161 """Show the Name/Comment editor widgets when the button is clicked.""" 1162 entry = builder.get_object('entry_%s' % widget_name) 1163 self.values[widget_name] = entry.get_text() 1164 widget.hide() 1165 entry.show() 1166 entry.grab_focus() 1167 1168 def on_NameComment_cancel(self, widget, widget_name, builder): 1169 """Hide the Name/Comment editor widgets when canceled.""" 1170 button = builder.get_object('button_%s' % widget_name) 1171 entry = builder.get_object('entry_%s' % widget_name) 1172 entry.hide() 1173 button.show() 1174 self.history.block() 1175 entry.set_text(self.values[widget_name]) 1176 self.history.unblock() 1177 button.grab_focus() 1178 1179 def on_NameComment_apply(self, *args): 1180 """Update the Name/Comment fields when the values are to be updated.""" 1181 if len(args) == 5: 1182 entry, iconpos, void, widget_name, builder = args 1183 else: 1184 widget, widget_name, builder = args 1185 entry = builder.get_object('entry_%s' % widget_name) 1186 button = builder.get_object('button_%s' % widget_name) 1187 entry.hide() 1188 button.show() 1189 new_value = entry.get_text() 1190 self.set_value(widget_name, new_value) 1191 1192# Store entry values when they lose focus. 1193 def on_entry_focus_out_event(self, widget, event, widget_name): 1194 """Store the new value in the history when changing fields.""" 1195 text = widget.get_text() 1196 if "~" in text: 1197 text = os.path.expanduser(text) 1198 self.set_value(widget_name, text) 1199 1200 def on_entry_changed(self, widget, widget_name): 1201 """Enable saving when an entry has been modified.""" 1202 if not self.history.is_blocked(): 1203 self.actions['save_launcher'].set_sensitive(True) 1204 self.save_button.set_sensitive(True) 1205 1206# Browse button functionality for Exec and Path widgets. 1207 def on_ExecPath_clicked(self, entry, icon, event, widget_name, builder): 1208 """Show the file selection dialog when Exec/Path Browse is clicked.""" 1209 if widget_name == 'Path': 1210 # Translators: File Chooser Dialog, window title. 1211 title = _("Select a working directory…") 1212 action = Gtk.FileChooserAction.SELECT_FOLDER 1213 else: 1214 # Translators: File Chooser Dialog, window title. 1215 title = _("Select an executable…") 1216 action = Gtk.FileChooserAction.OPEN 1217 1218 dialog = Dialogs.FileChooserDialog(self, title, action) 1219 result = dialog.run() 1220 dialog.hide() 1221 if result == Gtk.ResponseType.OK: 1222 filename = dialog.get_filename() 1223 if widget_name == 'Exec': 1224 # Handle spaces to script filenames (lp 1214815) 1225 if ' ' in filename: 1226 filename = '\"%s\"' % filename 1227 self.set_value(widget_name, filename) 1228 entry.grab_focus() 1229 1230 def on_StartupWmClass_clicked(self, entry, icon, event): 1231 dialog = Dialogs.XpropWindowDialog(self, self.get_value('Name')) 1232 wm_classes = dialog.run_xprop() 1233 current = entry.get_text() 1234 for wm_class in wm_classes: 1235 if wm_class != current: 1236 self.set_value("StartupWMClass", wm_class) 1237 return 1238 1239 1240# Applications Treeview 1241 def on_apps_browser_add_directory_enabled(self, widget, enabled, builder): 1242 """Update the Add Directory menu item when the selected row is 1243 changed.""" 1244 # Always allow creating sub directories 1245 enabled = True 1246 1247 self.actions['add_directory'].set_sensitive(enabled) 1248 for widget in self.action_items['add_directory']: 1249 widget.set_sensitive(enabled) 1250 widget.set_tooltip_text(None) 1251 1252 def on_apps_browser_cursor_changed(self, widget, value, builder): # noqa 1253 """Update the editor frame when the selected row is changed.""" 1254 missing = False 1255 1256 # Clear history 1257 self.history.clear() 1258 1259 # Hide the Name and Comment editors 1260 builder.get_object('entry_Name').hide() 1261 builder.get_object('entry_Comment').hide() 1262 1263 # Prevent updates to history. 1264 self.history.block() 1265 1266 # Clear the individual entries. 1267 for key in ['Exec', 'Path', 'Terminal', 'StartupNotify', 1268 'NoDisplay', 'GenericName', 'TryExec', 1269 'OnlyShowIn', 'NotShowIn', 'MimeType', 1270 'Keywords', 'StartupWMClass', 'Implements', 'Categories', 1271 'Hidden', 'DBusActivatable', 'PrefersNonDefaultGPU', 1272 'X-GNOME-UsesNotifications']: 1273 self.set_value(key, None) 1274 1275 # Clear the Actions and Icon. 1276 self.set_value('Actions', None, store=True) 1277 self.set_value('Icon', None, store=True) 1278 1279 model, row_data = self.treeview.get_selected_row_data() 1280 item_type = row_data[MenuEditor.COL_TYPE] 1281 1282 # If the selected row is a separator, hide the editor. 1283 if item_type == MenuItemTypes.SEPARATOR: 1284 self.editor.hide() 1285 # Translators: Separator menu item 1286 self.set_value('Name', _("Separator"), store=True) 1287 self.set_value('Comment', "", store=True) 1288 self.set_value('Filename', None, store=True) 1289 self.set_value('Type', 'Separator', store=True) 1290 1291 # Otherwise, show the editor and update the values. 1292 else: 1293 filename = self.treeview.get_selected_filename() 1294 new_launcher = filename is None 1295 1296 # Check if this file still exists 1297 if (not new_launcher) and (not os.path.isfile(filename)): 1298 # If it does not, try to fallback... 1299 basename = getBasename(filename) 1300 filename = util.getSystemLauncherPath(basename) 1301 if filename is not None: 1302 row_data[MenuEditor.COL_FILENAME] = filename 1303 self.treeview.update_launcher_instances(filename, row_data) 1304 1305 if new_launcher or (filename is not None): 1306 self.editor.show() 1307 displayed_name = row_data[MenuEditor.COL_NAME] 1308 comment = row_data[MenuEditor.COL_COMMENT] 1309 1310 self.set_value('Icon', row_data[MenuEditor.COL_ICON_NAME], store=True) 1311 self.set_value('Name', displayed_name, store=True) 1312 self.set_value('Comment', comment, store=True) 1313 self.set_value('Filename', filename, store=True) 1314 1315 if item_type == MenuItemTypes.APPLICATION: 1316 self.editor.show_all() 1317 entry = MenulibreXdg.MenulibreDesktopEntry(filename) 1318 for key in getRelatedKeys(item_type, key_only=True): 1319 if key in ['Actions', 'Comment', 'Filename', 'Icon', 1320 'Name']: 1321 continue 1322 self.set_value(key, entry[key], store=True) 1323 self.set_value('Actions', entry.get_actions(), 1324 store=True) 1325 self.set_value('Type', 'Application') 1326 self.execute_button.set_sensitive(True) 1327 else: 1328 entry = MenulibreXdg.MenulibreDesktopEntry(filename) 1329 for key in getRelatedKeys(item_type, key_only=True): 1330 if key in ['Comment', 'Filename', 'Icon', 'Name']: 1331 continue 1332 self.set_value(key, entry[key], store=True) 1333 self.set_value('Type', 'Directory') 1334 for widget in self.directory_hide_widgets: 1335 widget.hide() 1336 self.execute_button.set_sensitive(False) 1337 1338 else: 1339 # Display a dialog saying this item is missing 1340 dialog = Dialogs.LauncherRemovedDialog(self) 1341 dialog.run() 1342 dialog.destroy() 1343 # Mark this item as missing to delete it later. 1344 missing = True 1345 1346 # Renable updates to history. 1347 self.history.unblock() 1348 1349 if self.treeview.get_parent()[1] is None: 1350 self.treeview.set_sortable(False) 1351 move_up_enabled = not self.treeview.is_first() 1352 move_down_enabled = not self.treeview.is_last() 1353 else: 1354 self.treeview.set_sortable(True) 1355 if item_type == MenuItemTypes.APPLICATION or \ 1356 item_type == MenuItemTypes.LINK or \ 1357 item_type == MenuItemTypes.SEPARATOR: 1358 move_up_enabled = True 1359 move_down_enabled = True 1360 else: 1361 move_up_enabled = not self.treeview.is_first() 1362 move_down_enabled = not self.treeview.is_last() 1363 1364 self.treeview.set_move_up_enabled(move_up_enabled) 1365 self.treeview.set_move_down_enabled(move_down_enabled) 1366 1367 # Remove this item if it happens to be gone. 1368 if missing: 1369 self.delete_launcher() 1370 1371 def on_app_search_changed(self, widget, treeview, expand=False): 1372 """Update search results when query text is modified.""" 1373 query = widget.get_text() 1374 1375 # If blank query... 1376 if len(query) == 0: 1377 # Remove the clear button. 1378 widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, 1379 None) 1380 1381 # If the model is a filter, we want to remove the filter. 1382 self.treeview.set_searchable(False, expand) 1383 1384 # Enable add functionality 1385 for name in ['add_launcher', 'add_directory', 'add_separator', 1386 'add_button']: 1387 for widget in self.action_items[name]: 1388 widget.set_sensitive(True) 1389 if name in self.actions: 1390 self.actions[name].set_sensitive(True) 1391 1392 # Enable deletion (LP: #1751616) 1393 self.delete_button.set_sensitive(True) 1394 self.delete_button.set_tooltip_text("") 1395 1396 # If the entry has a query... 1397 else: 1398 # Show the clear button. 1399 widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, 1400 'edit-clear-symbolic') 1401 1402 self.treeview.set_searchable(True) 1403 1404 # Disable add functionality 1405 for name in ['add_launcher', 'add_directory', 'add_separator', 1406 'add_button']: 1407 for widget in self.action_items[name]: 1408 widget.set_sensitive(False) 1409 if name in self.actions: 1410 self.actions[name].set_sensitive(False) 1411 1412 # Rerun the filter. 1413 self.treeview.search(self.search_box.get_text()) 1414 1415 # Disable deletion (LP: #1751616) 1416 self.delete_button.set_sensitive(False) 1417 self.delete_button.set_tooltip_text("") 1418 1419 def on_search_cleared(self, widget, event, user_data=None): 1420 """Generic search cleared callback function.""" 1421 widget.set_text("") 1422 1423# Setters and Getters 1424 def set_editor_image(self, icon_name): 1425 """Set the editor Icon button image.""" 1426 button, image = self.widgets['Icon'] 1427 1428 if icon_name is not None: 1429 # Load the Icon Theme. 1430 icon_theme = Gtk.IconTheme.get_default() 1431 1432 # If the Icon Theme has the icon, set the image to that icon. 1433 if icon_theme.has_icon(icon_name): 1434 image.set_from_icon_name(icon_name, 48) 1435 self.icon_selector.set_icon_name(icon_name) 1436 return 1437 1438 # If the icon name is actually a file, render it to the Image. 1439 elif os.path.isfile(icon_name): 1440 pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_name) 1441 size = image.get_preferred_height()[1] 1442 scaled = pixbuf.scale_simple(size, size, 1443 GdkPixbuf.InterpType.HYPER) 1444 image.set_from_pixbuf(scaled) 1445 self.icon_selector.set_filename(icon_name) 1446 return 1447 1448 image.set_from_icon_name("applications-other", 48) 1449 1450 def set_editor_filename(self, filename): 1451 """Set the editor filename.""" 1452 # Since the filename has changed, check if it is now writable... 1453 if filename is None or os.access(filename, os.W_OK): 1454 self.delete_button.set_sensitive(True) 1455 self.delete_button.set_tooltip_text("") 1456 else: 1457 self.delete_button.set_sensitive(False) 1458 self.delete_button.set_tooltip_text( 1459 # Translators: This error is displayed when the user does not 1460 # have sufficient file system permissions to delete the 1461 # selected file. 1462 _("You do not have permission to delete this file.")) 1463 1464 # Disable deletion if we're in search mode (LP: #1751616) 1465 if self.search_box.get_text() != "": 1466 self.delete_button.set_sensitive(False) 1467 self.delete_button.set_tooltip_text("") 1468 1469 # If the filename is None, make it blank. 1470 if filename is None: 1471 filename = "" 1472 1473 # Get the filename widget. 1474 widget = self.widgets['Filename'] 1475 1476 # Set the label and tooltip. 1477 widget.set_label(filename) 1478 widget.set_tooltip_text(filename) 1479 1480 # Store the filename value. 1481 self.values['filename'] = filename 1482 1483 def get_editor_categories(self): 1484 """Get the editor categories. 1485 1486 Return the categories as a semicolon-delimited string.""" 1487 model = self.categories_treeview.get_model() 1488 categories = "" 1489 for row in model: 1490 categories = "%s%s;" % (categories, row[0]) 1491 return categories 1492 1493 def set_editor_categories(self, entries_string): 1494 """Populate the Categories treeview with the Categories string.""" 1495 if not entries_string: 1496 entries_string = "" 1497 1498 # Split the entries into a list. 1499 entries = entries_string.split(';') 1500 entries.sort() 1501 1502 # Clear the model. 1503 model = self.categories_treeview.get_model() 1504 model.clear() 1505 1506 # Clear tracked categories user explicitly deleted 1507 self.categories_removed = set() 1508 1509 # Clear the ThisEntry category list. 1510 this_index = self.categories_treestore.iter_n_children(None) - 1 1511 this_entry = self.categories_treestore.iter_nth_child(None, this_index) 1512 for i in range(self.categories_treestore.iter_n_children(this_entry)): 1513 child_iter = self.categories_treestore.iter_nth_child(this_entry, 1514 0) 1515 self.categories_treestore.remove(child_iter) 1516 1517 # Cleanup the entry text and generate a description. 1518 for entry in entries: 1519 entry = entry.strip() 1520 if len(entry) > 0: 1521 description = lookup_category_description(entry) 1522 model.append([entry, description]) 1523 1524 # Add unknown entries to the category list... 1525 category_keys = list(category_groups.keys()) + \ 1526 list(category_lookup.keys()) 1527 if entry not in category_keys: 1528 self.categories_treestore.append(this_entry, [entry]) 1529 1530 self.categories_treefilter.refilter() 1531 1532 def get_editor_actions_string(self): 1533 """Return the .desktop formatted actions.""" 1534 # Get the model. 1535 model = self.actions_treeview.get_model() 1536 1537 # Start the output string. 1538 actions = "\nActions=" 1539 groups = "\n" 1540 1541 # Return None if there are no actions. 1542 if len(model) == 0: 1543 return None 1544 1545 # For each row... 1546 for row in model: 1547 # Extract the details. 1548 show, name, displayed, executable = row[:] 1549 1550 # Append it to the actions list if it is selected to be shown. 1551 if show: 1552 actions = "%s%s;" % (actions, name) 1553 1554 # Populate the group text. 1555 group = "[Desktop Action %s]\n" \ 1556 "Name=%s\n" \ 1557 "Exec=%s\n" \ 1558 "OnlyShowIn=Unity\n" % (name, displayed, executable) 1559 1560 # Append the new group text to the groups string. 1561 groups = "%s\n%s" % (groups, group) 1562 1563 # Return the .desktop formatted actions. 1564 return actions + groups 1565 1566 def get_editor_actions(self): 1567 """Get the list of action groups.""" 1568 model = self.actions_treeview.get_model() 1569 1570 action_groups = [] 1571 1572 # Return [] if there are no actions. 1573 if len(model) == 0: 1574 return [] 1575 1576 # For each row... 1577 for row in model: 1578 # Extract the details. 1579 show, name, displayed, command = row[:] 1580 action_groups.append([name, displayed, command, show]) 1581 1582 return action_groups 1583 1584 def set_editor_actions(self, action_groups): 1585 """Set the editor Actions from the list action_groups.""" 1586 model = self.actions_treeview.get_model() 1587 model.clear() 1588 if not action_groups: 1589 return 1590 for name, displayed, command, show in action_groups: 1591 model.append([show, name, displayed, command]) 1592 1593 def get_inner_value(self, key): 1594 """Get the value stored for key.""" 1595 try: 1596 return self.values[key] 1597 except: # noqa 1598 return None 1599 1600 def set_inner_value(self, key, value): 1601 """Set the value stored for key.""" 1602 self.values[key] = value 1603 1604 def set_value(self, key, value, adjust_widget=True, store=False): # noqa 1605 """Set the DesktopSpec key, value pair in the editor.""" 1606 if store: 1607 self.history.store(key, value) 1608 if self.get_inner_value(key) == value: 1609 return 1610 self.history.append(key, self.get_inner_value(key), value) 1611 self.set_inner_value(key, value) 1612 if not adjust_widget: 1613 return 1614 # Name and Comment must formatted correctly for their buttons. 1615 if key in ['Name', 'Comment']: 1616 if not value: 1617 value = "" 1618 button, label, entry = self.widgets[key] 1619 if key == 'Name': 1620 markup = escapeText(value) 1621 else: 1622 markup = "%s" % (value) 1623 tooltip = escapeText(value) 1624 1625 button.set_tooltip_markup(tooltip) 1626 entry.set_text(value) 1627 label.set_markup(markup) 1628 1629 # Filename, Actions, Categories, and Icon have their own functions. 1630 elif key == 'Filename': 1631 self.set_editor_filename(value) 1632 elif key == 'Actions': 1633 self.set_editor_actions(value) 1634 elif key == 'Categories': 1635 self.set_editor_categories(value) 1636 elif key == 'Icon': 1637 self.set_editor_image(value) 1638 1639 # Type is just stored. 1640 elif key == 'Type': 1641 self.values['Type'] = value 1642 1643 # No associated widget for Version 1644 elif key == 'Version': 1645 pass 1646 1647 # Everything else is set by its widget type. 1648 elif key in self.widgets.keys(): 1649 widget = self.widgets[key] 1650 # GtkButton 1651 if isinstance(widget, Gtk.Button): 1652 if not value: 1653 value = "" 1654 widget.set_label(value) 1655 # GtkLabel 1656 elif isinstance(widget, Gtk.Label): 1657 if not value: 1658 value = "" 1659 widget.set_label(str(value)) 1660 # GtkEntry 1661 elif isinstance(widget, Gtk.Entry): 1662 if not value: 1663 value = "" 1664 widget.set_text(str(value)) 1665 # GtkSwitch 1666 elif isinstance(widget, Gtk.Switch): 1667 if not value: 1668 value = False 1669 widget.set_active(value) 1670 # If "Hide from menus", also clear Hidden setting. 1671 if key == 'NoDisplay' and value is False: 1672 self.set_value('Hidden', False) 1673 else: 1674 logger.warning(("Unknown widget: %s" % key)) 1675 else: 1676 logger.warning(("Unimplemented widget: %s" % key)) 1677 1678 def get_value(self, key): # noqa 1679 """Return the value stored for the specified key.""" 1680 if key in ['Name', 'Comment']: 1681 button, label, entry = self.widgets[key] 1682 return entry.get_text() 1683 elif key == 'Icon': 1684 return self.values[key] 1685 elif key == 'Type': 1686 return self.values[key] 1687 elif key == 'Categories': 1688 return self.get_editor_categories() 1689 elif key == 'Filename': 1690 if 'filename' in list(self.values.keys()): 1691 return self.values['filename'] 1692 else: 1693 widget = self.widgets[key] 1694 if isinstance(widget, Gtk.Button): 1695 return widget.get_label() 1696 elif isinstance(widget, Gtk.Label): 1697 return widget.get_label() 1698 elif isinstance(widget, Gtk.Entry): 1699 return widget.get_text() 1700 elif isinstance(widget, Gtk.Switch): 1701 return widget.get_active() 1702 else: 1703 return None 1704 return None 1705 1706# Action Functions 1707 def add_launcher(self): 1708 """Add Launcher callback function.""" 1709 # Translators: Placeholder text for a newly created launcher. 1710 name = _("New Launcher") 1711 # Translators: Placeholder text for a newly created launcher's 1712 # description. 1713 comment = _("A small descriptive blurb about this application.") 1714 categories = "" 1715 item_type = MenuItemTypes.APPLICATION 1716 icon_name = "applications-other" 1717 icon = Gio.ThemedIcon.new(icon_name) 1718 filename = None 1719 executable = "" 1720 new_row_data = [name, comment, executable, categories, item_type, icon, 1721 icon_name, filename, True] 1722 1723 model, parent_data = self.treeview.get_parent_row_data() 1724 model, row_data = self.treeview.get_selected_row_data() 1725 1726 # Exit early if no row is selected (LP #1556664) 1727 if not row_data: 1728 return 1729 1730 # Add to the treeview on the current level or as a child of a selected 1731 # directory 1732 dir_selected = row_data[MenuEditor.COL_TYPE] == MenuItemTypes.DIRECTORY 1733 if dir_selected: 1734 self.treeview.add_child(new_row_data) 1735 else: 1736 self.treeview.append(new_row_data) 1737 1738 # A parent item has been found, and the current selection is not a 1739 # directory, so the resulting item will be placed at the current level 1740 # fetch the parent's categories 1741 if parent_data is not None and not dir_selected: 1742 categories = util.getRequiredCategories(parent_data[MenuEditor.COL_FILENAME]) 1743 1744 elif parent_data is not None and dir_selected: 1745 1746 # A directory lower than the top-level has been selected - the 1747 # launcher will be added into it (e.g. as the first item), 1748 # therefore it essentially has a parent of the current selection 1749 categories = util.getRequiredCategories(row_data[MenuEditor.COL_FILENAME]) 1750 1751 else: 1752 1753 # Parent was not found, this is a toplevel category 1754 categories = util.getRequiredCategories(None) 1755 1756 self.set_editor_categories(';'.join(categories)) 1757 1758 self.actions['save_launcher'].set_sensitive(True) 1759 self.save_button.set_sensitive(True) 1760 1761 def add_directory(self): 1762 """Add Directory callback function.""" 1763 # Translators: Placeholder text for a newly created directory. 1764 name = _("New Directory") 1765 # Translators: Placeholder text for a newly created directory's 1766 # description. 1767 comment = _("A small descriptive blurb about this directory.") 1768 categories = "" 1769 item_type = MenuItemTypes.DIRECTORY 1770 icon_name = "folder" 1771 icon = Gio.ThemedIcon.new(icon_name) 1772 filename = None 1773 executable = "" 1774 row_data = [name, comment, executable, categories, item_type, icon, 1775 icon_name, filename, True, True] 1776 1777 self.treeview.append(row_data) 1778 1779 self.actions['save_launcher'].set_sensitive(True) 1780 self.save_button.set_sensitive(True) 1781 1782 def add_separator(self): 1783 """Add Separator callback function.""" 1784 name = "<s> </s>" 1785 # Translators: Separator menu item 1786 tooltip = _("Separator") 1787 categories = "" 1788 filename = None 1789 icon = None 1790 icon_name = "" 1791 item_type = MenuItemTypes.SEPARATOR 1792 filename = None 1793 executable = "" 1794 row_data = [name, tooltip, executable, categories, item_type, icon, 1795 icon_name, filename, False, True] 1796 1797 self.treeview.append(row_data) 1798 1799 self.save_button.set_sensitive(False) 1800 1801 self.treeview.update_menus() 1802 1803 def list_str_to_list(self, value): 1804 if isinstance(value, list): 1805 return value 1806 values = [] 1807 for value in value.replace(",", ";").split(";"): 1808 value = value.strip() 1809 if len(value) > 0: 1810 values.append(value) 1811 return values 1812 1813 def write_launcher(self, filename): # noqa 1814 keyfile = GLib.KeyFile.new() 1815 1816 for key, ktype, required in getRelatedKeys(self.get_value("Type")): 1817 if key == "Version": 1818 keyfile.set_string("Desktop Entry", "Version", "1.1") 1819 continue 1820 1821 if key == "Actions": 1822 action_list = [] 1823 for name, displayed, command, show in \ 1824 self.get_editor_actions(): 1825 group_name = "Desktop Action %s" % name 1826 keyfile.set_string(group_name, "Name", displayed) 1827 keyfile.set_string(group_name, "Exec", command) 1828 if show: 1829 action_list.append(name) 1830 keyfile.set_string_list("Desktop Entry", key, action_list) 1831 continue 1832 1833 value = self.get_value(key) 1834 if ktype == str: 1835 if len(value) > 0: 1836 keyfile.set_string("Desktop Entry", key, value) 1837 if ktype == float: 1838 if value != 0: 1839 keyfile.set_double("Desktop Entry", key, value) 1840 if ktype == bool: 1841 if value is not False: 1842 keyfile.set_boolean("Desktop Entry", key, value) 1843 if ktype == list: 1844 value = self.list_str_to_list(value) 1845 if len(value) > 0: 1846 keyfile.set_string_list("Desktop Entry", key, value) 1847 1848 try: 1849 if not keyfile.save_to_file(filename): 1850 return False 1851 except GLib.Error: 1852 return False 1853 1854 return True 1855 1856 def save_launcher(self, temp=False): # noqa 1857 """Save the current launcher details, remove from the current directory 1858 if it no longer has the required category.""" 1859 1860 if temp: 1861 filename = tempfile.mkstemp('.desktop', 'menulibre-')[1] 1862 else: 1863 # Get the filename to be used. 1864 original_filename = self.get_value('Filename') 1865 item_type = self.get_value('Type') 1866 name = self.get_value('Name') 1867 filename = util.getSaveFilename(name, original_filename, item_type) 1868 logger.debug("Saving launcher as \"%s\"" % filename) 1869 1870 if not temp: 1871 model, row_data = self.treeview.get_selected_row_data() 1872 item_type = row_data[MenuEditor.COL_TYPE] 1873 1874 model, parent_data = self.treeview.get_parent_row_data() 1875 1876 # Make sure required categories are in place - this is useful for 1877 # when a user moves a launcher from its original location to a new 1878 # directory - without the category associated with the new 1879 # directory (and no force-include), the launcher would not 1880 # otherwise show 1881 if parent_data is not None: 1882 # Parent was found, take its categories. 1883 required_categories = util.getRequiredCategories( 1884 parent_data[MenuEditor.COL_FILENAME]) 1885 else: 1886 # Parent was not found, this is a toplevel category 1887 required_categories = util.getRequiredCategories(None) 1888 current_categories = self.get_value('Categories').split(';') 1889 all_categories = current_categories 1890 for category in required_categories: 1891 1892 # Only add the 'required category' if the user has not 1893 # explicitly removed it 1894 if (category not in all_categories and 1895 category not in self.categories_removed): 1896 all_categories.append(category) 1897 1898 self.set_editor_categories(';'.join(all_categories)) 1899 1900 # Cleanup invalid entries and reorder the Categories and Actions 1901 self.cleanup_categories() 1902 self.cleanup_actions() 1903 1904 if not self.write_launcher(filename): 1905 dlg = Dialogs.SaveErrorDialog(self, filename) 1906 dlg.run() 1907 return 1908 1909 if temp: 1910 return filename 1911 1912 # Install the new item in its directory... 1913 self.treeview.xdg_menu_install(filename) 1914 1915 # Set the editor to the new filename. 1916 self.set_value('Filename', filename) 1917 1918 # Update the selected iter with the new details. 1919 name = self.get_value('Name') 1920 comment = self.get_value('Comment') 1921 executable = self.get_value('Exec') 1922 categories = self.get_value('Categories') 1923 icon_name = self.get_value('Icon') 1924 hidden = self.get_value('Hidden') or self.get_value('NoDisplay') 1925 self.treeview.update_selected(name, comment, executable, categories, 1926 item_type, icon_name, filename, not hidden) 1927 self.history.clear() 1928 1929 # Update all instances 1930 model, row_data = self.treeview.get_selected_row_data() 1931 self.treeview.update_launcher_instances(original_filename, row_data) 1932 1933 self.treeview.update_menus() 1934 1935 # Check and make sure that the launcher has been added to/removed from 1936 # directories that its category configuration dictates - this is not 1937 # deleting the launcher but removing it from various places in the UI 1938 self.update_launcher_category_dirs() 1939 1940 def update_launcher_categories(self, remove, add): # noqa 1941 original_filename = self.get_value('Filename') 1942 if original_filename is None or not os.path.isfile(original_filename): 1943 return 1944 item_type = self.get_value('Type') 1945 name = self.get_value('Name') 1946 save_filename = util.getSaveFilename(name, original_filename, 1947 item_type, force_update=True) 1948 logger.debug("Saving launcher as \"%s\"" % save_filename) 1949 1950 # Get the original contents 1951 keyfile = GLib.KeyFile.new() 1952 keyfile.load_from_file(original_filename, GLib.KeyFileFlags.NONE) 1953 1954 try: 1955 categories = keyfile.get_string_list("Desktop Entry", "Categories") 1956 except GLib.Error: 1957 categories = None 1958 1959 if categories is None: 1960 categories = [] 1961 1962 # Remove the old required categories 1963 for category in remove: 1964 if category in categories: 1965 categories.remove(category) 1966 1967 # Add the new required categories 1968 for category in add: 1969 if category not in categories: 1970 categories.append(category) 1971 1972 # Remove empty categories 1973 for category in categories: 1974 if category.strip() == "": 1975 try: 1976 categories.remove(category) 1977 except: # noqa 1978 pass 1979 1980 categories.sort() 1981 1982 # Commit the changes to a new file 1983 keyfile.set_string_list("Desktop Entry", "Categories", categories) 1984 keyfile.save_to_file(save_filename) 1985 1986 # Set the editor to the new filename. 1987 self.set_editor_filename(save_filename) 1988 1989 # Update all instances 1990 model, row_data = self.treeview.get_selected_row_data() 1991 row_data[MenuEditor.COL_CATEGORIES] = ';'.join(categories) 1992 row_data[MenuEditor.COL_FILENAME] = save_filename 1993 self.treeview.update_launcher_instances(original_filename, row_data) 1994 1995 def update_launcher_category_dirs(self): # noqa 1996 """Make sure launcher is present or absent from in all top-level 1997 directories that its categories dictate.""" 1998 1999 # Prior to menulibre being restarted, addition of a category does not 2000 # result in the launcher being added to or removed from the relevant 2001 # top-level directories - making sure this happens 2002 2003 # Fetching model and launcher information - removing empty category 2004 # at end of category split 2005 # Note that a user can remove all categories now if they want, which 2006 # would naturally remove the launcher from all top-level directories - 2007 # alacarte doesn't save any categories by default with a new launcher, 2008 # however to reach this point, any required categories (minus those the 2009 # user has explicitly deleted) will be added, so this shouldn't be a 2010 # problem 2011 model, row_data = self.treeview.get_selected_row_data() 2012 if row_data[MenuEditor.COL_CATEGORIES]: 2013 categories = row_data[MenuEditor.COL_CATEGORIES].split(';')[:-1] 2014 else: 2015 categories = [] 2016 filename = row_data[MenuEditor.COL_FILENAME] 2017 2018 required_category_directories = set() 2019 2020 # Obtaining a dictionary of iters to launcher instances in top-level 2021 # directories 2022 launcher_instances = self.treeview._get_launcher_instances(filename) 2023 launchers_in_top_level_dirs = {} 2024 for instance in launcher_instances: 2025 2026 # Make sure the launcher isn't top-level and is in a directory. 2027 # Must pass a model otherwise it gets the current selection iter 2028 # regardless... 2029 _, parent = self.treeview.get_parent(model, instance) 2030 if (parent is not None and 2031 model[parent][MenuEditor.COL_TYPE] == MenuItemTypes.DIRECTORY): 2032 2033 # Any direct parents are required directories. 2034 required_category_directories.add(model[parent][MenuEditor.COL_NAME]) 2035 2036 # Adding if the directory returned is top level 2037 _, parent_parent = self.treeview.get_parent(model, parent) 2038 if parent_parent is None: 2039 launchers_in_top_level_dirs[model[parent][MenuEditor.COL_NAME]] = instance 2040 2041 # Obtaining a lookup of top-level directories -> iters 2042 top_level_dirs = {} 2043 for row in model: 2044 if row[MenuEditor.COL_TYPE] == MenuItemTypes.DIRECTORY: 2045 top_level_dirs[row[MenuEditor.COL_NAME]] = model.get_iter(row.path) 2046 2047 # Looping through all set categories - category specified is at maximum 2048 # detail level, this needs to be converted to the parent group name, 2049 # and this needs to be converted into the directory name as it would 2050 # appear in the menu 2051 for category in categories: 2052 if category not in category_lookup.keys(): 2053 continue 2054 2055 category_group = category_lookup[category] 2056 directory_name = util.getDirectoryNameFromCategory(category_group) 2057 2058 # Adding to directories the launcher should be in 2059 if directory_name not in launchers_in_top_level_dirs: 2060 if directory_name in top_level_dirs.keys(): 2061 treeiter = self.treeview.add_child( 2062 row_data, top_level_dirs[directory_name], model, False) 2063 launchers_in_top_level_dirs[directory_name] = treeiter 2064 2065 # Building set of required category directories to detect 2066 # superfluous ones later 2067 if directory_name not in required_category_directories: 2068 required_category_directories.add(directory_name) 2069 2070 # Removing launcher from directories it should no longer be in 2071 superfluous_dirs = (set(launchers_in_top_level_dirs.keys()) 2072 - required_category_directories) 2073 _, parent_data = self.treeview.get_parent_row_data() 2074 2075 for directory_name in superfluous_dirs: 2076 2077 # Removing selected launcher from the UI if it is in the current 2078 # directory, otherwise just from the model 2079 if parent_data is not None and directory_name == parent_data[MenuEditor.COL_NAME]: 2080 self.treeview.remove_selected(True) 2081 2082 else: 2083 self.treeview.remove_iter( 2084 model, launchers_in_top_level_dirs[directory_name]) 2085 2086 def delete_separator(self): 2087 """Remove a separator row from the treeview, update the menu files.""" 2088 self.treeview.remove_selected() 2089 2090 def delete_launcher(self): 2091 """Delete the selected launcher.""" 2092 self.treeview.remove_selected() 2093 self.history.clear() 2094 2095 def restore_launcher(self): 2096 """Revert the current launcher.""" 2097 values = self.history.restore() 2098 2099 # Clear the history 2100 self.history.clear() 2101 2102 # Block updates 2103 self.history.block() 2104 2105 for key in list(values.keys()): 2106 self.set_value(key, values[key], store=True) 2107 2108 # Unblock updates 2109 self.history.unblock() 2110 2111# Callbacks 2112 def on_add_launcher_cb(self, widget): 2113 """Add Launcher callback function.""" 2114 self.add_launcher() 2115 2116 def on_add_directory_cb(self, widget): 2117 """Add Directory callback function.""" 2118 self.add_directory() 2119 2120 def on_add_separator_cb(self, widget): 2121 """Add Separator callback function.""" 2122 self.add_separator() 2123 2124 def on_save_launcher_cb(self, widget, builder): 2125 """Save Launcher callback function.""" 2126 self.on_NameComment_apply(None, 'Name', builder) 2127 self.on_NameComment_apply(None, 'Comment', builder) 2128 self.save_launcher() 2129 2130 def on_undo_cb(self, widget): 2131 """Undo callback function.""" 2132 key, value = self.history.undo() 2133 self.history.block() 2134 self.set_value(key, value) 2135 self.history.unblock() 2136 2137 def on_redo_cb(self, widget): 2138 """Redo callback function.""" 2139 key, value = self.history.redo() 2140 self.history.block() 2141 self.set_value(key, value) 2142 self.history.unblock() 2143 2144 def on_revert_cb(self, widget): 2145 """Revert callback function.""" 2146 dialog = Dialogs.RevertDialog(self) 2147 if dialog.run() == Gtk.ResponseType.OK: 2148 self.restore_launcher() 2149 dialog.destroy() 2150 2151 def find_in_path(self, command): 2152 if os.path.exists(os.path.abspath(command)): 2153 return os.path.abspath(command) 2154 for path in os.environ["PATH"].split(os.pathsep): 2155 if os.path.exists(os.path.join(path, command)): 2156 return os.path.join(path, command) 2157 return False 2158 2159 def find_command_in_string(self, command): 2160 for piece in shlex.split(command): 2161 if "=" not in piece: 2162 return piece 2163 return False 2164 2165 def on_execute_cb(self, widget, builder): 2166 """Execute callback function.""" 2167 self.on_NameComment_apply(None, 'Name', builder) 2168 self.on_NameComment_apply(None, 'Comment', builder) 2169 filename = self.save_launcher(True) 2170 2171 entry = MenulibreXdg.MenulibreDesktopEntry(filename) 2172 command = self.find_command_in_string(entry["Exec"]) 2173 2174 if self.find_in_path(command): 2175 subprocess.Popen(["xdg-open", filename]) 2176 GObject.timeout_add(2000, self.on_execute_timeout, filename) 2177 else: 2178 os.remove(filename) 2179 dlg = Dialogs.NotFoundInPathDialog(self, command) 2180 dlg.run() 2181 2182 def on_execute_timeout(self, filename): 2183 os.remove(filename) 2184 2185 def on_delete_cb(self, widget): 2186 """Delete callback function.""" 2187 model, row_data = self.treeview.get_selected_row_data() 2188 name = row_data[MenuEditor.COL_NAME] 2189 item_type = row_data[MenuEditor.COL_TYPE] 2190 2191 # Prepare the strings 2192 if item_type == MenuItemTypes.SEPARATOR: 2193 # Translators: Confirmation dialog to delete the selected 2194 # separator. 2195 question = _("Are you sure you want to delete this separator?") 2196 delete_func = self.delete_separator 2197 else: 2198 # Translators: Confirmation dialog to delete the selected launcher. 2199 question = _("Are you sure you want to delete \"%s\"?") % name 2200 delete_func = self.delete_launcher 2201 2202 dialog = Dialogs.DeleteDialog(self, question) 2203 2204 # Run 2205 if dialog.run() == Gtk.ResponseType.OK: 2206 delete_func() 2207 2208 dialog.destroy() 2209 2210 def on_quit_cb(self, widget): 2211 """Quit callback function. Send the quit signal to the parent 2212 GtkApplication instance.""" 2213 self.emit('quit', True) 2214 2215 def on_help_cb(self, widget): 2216 """Help callback function. Send the help signal to the parent 2217 GtkApplication instance.""" 2218 self.emit('help', True) 2219 2220 def on_about_cb(self, widget): 2221 """About callback function. Send the about signal to the parent 2222 GtkApplication instance.""" 2223 self.emit('about', True) 2224 2225 def on_bad_desktop_files_infobar_response(self, infobar, response_id): 2226 """Bad desktop files infobar callback function to request the bad 2227 desktop files report if desired.""" 2228 2229 # Dealing with request for details 2230 if response_id == Gtk.ResponseType.YES: 2231 self.bad_desktop_files_report_dialog() 2232 2233 # All response types should result in the infobar being hidden 2234 infobar.hide() 2235 2236 def bad_desktop_files_report_dialog(self): 2237 """Generate and display details of bad desktop files, or report 2238 successful parsing.""" 2239 2240 log_dialog = MenulibreLog.LogDialog(self) 2241 2242 # Building up a list of all known failures associated with the bad 2243 # desktop files 2244 for desktop_file in self.bad_desktop_files: 2245 log_dialog.add_item(desktop_file, 2246 util.validate_desktop_file(desktop_file)) 2247 2248 log_dialog.show() 2249 2250 2251class Application(Gtk.Application): 2252 """Menulibre GtkApplication""" 2253 2254 def __init__(self): 2255 """Initialize the GtkApplication.""" 2256 Gtk.Application.__init__(self) 2257 self.use_headerbar = False 2258 self.use_toolbar = False 2259 2260 self.settings_file = os.path.expanduser("~/.config/menulibre.cfg") 2261 2262 def set_use_headerbar(self, preference): 2263 try: 2264 settings = GLib.KeyFile.new() 2265 settings.set_boolean("menulibre", "UseHeaderbar", preference) 2266 settings.save_to_file(self.settings_file) 2267 except: # noqa 2268 pass 2269 2270 def get_use_headerbar(self): 2271 if not os.path.exists(self.settings_file): 2272 return None 2273 try: 2274 settings = GLib.KeyFile.new() 2275 settings.load_from_file(self.settings_file, GLib.KeyFileFlags.NONE) 2276 return settings.get_boolean("menulibre", "UseHeaderbar") 2277 except: # noqa 2278 return None 2279 2280 def do_activate(self): 2281 """Handle GtkApplication do_activate.""" 2282 if self.use_toolbar: 2283 headerbar = False 2284 self.set_use_headerbar(False) 2285 elif self.use_headerbar: 2286 headerbar = True 2287 self.set_use_headerbar(True) 2288 elif self.get_use_headerbar() is not None: 2289 headerbar = self.get_use_headerbar() 2290 elif current_desktop in ["budgie", "gnome", "pantheon"]: 2291 headerbar = True 2292 else: 2293 headerbar = False 2294 2295 self.win = MenulibreWindow(self, headerbar) 2296 self.win.show() 2297 2298 self.win.connect('about', self.about_cb) 2299 self.win.connect('help', self.help_cb) 2300 self.win.connect('quit', self.quit_cb) 2301 2302 def do_startup(self): 2303 """Handle GtkApplication do_startup.""" 2304 Gtk.Application.do_startup(self) 2305 2306 # 'Sections' without labels result in a separator separating functional 2307 # groups of menu items 2308 self.menu = Gio.Menu() 2309 section_1_menu = Gio.Menu() 2310 # Translators: Menu item to open the Parsing Errors dialog. 2311 section_1_menu.append(_("Parsing Error Log"), 2312 "app.bad_files") 2313 self.menu.append_section(None, section_1_menu) 2314 2315 section_2_menu = Gio.Menu() 2316 section_2_menu.append(_("Help"), "app.help") 2317 section_2_menu.append(_("About"), "app.about") 2318 section_2_menu.append(_("Quit"), "app.quit") 2319 self.menu.append_section(None, section_2_menu) 2320 2321 self.set_app_menu(self.menu) 2322 2323 # Bad desktop files detection on demand 2324 bad_files_action = Gio.SimpleAction.new("bad_files", None) 2325 bad_files_action.connect("activate", self.bad_files_cb) 2326 self.add_action(bad_files_action) 2327 2328 help_action = Gio.SimpleAction.new("help", None) 2329 help_action.connect("activate", self.help_cb) 2330 self.add_action(help_action) 2331 2332 about_action = Gio.SimpleAction.new("about", None) 2333 about_action.connect("activate", self.about_cb) 2334 self.add_action(about_action) 2335 2336 quit_action = Gio.SimpleAction.new("quit", None) 2337 quit_action.connect("activate", self.quit_cb) 2338 self.add_action(quit_action) 2339 2340 def bad_files_cb(self, widget, data=None): 2341 """Bad desktop files detection callback function.""" 2342 2343 # Determining paths of bad desktop files GMenu can't load, on demand 2344 # This state is normally tracked with the MenulibreWindow, so not 2345 # keeping it in this application object. By the time this is called, 2346 # self.win is valid 2347 self.win.bad_desktop_files = util.determine_bad_desktop_files() 2348 self.win.bad_desktop_files_report_dialog() 2349 2350 def help_cb(self, widget, data=None): 2351 """Help callback function.""" 2352 Dialogs.HelpDialog(self.win) 2353 2354 def about_cb(self, widget, data=None): 2355 """About callback function. Display the AboutDialog.""" 2356 dialog = Dialogs.AboutDialog(self.win) 2357 dialog.show() 2358 2359 def quit_cb(self, widget, data=None): 2360 """Signal handler for closing the MenulibreWindow.""" 2361 self.quit() 2362