1
+ """TODO: ctrl + left/right (move past word), ctrl + backspace/del (del word), shift + del (del line)
2
+ ...: Smart movement through leading indentation.
3
+ ...: Except for first line, up/down to work normally on multi-line console input.
4
+ """
5
+ from code import InteractiveConsole
6
+ from collections import deque
7
+ from dataclasses import dataclass
8
+ from io import StringIO
9
+ from itertools import chain , takewhile
10
+ from more_itertools import ilen
11
+ import sys
12
+ from kivy .uix .codeinput import CodeInput
13
+ from pygments .lexers import PythonConsoleLexer
14
+
15
+
16
+ @dataclass (frozen = True )
17
+ class Key :
18
+ # ANY equals everything! -- if you don't care about matching modifiers, set them equal to Key.ANY
19
+ ANY = type ('ANY' , (), { '__eq__' : lambda * args : True ,
20
+ '__repr__' : lambda self : 'ANY' ,
21
+ '__hash__' : lambda self : - 1 })()
22
+
23
+ code : int
24
+ shift : bool = False
25
+ ctrl : bool = False
26
+
27
+ def __eq__ (self , other ):
28
+ if isinstance (other , int ): return other == self .code
29
+ return self .__dict__ == other .__dict__
30
+
31
+ def iter_similar (self ):
32
+ """Return an iterator that yields keys equal to self."""
33
+ yield self
34
+ yield Key (self .code , self .shift , Key .ANY )
35
+ yield Key (self .code , Key .ANY , self .ctrl )
36
+ yield Key (self .code , Key .ANY , Key .ANY )
37
+
38
+
39
+ SHIFT , CTRL = (303 , 304 ), (305 , 306 )
40
+
41
+ EXACT = map (Key , (13 , 9 , 275 , 276 , 278 , 279 ))
42
+ ANY_MODS = (Key (code , Key .ANY , Key .ANY ) for code in (273 , 274 , 8 , 127 ))
43
+
44
+ KEYS \
45
+ = ENTER , TAB , RIGHT , LEFT , HOME , END , UP , DOWN , BACKSPACE , DELETE \
46
+ = tuple (chain (EXACT , ANY_MODS ))
47
+
48
+ del EXACT ; del ANY_MODS # Generators exhausted and we don't need them anymore
49
+
50
+ CUT = Key (120 , False , True ) # <ctrl + c>
51
+ COPY = Key (99 , False , True ) # <ctrl + x>
52
+ REDO = Key (122 , True , True ) # <ctrl + shift + z>
53
+
54
+ SELECT_LEFT = Key (276 , True , False ) # <shift + left>
55
+ SELECT_RIGHT = Key (275 , True , False ) # <shift + right>
56
+ SELECT_HOME = Key (278 , True , False ) # <shift + home>
57
+ SELECT_END = Key (279 , True , False ) # <shift + end>
58
+
59
+
60
+ class RedirectConsoleOut :
61
+ """Redirect sys.excepthook and sys.stdout in a single context manager.
62
+ InteractiveConsole (IC) `write` method won't be used if sys.excepthook isn't sys.__excepthook__,
63
+ so we redirect sys.excepthook when pushing to the IC. This redirect probably isn't necessary:
64
+ testing was done in IPython which sets sys.excepthook to a crashhandler, but running this file
65
+ normally would probably avoid the need for a redirect; still, better safe than sorry.
66
+ """
67
+ def __init__ (self ):
68
+ self .stack = deque ()
69
+
70
+ def __enter__ (self ):
71
+ self .old_hook = sys .excepthook
72
+ self .old_out = sys .stdout
73
+
74
+ sys .excepthook = sys .__excepthook__
75
+ sys .stdout = StringIO ()
76
+
77
+ sys .stdout .write ('\n ' )
78
+
79
+ def __exit__ (self , type , value , tb ):
80
+ self .stack .append (sys .stdout .getvalue ())
81
+
82
+ sys .stdout = self .old_out
83
+ sys .excepthook = self .old_hook
84
+
85
+
86
+ class Console (InteractiveConsole ):
87
+ def __init__ (self , text_input , locals = None , filename = "<console>" ):
88
+ super ().__init__ (locals , filename )
89
+ self .text_input = text_input
90
+ self .out_context = RedirectConsoleOut ()
91
+
92
+ def push (self , line ):
93
+ out = self .out_context
94
+ with out : needs_more = super ().push (line )
95
+
96
+ if not needs_more :
97
+ out .stack .reverse ()
98
+ self .text_input .text += '' .join (out .stack )
99
+ out .stack .clear ()
100
+
101
+ return needs_more
102
+
103
+ def write (self , data ):
104
+ self .out_context .stack .append (data )
105
+
106
+
107
+ class InputHandler :
108
+ def __init__ (self , text_input ):
109
+ self .text_input = text_input
110
+
111
+ self .pre = { COPY : self ._copy ,
112
+ CUT : self ._cut ,
113
+ REDO : self ._redo }
114
+
115
+ self .post = { LEFT : self ._left ,
116
+ RIGHT : self ._right ,
117
+ END : self ._end ,
118
+ HOME : self ._home ,
119
+ SELECT_LEFT : self ._select_left ,
120
+ SELECT_RIGHT : self ._select_right ,
121
+ SELECT_END : self ._select_end ,
122
+ SELECT_HOME : self ._select_home ,
123
+ TAB : self ._tab ,
124
+ ENTER : self ._enter ,
125
+ UP : self ._up ,
126
+ DOWN : self ._down ,
127
+ BACKSPACE : self ._backspace }
128
+
129
+ def __call__ (self , key , read_only ):
130
+ if handle := self .pre .get (key ): return handle
131
+
132
+ if read_only : return self ._read_only
133
+
134
+ for key in key .iter_similar ():
135
+ if handle := self .post .get (key ): return handle
136
+
137
+ def _copy (self , ** kwargs ): self .text_input .copy ()
138
+
139
+ def _cut (self , read_only , ** kwargs ):
140
+ self .text_input .copy () if read_only else self .text_input .cut ()
141
+
142
+ def _redo (self , ** kwargs ): self .text_input .do_redo ()
143
+
144
+ def _left (self , at_home , ** kwargs ):
145
+ self .text_input .cancel_selection ()
146
+ if not at_home : self .text_input .move_cursor ('left' )
147
+
148
+ def _right (self , at_end , ** kwargs ):
149
+ self .text_input .cancel_selection ()
150
+ if not at_end : self .text_input .move_cursor ('right' )
151
+
152
+ def _end (self , ** kwargs ):
153
+ self .text_input .cancel_selection ()
154
+ self .text_input .move_cursor ('end' )
155
+
156
+ def _home (self , ** kwargs ):
157
+ self .text_input .cancel_selection ()
158
+ self .text_input .move_cursor ('home' )
159
+
160
+ def _select_left (self , at_home , has_selection , _from , _to , ** kwargs ):
161
+ if at_home : return
162
+ i = self .text_input .move_cursor ('left' )
163
+ if not has_selection : self .text_input .select_text (i , i + 1 )
164
+ elif i < _from : self .text_input .select_text (i , _to )
165
+ elif i >= _from : self .text_input .select_text (_from , i )
166
+
167
+ def _select_right (self , at_end , has_selection , _from , _to , ** kwargs ):
168
+ if at_end : return
169
+ i = self .text_input .move_cursor ('right' )
170
+ if not has_selection : self .text_input .select_text (i - 1 , i )
171
+ elif i > _to : self .text_input .select_text (_from , i )
172
+ elif i <= _to : self .text_input .select_text (i , _to )
173
+
174
+ def _select_end (self , has_selection , _to , _from , i , end , ** kwargs ):
175
+ if not has_selection : start = i
176
+ elif _to == i : start = _from
177
+ else : start = _to
178
+ self .text_input .select_text (start , end )
179
+ self .text_input .move_cursor ('end' )
180
+
181
+ def _select_home (self , has_selection , _to , _from , i , home , ** kwargs ):
182
+ if not has_selection : fin = i
183
+ elif _from == i : fin = _to
184
+ else : fin = _from
185
+ self .text_input .select_text (home , fin )
186
+ self .text_input .move_cursor ('home' )
187
+
188
+ def _tab (self , has_selection , at_home , ** kwargs ):
189
+ ti = self .text_input
190
+ if not has_selection and at_home : ti .insert_text (' ' * ti .tab_width )
191
+
192
+ def _enter (self , home , ** kwargs ):
193
+ ti = self .text_input
194
+ text = ti .text [home :].rstrip ()
195
+
196
+ if text and (len (ti .history ) == 1 or ti .history [1 ] != text ):
197
+ ti .history .popleft ()
198
+ ti .history .appendleft (text )
199
+ ti .history .appendleft ('' )
200
+ ti ._history_index = 0
201
+
202
+ needs_more = ti .console .push (text )
203
+ ti .prompt (needs_more )
204
+
205
+ def _up (self , ** kwargs ): self .text_input .input_from_history ()
206
+
207
+ def _down (self , ** kwargs ): self .text_input .input_from_history (reverse = True )
208
+
209
+ def _backspace (self , at_home , has_selection , window , keycode , text , modifiers , ** kwargs ):
210
+ ti = self .text_input
211
+ if not at_home or has_selection :
212
+ super (KivyConsole , ti ).keyboard_on_key_down (window , keycode , text , modifiers )
213
+
214
+ def _read_only (self , key , window , keycode , text , modifiers , ** kwargs ):
215
+ ti = self .text_input
216
+ ti .cancel_selection ()
217
+ ti .move_cursor ('end' )
218
+ if key .code not in KEYS :
219
+ super (KivyConsole , ti ).keyboard_on_key_down (window , keycode , text , modifiers )
220
+
221
+
222
+ class KivyConsole (CodeInput ):
223
+ prompt_1 = '\n >>> '
224
+ prompt_2 = '\n ... '
225
+
226
+ _home_pos = 0
227
+ _indent_level = 0
228
+ _history_index = 0
229
+
230
+ def __init__ (self , * args , locals = None , banner = None , ** kwargs ):
231
+ super ().__init__ (* args , ** kwargs )
232
+ self .lexer = PythonConsoleLexer ()
233
+ self .history = deque (['' ])
234
+ self .console = Console (self , locals )
235
+ self .input_handler = InputHandler (self )
236
+
237
+ if banner is None :
238
+ self .text = (f'Python { sys .version .splitlines ()[0 ]} \n '
239
+ 'Welcome to the KivyConsole -- A Python interpreter widget for Kivy!\n ' )
240
+ else : self .text = banner
241
+ self .prompt ()
242
+
243
+ def prompt (self , needs_more = False ):
244
+ if needs_more :
245
+ prompt = self .prompt_2
246
+ self ._indent_level = self .count_indents ()
247
+ if self .text .rstrip ().endswith (':' ): self ._indent_level += 1
248
+ else :
249
+ prompt = self .prompt_1
250
+ self ._indent_level = 0
251
+
252
+ indent = self .tab_width * self ._indent_level
253
+ self .text += prompt + ' ' * indent
254
+ self ._home_pos = self .cursor_index () - indent
255
+ self .reset_undo ()
256
+
257
+ def count_indents (self ):
258
+ return ilen (takewhile (str .isspace , self .history [1 ])) // self .tab_width
259
+
260
+ def keyboard_on_key_down (self , window , keycode , text , modifiers ):
261
+ """Emulate a python console: disallow editing of previous console output."""
262
+ if keycode [0 ] in CTRL or keycode [0 ] in SHIFT and 'ctrl' in modifiers : return
263
+
264
+ key = Key (keycode [0 ], 'shift' in modifiers , 'ctrl' in modifiers )
265
+
266
+ # force `selection_from` <= `selection_to` (mouse selections can reverse the order):
267
+ _from , _to = sorted ((self .selection_from , self .selection_to ))
268
+ has_selection = bool (self .selection_text )
269
+ i , home , end = self .cursor_index (), self ._home_pos , len (self .text )
270
+
271
+ read_only = i < home or has_selection and _from < home
272
+ at_home = i == home
273
+ at_end = i == end
274
+
275
+ kwargs = locals (); del kwargs ['self' ]
276
+ if handle := self .input_handler (key , read_only ): return handle (** kwargs )
277
+
278
+ return super ().keyboard_on_key_down (window , keycode , text , modifiers )
279
+
280
+ def move_cursor (self , pos ):
281
+ """Similar to `do_cursor_movement` but we account for `_home_pos` and we return the new cursor index."""
282
+ if pos == 'end' : index = len (self .text )
283
+ elif pos == 'home' : index = self ._home_pos
284
+ elif pos == 'left' : index = self .cursor_index () - 1
285
+ elif pos == 'right' : index = self .cursor_index () + 1
286
+ self .cursor = self .get_cursor_from_index (index )
287
+ return index
288
+
289
+ def input_from_history (self , reverse = False ):
290
+ self ._history_index += - 1 if reverse else 1
291
+ self ._history_index = min (max (0 , self ._history_index ), len (self .history ) - 1 )
292
+ self .text = self .text [: self ._home_pos ] + self .history [self ._history_index ]
293
+
294
+
295
+ if __name__ == "__main__" :
296
+ from textwrap import dedent
297
+ from kivy .app import App
298
+ from kivy .lang import Builder
299
+
300
+ KV = """
301
+ KivyConsole:
302
+ font_name : './UbuntuMono-R.ttf'
303
+ style_name: 'monokai'
304
+ """
305
+
306
+
307
+ class KivyInterpreter (App ):
308
+ def build (self ): return Builder .load_string (dedent (KV ))
309
+
310
+
311
+ KivyInterpreter ().run ()
0 commit comments