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