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