├── LICENSE ├── README.md └── qr.py /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 naquad 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 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QuickRun 2 | 3 | QuickRun is a simple tool to help you choose and run long commands. 4 | Basically it is a launcher for shell with predefined set of commands. 5 | 6 | ## Motivation 7 | 8 | I have couple dozens of SSH connections and I've got tired of 9 | `$ ssh xyz` - shell completion was just not enough. 10 | So I wrote this small script. 11 | 12 | ## Requirements 13 | 14 | * Python 3 15 | * Urwid 16 | 17 | Maybe you'll have to edit the first line of `qr.py` to make sure it 18 | points to correct Python interpreter. 19 | 20 | ## Installation 21 | 22 | Put `qr.py` to some place in yout `$PATH` variable( 23 | thats usually `/usr/local/bin` or `/usr/bin`). Enjoy. 24 | 25 | ## Usage 26 | 27 | Create file file `.qr.conf` in your home directory that has following format: 28 | 29 | ``` 30 | # this is a comment 31 | # it is ignored same as blank lines 32 | 33 | # format is: 34 | name : command to execute 35 | 36 | # name will be displayed and command executed 37 | # whitespace in the beginning and of line is ignored 38 | # same as around : 39 | # name can not contain : 40 | ``` 41 | 42 | Thats pretty much it. 43 | 44 | **Hint:** if you're using Bash you can make it execute command on special 45 | keysequence, for example I have `qr` bound to `Ctrl+]` in `~/.bashrc`: 46 | ``` 47 | bind '"\C-]":"\C-u\C-kqr\C-j"' 48 | ``` 49 | this way using QuickRun is even more convinient. 50 | 51 | ## Keys 52 | 53 | * Escape / Ctrl+C - quit 54 | * Enter - launch 55 | * Arrow keys - navigate 56 | * Any alphabetical character - filter 57 | 58 | ## TODO 59 | 60 | * Make grid display items column-first, not row-first. 61 | -------------------------------------------------------------------------------- /qr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import urwid 4 | import urwid.curses_display 5 | import os 6 | import re 7 | import signal 8 | from operator import itemgetter 9 | 10 | PALETTE = [ 11 | ('body', '', '', 'standout'), 12 | ('focus', 'light magenta', '', 'standout'), 13 | ('head', 'brown', ''), 14 | ('input', 'underline', ''), 15 | ('group', 'yellow', '') 16 | ] 17 | 18 | class ConfigError(RuntimeError): 19 | pass 20 | 21 | class Config: 22 | ITEM = re.compile('^\s*([^:]+?)\s*:\s*(.+)$') 23 | GROUP = re.compile('^\s*\{\s*(.*?)\s*\}\s*$') 24 | INCLUDE = re.compile('^\s*source\s+(.+)\s*$') 25 | 26 | def __init__(self, path=None): 27 | self.groups = [] 28 | self.maxlen = 0 29 | self.path = None 30 | self.read(path or os.path.expanduser('~/.qr.conf')) 31 | 32 | def empty(self): 33 | return not self.groups 34 | 35 | def read(self, conf): 36 | self.path = conf 37 | group = '' 38 | groups = {} 39 | files = [conf] 40 | 41 | try: 42 | while files: 43 | group = '' 44 | path = files.pop(0) 45 | with open(path, 'r') as f: 46 | for lno, line in enumerate(f): 47 | item = line.strip() 48 | if item == '' or item.startswith('#'): 49 | continue 50 | 51 | match = self.ITEM.match(item) 52 | if match is None: 53 | match = self.GROUP.match(item) 54 | if match is not None: 55 | group = match.group(1) 56 | continue 57 | 58 | match = self.INCLUDE.match(item) 59 | if match is not None: 60 | fname = os.path.join(os.path.dirname(path), match.group(1)) 61 | files.append(fname) 62 | continue 63 | 64 | raise ConfigError('Invalid entry in %s:%d: %s' % ( 65 | path, 66 | lno + 1, 67 | line 68 | )) 69 | 70 | name = match.group(1) 71 | nl = len(name) 72 | if nl > self.maxlen: 73 | self.maxlen = nl 74 | groups.setdefault(group, []) 75 | groups[group].append((name, match.group(2))) 76 | except FileNotFoundError: 77 | pass 78 | 79 | key = itemgetter(0) 80 | for group, items in sorted(groups.items(), key=key): 81 | items.sort(key=key) 82 | self.groups.append((group, items)) 83 | 84 | class CmdWidget(urwid.AttrMap): 85 | def __init__(self, name, command): 86 | self.name = name 87 | self.command = command 88 | self.name_lowered = name.lower() 89 | urwid.AttrMap.__init__(self, urwid.SelectableIcon(name, 0), 'body', 'focus') 90 | 91 | class GroupWidget(urwid.AttrMap): 92 | filler = urwid.SolidFill('-') 93 | 94 | def __init__(self, name): 95 | self.name = name 96 | 97 | inner = urwid.Columns([ 98 | self.filler, 99 | (urwid.PACK, urwid.Text(name)), 100 | self.filler 101 | ], 1, box_columns=[0, 2]) 102 | 103 | urwid.AttrMap.__init__(self, inner, 'group') 104 | 105 | def selectable(self): 106 | return False 107 | 108 | class ReadlineEdit(urwid.Edit): 109 | WORD_FW = re.compile(r'\S+\s') 110 | WORD_BC = re.compile(r'\S+\s*$') 111 | 112 | def find_next_word(self): 113 | match = self.WORD_FW.search(self.edit_text[self.edit_pos:]) 114 | return match and len(match.group(0)) + self.edit_pos or len(self.edit_text) 115 | 116 | def find_prev_word(self): 117 | match = self.WORD_BC.search(self.edit_text[:self.edit_pos]) 118 | return match and self.edit_pos - len(match.group(0)) or 0 119 | 120 | def keypress(self, size, key): 121 | if key == 'ctrl k': 122 | self.set_edit_text(self.edit_text[:self.edit_pos]) 123 | elif key == 'ctrl a': 124 | self.set_edit_pos(0) 125 | elif key == 'ctrl w': 126 | prev_word = self.find_prev_word() 127 | self.set_edit_text(self.edit_text[:prev_word] + self.edit_text[self.edit_pos:]) 128 | self.set_edit_pos(prev_word) 129 | elif key == 'ctrl e': 130 | self.set_edit_pos(len(self.edit_text)) 131 | elif key == 'ctrl u': 132 | self.set_edit_text(self.edit_text[self.edit_pos:]) 133 | self.set_edit_pos(0) 134 | elif key == 'meta b': 135 | self.set_edit_pos(self.find_prev_word()) 136 | elif key == 'meta f': 137 | self.set_edit_pos(self.find_next_word()) 138 | elif key == 'meta d': 139 | next_word = self.find_next_word() 140 | self.set_edit_text(self.edit_text[:self.edit_pos] + self.edit_text[next_word:]) 141 | elif key == 'left' or key == 'right': 142 | return key 143 | else: 144 | return urwid.Edit.keypress(self, size, key) 145 | 146 | class FocusNoCursor(urwid.Filler): 147 | def render(self, size, focus=False): 148 | canv = urwid.canvas.CompositeCanvas(urwid.Filler.render(self, size, True)) 149 | canv.cursor = None 150 | return canv 151 | 152 | class QR(urwid.Frame): 153 | def __init__(self, config): 154 | self.command = None 155 | self.config = config 156 | self._build_widgets() 157 | self._populate_pile() 158 | 159 | self.filter = ReadlineEdit('') 160 | urwid.connect_signal(self.filter, 'change', self.on_filter) 161 | 162 | header = urwid.Columns([ 163 | ('pack', urwid.AttrMap(urwid.Text('Filter>'), 'head')), 164 | urwid.AttrMap(self.filter, 'input') 165 | ], 1) 166 | 167 | urwid.Frame.__init__(self, FocusNoCursor(self.pile, 'top'), header=header, focus_part='header') 168 | 169 | def _build_widgets(self): 170 | self.pile = urwid.Pile([]) 171 | self._widgets = [] 172 | opts = self.pile.options() 173 | 174 | for group, items in self.config.groups: 175 | out = [None, None, None] 176 | 177 | if group: 178 | out[0] = (GroupWidget(group), opts) 179 | 180 | out[1] = (urwid.GridFlow([], self.config.maxlen, 1, 0, urwid.LEFT), opts) 181 | go = out[1][0].options() 182 | out[2] = [ 183 | (CmdWidget(*item), go) 184 | for item in items 185 | ] 186 | 187 | self._widgets.append(out) 188 | 189 | nf = urwid.BigText('Not found', urwid.HalfBlock5x4Font()) 190 | nf = urwid.Padding(nf, 'center', 'clip') 191 | nf = urwid.Pile([urwid.Divider(), nf]) 192 | 193 | self._not_found = (nf, opts) 194 | 195 | def _populate_pile(self, search=None): 196 | result = [] 197 | 198 | if search: 199 | search = re.compile('.*?'.join([re.escape(x) for x in search.lower()])) 200 | 201 | for i, (gw, gfw, cmds) in enumerate(self._widgets): 202 | if search: 203 | matches = [] 204 | 205 | for cmd in cmds: 206 | match = search.search(cmd[0].name_lowered) 207 | if match: 208 | matches.append((len(match.group()), match.start(), cmd)) 209 | 210 | if not matches: 211 | continue 212 | 213 | cmds = [cmd for _, _, cmd in sorted(matches, key=itemgetter(slice(0, 2)))] 214 | 215 | if gw: 216 | result.append(gw) 217 | 218 | result.append(gfw) 219 | gfw[0].contents = cmds 220 | gfw[0].set_focus(0) 221 | 222 | if not result: 223 | result.append(self._not_found) 224 | 225 | self.pile.contents = result 226 | idx = int(not isinstance(result[0][0], urwid.GridFlow)) 227 | if idx < len(result): 228 | self.pile.set_focus(idx) 229 | 230 | def on_filter(self, _, text): 231 | self._populate_pile(text.lower()) 232 | 233 | PASS_TO_GRID = ['up', 'down', 'left', 'right'] 234 | 235 | def keypress(self, size, key): 236 | # an ugly hack to make page up/down to work at least somehow 237 | if key == 'esc': 238 | raise urwid.ExitMainLoop() 239 | elif key == 'enter': 240 | self.exec_cmd() 241 | elif key in self.PASS_TO_GRID: 242 | (maxcol, maxrow) = size 243 | 244 | if self.header is not None: 245 | maxrow -= self.header.rows((maxcol,)) 246 | if self.footer is not None: 247 | maxrow -= self.footer.rows((maxcol,)) 248 | 249 | if maxrow <= 0: 250 | return key 251 | 252 | return self.body.keypress((maxcol, maxrow), key) 253 | else: 254 | return self.__super.keypress(size, key) 255 | 256 | def exec_cmd(self): 257 | current = self.pile.focus 258 | current = current and current.focus 259 | if isinstance(current, CmdWidget): 260 | self.command = current 261 | raise urwid.ExitMainLoop() 262 | 263 | def main(): 264 | import sys 265 | config = Config() 266 | if config.empty(): 267 | print('No items in config. Please add some in %s' % config.path) 268 | sys.exit(0) 269 | 270 | def exit_main_loop(*unused): 271 | raise urwid.ExitMainLoop() 272 | 273 | signal.signal(signal.SIGINT, exit_main_loop) 274 | signal.signal(signal.SIGTERM, exit_main_loop) 275 | 276 | qr = QR(config) 277 | urwid.MainLoop(qr, PALETTE, handle_mouse=False).run() 278 | sys.stdout.flush() 279 | sys.stderr.flush() 280 | if qr.command is not None: 281 | print('%s\n\033]2;%s\a' % (qr.command.command, qr.command.name), end='') 282 | os.execl('/bin/sh', '/bin/sh', '-c', qr.command.command,) 283 | 284 | if __name__ == '__main__': 285 | main() 286 | --------------------------------------------------------------------------------