├── .gitignore ├── Default (Linux).sublime-keymap ├── Default (OSX).sublime-keymap ├── Default (Windows).sublime-keymap ├── README.markdown ├── edit.py └── elastic_tabstops.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.cache 3 | *.sublime-project 4 | *.hgignore 5 | *.hgtags 6 | *.DS_Store 7 | -------------------------------------------------------------------------------- /Default (Linux).sublime-keymap: -------------------------------------------------------------------------------- 1 | [ 2 | { "keys": ["ctrl+alt+right"], "command": "move_by_cells", "args": {"direction": "right", "extend": false} }, 3 | { "keys": ["ctrl+alt+left"], "command": "move_by_cells", "args": {"direction": "left", "extend": false} }, 4 | { "keys": ["ctrl+alt+shift+right"], "command": "move_by_cells", "args": {"direction": "right", "extend": true} }, 5 | { "keys": ["ctrl+alt+shift+left"], "command": "move_by_cells", "args": {"direction": "left", "extend": true} } 6 | ] 7 | -------------------------------------------------------------------------------- /Default (OSX).sublime-keymap: -------------------------------------------------------------------------------- 1 | [ 2 | { "keys": ["ctrl+alt+right"], "command": "move_by_cells", "args": {"direction": "right", "extend": false} }, 3 | { "keys": ["ctrl+alt+left"], "command": "move_by_cells", "args": {"direction": "left", "extend": false} }, 4 | { "keys": ["ctrl+alt+shift+right"], "command": "move_by_cells", "args": {"direction": "right", "extend": true} }, 5 | { "keys": ["ctrl+alt+shift+left"], "command": "move_by_cells", "args": {"direction": "left", "extend": true} } 6 | ] 7 | -------------------------------------------------------------------------------- /Default (Windows).sublime-keymap: -------------------------------------------------------------------------------- 1 | [ 2 | { "keys": ["ctrl+alt+right"], "command": "move_by_cells", "args": {"direction": "right", "extend": false} }, 3 | { "keys": ["ctrl+alt+left"], "command": "move_by_cells", "args": {"direction": "left", "extend": false} }, 4 | { "keys": ["ctrl+alt+shift+right"], "command": "move_by_cells", "args": {"direction": "right", "extend": true} }, 5 | { "keys": ["ctrl+alt+shift+left"], "command": "move_by_cells", "args": {"direction": "left", "extend": true} } 6 | ] 7 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | Notes 2 | ----- 3 | Now works with both Sublime Text 2 and 3! 4 | 5 | This plugin only works if you are indenting with tabs. Limitations in Sublime Text's API make it virtually impossible to use elastic tabstops with spaces. 6 | 7 | Because Sublime Text does not allow variable-width tabs, this plugin works by inserting extra spaces before tab characters. This has two side-effects: 8 | 9 | 1. The file will have extra spaces in it, obviously. 10 | 1. The file will show up correctly in editors that don't support elastic tabstops. Bonus! 11 | 12 | Keyboard shortcuts 13 | ------------------ 14 | ElasticTabstops includes keyboard shortcuts for navigating to the next and previous tab. This can be extremely useful if, for example, one has multiple selections on different rows. 15 | 16 | By default, these shortcuts are `ctrl+alt+left` and `ctrl+alt+right`, and holding `shift` will extend the selection as expected. 17 | 18 | Install 19 | ------- 20 | 21 | This plugin is available through Package Control, which is available here: 22 | 23 | http://wbond.net/sublime_packages/package_control 24 | 25 | Manual Install 26 | -------------- 27 | 28 | Go to your Packages subdirectory under ST2's data directory: 29 | 30 | * Windows: %APPDATA%\Sublime Text 2 31 | * OS X: ~/Library/Application Support/Sublime Text 2 32 | * Linux: ~/.config/sublime-text-2 33 | * Portable Installation: Sublime Text 2/Data 34 | 35 | Then clone this repository: 36 | 37 | git clone git://github.com/SublimeText/ElasticTabstops.git 38 | 39 | That's it! 40 | 41 | -------------------------------------------------------------------------------- /edit.py: -------------------------------------------------------------------------------- 1 | # edit.py 2 | # buffer editing for both ST2 and ST3 that "just works" 3 | # from https://raw.github.com/lunixbochs/SublimeXiki/74fa3e6b9b83f18da278e804e5b480e5511d6aef/edit.py 4 | 5 | import sublime 6 | import sublime_plugin 7 | 8 | try: 9 | sublime.edit_storage 10 | except AttributeError: 11 | sublime.edit_storage = {} 12 | 13 | class EditStep: 14 | def __init__(self, cmd, *args): 15 | self.cmd = cmd 16 | self.args = args 17 | 18 | def run(self, view, edit): 19 | if self.cmd == 'callback': 20 | return self.args[0](view, edit) 21 | 22 | funcs = { 23 | 'insert': view.insert, 24 | 'erase': view.erase, 25 | 'replace': view.replace, 26 | } 27 | func = funcs.get(self.cmd) 28 | if func: 29 | func(edit, *self.args) 30 | 31 | 32 | class Edit: 33 | def __init__(self, view, name=None): 34 | self.view = view 35 | self.name = name 36 | self.steps = [] 37 | 38 | def step(self, cmd, *args): 39 | step = EditStep(cmd, *args) 40 | self.steps.append(step) 41 | 42 | def insert(self, point, string): 43 | self.step('insert', point, string) 44 | 45 | def erase(self, region): 46 | self.step('erase', region) 47 | 48 | def replace(self, region, string): 49 | self.step('replace', region, string) 50 | 51 | def callback(self, func): 52 | self.step('callback', func) 53 | 54 | def run(self, view, edit, name): 55 | for step in self.steps: 56 | step.run(view, edit) 57 | 58 | def end(self): 59 | self.__exit__(None, None, None) 60 | 61 | def __enter__(self): 62 | return self 63 | 64 | def __exit__(self, type, value, traceback): 65 | view = self.view 66 | key = str(hash(tuple(self.steps))) 67 | sublime.edit_storage[key] = self.run 68 | view.run_command('apply_named_edit', {'key': key, 'name': self.name}) 69 | 70 | 71 | class apply_named_edit(sublime_plugin.TextCommand): 72 | def run(self, edit, key, name): 73 | sublime.edit_storage.pop(key)(self.view, edit, name) 74 | -------------------------------------------------------------------------------- /elastic_tabstops.py: -------------------------------------------------------------------------------- 1 | """ 2 | UPDATE 3 | Use this command to reprocess the whole file. Shouldn't be necessary except 4 | in specific cases. 5 | 6 | { "keys": ["super+j"], "command": "elastic_tabstops_update"}, 7 | """ 8 | 9 | 10 | import sublime 11 | import sublime_plugin 12 | import re 13 | import sys 14 | if sys.version_info[0] < 3: 15 | from edit import Edit 16 | from itertools import izip, izip_longest 17 | zip = izip 18 | zip_longest = izip_longest 19 | else: 20 | from ElasticTabstops.edit import Edit 21 | from itertools import zip_longest 22 | 23 | def lines_in_buffer(view): 24 | row, col = view.rowcol(view.size()) 25 | #"row" is the index of the last row; need to add 1 to get number of rows 26 | return row + 1 27 | 28 | def get_selected_rows(view): 29 | selected_rows = set() 30 | for s in view.sel(): 31 | begin_row,_ = view.rowcol(s.begin()) 32 | end_row,_ = view.rowcol(s.end()) 33 | # Include one row before and after the selection, to cover cases like 34 | # hitting enter at the beginning of a line: affect both the newly-split 35 | # block and the block remaining above. 36 | list(map(selected_rows.add, range(begin_row-1, end_row+1 + 1))) 37 | return selected_rows 38 | 39 | def tabs_for_row(view, row): 40 | row_tabs = [] 41 | for tab in re.finditer("\t", view.substr(view.line(view.text_point(row,0)))): 42 | row_tabs.append(tab.start()) 43 | return row_tabs 44 | 45 | def selection_columns_for_row(view, row): 46 | selections = [] 47 | for s in view.sel(): 48 | if s.empty(): 49 | r, c =view.rowcol(s.a) 50 | if r == row: 51 | selections.append(c) 52 | return selections 53 | 54 | def rightmost_selection_in_cell(selection_columns, cell_right_edge): 55 | rightmost = 0 56 | if len(selection_columns): 57 | rightmost = max([s if s <= cell_right_edge else 0 for s in selection_columns]) 58 | return rightmost 59 | 60 | def cell_widths_for_row(view, row): 61 | selection_columns = selection_columns_for_row(view, row) 62 | tabs = [-1] + tabs_for_row(view, row) 63 | widths = [0] * (len(tabs) - 1) 64 | line = view.substr(view.line(view.text_point(row,0))) 65 | for i in range(0,len(tabs)-1): 66 | left_edge = tabs[i]+1 67 | right_edge = tabs[i+1] 68 | rightmost_selection = rightmost_selection_in_cell(selection_columns, right_edge) 69 | cell = line[left_edge:right_edge] 70 | widths[i] = max(len(cell.rstrip()), rightmost_selection - left_edge) 71 | return widths 72 | 73 | def find_cell_widths_for_block(view, row): 74 | cell_widths = [] 75 | 76 | #starting row and backward 77 | row_iter = row 78 | while row_iter >= 0: 79 | widths = cell_widths_for_row(view, row_iter) 80 | if len(widths) == 0: 81 | break 82 | cell_widths.insert(0, widths) 83 | row_iter -= 1 84 | first_row = row_iter + 1 85 | 86 | #forward (not including starting row) 87 | row_iter = row 88 | num_rows = lines_in_buffer(view) 89 | while row_iter < num_rows - 1: 90 | row_iter += 1 91 | widths = cell_widths_for_row(view, row_iter) 92 | if len(widths) == 0: 93 | break 94 | cell_widths.append(widths) 95 | 96 | return cell_widths, first_row 97 | 98 | def adjust_row(view, glued, row, widths): 99 | row_tabs = tabs_for_row(view, row) 100 | if len(row_tabs) == 0: 101 | return glued 102 | bias = 0 103 | location = -1 104 | 105 | for w, it in zip(widths,row_tabs): 106 | location += 1 + w 107 | it += bias 108 | difference = location - it 109 | if difference == 0: 110 | continue 111 | 112 | end_tab_point = view.text_point(row, it) 113 | partial_line = view.substr(view.line(end_tab_point))[0:it] 114 | stripped_partial_line = partial_line.rstrip() 115 | ispaces = len(partial_line) - len(stripped_partial_line) 116 | if difference > 0: 117 | view.run_command("maybe_mark_undo_groups_for_gluing") 118 | glued = True 119 | with Edit(view, "ElasticTabstops") as edit: 120 | #put the spaces after the tab and then delete the tab, so any insertion 121 | #points behave as expected 122 | edit.insert(end_tab_point+1, (' ' * difference) + "\t") 123 | edit.erase(sublime.Region(end_tab_point, end_tab_point + 1)) 124 | bias += difference 125 | if difference < 0 and ispaces >= -difference: 126 | view.run_command("maybe_mark_undo_groups_for_gluing") 127 | glued = True 128 | with Edit(view, "ElasticTabstops") as edit: 129 | edit.erase(sublime.Region(end_tab_point, end_tab_point + difference)) 130 | bias += difference 131 | return glued 132 | 133 | def set_block_cell_widths_to_max(cell_widths): 134 | starting_new_block = True 135 | for c, column in enumerate(zip_longest(*cell_widths, fillvalue=-1)): 136 | #add an extra -1 to the end so that the end of the column automatically 137 | #finishes a block 138 | column += (-1,) 139 | done = False 140 | for r, width in enumerate(column): 141 | if starting_new_block: 142 | block_start_row = r 143 | starting_new_block = False 144 | max_width = 0 145 | if width == -1: 146 | #block ended 147 | block_end_row = r 148 | for j in range(block_start_row, block_end_row): 149 | cell_widths[j][c] = max_width 150 | starting_new_block = True 151 | max_width = max(max_width, width) 152 | 153 | def process_rows(view, rows): 154 | glued = False 155 | checked_rows = set() 156 | for row in rows: 157 | if row in checked_rows: 158 | continue 159 | 160 | cell_widths_by_row, row_index = find_cell_widths_for_block(view, row) 161 | set_block_cell_widths_to_max(cell_widths_by_row) 162 | for widths in cell_widths_by_row: 163 | checked_rows.add(row_index) 164 | glued = adjust_row(view, glued, row_index, widths) 165 | row_index += 1 166 | if glued: 167 | view.run_command("glue_marked_undo_groups") 168 | 169 | def fix_view(view): 170 | # When modifying a clone of a view, Sublime Text will only pass in 171 | # the original view ID, which means we refer to the wrong selections. 172 | # Fix which view we have. 173 | active_view = sublime.active_window().active_view() 174 | if view == None: 175 | view = active_view 176 | elif view.id() != active_view.id() and view.buffer_id() == active_view.buffer_id(): 177 | view = active_view 178 | return view 179 | 180 | class ElasticTabstopsListener(sublime_plugin.EventListener): 181 | selected_rows_by_view = {} 182 | running = False 183 | 184 | def on_modified(self, view): 185 | if self.running: 186 | return 187 | 188 | view = fix_view(view) 189 | 190 | history_item = view.command_history(1)[1] 191 | if history_item: 192 | if history_item.get('name') == "ElasticTabstops": 193 | return 194 | if history_item.get('commands') and history_item['commands'][0][1].get('name') == "ElasticTabstops": 195 | return 196 | 197 | selected_rows = self.selected_rows_by_view.get(view.id(), set()) 198 | selected_rows = selected_rows.union(get_selected_rows(view)) 199 | 200 | try: 201 | self.running = True 202 | translate = False 203 | if view.settings().get("translate_tabs_to_spaces"): 204 | translate = True 205 | view.settings().set("translate_tabs_to_spaces", False) 206 | 207 | process_rows(view, selected_rows) 208 | 209 | finally: 210 | self.running = False 211 | if translate: 212 | view.settings().set("translate_tabs_to_spaces",True) 213 | 214 | def on_selection_modified(self, view): 215 | view = fix_view(view) 216 | self.selected_rows_by_view[view.id()] = get_selected_rows(view) 217 | 218 | def on_activated(self, view): 219 | view = fix_view(view) 220 | self.selected_rows_by_view[view.id()] = get_selected_rows(view) 221 | 222 | class ElasticTabstopsUpdateCommand(sublime_plugin.TextCommand): 223 | def run(self, edit): 224 | rows = range(0,lines_in_buffer(self.view)) 225 | process_rows(self.view, rows) 226 | 227 | 228 | class MoveByCellsCommand(sublime_plugin.TextCommand): 229 | def run(self, edit, direction, extend): 230 | new_regions = [] 231 | for s in self.view.sel(): 232 | line = self.view.substr(self.view.line(s.b)) 233 | row, col = self.view.rowcol(s.b) 234 | if direction == "right": 235 | next_tab_col = line[col+1:].find('\t') 236 | if next_tab_col == -1: 237 | next_tab_col = len(line) 238 | else: 239 | next_tab_col += col + 1 240 | elif direction == "left": 241 | next_tab_col = line[:max(col-1, 0)].rfind('\t') 242 | if next_tab_col == -1: 243 | next_tab_col = 0 244 | else: 245 | next_tab_col += 1 246 | else: 247 | raise Exception("invalid direction") 248 | next_tab_col = s.b 249 | 250 | b = self.view.text_point(row, next_tab_col) 251 | 252 | if extend: 253 | new_regions.append(sublime.Region(s.a, b)) 254 | else: 255 | new_regions.append(sublime.Region(b, b)) 256 | sel = self.view.sel() 257 | sel.clear() 258 | for r in new_regions: 259 | sel.add(r) 260 | --------------------------------------------------------------------------------