├── .gitignore ├── LICENSE ├── README.md └── dmenu-frecency /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 kspi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dmenu-frecency 2 | 3 | A dmenu-based desktop application launcher that uses a combination of frequency 4 | and recency to sort the application list. This is similar to the way Firefox 5 | sorts its location bar suggestions. 6 | 7 | Applications that haven't been launched yet are sorted by modification date, so 8 | the newest ones are at the top. 9 | 10 | If no application title matches the input, it it executed as a shell command 11 | (and saved for later suggestions). 12 | 13 | It scans XDG desktop files and optionally executables from PATH (off by default). 14 | 15 | ![Screenshot](http://i.imgur.com/UqwtAGL.png) 16 | 17 | ## Requirements 18 | 19 | Python, pyxdg, docopt and dmenu. 20 | 21 | ## Configuration 22 | 23 | On first launch a `config.json` is saved in `~/.config/dmenu-frecency` (or 24 | wherever `XDG_CONFIG_PATH` is) where dmenu's command line and some other 25 | options can be customized. The command line arguments are specified as a JSON 26 | array, for example `["-i", "-b"]`. The application cache is updated every 27 | `cache-days` or if `--read-apps` is passed on the command line. 28 | 29 | PATH scanning can by activated with the "scan-path" option. 30 | -------------------------------------------------------------------------------- /dmenu-frecency: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Dmenu launcher with history sorted by frecency. 3 | 4 | Usage: 5 | dmenu-frecency [--read-apps] 6 | 7 | Options: 8 | --read-apps rereads all .desktop files. 9 | """ 10 | 11 | from docopt import docopt 12 | import os 13 | import sys 14 | import xdg.BaseDirectory 15 | from xdg.DesktopEntry import DesktopEntry 16 | from subprocess import Popen, PIPE 17 | from datetime import datetime 18 | from collections import defaultdict 19 | import pickle 20 | import re 21 | import gzip 22 | import json 23 | import tempfile 24 | import shlex 25 | 26 | 27 | CONFIG_DIR = xdg.BaseDirectory.save_config_path('dmenu-frecency') 28 | 29 | 30 | # Python 2 compatibility 31 | try: 32 | FileNotFoundError 33 | except NameError: 34 | FileNotFoundError = IOError 35 | 36 | 37 | class Application: 38 | def __init__(self, name, command_line, mtime=None, path=None, is_desktop=False): 39 | self.name = name 40 | self.path = path 41 | self.command_line = command_line 42 | self.is_desktop = is_desktop 43 | self.show_command = False 44 | if mtime is None: 45 | self.mtime = datetime.now() 46 | else: 47 | self.mtime = mtime 48 | 49 | def run(self): 50 | if os.fork() == 0: 51 | if self.path: 52 | os.chdir(self.path) 53 | os.execvp(os.path.expanduser(self.command_line[0]), self.command_line) 54 | 55 | def __lt__(self, other): 56 | return (self.is_desktop, self.mtime) < (other.is_desktop, other.mtime) 57 | 58 | def __eq__(self, other): 59 | return self.name == other.name 60 | 61 | def __hash__(self): 62 | return hash(self.name) 63 | 64 | def __str__(self): 65 | return "".format(self.name, self.command_line) 66 | 67 | 68 | STATE_VERSION = 4 69 | 70 | 71 | def get_command(desktop_entry): 72 | tokens = [] 73 | for token in shlex.split(desktop_entry.getExec()): 74 | if token == '%i': 75 | if desktop_entry.getIcon(): 76 | tokens.append('--icon') 77 | tokens.append(desktop_entry.getIcon()) 78 | else: 79 | i = 0 80 | newtok = "" 81 | nc = len(token) 82 | while i < nc: 83 | c = token[i] 84 | if c == '%' and i < nc - 1: 85 | i += 1 86 | code = token[i] 87 | if code == 'c' and desktop_entry.getName(): 88 | newtok += desktop_entry.getName() 89 | elif code == '%': 90 | newtok += '%' 91 | else: 92 | newtok += c 93 | i += 1 94 | if newtok: 95 | tokens.append(newtok) 96 | return tuple(tokens) 97 | 98 | 99 | class LauncherState: 100 | STATE_FILENAME = os.path.join(CONFIG_DIR, 'state') 101 | 102 | def __init__(self, config): 103 | self.version = STATE_VERSION 104 | self.config = config 105 | self.find_apps() 106 | self.apps_generated_at = datetime.now() 107 | self.visits = defaultdict(list) 108 | self.visit_count = defaultdict(int) 109 | self.app_last_visit = None 110 | self.frecency_cache = {} 111 | 112 | def apps_by_frecency(self): 113 | app_last_visit = self.app_last_visit if self.config['preselect-last-visit'] else None 114 | if app_last_visit is not None: 115 | yield app_last_visit 116 | for app, frec in sorted(self.frecency_cache.items(), key=lambda x: (-x[1], x[0])): 117 | if app_last_visit is None or app_last_visit != app: 118 | yield app 119 | for app in self.sorted_apps: 120 | if app not in self.frecency_cache: 121 | if app_last_visit is None or app_last_visit != app: 122 | yield app 123 | 124 | def add_visit(self, app): 125 | if not app.is_desktop and app.command_line in self.command_apps: 126 | app = self.command_apps[app.command_line] 127 | app.show_command = True 128 | try: 129 | self.sorted_apps.remove(app) 130 | except ValueError: 131 | pass # not in list 132 | vs = self.visits[app] 133 | now = datetime.now() 134 | vs.append(now) 135 | self.visit_count[app] += 1 136 | self.visits[app] = vs[-self.config['frecency-visits']:] 137 | self.app_last_visit = app if self.config['preselect-last-visit'] else None 138 | 139 | def update_frecencies(self): 140 | for app in self.visits.keys(): 141 | self.frecency_cache[app] = self.frecency(app) 142 | 143 | def frecency(self, app): 144 | points = 0 145 | for v in self.visits[app]: 146 | days_ago = (datetime.now() - v).days 147 | if days_ago < 4: 148 | points += 100 149 | elif days_ago < 14: 150 | points += 70 151 | elif days_ago < 31: 152 | points += 50 153 | elif days_ago < 90: 154 | points += 30 155 | else: 156 | points += 10 157 | 158 | return int(self.visit_count[app] * points / len(self.visits[app])) 159 | 160 | @classmethod 161 | def load(cls, config): 162 | try: 163 | with gzip.open(cls.STATE_FILENAME, 'rb') as f: 164 | obj = pickle.load(f) 165 | version = getattr(obj, 'version', 0) 166 | if version < STATE_VERSION: 167 | new_obj = cls(config) 168 | if version <= 1: 169 | for app, vs in obj.visits.items(): 170 | vc = obj.visit_count[app] 171 | app.is_desktop = True 172 | new_obj.visit_count[app] = vc 173 | new_obj.visits[app] = vs 174 | new_obj.find_apps() 175 | new_obj.clean_cache() 176 | new_obj.update_frecencies() 177 | new_obj.config = config 178 | return new_obj 179 | else: 180 | obj.config = config 181 | return obj 182 | except FileNotFoundError: 183 | return cls(config) 184 | 185 | def save(self): 186 | with tempfile.NamedTemporaryFile( 187 | 'wb', 188 | dir=os.path.dirname(self.STATE_FILENAME), 189 | delete=False) as tf: 190 | tempname = tf.name 191 | with gzip.open(tempname, 'wb') as gzipf: 192 | pickle.dump(self, gzipf) 193 | os.rename(tempname, self.STATE_FILENAME) 194 | 195 | def find_apps(self): 196 | self.apps = {} 197 | self.command_apps = {} 198 | if self.config['scan-desktop-files']: 199 | for applications_directory in xdg.BaseDirectory.load_data_paths("applications"): 200 | if os.path.exists(applications_directory): 201 | for dirpath, dirnames, filenames in os.walk(applications_directory): 202 | for f in filenames: 203 | if f.endswith('.desktop'): 204 | full_filename = os.path.join(dirpath, f) 205 | self.add_desktop(full_filename) 206 | 207 | if self.config['scan-path']: 208 | for pathdir in os.environ["PATH"].split(os.pathsep): 209 | pathdir = pathdir.strip('"') 210 | if not os.path.isdir(pathdir): 211 | continue 212 | 213 | for f in os.listdir(pathdir): 214 | filename = os.path.join(pathdir, f) 215 | if os.path.isfile(filename) and os.access(filename, os.X_OK): 216 | app = Application( 217 | name=f, 218 | command_line=(f,), 219 | mtime=datetime.fromtimestamp(os.path.getmtime(filename))) 220 | self.add_app(app) 221 | self.sorted_apps = sorted(self.apps.values(), reverse=True) 222 | 223 | def add_desktop(self, filename): 224 | try: 225 | d = DesktopEntry(filename) 226 | if d.getHidden() or d.getNoDisplay() or d.getTerminal() or d.getType() != 'Application': 227 | return 228 | app = Application( 229 | name=d.getName(), 230 | command_line=get_command(d), 231 | mtime=datetime.fromtimestamp(os.path.getmtime(filename)), 232 | is_desktop=True) 233 | if d.getPath(): 234 | app.path = d.getPath() 235 | self.add_app(app) 236 | except (xdg.Exceptions.ParsingError, 237 | xdg.Exceptions.DuplicateGroupError, 238 | xdg.Exceptions.DuplicateKeyError, 239 | ValueError) as e: 240 | sys.stderr.write("Failed to parse desktop file '{}': {!r}\n".format(filename, e)) 241 | 242 | def add_app(self, app): 243 | if app.command_line not in self.command_apps: 244 | self.apps[app.name] = app 245 | self.command_apps[app.command_line] = app 246 | 247 | def clean_cache(self): 248 | for app in list(self.frecency_cache.keys()): 249 | if app.is_desktop and app.name not in self.apps: 250 | del self.frecency_cache[app] 251 | if self.app_last_visit is not None and self.app_last_visit.name not in self.apps: 252 | self.app_last_visit = None 253 | 254 | 255 | class DmenuFrecency: 256 | CONFIG_FILENAME = os.path.join(CONFIG_DIR, 'config.json') 257 | DEFAULT_CONFIG = { 258 | 'dmenu': 'dmenu', 259 | 'dmenu-args': ['-i'], 260 | 'cache-days': 1, 261 | 'frecency-visits': 10, 262 | 'preselect-last-visit': False, 263 | 'scan-desktop-files': True, 264 | 'scan-path': False, 265 | } 266 | NAME_WITH_COMMAND = re.compile(r"(.+) \([^()]+\)") 267 | 268 | def __init__(self, arguments): 269 | self.read_apps = arguments['--read-apps'] 270 | self.load_config() 271 | self.state = LauncherState.load(self.config) 272 | assert self.state, "Failed to load state." 273 | 274 | def load_config(self): 275 | self.config = {} 276 | self.config.update(self.DEFAULT_CONFIG) 277 | try: 278 | with open(self.CONFIG_FILENAME, 'r') as f: 279 | self.config.update(json.load(f)) 280 | except FileNotFoundError: 281 | with open(self.CONFIG_FILENAME, 'w') as f: 282 | json.dump(self.config, f, sort_keys=True, indent=4) 283 | f.write('\n') 284 | 285 | def main(self): 286 | if self.read_apps: 287 | self.state.find_apps() 288 | self.state.clean_cache() 289 | self.state.save() 290 | return 291 | 292 | dmenu = Popen([self.config['dmenu']] + self.config['dmenu-args'], stdin=PIPE, stdout=PIPE) 293 | for app in self.state.apps_by_frecency(): 294 | app_name = app.name.encode('utf-8') 295 | dmenu.stdin.write(app_name) 296 | if app.show_command and app.name != app.command_line[0]: 297 | dmenu.stdin.write(" ({})".format(' '.join(app.command_line)).encode('utf-8')) 298 | dmenu.stdin.write(b'\n') 299 | stdout, stderr = dmenu.communicate() 300 | result = stdout.decode('utf-8').strip() 301 | 302 | if not result: 303 | return 304 | 305 | if result in self.state.apps: 306 | app = self.state.apps[result] 307 | else: 308 | m = self.NAME_WITH_COMMAND.match(result) 309 | if m and m.group(1) in self.state.apps: 310 | app = self.state.apps[m.group(1)] 311 | else: 312 | app = Application( 313 | name=result, 314 | command_line=tuple(shlex.split(result))) 315 | 316 | app.run() 317 | 318 | self.state.add_visit(app) 319 | self.state.update_frecencies() 320 | 321 | if (datetime.now() - self.state.apps_generated_at).days >= self.config['cache-days']: 322 | self.state.find_apps() 323 | self.state.clean_cache() 324 | self.state.save() 325 | 326 | 327 | if __name__ == '__main__': 328 | arguments = docopt(__doc__, version="0.1") 329 | DmenuFrecency(arguments).main() 330 | --------------------------------------------------------------------------------