Skip to content

Commit 60a0687

Browse files
manual_readline refactor
1 parent bd11ff7 commit 60a0687

File tree

4 files changed

+212
-64
lines changed

4 files changed

+212
-64
lines changed

bpython/curtsiesfrontend/interaction.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from bpython.repl import Interaction as BpythonInteraction
66

7-
from bpython.curtsiesfrontend.manual_readline import char_sequences as rl_char_sequences
7+
from bpython.curtsiesfrontend.manual_readline import edit_keys
88

99
class StatusBar(BpythonInteraction):
1010
"""StatusBar and Interaction for Repl
@@ -68,8 +68,8 @@ def process_event(self, e):
6868
elif isinstance(e, events.PasteEvent):
6969
for ee in e.events:
7070
self.add_normal_character(ee if len(ee) == 1 else ee[-1]) #strip control seq
71-
elif e in rl_char_sequences:
72-
self.cursor_offset_in_line, self._current_line = rl_char_sequences[e](self.cursor_offset_in_line, self._current_line)
71+
elif e in edit_keys:
72+
self.cursor_offset_in_line, self._current_line = edit_keys[e](self.cursor_offset_in_line, self._current_line)
7373
elif e == "<Ctrl-c>":
7474
raise KeyboardInterrupt()
7575
elif e == "<Ctrl-d>":

bpython/curtsiesfrontend/manual_readline.py

Lines changed: 147 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,172 @@
1-
"""implementations of simple readline control sequences
1+
"""implementations of simple readline edit operations
22
33
just the ones that fit the model of transforming the current line
44
and the cursor location
5-
in the order of description at http://www.bigsmoke.us/readline/shortcuts"""
5+
based on http://www.bigsmoke.us/readline/shortcuts"""
66

77
import re
8-
char_sequences = {}
8+
import inspect
99

1010
INDENT = 4
1111

1212
#TODO Allow user config of keybindings for these actions
1313

14-
def on(seq):
15-
def add_to_char_sequences(func):
16-
char_sequences[seq] = func
17-
return func
18-
return add_to_char_sequences
19-
20-
@on('<Ctrl-b>')
21-
@on('<LEFT>')
14+
class AbstractEdits(object):
15+
16+
default_kwargs = {
17+
'line': 'hello world',
18+
'cursor_offset': 5,
19+
'cut_buffer': 'there',
20+
'indent': 4,
21+
}
22+
23+
def __contains__(self, key):
24+
try:
25+
self[key]
26+
except KeyError:
27+
return False
28+
else:
29+
return True
30+
31+
def add(self, key, func):
32+
if key in self:
33+
raise ValueError('key %r already has a mapping' % (key,))
34+
params = inspect.getargspec(func)[0]
35+
args = dict((k, v) for k, v in self.default_kwargs.items() if k in params)
36+
r = func(**args)
37+
if len(r) == 2:
38+
self.simple_edits[key] = func
39+
elif len(r) == 3:
40+
self.cut_buffer_edits[key] = func
41+
else:
42+
raise ValueError('return type of function %r not recognized' % (func,))
43+
44+
def add_config_attr(self, config_attr, func):
45+
if config_attr in self.awaiting_config:
46+
raise ValueError('config attrribute %r already has a mapping' % (config_attr,))
47+
self.awaiting_config[config_attr] = func
48+
49+
def call(self, key, **kwargs):
50+
func = self[key]
51+
params = inspect.getargspec(func)[0]
52+
args = dict((k, v) for k, v in kwargs.items() if k in params)
53+
return func(**args)
54+
55+
def __getitem__(self, key):
56+
if key in self.simple_edits: return self.simple_edits[key]
57+
if key in self.cut_buffer_edits: return self.cut_buffer_edits[key]
58+
raise KeyError("key %r not mapped" % (key,))
59+
60+
61+
class UnconfiguredEdits(AbstractEdits):
62+
"""Maps key to edit functions, and bins them by what parameters they take.
63+
64+
Only functions with specific signatures can be added:
65+
* func(**kwargs) -> cursor_offset, line
66+
* func(**kwargs) -> cursor_offset, line, cut_buffer
67+
where kwargs are in among the keys of Edits.default_kwargs
68+
These functions will be run to determine their return type, so no side effects!
69+
70+
More concrete Edits instances can be created by applying a config with
71+
Edits.mapping_with_config() - this creates a new Edits instance
72+
that uses a config file to assign config_attr bindings.
73+
74+
Keys can't be added twice, config attributes can't be added twice.
75+
"""
76+
77+
def __init__(self):
78+
self.simple_edits = {}
79+
self.cut_buffer_edits = {}
80+
self.awaiting_config = {}
81+
82+
def mapping_with_config(self, config, key_dispatch):
83+
"""Creates a new mapping object by applying a config object"""
84+
return ConfiguredEdits(self.simple_edits, self.cut_buffer_edits,
85+
self.awaiting_config, config, key_dispatch)
86+
87+
def on(self, key=None, config=None):
88+
if (key is None and config is None or
89+
key is not None and config is not None):
90+
raise ValueError("Must use exactly one of key, config")
91+
if key is not None:
92+
def add_to_keybinds(func):
93+
self.add(key, func)
94+
return func
95+
return add_to_keybinds
96+
else:
97+
def add_to_config(func):
98+
self.add_config_attr(config, func)
99+
return func
100+
return add_to_config
101+
102+
class ConfiguredEdits(AbstractEdits):
103+
def __init__(self, simple_edits, cut_buffer_edits, awaiting_config, config, key_dispatch):
104+
self.simple_edits = dict(simple_edits)
105+
self.cut_buffer_edits = dict(cut_buffer_edits)
106+
for attr, func in awaiting_config.items():
107+
super(ConfiguredEdits, self).add(key_dispatch[getattr(config, attr)], func)
108+
109+
def add_config_attr(self, config_attr, func):
110+
raise NotImplementedError("Config already set on this mapping")
111+
112+
def add(self, key, func):
113+
raise NotImplementedError("Config already set on this mapping")
114+
115+
edit_keys = UnconfiguredEdits()
116+
117+
# Because the edits.on decorator runs the functions, functions which depend
118+
# on other functions must be declared after their dependencies
119+
120+
@edit_keys.on('<Ctrl-b>')
121+
@edit_keys.on('<LEFT>')
22122
def left_arrow(cursor_offset, line):
23123
return max(0, cursor_offset - 1), line
24124

25-
@on('<Ctrl-f>')
26-
@on('<RIGHT>')
125+
@edit_keys.on('<Ctrl-f>')
126+
@edit_keys.on('<RIGHT>')
27127
def right_arrow(cursor_offset, line):
28128
return min(len(line), cursor_offset + 1), line
29129

30-
@on('<Ctrl-a>')
31-
@on('<HOME>')
130+
@edit_keys.on('<Ctrl-a>')
131+
@edit_keys.on('<HOME>')
32132
def beginning_of_line(cursor_offset, line):
33133
return 0, line
34134

35-
@on('<Ctrl-e>')
36-
@on('<END>')
135+
@edit_keys.on('<Ctrl-e>')
136+
@edit_keys.on('<END>')
37137
def end_of_line(cursor_offset, line):
38138
return len(line), line
39139

40-
@on('<Esc+f>')
41-
@on('<Ctrl-RIGHT>')
42-
@on('<Esc+RIGHT>')
140+
@edit_keys.on('<Esc+f>')
141+
@edit_keys.on('<Ctrl-RIGHT>')
142+
@edit_keys.on('<Esc+RIGHT>')
43143
def forward_word(cursor_offset, line):
44144
patt = r"\S\s"
45145
match = re.search(patt, line[cursor_offset:]+' ')
46146
delta = match.end() - 1 if match else 0
47147
return (cursor_offset + delta, line)
48148

49-
@on('<Esc+b>')
50-
@on('<Ctrl-LEFT>')
51-
@on('<Esc+LEFT>')
52-
def back_word(cursor_offset, line):
53-
return (last_word_pos(line[:cursor_offset]), line)
54-
55149
def last_word_pos(string):
56150
"""returns the start index of the last word of given string"""
57151
patt = r'\S\s'
58152
match = re.search(patt, string[::-1])
59153
index = match and len(string) - match.end() + 1
60154
return index or 0
61155

62-
@on('<PADDELETE>')
156+
@edit_keys.on('<Esc+b>')
157+
@edit_keys.on('<Ctrl-LEFT>')
158+
@edit_keys.on('<Esc+LEFT>')
159+
def back_word(cursor_offset, line):
160+
return (last_word_pos(line[:cursor_offset]), line)
161+
162+
@edit_keys.on('<PADDELETE>')
63163
def delete(cursor_offset, line):
64164
return (cursor_offset,
65165
line[:cursor_offset] + line[cursor_offset+1:])
66166

67-
@on('<Ctrl-h>')
68-
@on('<BACKSPACE>')
167+
@edit_keys.on('<Ctrl-h>')
168+
@edit_keys.on('<BACKSPACE>')
169+
@edit_keys.on(config='delete_key')
69170
def backspace(cursor_offset, line):
70171
if cursor_offset == 0:
71172
return cursor_offset, line
@@ -76,59 +177,61 @@ def backspace(cursor_offset, line):
76177
return (cursor_offset - 1,
77178
line[:cursor_offset - 1] + line[cursor_offset:])
78179

79-
@on('<Ctrl-u>')
180+
@edit_keys.on('<Ctrl-u>')
181+
@edit_keys.on(config='clear_line_key')
80182
def delete_from_cursor_back(cursor_offset, line):
81183
return 0, line[cursor_offset:]
82184

83-
@on('<Ctrl-k>')
84-
def delete_from_cursor_forward(cursor_offset, line):
85-
return cursor_offset, line[:cursor_offset]
86-
87-
@on('<Esc+d>') # option-d
185+
@edit_keys.on('<Esc+d>') # option-d
88186
def delete_rest_of_word(cursor_offset, line):
89187
m = re.search(r'\w\b', line[cursor_offset:])
90188
if not m:
91189
return cursor_offset, line
92190
return cursor_offset, line[:cursor_offset] + line[m.start()+cursor_offset+1:]
93191

94-
@on('<Ctrl-w>')
192+
@edit_keys.on('<Ctrl-w>')
193+
@edit_keys.on(config='clear_word_key')
95194
def delete_word_to_cursor(cursor_offset, line):
96195
matches = list(re.finditer(r'\s\S', line[:cursor_offset]))
97196
start = matches[-1].start()+1 if matches else 0
98197
return start, line[:start] + line[cursor_offset:]
99198

100-
@on('<Esc+y>')
199+
@edit_keys.on('<Esc+y>')
101200
def yank_prev_prev_killed_text(cursor_offset, line):
102201
return cursor_offset, line #TODO Not implemented
103202

104-
@on('<Ctrl-t>')
203+
@edit_keys.on('<Ctrl-t>')
105204
def transpose_character_before_cursor(cursor_offset, line):
106205
return (min(len(line), cursor_offset + 1),
107206
line[:cursor_offset-1] +
108207
(line[cursor_offset] if len(line) > cursor_offset else '') +
109208
line[cursor_offset - 1] +
110209
line[cursor_offset+1:])
111210

112-
@on('<Esc+t>')
211+
@edit_keys.on('<Esc+t>')
113212
def transpose_word_before_cursor(cursor_offset, line):
114213
return cursor_offset, line #TODO Not implemented
115214

116215
# bonus functions (not part of readline)
117216

118-
@on('<Esc+r>')
217+
@edit_keys.on('<Esc+r>')
119218
def delete_line(cursor_offset, line):
120219
return 0, ""
121220

122-
@on('<Esc+u>')
221+
@edit_keys.on('<Esc+u>')
123222
def uppercase_next_word(cursor_offset, line):
124223
return cursor_offset, line #TODO Not implemented
125224

126-
@on('<Esc+c>')
225+
@edit_keys.on('<Ctrl-k>')
226+
def delete_from_cursor_forward(cursor_offset, line):
227+
return cursor_offset, line[:cursor_offset]
228+
229+
@edit_keys.on('<Esc+c>')
127230
def titlecase_next_word(cursor_offset, line):
128231
return cursor_offset, line #TODO Not implemented
129232

130-
@on('<Esc+BACKSPACE>')
131-
@on('<Meta-BACKSPACE>')
233+
@edit_keys.on('<Esc+BACKSPACE>')
234+
@edit_keys.on('<Meta-BACKSPACE>')
132235
def delete_word_from_cursor_back(cursor_offset, line):
133236
"""Whatever my option-delete does in bash on my mac"""
134237
if not line:
@@ -138,10 +241,3 @@ def delete_word_from_cursor_back(cursor_offset, line):
138241
return starts[-1], line[:starts[-1]] + line[cursor_offset:]
139242
return cursor_offset, line
140243

141-
def get_updated_char_sequences(key_dispatch, config):
142-
updated_char_sequences = dict(char_sequences)
143-
updated_char_sequences[key_dispatch[config.delete_key]] = backspace
144-
updated_char_sequences[key_dispatch[config.clear_word_key]] = delete_word_to_cursor
145-
updated_char_sequences[key_dispatch[config.clear_line_key]] = delete_from_cursor_back
146-
return updated_char_sequences
147-

bpython/curtsiesfrontend/repl.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,7 @@
4141
from bpython.curtsiesfrontend.coderunner import CodeRunner, FakeOutput
4242
from bpython.curtsiesfrontend.filewatch import ModuleChangedEventHandler
4343
from bpython.curtsiesfrontend.interaction import StatusBar
44-
from bpython.curtsiesfrontend.manual_readline import char_sequences as rl_char_sequences
45-
from bpython.curtsiesfrontend.manual_readline import get_updated_char_sequences
44+
from bpython.curtsiesfrontend.manual_readline import edit_keys
4645

4746
#TODO other autocomplete modes (also fix in other bpython implementations)
4847

@@ -72,18 +71,18 @@
7271

7372
class FakeStdin(object):
7473
"""Stdin object user code references so sys.stdin.read() asked user for interactive input"""
75-
def __init__(self, coderunner, repl, updated_rl_char_sequences=None):
74+
def __init__(self, coderunner, repl, configured_edit_keys=None):
7675
self.coderunner = coderunner
7776
self.repl = repl
7877
self.has_focus = False # whether FakeStdin receives keypress events
7978
self.current_line = ''
8079
self.cursor_offset = 0
8180
self.old_num_lines = 0
8281
self.readline_results = []
83-
if updated_rl_char_sequences:
84-
self.rl_char_sequences = updated_rl_char_sequences
82+
if configured_edit_keys:
83+
self.rl_char_sequences = configured_edit_keys
8584
else:
86-
self.rl_char_sequences = rl_char_sequences
85+
self.rl_char_sequences = edit_keys
8786

8887
def process_event(self, e):
8988
assert self.has_focus
@@ -267,7 +266,7 @@ def smarter_request_reload(desc):
267266
if config.curtsies_fill_terminal else ''),
268267
refresh_request=self.request_refresh
269268
)
270-
self.rl_char_sequences = get_updated_char_sequences(key_dispatch, config)
269+
self.edit_keys = edit_keys.mapping_with_config(config, key_dispatch)
271270
logger.debug("starting parent init")
272271
super(Repl, self).__init__(interp, config)
273272
#TODO bring together all interactive stuff - including current directory in path?
@@ -296,7 +295,7 @@ def smarter_request_reload(desc):
296295
self.coderunner = CodeRunner(self.interp, self.request_refresh)
297296
self.stdout = FakeOutput(self.coderunner, self.send_to_stdout)
298297
self.stderr = FakeOutput(self.coderunner, self.send_to_stderr)
299-
self.stdin = FakeStdin(self.coderunner, self, self.rl_char_sequences)
298+
self.stdin = FakeStdin(self.coderunner, self, self.edit_keys)
300299

301300
self.request_paint_to_clear_screen = False # next paint should clear screen
302301
self.last_events = [None] * 50
@@ -459,8 +458,12 @@ def process_key_event(self, e):
459458
self.up_one_line()
460459
elif e in ("<DOWN>",) + key_dispatch[self.config.down_one_line_key]:
461460
self.down_one_line()
462-
elif e in self.rl_char_sequences:
463-
self.cursor_offset, self.current_line = self.rl_char_sequences[e](self.cursor_offset, self.current_line)
461+
elif e in self.edit_keys:
462+
self.cursor_offset, self.current_line = self.edit_keys[e](self.cursor_offset, self.current_line)
463+
elif e in key_dispatch[self.config.cut_to_buffer_key]:
464+
self.cut_to_buffer()
465+
elif e in key_dispatch[self.config.yank_from_buffer_key]:
466+
self.yank_from_buffer()
464467
elif e in key_dispatch[self.config.reimport_key]:
465468
self.clear_modules_and_reevaluate()
466469
elif e in key_dispatch[self.config.toggle_file_watch_key]:
@@ -558,6 +561,13 @@ def on_control_d(self):
558561
else:
559562
self.current_line = self.current_line[:self.cursor_offset] + self.current_line[self.cursor_offset+1:]
560563

564+
def cut_to_buffer(self):
565+
self.cut_buffer = self.current_line[self.cursor_offset:]
566+
self.current_line = self.current_line[:self.cursor_offset]
567+
568+
def yank_from_buffer(self):
569+
pass
570+
561571
def up_one_line(self):
562572
self.rl_history.enter(self.current_line)
563573
self._set_current_line(self.rl_history.back(False, search=self.config.curtsies_right_arrow_completion),

0 commit comments

Comments
 (0)