liveConsole 1.1.0__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: liveConsole
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: An IDLE-like debugger to allow for real-time command injection for debugging and testing python code
5
5
  Author-email: Tzur Soffer <tzur.soffer@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "liveConsole"
3
- version = "1.1.0"
3
+ version = "1.2.0"
4
4
  description = "An IDLE-like debugger to allow for real-time command injection for debugging and testing python code"
5
5
  authors = [{ name="Tzur Soffer", email="tzur.soffer@gmail.com" }]
6
6
  license = {text = "MIT"}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: liveConsole
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: An IDLE-like debugger to allow for real-time command injection for debugging and testing python code
5
5
  Author-email: Tzur Soffer <tzur.soffer@gmail.com>
6
6
  License: MIT
@@ -0,0 +1,641 @@
1
+ import customtkinter as ctk
2
+ import tkinter as tk
3
+ import traceback
4
+ import inspect
5
+ import threading
6
+ import sys
7
+ import io
8
+ import pygments
9
+ from pygments.lexers import PythonLexer
10
+ from pygments.styles import get_style_by_name
11
+ import keyword
12
+ import builtins
13
+
14
+
15
+ class StdoutRedirect(io.StringIO):
16
+ """Redirects stdout/stderr to a callback function."""
17
+
18
+ def __init__(self, writeCallback):
19
+ super().__init__()
20
+ self.writeCallback = writeCallback
21
+
22
+ def write(self, s):
23
+ if s.strip():
24
+ self.writeCallback(s, "output")
25
+
26
+ def flush(self):
27
+ pass
28
+
29
+
30
+ class CodeSuggestionManager:
31
+ """Manages code suggestions and autocomplete functionality."""
32
+
33
+ def __init__(self, textWidget):
34
+ self.textWidget = textWidget
35
+ self.suggestionWindow = None
36
+ self.suggestionListbox = None
37
+ self.suggestions = []
38
+ self.selectedSuggestion = 0
39
+
40
+ # Build suggestion sources
41
+ self.keywords = keyword.kwlist
42
+ self.builtins = [name for name in dir(builtins) if not name.startswith('_')]
43
+
44
+ def getCurrentWord(self):
45
+ """Extract the word being typed at cursor position."""
46
+ cursorPos = self.textWidget.index(tk.INSERT)
47
+ lineStart = self.textWidget.index(f"{cursorPos} linestart")
48
+ currentLine = self.textWidget.get(lineStart, cursorPos)
49
+
50
+ # Find the current word
51
+ words = currentLine.split()
52
+ if not words:
53
+ return ""
54
+
55
+ currentWord = words[-1]
56
+ # Handle cases like "print(" where we want to suggest after special chars
57
+ for char in "([{,.":
58
+ if char in currentWord:
59
+ currentWord = currentWord.split(char)[-1]
60
+
61
+ return currentWord
62
+
63
+ def getSuggestions(self, partialWord):
64
+ """Get code suggestions for partial word."""
65
+ if len(partialWord) < 2:
66
+ return []
67
+
68
+ suggestions = []
69
+
70
+ # Add matching keywords
71
+ for kw in self.keywords:
72
+ if kw.startswith(partialWord.lower()):
73
+ suggestions.append(kw)
74
+
75
+ # Add matching builtins
76
+ for builtin in self.builtins:
77
+ if builtin.startswith(partialWord):
78
+ suggestions.append(builtin)
79
+
80
+ # Add matching variables from namespace
81
+ master = self.textWidget.master
82
+ if hasattr(master, 'userLocals'):
83
+ for var in master.userLocals:
84
+ if var.startswith(partialWord) and not var.startswith('_'):
85
+ suggestions.append(var)
86
+
87
+ if hasattr(master, 'userGlobals'):
88
+ for var in master.userGlobals:
89
+ if var.startswith(partialWord) and not var.startswith('_'):
90
+ suggestions.append(var)
91
+
92
+ # Remove duplicates and sort
93
+ return sorted(list(set(suggestions)))[:10]
94
+
95
+ def showSuggestions(self):
96
+ """Display the suggestions popup."""
97
+ currentWord = self.getCurrentWord()
98
+ suggestions = self.getSuggestions(currentWord)
99
+
100
+ if not suggestions:
101
+ self.hideSuggestions()
102
+ return
103
+
104
+ self.suggestions = suggestions
105
+ self.selectedSuggestion = 0
106
+
107
+ # Create suggestion window if needed
108
+ if not self.suggestionWindow:
109
+ self._createSuggestionWindow()
110
+
111
+ # Update listbox content
112
+ self.suggestionListbox.delete(0, tk.END)
113
+ for suggestion in suggestions:
114
+ self.suggestionListbox.insert(tk.END, suggestion)
115
+
116
+ self.suggestionListbox.selection_set(0)
117
+
118
+ # Position window near cursor
119
+ self._positionSuggestionWindow()
120
+ self.suggestionWindow.deiconify()
121
+
122
+ def _createSuggestionWindow(self):
123
+ """Create the suggestion popup window."""
124
+ self.suggestionWindow = tk.Toplevel(self.textWidget)
125
+ self.suggestionWindow.wm_overrideredirect(True)
126
+ self.suggestionWindow.configure(bg="#2d2d2d")
127
+
128
+ self.suggestionListbox = tk.Listbox(
129
+ self.suggestionWindow,
130
+ bg="#2d2d2d",
131
+ fg="white",
132
+ selectbackground="#0066cc",
133
+ font=("Consolas", 10),
134
+ height=8
135
+ )
136
+ self.suggestionListbox.pack()
137
+
138
+ def _positionSuggestionWindow(self):
139
+ """Position the suggestion window near the cursor."""
140
+ cursorPos = self.textWidget.index(tk.INSERT)
141
+ x, y, _, _ = self.textWidget.bbox(cursorPos)
142
+ x += self.textWidget.winfo_rootx()
143
+ y += self.textWidget.winfo_rooty() + 20
144
+ self.suggestionWindow.geometry(f"+{x}+{y}")
145
+
146
+ def hideSuggestions(self):
147
+ """Hide the suggestions popup."""
148
+ if self.suggestionWindow:
149
+ self.suggestionWindow.withdraw()
150
+
151
+ def applySuggestion(self, suggestion=None):
152
+ """Apply the selected suggestion at cursor position."""
153
+ if not suggestion and self.suggestions:
154
+ suggestion = self.suggestions[self.selectedSuggestion]
155
+ if not suggestion:
156
+ return
157
+
158
+ currentWord = self.getCurrentWord()
159
+ if suggestion.startswith(currentWord):
160
+ # Only insert the missing part
161
+ missingPart = suggestion[len(currentWord):]
162
+ cursorPos = self.textWidget.index(tk.INSERT)
163
+ self.textWidget.insert(cursorPos, missingPart)
164
+
165
+ self.hideSuggestions()
166
+
167
+ def handleNavigation(self, direction):
168
+ """Handle up/down navigation in suggestions."""
169
+ if not self.suggestions:
170
+ return
171
+
172
+ if direction == "down":
173
+ self.selectedSuggestion = min(self.selectedSuggestion + 1, len(self.suggestions) - 1)
174
+ else: # up
175
+ self.selectedSuggestion = max(self.selectedSuggestion - 1, 0)
176
+
177
+ self.suggestionListbox.selection_clear(0, tk.END)
178
+ self.suggestionListbox.selection_set(self.selectedSuggestion)
179
+
180
+
181
+ class CommandHistory:
182
+ """Manages command history and navigation."""
183
+
184
+ def __init__(self):
185
+ self.history = []
186
+ self.index = -1
187
+ self.tempCommand = ""
188
+
189
+ def add(self, command):
190
+ """Add a command to history."""
191
+ if command.strip():
192
+ self.history.append(command)
193
+ self.index = len(self.history)
194
+
195
+ def navigateUp(self):
196
+ """Get previous command from history."""
197
+ if self.index > 0:
198
+ self.index -= 1
199
+ return self.history[self.index]
200
+ return None
201
+
202
+ def navigateDown(self):
203
+ """Get next command from history."""
204
+ if self.index < len(self.history) - 1:
205
+ self.index += 1
206
+ return self.history[self.index]
207
+ elif self.index == len(self.history) - 1:
208
+ self.index = len(self.history)
209
+ return self.tempCommand
210
+ return None
211
+
212
+ def setTemp(self, command):
213
+ """Store temporary command while navigating history."""
214
+ self.tempCommand = command
215
+
216
+
217
+ class InteractiveConsoleText(tk.Text):
218
+ """A tk.Text widget with Python syntax highlighting for interactive console."""
219
+
220
+ PROMPT = ">>> "
221
+ PROMPT_LENGTH = 4
222
+
223
+ def __init__(self, master, **kwargs):
224
+ super().__init__(master, **kwargs)
225
+
226
+ # Initialize components
227
+ self.suggestionManager = CodeSuggestionManager(self)
228
+
229
+ self.navigatingHistory = False
230
+ self.history = CommandHistory()
231
+
232
+ # Syntax highlighting setup
233
+ self.lexer = PythonLexer()
234
+ self.style = get_style_by_name("monokai")
235
+
236
+ # Track current command
237
+ self.currentCommandLine = 1
238
+ self.isExecuting = False
239
+
240
+ # Setup tags and bindings
241
+ self._setupTags()
242
+ self._setupBindings()
243
+
244
+ # Initialize with first prompt
245
+ self.addPrompt()
246
+
247
+ def _setupTags(self):
248
+ """Configure text tags for different output types."""
249
+ self.tag_configure("prompt", foreground="#00ff00", font=("Consolas", 12, "bold"))
250
+ self.tag_configure("output", foreground="#ffffff", font=("Consolas", 12))
251
+ self.tag_configure("error", foreground="#ff6666", font=("Consolas", 12))
252
+ self.tag_configure("result", foreground="#66ccff", font=("Consolas", 12))
253
+
254
+ # Configure syntax highlighting tags
255
+ for token, style in self.style:
256
+ if style["color"]:
257
+ fg = f"#{style['color']}"
258
+ font = ("Consolas", 12, "bold" if style["bold"] else "normal")
259
+ self.tag_configure(str(token), foreground=fg, font=font)
260
+
261
+ def _setupBindings(self):
262
+ """Setup all key and mouse bindings."""
263
+ self.bind("<Return>", self.onEnter)
264
+ self.bind("<Shift-Return>", self.onShiftEnter)
265
+ self.bind("<Control-c>", self.cancel)
266
+ self.bind("<Tab>", self.onTab)
267
+ self.bind("<BackSpace>", self.onBackspace)
268
+ self.bind("<KeyRelease>", self.onKeyRelease)
269
+ self.bind("<KeyPress>", self.onKeyPress)
270
+ self.bind("<Button-1>", self.onClick)
271
+ self.bind("<Up>", self.onUp)
272
+ self.bind("<Down>", self.onDown)
273
+
274
+ def getCurrentLineNumber(self):
275
+ """Get the line number where current command starts."""
276
+ return int(self.index("end-1c").split(".")[0])
277
+
278
+ def getPromptPosition(self):
279
+ """Get the position right after the prompt on current command line."""
280
+ return f"{self.currentCommandLine}.{self.PROMPT_LENGTH}"
281
+
282
+ def getCommandStartPosition(self):
283
+ """Get the starting position of the current command."""
284
+ return f"{self.currentCommandLine}.0"
285
+
286
+ def getCurrentCommand(self):
287
+ """Extract the current command text (without prompt)."""
288
+ if self.isExecuting:
289
+ return ""
290
+
291
+ start = self.getPromptPosition()
292
+ end = "end-1c"
293
+ return self.get(start, end)
294
+
295
+ def replaceCurrentCommand(self, newCommand):
296
+ """Replace the current command with new text."""
297
+ if self.isExecuting:
298
+ return
299
+
300
+ start = self.getPromptPosition()
301
+ end = "end-1c"
302
+
303
+ self.delete(start, end)
304
+ self.insert(start, newCommand)
305
+ self.see("end")
306
+
307
+ def isCursorInEditableArea(self):
308
+ """Check if cursor is in the editable command area."""
309
+ if self.isExecuting:
310
+ return False
311
+
312
+ cursorLine = int(self.index("insert").split(".")[0])
313
+ cursorCol = int(self.index("insert").split(".")[1])
314
+
315
+ return (cursorLine >= self.currentCommandLine and
316
+ (cursorLine > self.currentCommandLine or cursorCol >= self.PROMPT_LENGTH))
317
+
318
+ def onEnter(self, event):
319
+ """Handle Enter key - execute command."""
320
+ self.suggestionManager.hideSuggestions()
321
+
322
+ if self.isExecuting:
323
+ return "break"
324
+
325
+ command = self.getCurrentCommand()
326
+
327
+ if not command.strip():
328
+ return "break"
329
+
330
+ # Check if statement is incomplete
331
+ if self.isIncompleteStatement(command):
332
+ return self.onShiftEnter(event)
333
+
334
+ # Execute the command
335
+ self.history.add(command)
336
+ self.mark_set("insert", "end")
337
+ self.insert("end", "\n")
338
+ self.see("end")
339
+
340
+ # Execute in thread
341
+ self.isExecuting = True
342
+ threading.Thread(
343
+ target=self.executeCommandThreaded,
344
+ args=(command,),
345
+ daemon=True
346
+ ).start()
347
+
348
+ return "break"
349
+
350
+ def onShiftEnter(self, event):
351
+ """Handle Shift+Enter - new line with auto-indent."""
352
+ self.suggestionManager.hideSuggestions()
353
+
354
+ if self.isExecuting:
355
+ return "break"
356
+
357
+ # Get current line for indent calculation
358
+ cursorPos = self.index("insert")
359
+ lineStart = self.index(f"{cursorPos} linestart")
360
+ lineEnd = self.index(f"{cursorPos} lineend")
361
+ currentLine = self.get(lineStart, lineEnd)
362
+
363
+ # Calculate indentation
364
+ indent = self.calculateIndent(currentLine)
365
+
366
+ # Insert newline with indent
367
+ self.insert("insert", "\n" + " " * indent)
368
+ self.see("end")
369
+
370
+ return "break"
371
+
372
+ def onTab(self, event):
373
+ """Handle Tab key for autocompletion."""
374
+ if self.isExecuting:
375
+ return "break"
376
+
377
+ if self.suggestionManager.suggestionWindow and \
378
+ self.suggestionManager.suggestionWindow.winfo_viewable():
379
+ self.suggestionManager.applySuggestion()
380
+ else:
381
+ self.suggestionManager.showSuggestions()
382
+
383
+ return "break"
384
+
385
+ def onBackspace(self, event):
386
+ """Prevent backspace from deleting the prompt."""
387
+ if not self.isCursorInEditableArea():
388
+ return "break"
389
+
390
+ # Check if we're at the prompt boundary
391
+ cursorPos = self.index("insert")
392
+ promptPos = self.getPromptPosition()
393
+
394
+ if self.compare(cursorPos, "<=", promptPos):
395
+ return "break"
396
+
397
+ def onClick(self, event):
398
+ """Handle mouse clicks - prevent clicking before prompt."""
399
+ self.suggestionManager.hideSuggestions()
400
+ return None
401
+
402
+ def onKeyPress(self, event):
403
+ """Handle key press events."""
404
+ # print(event.keysym)
405
+ if self.suggestionManager.suggestionWindow and \
406
+ self.suggestionManager.suggestionWindow.winfo_viewable():
407
+ if event.keysym == "Escape":
408
+ self.suggestionManager.hideSuggestions()
409
+ return "break"
410
+
411
+ # Prevent editing outside command area
412
+ if not event.keysym in ["Shift_L", "Shift_R", "Control_L", "Control_R"]:
413
+ self.navigatingHistory = False
414
+ if not self.isCursorInEditableArea():
415
+ self.mark_set("insert", "end")
416
+
417
+ if event.keysym in ["Left", "Right"]:
418
+ if self.index("insert") == self.getPromptPosition():
419
+ self.mark_set("insert", "1.4")
420
+ return "break"
421
+
422
+ def onKeyRelease(self, event):
423
+ """Handle key release events."""
424
+ if event.keysym in ["Return", "Escape", "Left", "Right", "Home", "End"]:
425
+ self.suggestionManager.hideSuggestions()
426
+ elif event.keysym not in ["Up", "Down", "Shift_L", "Shift_R", "Control_L", "Control_R"]:
427
+ if not self.isExecuting:
428
+ self.after_idle(self.suggestionManager.showSuggestions)
429
+ self.after_idle(self.highlightCurrentCommand)
430
+
431
+ def cancel(self, event):
432
+ self.history.add(self.getCurrentCommand())
433
+ self.replaceCurrentCommand("")
434
+
435
+ def historyReplace(self, command):
436
+ if self.getCurrentCommand() == "" or self.navigatingHistory:
437
+ if self.isExecuting:
438
+ return "break"
439
+
440
+ if self.history.index == len(self.history.history):
441
+ self.history.setTemp(self.getCurrentCommand())
442
+
443
+ if command is not None:
444
+ self.replaceCurrentCommand(command)
445
+ self.navigatingHistory = True
446
+ return("break")
447
+
448
+ def onUp(self, event):
449
+ if self.suggestionManager.suggestionWindow and \
450
+ self.suggestionManager.suggestionWindow.winfo_viewable():
451
+ if event.keysym == "Up":
452
+ self.suggestionManager.handleNavigation("up")
453
+ return "break"
454
+ command = self.history.navigateUp()
455
+ return(self.historyReplace(command))
456
+ # self.mark_set("insert", "insert -1 line")
457
+
458
+ def onDown(self, event):
459
+ if self.suggestionManager.suggestionWindow and \
460
+ self.suggestionManager.suggestionWindow.winfo_viewable():
461
+ if event.keysym == "Down":
462
+ self.suggestionManager.handleNavigation("down")
463
+ return "break"
464
+ command = self.history.navigateDown()
465
+ return(self.historyReplace(command))
466
+
467
+ def isIncompleteStatement(self, code):
468
+ """Check if the code is an incomplete statement."""
469
+ lines = code.split("\n")
470
+ if not lines[-1].strip():
471
+ return False
472
+
473
+ # Check for line ending with colon
474
+ for line in lines:
475
+ if line.strip().endswith(":"):
476
+ return True
477
+
478
+ return False
479
+
480
+ def calculateIndent(self, line):
481
+ """Calculate the indentation level for the next line."""
482
+ currentIndent = len(line) - len(line.lstrip())
483
+
484
+ # If line ends with colon, increase indent
485
+ if line.strip().endswith(":"):
486
+ return currentIndent + 4
487
+
488
+ return currentIndent
489
+
490
+ def highlightCurrentCommand(self):
491
+ """Apply syntax highlighting to the current command."""
492
+ if self.isExecuting:
493
+ return
494
+
495
+ # Clear existing highlighting
496
+ start = self.getPromptPosition()
497
+ end = "end-1c"
498
+
499
+ for token, _ in self.style:
500
+ self.tag_remove(str(token), start, end)
501
+
502
+ # Get and highlight the command
503
+ command = self.getCurrentCommand()
504
+ if not command:
505
+ return
506
+
507
+ self.mark_set("highlight_pos", start)
508
+
509
+ for token, content in pygments.lex(command, self.lexer):
510
+ if content:
511
+ endPos = f"highlight_pos + {len(content)}c"
512
+ if content.strip(): # Only highlight non-whitespace
513
+ self.tag_add(str(token), "highlight_pos", endPos)
514
+ self.mark_set("highlight_pos", endPos)
515
+
516
+ def writeOutput(self, text, tag="output"):
517
+ """Write output to the console (thread-safe)."""
518
+ def _write():
519
+ self.insert("end", text + "\n", tag)
520
+ self.see("end")
521
+
522
+ self.after(0, _write)
523
+
524
+ def addPrompt(self):
525
+ """Add a new command prompt."""
526
+ def _add():
527
+ # Store the line number for the new command
528
+ self.currentCommandLine = self.getCurrentLineNumber()
529
+
530
+ # Insert prompt
531
+ self.insert("end", self.PROMPT)
532
+ promptStart = f"{self.currentCommandLine}.0"
533
+ promptEnd = f"{self.currentCommandLine}.{self.PROMPT_LENGTH}"
534
+ self.tag_add("prompt", promptStart, promptEnd)
535
+
536
+ self.mark_set("insert", "end")
537
+ self.see("end")
538
+ self.isExecuting = False
539
+
540
+ if self.isExecuting:
541
+ self.after(0, _add)
542
+ else:
543
+ _add()
544
+
545
+ def executeCommandThreaded(self, command):
546
+ """Execute a command in a separate thread."""
547
+ try:
548
+ # Try eval first for expressions
549
+ result = eval(command, self.master.userGlobals, self.master.userLocals)
550
+ if result is not None:
551
+ self.writeOutput(str(result), "result")
552
+ self.master.userLocals["_"] = result
553
+ except SyntaxError:
554
+ try:
555
+ # Try exec for statements
556
+ exec(command, self.master.userGlobals, self.master.userLocals)
557
+ except Exception:
558
+ self.writeOutput(traceback.format_exc(), "error")
559
+ except Exception:
560
+ self.writeOutput(traceback.format_exc(), "error")
561
+
562
+ # Add new prompt after execution
563
+ self.addPrompt()
564
+
565
+
566
+ class InteractiveConsole(ctk.CTk):
567
+ """Main console window application."""
568
+
569
+ def __init__(self, userGlobals=None, userLocals=None):
570
+ super().__init__()
571
+
572
+ # Window setup
573
+ self.title("Live Interactive Console")
574
+ self.geometry("900x600")
575
+
576
+ ctk.set_appearance_mode("dark")
577
+ ctk.set_default_color_theme("blue")
578
+
579
+ # Get namespace from caller if not provided
580
+ if userGlobals is None or userLocals is None:
581
+ callerFrame = inspect.currentframe().f_back
582
+ if userGlobals is None:
583
+ userGlobals = callerFrame.f_globals
584
+ if userLocals is None:
585
+ userLocals = callerFrame.f_locals
586
+
587
+ self.userGlobals = userGlobals
588
+ self.userLocals = userLocals
589
+
590
+ # Create UI
591
+ self._createUi()
592
+
593
+ # Redirect stdout/stderr
594
+ self._setupOutputRedirect()
595
+
596
+ def _createUi(self):
597
+ """Create the user interface."""
598
+ # Main frame
599
+ frame = ctk.CTkFrame(self)
600
+ frame.pack(padx=10, pady=10, fill="both", expand=True)
601
+
602
+ # Console text widget
603
+ self.console = InteractiveConsoleText(
604
+ frame,
605
+ wrap="word",
606
+ bg="#1e1e1e",
607
+ fg="white",
608
+ insertbackground="white",
609
+ font=("Consolas", 12)
610
+ )
611
+ self.console.pack(fill="both", expand=True, padx=5, pady=5)
612
+
613
+ # Give console access to namespace
614
+ self.console.master = self
615
+
616
+ def _setupOutputRedirect(self):
617
+ """Setup stdout/stderr redirection to console."""
618
+ sys.stdout = StdoutRedirect(self.console.writeOutput)
619
+ sys.stderr = StdoutRedirect(
620
+ lambda text, tag: self.console.writeOutput(text, "error")
621
+ )
622
+
623
+ def probe(self, *args, **kwargs):
624
+ """Start the console main loop."""
625
+ self.mainloop(*args, **kwargs)
626
+
627
+
628
+ # Example usage
629
+ if __name__ == "__main__":
630
+ # Example variables and functions for testing
631
+ foo = 42
632
+
633
+ def greet(name):
634
+ print(f"Hello {name}!")
635
+ return f"Greeted {name}"
636
+
637
+ # Create the list for testing autocomplete
638
+ exampleList = [1, 2, 3, 4, 5]
639
+
640
+ # Start the console
641
+ InteractiveConsole().probe()
@@ -1,492 +0,0 @@
1
- import customtkinter as ctk
2
- import tkinter as tk
3
- import traceback
4
- import inspect
5
- import threading
6
- import sys
7
- import io
8
- import pygments
9
- from pygments.lexers import PythonLexer
10
- from pygments.styles import get_style_by_name
11
- import keyword
12
- import builtins
13
-
14
-
15
- class StdoutRedirect(io.StringIO):
16
- def __init__(self, write_callback):
17
- super().__init__()
18
- self.write_callback = write_callback
19
-
20
- def write(self, s):
21
- if s.strip():
22
- self.write_callback(s, "output")
23
-
24
- def flush(self):
25
- pass
26
-
27
-
28
- class InteractiveConsoleText(tk.Text):
29
- """A tk.Text widget with Python syntax highlighting for interactive console."""
30
- def __init__(self, master, **kwargs):
31
- super().__init__(master, **kwargs)
32
-
33
- self.lexer = PythonLexer()
34
- self.style = get_style_by_name("monokai")
35
-
36
- # Configure tags for different output types
37
- self.tag_configure("prompt", foreground="#00ff00", font=("Consolas", 12, "bold"))
38
- self.tag_configure("output", foreground="#ffffff", font=("Consolas", 12))
39
- self.tag_configure("error", foreground="#ff6666", font=("Consolas", 12))
40
- self.tag_configure("result", foreground="#66ccff", font=("Consolas", 12))
41
- self.tag_configure("suggestion", background="#444444", foreground="#cccccc", font=("Consolas", 12))
42
-
43
- # Apply tag configs for syntax highlighting
44
- for token, style in self.style:
45
- if style["color"]:
46
- fg = f"#{style['color']}"
47
- font = ("Consolas", 12, "bold" if style["bold"] else "normal")
48
- self.tag_configure(str(token), foreground=fg, font=font)
49
-
50
- # Bind events
51
- self.bind("<KeyRelease>", self.on_key_release)
52
- self.bind("<Return>", self.on_enter)
53
- self.bind("<Shift-Return>", self.on_shift_enter)
54
- self.bind("<Button-1>", self.on_click)
55
- self.bind("<KeyPress>", self.on_key_press)
56
- self.bind("<Motion>", self.on_mouse_motion)
57
- self.bind("<Tab>", self.on_tab)
58
-
59
- # Track current command
60
- self.current_prompt_start = None
61
- self.current_prompt_end = None
62
- self.command_history = []
63
- self.history_index = -1
64
- self.hover_command = None
65
- self.suggestion_window = None
66
- self.suggestions = []
67
- self.selected_suggestion = 0
68
-
69
- # Build suggestion lists
70
- self.keywords = keyword.kwlist
71
- self.builtins = [name for name in dir(builtins) if not name.startswith('_')]
72
-
73
- # Initialize with prompt
74
- self.insert("end", ">>> ")
75
- self.tag_add("prompt", "end-4c", "end")
76
- self.current_prompt_start = self.index("end-4c")
77
- self.mark_set("insert", "end")
78
-
79
- def get_suggestions(self, partial_word):
80
- """Get code suggestions for partial word."""
81
- suggestions = []
82
-
83
- # Add matching keywords
84
- for kw in self.keywords:
85
- if kw.startswith(partial_word.lower()):
86
- suggestions.append(kw)
87
-
88
- # Add matching builtins
89
- for builtin in self.builtins:
90
- if builtin.startswith(partial_word):
91
- suggestions.append(builtin)
92
-
93
- # Add matching variables from namespace
94
- if hasattr(self.master, 'userLocals'):
95
- for var in self.master.userLocals:
96
- if var.startswith(partial_word) and not var.startswith('_'):
97
- suggestions.append(var)
98
-
99
- if hasattr(self.master, 'userGlobals'):
100
- for var in self.master.userGlobals:
101
- if var.startswith(partial_word) and not var.startswith('_'):
102
- suggestions.append(var)
103
-
104
- # Remove duplicates and sort
105
- suggestions = sorted(list(set(suggestions)))
106
- return suggestions[:10] # Limit to 10 suggestions
107
-
108
- def show_suggestions(self):
109
- """Show code suggestions popup."""
110
- # Get current word being typed
111
- cursor_pos = self.index(tk.INSERT)
112
- line_start = self.index(f"{cursor_pos} linestart")
113
- current_line = self.get(line_start, cursor_pos)
114
-
115
- # Find the current word
116
- words = current_line.split()
117
- if not words:
118
- return
119
-
120
- current_word = words[-1]
121
- # Handle cases like "print(" where we want to suggest after the parenthesis
122
- for char in "([{,.":
123
- if char in current_word:
124
- current_word = current_word.split(char)[-1]
125
-
126
- if len(current_word) < 2: # Only show suggestions for 2+ characters
127
- self.hide_suggestions()
128
- return
129
-
130
- suggestions = self.get_suggestions(current_word)
131
- if not suggestions:
132
- self.hide_suggestions()
133
- return
134
-
135
- self.suggestions = suggestions
136
- self.selected_suggestion = 0
137
-
138
- # Create or update suggestion window
139
- if not self.suggestion_window:
140
- self.suggestion_window = tk.Toplevel(self)
141
- self.suggestion_window.wm_overrideredirect(True)
142
- self.suggestion_window.configure(bg="#2d2d2d")
143
-
144
- self.suggestion_listbox = tk.Listbox(
145
- self.suggestion_window,
146
- bg="#2d2d2d",
147
- fg="white",
148
- selectbackground="#0066cc",
149
- font=("Consolas", 10),
150
- height=min(len(suggestions), 8)
151
- )
152
- self.suggestion_listbox.pack()
153
-
154
- # Clear and populate listbox
155
- self.suggestion_listbox.delete(0, tk.END)
156
- for suggestion in suggestions:
157
- self.suggestion_listbox.insert(tk.END, suggestion)
158
-
159
- self.suggestion_listbox.selection_set(0)
160
-
161
- # Position window near cursor
162
- x, y, _, _ = self.bbox(cursor_pos)
163
- x += self.winfo_rootx()
164
- y += self.winfo_rooty() + 20
165
-
166
- self.suggestion_window.geometry(f"+{x}+{y}")
167
- self.suggestion_window.deiconify()
168
-
169
- def hide_suggestions(self):
170
- """Hide suggestions popup."""
171
- if self.suggestion_window:
172
- self.suggestion_window.withdraw()
173
-
174
- def apply_suggestion(self, suggestion=None):
175
- """Apply selected suggestion at the cursor position (only missing letters)."""
176
- if not suggestion and self.suggestions:
177
- suggestion = self.suggestions[self.selected_suggestion]
178
- if not suggestion:
179
- return
180
-
181
- # Current cursor position
182
- cursor_pos = self.index(tk.INSERT)
183
-
184
- # Get the word fragment before the cursor
185
- line_start = self.index(f"{cursor_pos} linestart")
186
- current_line = self.get(line_start, cursor_pos)
187
-
188
- fragment = ""
189
- for i in range(len(current_line)):
190
- if current_line[-(i+1)] in " \t([{,.)":
191
- break
192
- fragment = current_line[-(i+1):]
193
-
194
- # Only insert the missing part
195
- if suggestion.startswith(fragment):
196
- missing_part = suggestion[len(fragment):]
197
- self.insert(cursor_pos, missing_part)
198
- self.mark_set("insert", f"{cursor_pos} + {len(missing_part)}c")
199
-
200
- self.hide_suggestions()
201
-
202
-
203
- def on_tab(self, event):
204
- """Handle Tab key for autocompletion."""
205
- if self.suggestion_window and self.suggestion_window.winfo_viewable():
206
- self.apply_suggestion()
207
- return "break"
208
- else:
209
- self.show_suggestions()
210
- return "break"
211
-
212
- def is_incomplete_statement(self, code):
213
- """Check if the code is an incomplete statement that needs more lines."""
214
- code = code.split("\n")
215
- if code[-1].strip() == "":
216
- return(False)
217
- if code[0].strip().endswith(":"):
218
- return(True)
219
- return(False)
220
-
221
- def get_indent_level(self, line):
222
- """Get the indentation level of a line."""
223
- return len(line) - len(line.lstrip(' '))
224
-
225
- def should_auto_indent(self, line):
226
- """Check if we should add indentation after this line."""
227
- stripped = line.strip()
228
- return (stripped and stripped[-1] == ':')
229
-
230
- def on_click(self, event):
231
- self.hide_suggestions()
232
- click_pos = self.index(f"@{event.x},{event.y}")
233
-
234
- if self.current_prompt_start:
235
- click_pos = self.index(tk.CURRENT)
236
- if self.compare(click_pos, "<", self.current_prompt_start):
237
- self.mark_set("insert", "end")
238
- return "break"
239
-
240
- def on_mouse_motion(self, event):
241
- """Handle mouse motion for hover copying previous commands."""
242
-
243
- mouse_pos = self.index(f"@{event.x},{event.y}")
244
- line_start = self.index(f"{mouse_pos} linestart")
245
- line_end = self.index(f"{mouse_pos} lineend")
246
- line_text = self.get(line_start, line_end)
247
-
248
- # Check if this line starts with ">>> " (a previous command)
249
- if line_text.startswith(">>> ") and line_start != self.current_prompt_start:
250
- command = line_text[4:] # Remove ">>> "
251
- if command.strip():
252
- # Change cursor to indicate clickable
253
- self.config(cursor="hand2")
254
- self.hover_command = command.strip()
255
- else:
256
- self.config(cursor="xterm")
257
- self.hover_command = None
258
- else:
259
- self.config(cursor="xterm")
260
- self.hover_command = None
261
-
262
- def on_key_press(self, event):
263
- if self.suggestion_window and self.suggestion_window.winfo_viewable():
264
- if event.keysym == "Down":
265
- self.selected_suggestion = min(self.selected_suggestion + 1, len(self.suggestions) - 1)
266
- self.suggestion_listbox.selection_clear(0, tk.END)
267
- self.suggestion_listbox.selection_set(self.selected_suggestion)
268
- return "break"
269
- elif event.keysym == "Up":
270
- self.selected_suggestion = max(self.selected_suggestion - 1, 0)
271
- self.suggestion_listbox.selection_clear(0, tk.END)
272
- self.suggestion_listbox.selection_set(self.selected_suggestion)
273
- return "break"
274
- elif event.keysym == "Escape":
275
- self.hide_suggestions()
276
- return "break"
277
- elif event.keysym in ["Return", "Tab"]:
278
- self.apply_suggestion()
279
- return "break"
280
-
281
- # Ensure cursor is always at least 4 chars after current_prompt_start
282
- prompt_end_index = f"{self.current_prompt_start} + 3c"
283
-
284
- if event.keysym not in ["Up", "Down", "Left", "Right", "Shift_L", "Shift_R", "Control_L", "Control_R"]:
285
- if self.compare("insert", "<", prompt_end_index):
286
- self.mark_set("insert", prompt_end_index)
287
-
288
- # Block Backspace if at or before prompt
289
- if event.keysym == "BackSpace" and self.compare("insert", "<=", prompt_end_index):
290
- return "break"
291
-
292
- def on_key_release(self, event):
293
- # Hide suggestions on certain keys
294
- if event.keysym in ["Return", "Escape", "Left", "Right", "Home", "End"]:
295
- self.hide_suggestions()
296
- # Show suggestions on typing
297
- elif event.keysym not in ["Up", "Down", "Shift_L", "Shift_R", "Control_L", "Control_R"]:
298
- self.after_idle(self.show_suggestions)
299
-
300
- # Only highlight the current command line
301
- if self.current_prompt_start:
302
- self.highlight_current_line()
303
-
304
- def on_shift_enter(self, event):
305
- """Handle Shift+Enter for new line with auto-indent."""
306
- self.hide_suggestions()
307
-
308
- if self.current_prompt_start:
309
- # Get current line to determine indent
310
- current_line_start = self.index("insert linestart")
311
- current_line_end = self.index("insert lineend")
312
- current_line = self.get(current_line_start, current_line_end)
313
-
314
- # Calculate indent level
315
- base_indent = self.get_indent_level(current_line)
316
-
317
- # If the current line should increase indent, add 4 spaces
318
- if self.should_auto_indent(current_line):
319
- base_indent += 4
320
-
321
- # Insert newline with proper indentation
322
- self.insert("insert", "\n" + " " * base_indent)
323
- self.mark_set("insert", "end")
324
- return "break"
325
-
326
- def on_enter(self, event):
327
- """Handle Enter key - execute if complete, newline if incomplete."""
328
- self.hide_suggestions()
329
-
330
- if self.current_prompt_start:
331
- # Get text from after the prompt to end
332
- prompt_end = f"{self.current_prompt_start} + 3c" # Skip ">>> "
333
- command = self.get(prompt_end, "end-1c")
334
-
335
- if not command.strip():
336
- return "break"
337
-
338
- # Check if it's an incomplete statement
339
- if self.is_incomplete_statement(command):
340
- # Add newline with auto-indent
341
- current_line_start = self.index("insert linestart")
342
- current_line_end = self.index("insert lineend")
343
- current_line = self.get(current_line_start, current_line_end)
344
- base_indent = self.get_indent_level(current_line)
345
-
346
- if self.should_auto_indent(current_line):
347
- base_indent += 4
348
-
349
- self.insert("insert", "\n" + " " * base_indent)
350
- self.see("end")
351
- return "break"
352
-
353
- # Execute the complete command
354
- if command.strip():
355
- self.command_history.append(command)
356
- self.history_index = len(self.command_history)
357
-
358
- # Move to end and add newline for the executed command
359
- self.mark_set("insert", "end")
360
- self.insert("end", "\n")
361
- self.see("end")
362
-
363
- # Execute the command in a thread to prevent freezing
364
- threading.Thread(target=self.execute_command_and_add_prompt, args=(command,), daemon=True).start()
365
- # self.see("end")
366
-
367
- return "break"
368
-
369
- def highlight_current_line(self):
370
- if not self.current_prompt_start:
371
- return
372
-
373
- # Clear existing syntax highlighting tags from current line
374
- line_start = self.current_prompt_start
375
- line_end = "end-1c"
376
-
377
- # Remove all token tags from current line
378
- for token, style in self.style:
379
- self.tag_remove(str(token), line_start, line_end)
380
-
381
- # Get the command text (without the prompt)
382
- command = self.get(line_start, line_end)
383
-
384
- if not command.strip():
385
- return
386
-
387
- # Highlight the command
388
- self.mark_set("range_start", line_start)
389
-
390
- for token, content in pygments.lex(command, self.lexer):
391
- if content.strip(): # Only highlight non-whitespace
392
- self.mark_set("range_end", f"range_start + {len(content)}c")
393
- self.tag_add(str(token), "range_start", "range_end")
394
- self.mark_set("range_start", f"range_start + {len(content)}c")
395
-
396
- def write_output(self, text, tag="output"):
397
- """Write output to the console - thread safe."""
398
- def _write():
399
- # Insert output at the end
400
- self.insert("end", text + "\n", tag)
401
- self.see("end")
402
-
403
- # Use after() to ensure GUI updates happen on main thread
404
- self.after(0, _write)
405
-
406
- def add_new_prompt(self):
407
- """Add a new prompt - thread safe."""
408
- def _add_prompt():
409
- self.insert("end", ">>> ")
410
- self.tag_add("prompt", "end-4c", "end")
411
- self.current_prompt_start = self.index("end-4c")
412
- self.mark_set("insert", "end")
413
- self.see("end")
414
-
415
- self.after(0, _add_prompt)
416
-
417
- def execute_command_and_add_prompt(self, command):
418
- """Execute a command and then add a new prompt."""
419
- try:
420
- # Try eval first for expressions
421
- result = eval(command, self.master.userGlobals, self.master.userLocals)
422
- if result is not None:
423
- self.write_output(str(result), "result")
424
- self.master.userLocals["_"] = result
425
- except SyntaxError:
426
- try:
427
- # If eval fails, try exec for statements
428
- exec(command, self.master.userGlobals, self.master.userLocals)
429
- except Exception:
430
- self.write_output(traceback.format_exc(), "error")
431
- except Exception:
432
- self.write_output(traceback.format_exc(), "error")
433
-
434
- # Add new prompt after execution is complete
435
- self.add_new_prompt()
436
-
437
-
438
- class InteractiveConsole(ctk.CTk):
439
- def __init__(self, userGlobals=None, userLocals=None):
440
- super().__init__()
441
- self.title("Live Interactive Console")
442
- self.geometry("900x600")
443
-
444
- ctk.set_appearance_mode("dark")
445
- ctk.set_default_color_theme("blue")
446
-
447
- # If no globals/locals provided, get them from caller frame
448
- if userGlobals is None or userLocals is None:
449
- caller_frame = inspect.currentframe().f_back
450
- if userGlobals is None:
451
- userGlobals = caller_frame.f_globals
452
- if userLocals is None:
453
- userLocals = caller_frame.f_locals
454
-
455
- # Create frame for the text widget
456
- frame = ctk.CTkFrame(self)
457
- frame.pack(padx=10, pady=10, fill="both", expand=True)
458
-
459
- # Single console text widget
460
- self.console = InteractiveConsoleText(
461
- frame,
462
- wrap="word",
463
- bg="#1e1e1e",
464
- fg="white",
465
- insertbackground="white",
466
- font=("Consolas", 12)
467
- )
468
- self.console.pack(fill="both", expand=True, padx=5, pady=5)
469
-
470
- # Namespace
471
- self.userGlobals = userGlobals
472
- self.userLocals = userLocals
473
-
474
- # Redirect stdout/stderr to write to console
475
- sys.stdout = StdoutRedirect(self.console.write_output)
476
- sys.stderr = StdoutRedirect(lambda text, tag: self.console.write_output(text, "error"))
477
-
478
- # Give console access to namespaces
479
- self.console.master = self
480
-
481
- def probe(self, *args, **kwargs):
482
- self.mainloop(*args, **kwargs)
483
-
484
- # Example usage
485
- if __name__ == "__main__":
486
- foo = 42
487
-
488
- def greet(name):
489
- print(f"Hello {name}!")
490
- return f"Greeted {name}"
491
-
492
- InteractiveConsole().probe()
File without changes
File without changes
File without changes
File without changes