Skip to content

Commit 64d875e

Browse files
Merge branch 'paste-detection'
2 parents 2a9b51f + fa08b74 commit 64d875e

File tree

4 files changed

+192
-13
lines changed

4 files changed

+192
-13
lines changed

bpython/curtsies.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import absolute_import
22

33
import code
4+
import collections
45
import io
56
import logging
67
import sys
@@ -91,10 +92,45 @@ def main(args=None, locals_=None, banner=None, welcome_message=None):
9192
return extract_exit_value(exit_value)
9293

9394

95+
def _combined_events(event_provider, paste_threshold):
96+
"""Combines consecutive keypress events into paste events."""
97+
timeout = yield 'nonsense_event' # so send can be used immediately
98+
queue = collections.deque()
99+
while True:
100+
e = event_provider.send(timeout)
101+
if isinstance(e, curtsies.events.Event):
102+
timeout = yield e
103+
continue
104+
elif e is None:
105+
timeout = yield None
106+
continue
107+
else:
108+
queue.append(e)
109+
e = event_provider.send(0)
110+
while not (e is None or isinstance(e, curtsies.events.Event)):
111+
queue.append(e)
112+
e = event_provider.send(0)
113+
if len(queue) >= paste_threshold:
114+
paste = curtsies.events.PasteEvent()
115+
paste.events.extend(queue)
116+
queue.clear()
117+
timeout = yield paste
118+
else:
119+
while len(queue):
120+
timeout = yield queue.popleft()
121+
122+
123+
def combined_events(event_provider, paste_threshold=3):
124+
g = _combined_events(event_provider, paste_threshold)
125+
next(g)
126+
return g
127+
128+
94129
def mainloop(config, locals_, banner, interp=None, paste=None,
95130
interactive=True):
96-
with curtsies.input.Input(keynames='curtsies', sigint_event=True) as \
97-
input_generator:
131+
with curtsies.input.Input(keynames='curtsies',
132+
sigint_event=True,
133+
paste_threshold=None) as input_generator:
98134
with curtsies.window.CursorAwareWindow(
99135
sys.stdout,
100136
sys.stdin,
@@ -180,12 +216,13 @@ def process_event(e):
180216

181217
# do a display before waiting for first event
182218
process_event(None)
219+
inputs = combined_events(input_generator)
183220
for unused in find_iterator:
184-
e = input_generator.send(0)
221+
e = inputs.send(0)
185222
if e is not None:
186223
process_event(e)
187224

188-
for e in input_generator:
225+
for e in inputs:
189226
process_event(e)
190227

191228

bpython/curtsiesfrontend/repl.py

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@
8484
Press {config.edit_config_key} to edit this config file.
8585
"""
8686
EXAMPLE_CONFIG_URL = 'https://raw.githubusercontent.com/bpython/bpython/master/bpython/sample-config'
87+
MAX_EVENTS_POSSIBLY_NOT_PASTE = 20 # more than this many events will be assumed to
88+
# be a true paste event, i.e. control characters
89+
# like '<Ctrl-a>' will be stripped
8790

8891
# This is needed for is_nop and should be removed once is_nop is fixed.
8992
if py3:
@@ -545,16 +548,25 @@ def process_control_event(self, e):
545548
ctrl_char = compress_paste_event(e)
546549
if ctrl_char is not None:
547550
return self.process_event(ctrl_char)
548-
simple_events = just_simple_events(e.events)
549-
source = preprocess(''.join(simple_events),
550-
self.interp.compile)
551-
552551
with self.in_paste_mode():
553-
for ee in source:
554-
if self.stdin.has_focus:
555-
self.stdin.process_event(ee)
556-
else:
557-
self.process_simple_keypress(ee)
552+
# Might not really be a paste, UI might just be lagging
553+
if (len(e.events) <= MAX_EVENTS_POSSIBLY_NOT_PASTE and
554+
any(not is_simple_event(ee) for ee in e.events)):
555+
for ee in e.events:
556+
if self.stdin.has_focus:
557+
self.stdin.process_event(ee)
558+
else:
559+
self.process_event(ee)
560+
else:
561+
simple_events = just_simple_events(e.events)
562+
source = preprocess(''.join(simple_events),
563+
self.interp.compile)
564+
for ee in source:
565+
if self.stdin.has_focus:
566+
self.stdin.process_event(ee)
567+
else:
568+
self.process_simple_keypress(ee)
569+
558570

559571
elif isinstance(e, bpythonevents.RunStartupFileEvent):
560572
try:
@@ -1619,11 +1631,24 @@ def just_simple_events(event_list):
16191631
pass # ignore events
16201632
elif e == '<SPACE>':
16211633
simple_events.append(' ')
1634+
elif len(e) > 1:
1635+
pass # get rid of <Ctrl-a> etc.
16221636
else:
16231637
simple_events.append(e)
16241638
return simple_events
16251639

16261640

1641+
def is_simple_event(e):
1642+
if isinstance(e, events.Event):
1643+
return False
1644+
if e in ("<Ctrl-j>", "<Ctrl-m>", "<PADENTER>", "\n", "\r", "<SPACE>"):
1645+
return True
1646+
if len(e) > 1:
1647+
return False
1648+
else:
1649+
return True
1650+
1651+
16271652
# TODO this needs some work to function again and be useful for embedding
16281653
def simple_repl():
16291654
refreshes = []

bpython/test/test_curtsies.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# coding: utf-8
2+
from __future__ import unicode_literals
3+
4+
from collections import namedtuple
5+
6+
from bpython.curtsies import combined_events
7+
from bpython.test import (FixLanguageTestCase as TestCase, unittest)
8+
9+
import curtsies.events
10+
11+
12+
ScheduledEvent = namedtuple('ScheduledEvent', ['when', 'event'])
13+
14+
15+
class EventGenerator(object):
16+
def __init__(self, initial_events=(), scheduled_events=()):
17+
self._events = []
18+
self._current_tick = 0
19+
for e in initial_events:
20+
self.schedule_event(e, 0)
21+
for e, w in scheduled_events:
22+
self.schedule_event(e, w)
23+
24+
def schedule_event(self, event, when):
25+
self._events.append(ScheduledEvent(when, event))
26+
self._events.sort()
27+
28+
def send(self, timeout=None):
29+
if timeout not in [None, 0]:
30+
raise ValueError('timeout value %r not supported' % timeout)
31+
if not self._events:
32+
return None
33+
if self._events[0].when <= self._current_tick:
34+
return self._events.pop(0).event
35+
36+
if timeout == 0:
37+
return None
38+
elif timeout is None:
39+
e = self._events.pop(0)
40+
self._current_tick = e.when
41+
return e.event
42+
else:
43+
raise ValueError('timeout value %r not supported' % timeout)
44+
45+
def tick(self, dt=1):
46+
self._current_tick += dt
47+
return self._current_tick
48+
49+
50+
class TestCurtsiesPasteDetection(TestCase):
51+
def test_paste_threshold(self):
52+
eg = EventGenerator(list('abc'))
53+
cb = combined_events(eg, paste_threshold=3)
54+
e = next(cb)
55+
self.assertIsInstance(e, curtsies.events.PasteEvent)
56+
self.assertEqual(e.events, list('abc'))
57+
self.assertEqual(next(cb), None)
58+
59+
eg = EventGenerator(list('abc'))
60+
cb = combined_events(eg, paste_threshold=4)
61+
self.assertEqual(next(cb), 'a')
62+
self.assertEqual(next(cb), 'b')
63+
self.assertEqual(next(cb), 'c')
64+
self.assertEqual(next(cb), None)
65+
66+
def test_set_timeout(self):
67+
eg = EventGenerator('a', zip('bcdefg', [1, 2, 3, 3, 3, 4]))
68+
eg.schedule_event(curtsies.events.SigIntEvent(), 5)
69+
eg.schedule_event('h', 6)
70+
cb = combined_events(eg, paste_threshold=3)
71+
self.assertEqual(next(cb), 'a')
72+
self.assertEqual(cb.send(0), None)
73+
self.assertEqual(next(cb), 'b')
74+
self.assertEqual(cb.send(0), None)
75+
eg.tick()
76+
self.assertEqual(cb.send(0), 'c')
77+
self.assertEqual(cb.send(0), None)
78+
eg.tick()
79+
self.assertIsInstance(cb.send(0), curtsies.events.PasteEvent)
80+
self.assertEqual(cb.send(0), None)
81+
self.assertEqual(cb.send(None), 'g')
82+
self.assertEqual(cb.send(0), None)
83+
eg.tick(1)
84+
self.assertIsInstance(cb.send(0), curtsies.events.SigIntEvent)
85+
self.assertEqual(cb.send(0), None)
86+
self.assertEqual(cb.send(None), 'h')
87+
self.assertEqual(cb.send(None), None)
88+
89+
90+
if __name__ == '__main__':
91+
unittest.main()

bpython/test/test_curtsies_repl.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from bpython.test import (FixLanguageTestCase as TestCase, MagicIterMock, mock,
2121
builtin_target, unittest)
2222

23+
from curtsies import events
24+
2325
if py3:
2426
from importlib import invalidate_caches
2527
else:
@@ -422,5 +424,29 @@ def test_startup_event_latin1(self):
422424
self.assertIn('a', self.repl.interp.locals)
423425

424426

427+
class TestCurtsiesPasteEvents(TestCase):
428+
429+
def setUp(self):
430+
self.repl = create_repl()
431+
432+
def test_control_events_in_small_paste(self):
433+
self.assertGreaterEqual(curtsiesrepl.MAX_EVENTS_POSSIBLY_NOT_PASTE, 6,
434+
'test assumes UI lag could cause 6 events')
435+
p = events.PasteEvent()
436+
p.events = ['a', 'b', 'c', 'd', '<Ctrl-a>', 'e']
437+
self.repl.process_event(p)
438+
self.assertEqual(self.repl.current_line, 'eabcd')
439+
440+
441+
def test_control_events_in_large_paste(self):
442+
"""Large paste events should ignore control characters"""
443+
p = events.PasteEvent()
444+
p.events = (['a', '<Ctrl-a>'] +
445+
['e'] * curtsiesrepl.MAX_EVENTS_POSSIBLY_NOT_PASTE)
446+
self.repl.process_event(p)
447+
self.assertEqual(self.repl.current_line,
448+
'a' + 'e'*curtsiesrepl.MAX_EVENTS_POSSIBLY_NOT_PASTE)
449+
450+
425451
if __name__ == '__main__':
426452
unittest.main()

0 commit comments

Comments
 (0)