├── .gitignore ├── LICENSE ├── README.md ├── TODO ├── keybindings.py ├── lightning-cd.py ├── settings.py └── termtest.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | *.py[cod] 3 | 4 | # NeoVim 5 | .*.swp 6 | 7 | # Lisp (Lisp development is happening in a separate branch) 8 | *.fasl 9 | *.lisp 10 | 11 | # Emacs 12 | *~ 13 | 14 | # spec files 15 | *.spec 16 | 17 | # binary files 18 | *.elf 19 | 20 | # misc 21 | error.txt 22 | lightning.txt 23 | lightning-error.txt 24 | *.png 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lightning-cd 2 | 3 | [![asciicast](https://asciinema.org/a/29694.png)](https://asciinema.org/a/29694) 4 | 5 | Lightning is a tool designed to allow you to find and open files as fast as physically possible. It acts as a complement to autojump (https://github.com/wting/autojump); while autojump allows you to go from anywhere to your most accessed directories with just a few keystrokes, Lightning allows you to fly around your filesystem with reckless abandon, flitting through directories and opening files with ease. 6 | 7 | Dependencies 8 | ----------- 9 | 10 | - Termbox (https://github.com/nsf/termbox) with Python 3 bindings installed 11 | - A relatively recent version of Python 3 12 | 13 | Installation 14 | ------------ 15 | 16 | Merely copy lightning-cd.py to someplace convenient and add an alias to it, like so. Lightning takes as its first argument the full path of a file that it writes a directory path to when quitting. If your alias is set up like the below, then you should be able to use Lightning to change your shell's current working directory: 17 | 18 | alias i='python3 ~/code/lightning-cd/lightning-cd.py ~/.lightningpath && cd "\`cat ~/.lightningpath\`"' 19 | 20 | Usage 21 | ----- 22 | 23 | Lightning is an interactive, modal tool with letter keybindings very similar to vim. It contains two modes, search and normal. Normal mode allows you to select a file by moving up and down through a list. Search mode, the default, takes letters that you type, filters them, and then uses them to open a file once the search buffer contains enough to uniquely identify a file. 24 | 25 | Common keybindings: 26 | - Comma moves up one directory 27 | - Space toggles the mode between search and normal 28 | - Semicolon quits Lightning 29 | - Single quote "does the right thing" on either the selected file (normal mode) or the first matching file (search mode) 30 | - Double quote refreshes the file list 31 | - Question mark toggles the visibility of "hidden" files (defined by those matching hiddenFilesPattern in settings.py) 32 | 33 | Search keybindings: 34 | - Letters are converted to lowercase, and along with period and numbers are valid search characters 35 | - Dash removes a character from the search buffer 36 | 37 | Normal keybindings: 38 | - j and k move down and up, respectively 39 | - f opens the current directory with your file browser (Nautilus by default) 40 | - t opens Tmux at the current directory 41 | 42 | All of the above keybindings can (and should) be changed by editing keybindings.py. 43 | 44 | Settings in settings.py: 45 | - showDeselectedFiles controls the visibility of filenames that don't match the search buffer 46 | - defaultMode sets the default mode for startup and mode switches 47 | - fileBrowser is the file browser opened when the file browser shortcut ('f' by default) is pressed 48 | - persistentMode, if set, causes the current mode to stay the same on directory change 49 | - lightningPathFile is the path of the file that Lightning writes the directory that should be cd'ed to 50 | - showHiddenFiles sets the default visibility of hidden files 51 | - hiddenFilesPattern is the regexp (Python3 re module) that, if matched, declares a pathname (works for files and directories) to be "hidden" 52 | - restrictBuffer, if set, will cause the search buffer to refuse to accept characters that don't match at least one existing pathname 53 | - mimePatterns tells Lightning, using mimetypes, how to handle files when told to open them 54 | 55 | One of the basic assumptions behind Lightning is that you'll be spending most of your time in search mode, and only switching to normal mode to select files that would otherwise require a (relatively) large number of keystrokes. You may violate this assumption if you wish, at which point you will discover that you are using a watered-down `mc` (http://www.midnight-commander.org/). Play off Lightning's strengths, avoid its weaknesses (or use another tool), and you may find yourself enjoying productivity gains due to lower mental costs associated with switching source code files. 56 | 57 | Disclaimer 58 | --------- 59 | 60 | Lightning is ALPHA, very much buggy and non-feature-complete, and likely to change very quickly (with changes possibly including *a move to another programming language* - use at your own risk). With that said, the basic idea (of finding and opening files as quickly as physically possible) will remain the same, and you are encouraged to try it out and give feedback. 61 | 62 | (it was also built for a Dvorak Simplified Keyboard user, so you may want to tweak the keybindings for convenience and comfort if you use QWERTY) 63 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | Implement scrolling/paging for large directory listings 2 | Implement vertical scrolling for normal mode 3 | Consider fuzzy search or rankings in terms of string length 4 | Color (using mimetypes)? 5 | Rearrange hidden files so that they are intermingled with normal files (for non-dotfiles such as fasls) 6 | Design and implement glass mode 7 | Make searching in large directories easier by only subselecting out of already selected files 8 | Just throw all pretense of modularity to the wind and make the entire thing a big state machine 9 | Add a preview pane when in select mode (thanks to Mithaldu on HN) 10 | 11 | Lisp edition: 12 | 13 | Use full ls in a directory to avoid having to continually get subshells to check for file/directory status. 14 | 15 | BUGFIX: exec'ing to a process and then using another Lightning instance will cause the exec'd instance to cd to the wrong directory. Fix or edge case? Solution: Use a directory-stack-based dirwrite file. 16 | -------------------------------------------------------------------------------- /keybindings.py: -------------------------------------------------------------------------------- 1 | KEY_UP = 'k' 2 | KEY_DOWN = 'j' 3 | KEY_FILE_BROWSER = 'f' 4 | KEY_TMUX = 't' 5 | KEY_SWAP_HIDDEN = 'd' 6 | KEY_UP_DIR = ',' 7 | KEY_QUIT = ';' 8 | KEY_SMART = '\'' 9 | KEY_DELETE = '-' 10 | KEY_REFRESH = '"' 11 | KEY_TOGGLE_HIDDEN = '?' 12 | -------------------------------------------------------------------------------- /lightning-cd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import traceback 4 | import termbox 5 | import os 6 | import sys 7 | import re 8 | import subprocess 9 | 10 | sys.path.append(os.path.dirname(os.path.abspath(__file__))) 11 | import settings 12 | import keybindings 13 | 14 | def writeText(t, x, y, text, foreground, background): 15 | """writeText(t Termbox, x integer, x integer, fg termbox.COLOR, fg termbox.COLOR) -> None 16 | Execute a series of change_cell's in a sequential manner such as to write a line of text""" 17 | for i in range(len(text)): 18 | t.change_cell(x + i, y, ord(text[i]), foreground, background) 19 | 20 | def getCharRange(): 21 | """getCharRange() -> str 22 | Get a string of the characters that are valid to enter in search mode""" 23 | chars = '' 24 | for i in range(255): 25 | if (i >= ord('a') and i <= ord('z')) or (i >= ord('0') and i <= ord('9')): 26 | chars = chars + chr(i) 27 | chars = chars + '.' 28 | return chars 29 | 30 | def filenameClean(filename): 31 | """filenameClean(filename str) -> str 32 | Convert a raw filename to a simplified one that can be searched for""" 33 | newFilename = '' 34 | filename = filename.lower() 35 | acceptableChars = getCharRange() 36 | for char in filename: 37 | if char in acceptableChars: 38 | newFilename = newFilename + char 39 | return newFilename 40 | 41 | def selectFilesOnsearchBuffer(files, searchBuffer): 42 | """selectFilesOnsearchBuffer(files list[str], searchBuffer str) -> list[str] 43 | Return a list of selected files by comparing simplified filenames with the search buffer""" 44 | selected = [] 45 | for f in files: 46 | if searchBuffer == filenameClean(f)[:len(searchBuffer)]: 47 | selected.append(f) 48 | return selected 49 | 50 | def getFileColors(mode, selected, thisFile, fileList): 51 | """getFileColors(mode Enum, selected int, thisFile string, fileList list[str]) -> (termbox.COLOR, termbox.COLOR) 52 | Takes a bunch of things and returns the color that the given file should be when displayed""" 53 | if mode == settings.Mode.SEARCH and thisFile in selected and settings.showDeselectedFiles: 54 | fg, bg = termbox.BLACK, termbox.WHITE 55 | elif mode == settings.Mode.NORMAL and thisFile == fileList[selected]: 56 | fg, bg = termbox.BLACK, termbox.WHITE 57 | else: 58 | fg, bg = termbox.BLUE if os.path.isdir(thisFile) else termbox.WHITE, 0 59 | return (fg, bg) 60 | 61 | def showThisFile(thisFile, mode, selected): 62 | """showThisFile(string, Enum, integer) -> bool 63 | Returns whether or not the given file should be actually displayed""" 64 | return settings.showDeselectedFiles or mode == settings.Mode.NORMAL or thisFile in selected or selected == [] 65 | 66 | def writePath(filename, path): 67 | """writePath(filename str, path str) -> None 68 | Write a string (usually the path that the calling shell should cd to) to a file""" 69 | f = open(filename, 'w+') 70 | f.write(path) 71 | f.close() 72 | 73 | def selectedValueForMode(mode): 74 | """selectedValueForMode(mode Enum) -> list/int 75 | Return the correct default value for 'selected', which can either be a list or an int""" 76 | return [] if mode == settings.Mode.SEARCH else 0 77 | 78 | def drawFileList(t, ystart, yend, xend, mode, selected): 79 | """drawFileList(t Termbox, ystart integer, yend integer, xend integer, mode Enum, selected int/list) -> None 80 | Draw the list of selected files onto the screen""" 81 | x = 0 82 | width = 1 83 | y = ystart 84 | for f in files: 85 | # if we've reached the end of a column then begin at the top of the next one 86 | if y == yend: 87 | y = ystart 88 | x += width 89 | width = 1 90 | if x >= xend: 91 | break 92 | # get the foreground and background colors for a particular filename 93 | fg, bg = getFileColors(mode, selected, f, files) 94 | if showThisFile(f, mode, selected): 95 | if os.path.isdir(f): 96 | f = f + '/' 97 | width = max(width, len(f) + 1) 98 | writeText(t, x, y, f, fg, bg) 99 | #writeText(t, x, y, re.sub('grant', 'fouric', f), fg, bg) 100 | y += 1 101 | 102 | def switchMode(prevMode, selected): 103 | """switchMode(prevMode Enum, selected int/list) -> (Enum, int/list, str) 104 | Switch the mode to either search or normal and do associated setup for each mode""" 105 | if prevMode == settings.Mode.SEARCH: 106 | newMode = settings.Mode.NORMAL 107 | selected = files.index(selected[0]) if len(selected) else 0 108 | else: 109 | newMode = settings.Mode.SEARCH 110 | selected = [] 111 | searchBuffer = '' 112 | return (newMode, selected, searchBuffer) 113 | 114 | def takeActionOnPath(f, path): 115 | """takeActionOnPath(f str, path str) -> (Enum, int/list, str) 116 | Do something with a filename that the user selected""" 117 | if os.path.isdir(f): 118 | os.chdir(f) 119 | newMode = mode if settings.persistentMode else settings.defaultMode 120 | selected = 0 if newMode == settings.Mode.NORMAL else [] 121 | searchBuffer = '' 122 | return (newMode, selected, searchBuffer) 123 | elif os.path.isfile(f): 124 | mimetype = str(subprocess.check_output(['mimetype', f])).split(' ')[-1][:-3] 125 | for mapping in settings.mimePatterns: 126 | if re.compile(mapping[0]).match(mimetype): 127 | escape = '\\' if 'escape-slash' in (mapping[3] if len(mapping) >= 4 else '') else '' 128 | runCommandOnFile(path, mapping[1] + ' ' + escape + '"' + f + escape + '"' + (mapping[2] if len(mapping) >= 3 else '')) 129 | 130 | def runCommandOnFile(path, command): 131 | """runCommandOnFile(path str, command str) -> [doesn't return] 132 | Close lightning, write the current path, and execute the command""" 133 | t.close() 134 | writePath(settings.lightningPathFile, path) 135 | os.system(command) 136 | quit() 137 | 138 | def slicer(num): 139 | """slicer(num int) -> f(int) 140 | Returns a function that returns the first num characters of the given string""" 141 | def f(s): 142 | return s[:num] 143 | return f 144 | 145 | if __name__ == '__main__': 146 | try: 147 | mode = settings.defaultMode 148 | searchBuffer = '' 149 | selected = selectedValueForMode(mode) 150 | files = None 151 | charRange = getCharRange() 152 | hiddenExpression = re.compile(settings.hiddenFilesPattern) 153 | 154 | t = termbox.Termbox() 155 | while True: 156 | t.clear() 157 | if not files: 158 | files = sorted(os.listdir('.')) 159 | normalfiles = [] 160 | dotfiles = [] 161 | for f in files: 162 | (dotfiles if hiddenExpression.match(f) else normalfiles).append(f) 163 | files = normalfiles 164 | if settings.showHiddenFiles: 165 | files += dotfiles 166 | if mode == settings.Mode.SEARCH: 167 | selected = selectFilesOnsearchBuffer(files, searchBuffer) 168 | drawFileList(t, 1, t.height() - 1, t.width() - 1, mode, selected) 169 | if mode == settings.Mode.SEARCH: 170 | if len(selected) == 1 and len(searchBuffer): 171 | mode, selected, searchBuffer = takeActionOnPath(selected[0], os.path.realpath('.')) 172 | files = None 173 | continue 174 | writeText(t, 0, t.height() - 1, searchBuffer, termbox.WHITE, 0) 175 | modeText = 'search' if mode == settings.Mode.SEARCH else 'normal' 176 | writeText(t, 0, 0, modeText + ": ", termbox.WHITE, 0) 177 | #writeText(t, len(modeText) + 2, 0, re.sub('grant', 'fouric', os.path.realpath('.')), termbox.WHITE, 0) 178 | writeText(t, len(modeText) + 2, 0, os.path.realpath('.'), termbox.WHITE, 0) 179 | t.present() 180 | 181 | event = t.poll_event() 182 | letter, keycode = event[1], event[2] 183 | if keycode == termbox.KEY_SPACE: 184 | mode, selected, searchBuffer = switchMode(mode, selected) 185 | elif letter == keybindings.KEY_UP_DIR: 186 | os.chdir('..') 187 | searchBuffer = '' 188 | if not settings.persistentMode: 189 | mode = settings.defaultMode 190 | selected = selectedValueForMode(mode) 191 | files = None 192 | elif letter == keybindings.KEY_QUIT: 193 | runCommandOnFile(os.path.realpath('.'), 'true') 194 | elif letter == keybindings.KEY_SMART: 195 | mode, selected, searchBuffer = takeActionOnPath(files[files.index(selected[0]) if mode == settings.Mode.SEARCH else selected], os.path.realpath('.')) 196 | files = None 197 | elif letter == keybindings.KEY_REFRESH: 198 | files = None 199 | elif letter == keybindings.KEY_TOGGLE_HIDDEN: 200 | settings.showHiddenFiles = not settings.showHiddenFiles 201 | files = None 202 | elif mode == settings.Mode.NORMAL: 203 | if letter == keybindings.KEY_UP: 204 | selected = (selected - 1) % len(files) 205 | elif letter == keybindings.KEY_DOWN: 206 | selected = (selected + 1) % len(files) 207 | elif letter == keybindings.KEY_FILE_BROWSER: 208 | runCommandOnFile(os.path.realpath('.'), fileBrowser + ' "' + os.path.realpath('.') + '" > /dev/null 2>&1') 209 | elif letter == keybindings.KEY_TMUX: 210 | runCommandOnFile(os.path.realpath('.'), 'tmux > /dev/null') 211 | elif mode == settings.Mode.SEARCH: 212 | if letter: 213 | if letter in charRange: 214 | if not settings.restrictBuffer or (searchBuffer + letter) in list(map(slicer(len(searchBuffer) + 1), map(filenameClean, files))): 215 | searchBuffer = searchBuffer + letter 216 | elif letter == keybindings.KEY_DELETE: 217 | searchBuffer = searchBuffer[:-1] 218 | 219 | t.close() 220 | except Exception as e: 221 | f = open(os.path.dirname(os.path.abspath(__file__)) + '/lightning-error.txt', 'w') 222 | f.write(traceback.format_exc() + '\n') 223 | f.close() 224 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from enum import Enum 3 | 4 | showDeselectedFiles = False 5 | Mode = Enum('Mode', 'NORMAL SEARCH') 6 | defaultMode = Mode.SEARCH 7 | fileBrowser = 'nautilus' 8 | persistentMode = True 9 | lightningPathFile = os.sys.argv[1] 10 | showHiddenFiles = False 11 | hiddenFilesPattern = '(^\\..*)|(.*~$)|(.*\\.(swp|pyc|fasl|o|spec|bak)$)|(__pycache__?.)' 12 | restrictBuffer = True 13 | mimePatterns = [('text\\/.*', 'nvim'), ('application\\/x-shellscript', 'nvim'), ('application\\/x-executable', 'bash -c "exec', '"', 'escape-slash'), ('.*', 'xdg-open')] 14 | -------------------------------------------------------------------------------- /termtest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import termbox 4 | 5 | t = termbox.Termbox() 6 | 7 | t.clear() 8 | 9 | width = t.width() 10 | height = t.height() 11 | cell_count = width * height 12 | char = ord('a') 13 | for c in range(1): 14 | for i in range(26): 15 | for y in range(height): 16 | for x in range(width): 17 | t.change_cell(x, y, char, termbox.WHITE, termbox.BLACK) 18 | t.present() 19 | char += 1 20 | 21 | t.close() 22 | --------------------------------------------------------------------------------