#!/usr/bin/env python3 # coding: utf-8 # vim:et:sta:sts=4:sw=4:ts=8:tw=79: import os import sys import getopt import fnmatch import xdg.DesktopEntry as dentry import xdg.Exceptions as exc import xdg.BaseDirectory as bd from operator import attrgetter import configparser as cp # Load the gtk compatibility layer from gi import pygtkcompat pygtkcompat.enable() pygtkcompat.enable_gtk(version='3.0') import gtk seticon = False iconsize = 16 nosvg = False desktop = False submenu = True pekwmdynamic = False twmtitles = False max_icon_size = False # the following line gets changed by the Makefile. If it is set to # 'not_set' it looks in the currect directory tree for the .directory # files. If it is actually set to something else, it looks under there # for them, where they should be if this was installed properly prefix = 'not_set' if prefix == 'not_set': desktop_dir = '../desktop-directories/' else: desktop_dir = '{}/share/desktop-directories/'.format(prefix) if not os.path.isdir(desktop_dir): sys.exit('ERROR: Could not find {}'.format(desktop_dir)) class App: ''' A class to keep individual app details in. ''' def __init__(self, name, icon, command, path): self.name = name self.icon = icon self.command = command self.path = path def __repr__(self): return repr((self.name, self.icon, self.command, self.path)) class MenuEntry: ''' A class for each menu entry. Includes the class category and app details from the App class. ''' def __init__(self, category, app): self.category = category self.app = app def __repr__(self): return repr((self.category, self.app.name, self.app.icon, self.app.command, self.app.path)) class MenuCategory: ''' A class for each menu category. Keeps the category name and the list of apps that go in that category. ''' def __init__(self, category, applist): self.category = category self.applist = applist de = dentry.DesktopEntry(filename=desktop_dir + 'xdgmenumaker-applications.directory') applications = de.getName().encode('utf-8') apps_name = applications.decode() applications_icon = de.getIcon() de = dentry.DesktopEntry(filename=desktop_dir + 'xdgmenumaker-accessories.directory') accessories = de.getName().encode('utf-8') accessories_icon = de.getIcon() de = dentry.DesktopEntry(filename=desktop_dir + 'xdgmenumaker-development.directory') development = de.getName().encode('utf-8') development_icon = de.getIcon() de = dentry.DesktopEntry(filename=desktop_dir + 'xdgmenumaker-education.directory') education = de.getName().encode('utf-8') education_icon = de.getIcon() de = dentry.DesktopEntry(filename=desktop_dir + 'xdgmenumaker-games.directory') games = de.getName().encode('utf-8') games_icon = de.getIcon() de = dentry.DesktopEntry(filename=desktop_dir + 'xdgmenumaker-graphics.directory') graphics = de.getName().encode('utf-8') graphics_icon = de.getIcon() de = dentry.DesktopEntry(filename=desktop_dir + 'xdgmenumaker-multimedia.directory') multimedia = de.getName().encode('utf-8') multimedia_icon = de.getIcon() de = dentry.DesktopEntry(filename=desktop_dir + 'xdgmenumaker-network.directory') network = de.getName().encode('utf-8') network_icon = de.getIcon() de = dentry.DesktopEntry(filename=desktop_dir + 'xdgmenumaker-office.directory') office = de.getName().encode('utf-8') office_icon = de.getIcon() de = dentry.DesktopEntry(filename=desktop_dir + 'xdgmenumaker-settings.directory') settings = de.getName().encode('utf-8') settings_icon = de.getIcon() de = dentry.DesktopEntry(filename=desktop_dir + 'xdgmenumaker-system.directory') system = de.getName().encode('utf-8') system_icon = de.getIcon() de = dentry.DesktopEntry(filename=desktop_dir + 'xdgmenumaker-other.directory') other = de.getName().encode('utf-8') other_icon = de.getIcon() # Find out which terminal emulator to use for apps that need to be # launched in a terminal. # First check if the XDGMENUMAKERTERM environment variable is set and use it if # it is. # Then see if there is a user specified terminal emulator in the # xdgmenumaker.cfg file. terminal_app = os.getenv("XDGMENUMAKERTERM") if not terminal_app: try: config = cp.ConfigParser() config.read(os.path.expanduser('~/.config/xdgmenumaker.cfg')) terminal_app = config.get('Terminal', 'terminal') # if there isn't, on debian and debian-likes, use the alternatives # system, otherwise default to xterm except (cp.NoSectionError, cp.NoOptionError) as e: if (os.path.exists('/etc/alternatives/x-terminal-emulator') and os.path.exists('/usr/bin/x-terminal-emulator')): terminal_app = '/usr/bin/x-terminal-emulator' else: terminal_app = 'xterm' def main(argv): global desktop global seticon global iconsize global nosvg global submenu global pekwmdynamic global twmtitles global max_icon_size try: opts, args = getopt.getopt(argv, "hins:f:", ["help", "icons", "no-submenu", "pekwm-dynamic", "twm-titles", "max-icon-size", "no-svg", "size=", "format="]) except getopt.GetoptError: usage() sys.exit(2) for opt, arg in opts: if opt in ("-h", "--help"): usage() sys.exit(0) elif opt in ("-i", "--icons"): seticon = True elif opt in ("-s", "--size"): try: iconsize = int(arg) except ValueError: usage() sys.exit('ERROR: size must be a number') elif opt in ("-n", "--no-submenu"): submenu = False elif opt in ("--pekwm-dynamic",): pekwmdynamic = True elif opt in ("--twm-titles",): twmtitles = True elif opt in ("--max-icon-size",): try: # Pillow is optional and loaded only if we want to restrict the # icon sizes (useful for Fvwm). Yeah, I know it's not a good # idea to load a module in here, but I really don't want to # load it by default at the top. It would make xdgmenumaker a # bit slower to run even if it is not needed. This way it only # slows down when it is actually needed. global Image from PIL import Image max_icon_size = True except ImportError: usage() sys.exit('ERROR: --max-icon-size requires Pillow') elif opt == "--no-svg": nosvg = True elif opt in ("-f", "--format"): desktop = arg if not desktop: usage() sys.exit('ERROR: You must specify the output format with -f') elif desktop == "blackbox": blackbox() elif desktop == "fluxbox": fluxbox() elif desktop == "fvwm": fvwm() elif desktop == "windowmaker": seticon = False windowmaker() elif desktop == "icewm": icewm() elif desktop == "pekwm": pekwmmenu() elif desktop == "jwm": jwm() elif desktop == "compizboxmenu": compizboxmenu() elif desktop == "twm": twm() elif desktop == "amiwm": seticon = False amiwm() else: usage() sys.exit(2) def usage(): print('USAGE:', os.path.basename(sys.argv[0]), '[OPTIONS]') print() print('OPTIONS:') print(' -f, --format the output format to use.') print(' Valid options are amiwm, blackbox, compizboxmenu,') print(' fluxbox, fvwm, twm, icewm, jwm, windowmaker and pekwm') print(' -i, --icons enable support for icons in the') print(' menus. Does not work with windowmaker or amiwm') print(' --no-svg Do not use SVG icons even for WMs that support it') print(' -s, --size preferred icon size in pixels (default: 16)') print(' -n, --no-submenu do not create a submenu. Does not work with') print(' windowmaker') print(' --max-icon-size restrict the icon sizes to the specified size') print(' --pekwm-dynamic generate dynamic menus for pekwm') print(' --twm-titles show menu titles in twm menus') print(' -h, --help show this help message') print(' You have to specify the output format using the -f switch.') print() print('EXAMPLES:') print(' xdgmenumaker -f windowmaker') print(' xdgmenumaker -i -f fluxbox') def icon_strip(icon): # strip the directory and extension from the icon name icon = os.path.basename(icon) main, ext = os.path.splitext(icon) ext = ext.lower() if ext == '.png' or ext == '.svg' or ext == '.svgz' or ext == '.xpm': return main return icon def icon_max_size(icon): # Checks if the icon size is bigger than the requested size and discards # the icon if it is, only allowing sizes smaller or equal to the requested # size try: img = Image.open(icon) except: # if there is any error reading the icon, just discard it return None if img.size[0] <= iconsize: return icon return None def icon_full_path(icon): # If the icon path is absolute and exists, leave it alone. # This takes care of software that has its own icons stored # in non-standard directories. ext = os.path.splitext(icon)[1].lower() if os.path.exists(icon): if ext == ".svg" or ext == ".svgz": # only jwm supports svg icons if desktop == "jwm" and not nosvg: return icon else: # icon is not svg if max_icon_size: return icon_max_size(icon) else: return icon # fall back to looking for the icon elsewhere in the system icon = icon_strip(icon) icon_theme = gtk.icon_theme_get_default() try: # JWM supports svg icons if desktop == "jwm" and not nosvg: icon = icon_theme.lookup_icon(icon, iconsize, gtk.ICON_LOOKUP_FORCE_SVG) # but none of the other WMs does else: icon = icon_theme.lookup_icon(icon, iconsize, gtk.ICON_LOOKUP_NO_SVG) except AttributeError: sys.exit('ERROR: You need to run xdgmenumaker inside an X session.') if icon: icon = icon.get_filename() # icon size only matters for non-SVG icons if icon and max_icon_size and (ext != ".svg" or ext != ".svgz"): icon = icon_max_size(icon) return icon def remove_command_keys(command, desktopfile, icon): # replace the %i (icon key) if it's there. This is what freedesktop has to # say about it: "The Icon key of the desktop entry expanded as two # arguments, first --icon and then the value of the Icon key. Should not # expand to any arguments if the Icon key is empty or missing." if icon: command = command.replace('"%i"', '--icon {}'.format(icon)) command = command.replace("'%i'", '--icon {}'.format(icon)) command = command.replace('%i', '--icon {}'.format(icon)) # some KDE apps have this "-caption %c" in a few variations. %c is "The # translated name of the application as listed in the appropriate Name key # in the desktop entry" according to freedesktop. All apps launch without a # problem without it as far as I can tell, so it's better to remove it than # have to deal with extra sets of nested quotes which behave differently in # each WM. This is not 100% failure-proof. There might be other variations # of this out there, but we can't account for every single one. If someone # finds one another one, I can always add it later. command = command.replace('-caption "%c"', '') command = command.replace("-caption '%c'", '') command = command.replace('-caption %c', '') # same as before, although with -qwindowtitle (typical for Qt 5 apps). command = command.replace('-qwindowtitle "%c"', '') command = command.replace("-qwindowtitle '%c'", '') command = command.replace('-qwindowtitle %c', '') # replace the %k key. This is what freedesktop says about it: "The # location of the desktop file as either a URI (if for example gotten from # the vfolder system) or a local filename or empty if no location is # known." command = command.replace('"%k"', desktopfile) command = command.replace("'%k'", desktopfile) command = command.replace('%k', desktopfile) # removing any remaining keys from the command. That can potentially remove # any other trailing options after the keys, command = command.partition('%')[0] return command def clean_up_categories(categories): # cleaning up categories and keeping only registered freedesktop.org main # categories category_menus = { "AudioVideo": multimedia, "Audio": multimedia, "Video": multimedia, "Development": development, "Education": education, "Game": games, "Graphics": graphics, "Network": network, "Office": office, "System": system, "Settings": settings, "Utility": accessories } category = other for candidate in categories: if candidate in category_menus: category = category_menus.get(candidate) break return category def get_entry_info(desktopfile, ico_paths=True): de = dentry.DesktopEntry(filename=desktopfile) # skip processing the rest of the desktop entry if the item is to not be # displayed anyway onlyshowin = de.getOnlyShowIn() notshowin = de.getNotShowIn() hidden = de.getHidden() nodisplay = de.getNoDisplay() # none of the freedesktop registered environments are supported by # OnlyShowIn but it might be worth using some extra logic here. # http://standards.freedesktop.org/menu-spec/latest/apb.html if (onlyshowin != [] and not (desktop.lower() in (name.lower() for name in onlyshowin))) \ or (desktop.lower() in (name.lower for name in notshowin)) \ or hidden or nodisplay: return None name = de.getName().encode('utf-8') if seticon: icon = de.getIcon() if ico_paths: icon = icon_full_path(icon) else: icon = icon_strip(icon) else: icon = None command = de.getExec() command = remove_command_keys(command, desktopfile, icon) terminal = de.getTerminal() if terminal: command = '{term} -e {cmd}'.format(term=terminal_app, cmd=command) path = de.getPath() if not path: path = None categories = de.getCategories() category = clean_up_categories(categories) app = App(name, icon, command, path) mentry = MenuEntry(category, app) return mentry def sortedcategories(applist): categories = [] for e in applist: categories.append(e.category) categories = sorted(set(categories)) return categories def desktopfilelist(): # if this env variable is set to 1, then only read .desktop files from the # tests directory, not systemwide. This gives a standard set of .desktop # files to compare against for testing. testing = os.getenv('XDGMENUMAKER_TEST') if testing == "1": dirs = ['../tests'] else: dirs = [] # some directories are mentioned twice in bd.xdg_data_dirs, once # with and once without a trailing / for i in bd.xdg_data_dirs: i = i.rstrip('/') if i not in dirs: dirs.append(i) filelist = [] df_temp = [] for d in dirs: xdgdir = '{}/applications'.format(d) if os.path.isdir(xdgdir): for root, dirnames, filenames in os.walk(xdgdir): for i in fnmatch.filter(filenames, '*.desktop'): # for duplicate .desktop files that exist in more # than one locations, only keep the first occurence. # That one should have precedence anyway (e.g. # ~/.local/share/applications has precedence over # /usr/share/applications if i not in df_temp: df_temp.append(i) filelist.append(os.path.join(root, i)) return filelist def menu(ico_paths=True): applist = [] for desktopfile in desktopfilelist(): try: entry = get_entry_info(desktopfile, ico_paths=ico_paths) if entry is not None: applist.append(entry) except exc.ParsingError: pass sortedapplist = sorted(applist, key=attrgetter('category', 'app.name')) menu = [] for c in sortedcategories(applist): appsincategory = [] for i in sortedapplist: if i.category == c: appsincategory.append(i.app) menu_category = MenuCategory(c, appsincategory) menu.append(menu_category) return menu def category_icon(category): if category == accessories: icon = accessories_icon elif category == development: icon = development_icon elif category == education: icon = education_icon elif category == games: icon = games_icon elif category == graphics: icon = graphics_icon elif category == multimedia: icon = multimedia_icon elif category == network: icon = network_icon elif category == office: icon = office_icon elif category == settings: icon = settings_icon elif category == system: icon = system_icon elif category == other: icon = other_icon else: icon = None return icon def blackbox(): # Blackbox menus are the same as Fluxbox menus. They just don't support # icons. global seticon seticon = False fluxbox() def fluxbox(): if submenu: spacing = ' ' if seticon: app_icon = icon_full_path(applications_icon) if app_icon is None: print('[submenu] ({})'.format(apps_name)) else: print('[submenu] ({}) <{}>'.format(apps_name, app_icon)) else: print('[submenu] ({})'.format(apps_name)) else: spacing = '' for menu_category in menu(): category = menu_category.category cat_name = category.decode() if seticon: cat_icon = category_icon(category) cat_icon = icon_full_path(cat_icon) if cat_icon: print('{s}[submenu] ({c}) <{i}>'.format(s=spacing, c=cat_name, i=cat_icon)) else: print('{s}[submenu] ({c})'.format(s=spacing, c=cat_name)) else: print('{s}[submenu] ({c})'.format(s=spacing, c=cat_name)) for app in menu_category.applist: # closing parentheses need to be escaped, otherwise they are # cropped out, along with everything that comes after them name = app.name.decode().replace(')', '\)') icon = app.icon command = app.command path = app.path if path is not None: command = 'cd {p} ; {c}'.format(p=path, c=command) if icon is None: print('{s} [exec] ({n}) {{{c}}}'.format(s=spacing, n=name, c=command)) else: print('{s} [exec] ({n}) {{{c}}} <{i}>'.format(s=spacing, n=name, c=command, i=icon)) print('{s}[end] # ({c})'.format(s=spacing, c=cat_name)) if submenu: print('[end] # ({})'.format(apps_name)) def fvwm(): if submenu: print('DestroyMenu "xdgmenu"') print('AddToMenu "xdgmenu"') if seticon: app_icon = icon_full_path(applications_icon) if app_icon: print('+ "{a}%{i}% " Title'.format(a=apps_name, i=app_icon)) else: print('+ "{a}" Title'.format(a=apps_name)) app_icon = icon_full_path(applications_icon) for menu_category in menu(): category = menu_category.category cat_name = category.decode() cat_icon = category_icon(category) cat_icon = icon_full_path(cat_icon) if cat_icon: print('+ "{c}%{i}%" Popup "{c}"'.format(c=cat_name, i=cat_icon)) else: print('+ "{c}" Popup "{c}"'.format(c=cat_name)) else: for menu_category in menu(): category = menu_category.category cat_name = category.decode() print('+ "{c}" Popup "{c}"'.format(c=cat_name)) print() for menu_category in menu(): category = menu_category.category cat_name = category.decode() print('DestroyMenu "{}"'.format(cat_name)) print('AddToMenu "{c}"'.format(c=cat_name)) if seticon: cat_icon = category_icon(category) cat_icon = icon_full_path(cat_icon) if cat_icon: print('+ "{c}%{i}%" Title'.format(c=cat_name, i=cat_icon)) else: print('+ "{c}" Title'.format(c=cat_name)) else: print('+ "{c}" Title'.format(c=cat_name)) for app in menu_category.applist: name = app.name.decode() icon = app.icon command = app.command path = app.path if path is not None: command = 'cd {p} ; {c}'.format(p=path, c=command) if icon is None: print('+ "{n}" Exec {c}'.format(n=name, c=command)) else: print('+ "{n}%{i}%" Exec {c}'.format(n=name, c=command, i=icon)) print() def windowmaker(): print('"{}" MENU'.format(apps_name)) for menu_category in menu(): category = menu_category.category cat_name = category.decode() print(' "{}" MENU'.format(cat_name)) for app in menu_category.applist: name = app.name.decode() command = app.command print(' "{n}" EXEC {c}'.format(n=name, c=command)) print(' "{}" END'.format(cat_name)) print('"{}" END'.format(apps_name)) def icewm(): if submenu: spacing = ' ' if seticon: app_icon = icon_full_path(applications_icon) if app_icon is None: app_icon = "_none_" print('menu "{a}" {i} {{'.format(a=apps_name, i=app_icon)) else: print('menu "{}" _none_ {{'.format(apps_name)) else: spacing = '' for menu_category in menu(): category = menu_category.category cat_name = category.decode() cat_icon = category_icon(category) cat_icon = icon_full_path(cat_icon) if seticon and cat_icon is not None: print('{s}menu "{c}" {i} {{'.format(s=spacing, c=cat_name, i=cat_icon)) else: print('{s}menu "{c}" _none_ {{'.format(s=spacing, c=cat_name)) for app in menu_category.applist: name = app.name.decode() icon = app.icon command = app.command if seticon and icon is not None: print('{s} prog "{n}" {i} {c}'.format(s=spacing, n=name, i=icon, c=command)) else: print('{s} prog "{n}" _none_ {c}'.format(s=spacing, n=name, c=command)) print('{}}}'.format(spacing)) if submenu: print('}') def pekwmmenu(): if pekwmdynamic: print("Dynamic {") dspacing = ' ' else: dspacing = '' if submenu: spacing = ' ' if seticon: app_icon = icon_full_path(applications_icon) print('{s}Submenu = "{a}" {{ Icon = "{i}"'.format(s=dspacing, a=apps_name, i=app_icon)) else: print('{s}Submenu = "{a}" {{'.format(s=dspacing, a=apps_name)) else: spacing = '' for menu_category in menu(): category = menu_category.category cat_name = category.decode() cat_icon = category_icon(category) cat_icon = icon_full_path(cat_icon) if seticon and cat_icon is not None: print('{d}{s}Submenu = "{c}" {{ Icon = "{i}"'.format(d=dspacing, s=spacing, c=cat_name, i=cat_icon)) else: print('{d}{s}Submenu = "{c}" {{'.format(d=dspacing, s=spacing, c=cat_name)) for app in menu_category.applist: name = app.name.decode() icon = app.icon # for some apps (like netbeans) the command is launched with # /bin/sh "command" # and the quotes get mixed up with the quotes pekwm puts # around Actions, so we're just stripping the quotes command = app.command.replace('"', '') path = app.path if path is not None: # pekwm doesn't like "cd path ; command", but it works # with "&&" and "||", so we'll launch the command even if the # path does not exist command = 'cd {p} && {c} || {c}'.format(p=path, c=command) if seticon and icon is not None: print('{d}{s} Entry = "{n}" {{ Icon = "{i}"; Actions = "Exec {c} &" }}' .format(d=dspacing, s=spacing, n=name, i=icon, c=command)) else: print('{d}{s} Entry = "{n}" {{ Actions = "Exec {c} &" }}' .format(d=dspacing, s=spacing, n=name, c=command)) print('{d}{s}}}'.format(d=dspacing, s=spacing)) if submenu: print('{}}}'.format(dspacing)) if pekwmdynamic: print("}") def jwm(): print('') print('') if submenu: spacing = ' ' if seticon: app_icon = icon_full_path(applications_icon) if app_icon is None: print(''.format(apps_name)) else: print(''.format(i=app_icon, a=apps_name)) else: print(''.format(apps_name)) else: spacing = '' for menu_category in menu(): category = menu_category.category cat_name = category.decode() cat_icon = category_icon(category) cat_icon = icon_full_path(cat_icon) if seticon and cat_icon is not None: print('{s}'.format(s=spacing, i=cat_icon, c=cat_name)) else: print('{s}'.format(s=spacing, c=cat_name)) for app in menu_category.applist: name = app.name.decode() icon = app.icon command = app.command path = app.path if path is not None: command = 'cd {p} ; {c}'.format(p=path, c=command) if seticon and icon is not None: print('{s} {c}' .format(s=spacing, i=icon, n=name, c=command)) else: print('{s} {c}' .format(s=spacing, n=name, c=command)) print('{}'.format(spacing)) if submenu: print('') print('') def compizboxmenu(): if submenu: spacing = ' ' if seticon: app_icon = icon_strip(applications_icon) print(''.format(i=app_icon, a=apps_name)) else: print(''.format(apps_name)) else: spacing = '' for menu_category in menu(ico_paths=False): category = menu_category.category cat_name = category.decode() cat_icon = category_icon(category) if seticon and cat_icon is not None: print('{s}'.format( s=spacing, i=cat_icon, c=cat_name)) else: print('{s}'.format(s=spacing, c=cat_name)) for app in menu_category.applist: name = app.name.decode().replace('&', '&') icon = app.icon command = app.command.replace("'", "'\\''").replace('&', '&') path = app.path if path is not None: path = path.replace("'", "'\\''") command = 'sh -c \'cd "{p}" ;{c}\''.format(p=path, c=command) if seticon and icon is not None: print(('{s} {n}' '{i}' '{c}').format(s=spacing, n=name, i=icon, c=command)) else: print(('{s} {n}' '{c}').format(s=spacing, n=name, c=command)) print('{}'.format(spacing)) if submenu: print('') def twm(): if submenu: print('menu "xdgmenu"') print("{") if twmtitles: print(' "{a}" f.title'.format(a=apps_name)) for menu_category in menu(): category = menu_category.category cat_name = category.decode() print(' "{c}" f.menu "{c}"'.format(c=cat_name)) print("}") for menu_category in menu(): category = menu_category.category cat_name = category.decode() print('menu "{}"'.format(cat_name)) print("{") if twmtitles: print(' "{c}" f.title'.format(c=cat_name)) for app in menu_category.applist: name = app.name.decode() # for some apps (like netbeans) the command is launched with # /bin/sh "command" # and the quotes get mixed up with the quotes twm puts # around the command, so we're just stripping the quotes command = app.command.replace('"', '') path = app.path if path is not None: print(' "{n}" f.exec "cd {p} ; {c} &"'.format(n=name, p=path, c=command)) else: print(' "{n}" f.exec "{c} &"'.format(n=name, c=command)) print("}") def amiwm(): for menu_category in menu(): category = menu_category.category cat_name = category.decode() print('ToolItem "{}" {{'.format(cat_name)) for app in menu_category.applist: name = app.name.decode() # for some apps (like netbeans) the command is launched with # /bin/sh "command" # and the quotes get mixed up with the quotes amim needs # around the command, so we're just stripping the quotes command = app.command.replace('"', '') path = app.path if path is not None: print(' ToolItem "{n}" "cd {p} ; {c}" ""'.format(n=name, p=path, c=command)) else: print(' ToolItem "{n}" "{c}" ""'.format(n=name, c=command)) print("}") if __name__ == "__main__": main(sys.argv[1:])