-
Notifications
You must be signed in to change notification settings - Fork 24
/
easy_motion.py
298 lines (232 loc) · 11.6 KB
/
easy_motion.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
import sublime
import sublime_plugin
import re
from itertools import izip_longest
from pprint import pprint
REGEX_ESCAPE_CHARS = '\\+*()[]{}^$?|:].,'
# not a fan of using globals like this, but not sure if there's a better way with the plugin
# API that ST2 provides. Tried attaching as fields to active_view, but didn't persiste, I'm guessing
# it's just a representation of something that gets regenerated on demand so dynamic fields are transient
JUMP_GROUP_GENERATOR = None
CURRENT_JUMP_GROUP = None
EASY_MOTION_EDIT = None
SELECT_TEXT = False
COMMAND_MODE_WAS = False
JUMP_TARGET_SCOPE = 'string'
class JumpGroupGenerator:
'''
given a list of region jump targets matching the given character, can emit a series of
JumpGroup dictionaries going forwards with next and backwards with previous
'''
def __init__(self, view, character, placeholder_chars, case_sensitive):
self.view = view
self.case_sensitive = case_sensitive
self.placeholder_chars = placeholder_chars
self.all_jump_targets = self.find_all_jump_targets_in_visible_region(character)
self.interleaved_jump_targets = self.interleave_jump_targets_from_cursor()
self.jump_target_index = 0
self.jump_target_groups = self.create_jump_target_groups()
self.jump_target_group_index = -1
def determine_re_flags(self, character):
if character == 'enter':
return '(?m)'
elif self.case_sensitive:
return '(?i)'
else:
return ''
def interleave_jump_targets_from_cursor(self):
sel = self.view.sel()[0] # multi select not supported, doesn't really make sense
sel_begin = sel.begin()
sel_end = sel.end()
before = []
after = []
# split them into two lists radiating out from the cursor position
for target in self.all_jump_targets:
if target.begin() < sel_begin:
# add to beginning of list so closest targets to cursor are first
before.insert(0, target)
elif target.begin() > sel_end:
after.append(target)
# now interleave the two lists together into one list
return [target for targets in izip_longest(before, after) for target in targets if target is not None]
def create_jump_target_groups(self):
jump_target_groups = []
while self.has_next_jump_target():
jump_group = dict()
for placeholder_char in self.placeholder_chars:
if self.has_next_jump_target():
jump_group[placeholder_char] = self.interleaved_jump_targets[self.jump_target_index]
self.jump_target_index += 1
else:
break
jump_target_groups.append(jump_group)
return jump_target_groups
def has_next_jump_target(self):
return self.jump_target_index < len(self.interleaved_jump_targets)
def __len__(self):
return len(self.jump_target_groups)
def next(self):
self.jump_target_group_index += 1
if self.jump_target_group_index >= len(self.jump_target_groups) or self.jump_target_group_index < 0:
self.jump_target_group_index = 0
return self.jump_target_groups[self.jump_target_group_index]
def previous(self):
self.jump_target_group_index -= 1
if self.jump_target_group_index < 0 or self.jump_target_group_index >= len(self.jump_target_groups):
self.jump_target_group_index = len(self.jump_target_groups) - 1
return self.jump_target_groups[self.jump_target_group_index]
def find_all_jump_targets_in_visible_region(self, character):
visible_region_begin = self.visible_region_begin()
visible_text = self.visible_text()
folded_regions = self.get_folded_regions(self.view)
matching_regions = []
target_regexp = self.target_regexp(character)
for char_at in (match.start() for match in re.finditer(target_regexp, visible_text)):
char_point = char_at + visible_region_begin
char_region = sublime.Region(char_point, char_point + 1)
if not self.region_list_contains_region(folded_regions, char_region):
matching_regions.append(char_region)
return matching_regions
def region_list_contains_region(self, region_list, region):
for element_region in region_list:
if element_region.contains(region):
return True
return False
def visible_region_begin(self):
return self.view.visible_region().begin()
def visible_text(self):
visible_region = self.view.visible_region()
return self.view.substr(visible_region)
def target_regexp(self, character):
re_flags = self.determine_re_flags(character)
if (REGEX_ESCAPE_CHARS.find(character) >= 0):
return re_flags + '\\' + character
elif character == "enter":
return re_flags + "(?=^).|.(?=$)"
else:
return re_flags + character
def get_folded_regions(self, view):
'''
No way in the API to get the folded regions without unfolding them first
seems to be quick enough that you can't actually see them fold/unfold
'''
folded_regions = view.unfold(view.visible_region())
view.fold(folded_regions)
return folded_regions
class EasyMotionCommand(sublime_plugin.WindowCommand):
winning_selection = None
def run(self, character=None, select_text=False):
global JUMP_GROUP_GENERATOR, SELECT_TEXT, JUMP_TARGET_SCOPE
sublime.status_message("EasyMotion: Jump to " + character)
SELECT_TEXT = select_text
active_view = self.window.active_view()
settings = sublime.load_settings("EasyMotion.sublime-settings")
placeholder_chars = settings.get('placeholder_chars', 'abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ')
JUMP_TARGET_SCOPE = settings.get('jump_target_scope', 'string')
case_sensitive = settings.get('case_sensitive', True)
JUMP_GROUP_GENERATOR = JumpGroupGenerator(active_view, character, placeholder_chars, case_sensitive)
if len(JUMP_GROUP_GENERATOR) > 0:
self.activate_mode(active_view)
self.window.run_command("show_jump_group")
else:
sublime.status_message("EasyMotion: unable to find any instances of " + character + " in visible region")
def activate_mode(self, active_view):
global COMMAND_MODE_WAS
active_view.settings().set('easy_motion_mode', True)
# yes, this feels a little dirty to mess with the Vintage plugin, but there
# doesn't appear to be any other way to tell it to not intercept keys, so turn it
# off (if it's on) while we're running EasyMotion
COMMAND_MODE_WAS = active_view.settings().get('command_mode')
if (COMMAND_MODE_WAS):
active_view.settings().set('command_mode', False)
class ShowJumpGroup(sublime_plugin.WindowCommand):
active_view = None
def run(self, next=True):
self.active_view = self.window.active_view()
self.show_jump_group(next)
def show_jump_group(self, next=True):
global JUMP_GROUP_GENERATOR, CURRENT_JUMP_GROUP
if next:
CURRENT_JUMP_GROUP = JUMP_GROUP_GENERATOR.next()
else:
CURRENT_JUMP_GROUP = JUMP_GROUP_GENERATOR.previous()
self.activate_current_jump_group()
def activate_current_jump_group(self):
global CURRENT_JUMP_GROUP, EASY_MOTION_EDIT, JUMP_TARGET_SCOPE
'''
Start up an edit object if we don't have one already, then create all of the jump targets
'''
if (EASY_MOTION_EDIT is not None):
# normally would call deactivate_current_jump_group here, but apparent ST2 bug prevents it from calling undo correctly
# instead just decorate the new character and keep the same edit object so all changes get undone properly
self.active_view.erase_regions("jump_match_regions")
else:
EASY_MOTION_EDIT = self.active_view.begin_edit()
for placeholder_char in CURRENT_JUMP_GROUP.keys():
self.active_view.replace(EASY_MOTION_EDIT, CURRENT_JUMP_GROUP[placeholder_char], placeholder_char)
self.active_view.add_regions("jump_match_regions", CURRENT_JUMP_GROUP.values(), JUMP_TARGET_SCOPE, "dot")
class JumpTo(sublime_plugin.WindowCommand):
def run(self, character=None):
global COMMAND_MODE_WAS
self.active_view = self.window.active_view()
self.winning_selection = self.winning_selection_from(character)
self.finish_easy_motion()
self.active_view.settings().set('easy_motion_mode', False)
if (COMMAND_MODE_WAS):
self.active_view.settings().set('command_mode', True)
def winning_selection_from(self, selection):
global CURRENT_JUMP_GROUP, SELECT_TEXT, COMMAND_MODE_WAS
winning_region = None
in_insert_mode = not COMMAND_MODE_WAS
if selection in CURRENT_JUMP_GROUP:
winning_region = CURRENT_JUMP_GROUP[selection]
if winning_region is not None:
if SELECT_TEXT:
for current_selection in self.active_view.sel():
if winning_region.begin() < current_selection.begin():
return sublime.Region(current_selection.end(), winning_region.begin())
else:
return sublime.Region(current_selection.begin(), winning_region.end())
elif in_insert_mode:
return sublime.Region(winning_region.begin()+1, winning_region.begin()+1)
else:
return sublime.Region(winning_region.begin(), winning_region.begin())
def finish_easy_motion(self):
'''
We need to clean up after ourselves by restoring the view to it's original state, if the user did
press a jump target that we've got saved, jump to it as the last action
'''
self.deactivate_current_jump_group()
self.jump_to_winning_selection()
def deactivate_current_jump_group(self):
'''
Close out the edit that we've been messing with and then undo it right away to return the buffer to
the pristine state that we found it in. Other methods ended up leaving the window in a dirty save state
and this seems to be the cleanest way to get back to the original state
'''
global EASY_MOTION_EDIT
if (EASY_MOTION_EDIT is not None):
self.active_view.end_edit(EASY_MOTION_EDIT)
self.window.run_command("undo")
EASY_MOTION_EDIT = None
self.active_view.erase_regions("jump_match_regions")
def jump_to_winning_selection(self):
if self.winning_selection is not None:
self.active_view.run_command("jump_to_winning_selection", {"begin": self.winning_selection.begin(), "end": self.winning_selection.end()})
class DeactivateJumpTargets(sublime_plugin.WindowCommand):
def run(self):
pprint("DeactivateJumpTargets called")
global EASY_MOTION_EDIT
active_view = self.window.active_view()
if (EASY_MOTION_EDIT is not None):
active_view.end_edit(EASY_MOTION_EDIT)
self.window.run_command("undo")
EASY_MOTION_EDIT = None
active_view.erase_regions("jump_match_regions")
class JumpToWinningSelection(sublime_plugin.TextCommand):
def run(self, edit, begin, end):
winning_region = sublime.Region(long(begin), long(end))
sel = self.view.sel()
sel.clear()
sel.add(winning_region)
self.view.show(winning_region)