py2gui 0.1.0__py3-none-any.whl

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.
py2gui/py2gui.py ADDED
@@ -0,0 +1,875 @@
1
+ import tkinter as tk
2
+ from tkinter import scrolledtext, simpledialog, Menu, Frame, Entry, Button, StringVar, font
3
+ import queue
4
+ import threading
5
+ import traceback
6
+ import re
7
+ import json
8
+ import os
9
+ from typing import Callable, Any, Optional, List, Tuple, Dict
10
+
11
+
12
+ class Py2GUI:
13
+ def __init__(self, title: str = "Py2GUI", width: int = 80, height: int = 20, config_file: str = "config.json"):
14
+ self.root = tk.Tk()
15
+ self.root.title(title)
16
+ self.root.resizable(True, True)
17
+ self.width = width
18
+ self.height = height
19
+ self.running = True
20
+ self.config_file = config_file
21
+
22
+ # Load configuration
23
+ self.config = self._load_config()
24
+
25
+ # Extended ANSI color configuration with full 256 colors
26
+ self.ansi_colors = {
27
+ # Basic colors
28
+ '30': '#000000', # Black
29
+ '31': '#ff0000', # Red
30
+ '32': '#00ff00', # Green
31
+ '33': '#ffff00', # Yellow
32
+ '34': '#0000ff', # Blue
33
+ '35': '#ff00ff', # Magenta
34
+ '36': '#00ffff', # Cyan
35
+ '37': '#ffffff', # White
36
+
37
+ # Bright colors
38
+ '90': '#808080', # Gray
39
+ '91': '#ff8080', # Bright red
40
+ '92': '#80ff80', # Bright green
41
+ '93': '#ffff80', # Bright yellow
42
+ '94': '#8080ff', # Bright blue
43
+ '95': '#ff80ff', # Bright magenta
44
+ '96': '#80ffff', # Bright cyan
45
+ '97': '#ffffff', # Bright white
46
+
47
+ # Background colors
48
+ '40': '#1a1a1a', # Black background
49
+ '41': '#ff0000', # Red background
50
+ '42': '#00ff00', # Green background
51
+ '43': '#ffff00', # Yellow background
52
+ '44': '#0000ff', # Blue background
53
+ '45': '#ff00ff', # Magenta background
54
+ '46': '#00ffff', # Cyan background
55
+ '47': '#ffffff', # White background
56
+
57
+ # Extended 256 colors (common ones)
58
+ '38;5;0': '#000000', # Black
59
+ '38;5;1': '#800000', # Dark red
60
+ '38;5;2': '#008000', # Dark green
61
+ '38;5;3': '#808000', # Dark yellow
62
+ '38;5;4': '#000080', # Dark blue
63
+ '38;5;5': '#800080', # Dark magenta
64
+ '38;5;6': '#008080', # Dark cyan
65
+ '38;5;7': '#c0c0c0', # Light gray
66
+ '38;5;8': '#808080', # Dark gray
67
+ '38;5;9': '#ff0000', # Red
68
+ '38;5;10': '#00ff00', # Green
69
+ '38;5;11': '#ffff00', # Yellow
70
+ '38;5;12': '#0000ff', # Blue
71
+ '38;5;13': '#ff00ff', # Magenta
72
+ '38;5;14': '#00ffff', # Cyan
73
+ '38;5;15': '#ffffff', # White
74
+
75
+ # Extended background colors
76
+ '48;5;0': '#000000', # Black background
77
+ '48;5;1': '#800000', # Dark red background
78
+ '48;5;2': '#008000', # Dark green background
79
+ '48;5;3': '#808000', # Dark yellow background
80
+ '48;5;4': '#000080', # Dark blue background
81
+ '48;5;5': '#800080', # Dark magenta background
82
+ '48;5;6': '#008080', # Dark cyan background
83
+ '48;5;7': '#c0c0c0', # Light gray background
84
+
85
+ # True color support (RGB)
86
+ '38;2;0;0;0': '#000000', # Black
87
+ '38;2;255;0;0': '#ff0000', # Red
88
+ '38;2;0;255;0': '#00ff00', # Green
89
+ '38;2;0;0;255': '#0000ff', # Blue
90
+ }
91
+
92
+ # Color name to hex mapping for display_colored method
93
+ self.color_name_to_hex = {
94
+ 'black': '#000000',
95
+ 'red': '#ff0000',
96
+ 'green': '#00ff00',
97
+ 'yellow': '#ffff00',
98
+ 'blue': '#0000ff',
99
+ 'magenta': '#ff00ff',
100
+ 'cyan': '#00ffff',
101
+ 'white': '#ffffff',
102
+ 'gray': '#808080',
103
+ 'bright red': '#ff8080',
104
+ 'bright green': '#80ff80',
105
+ 'bright yellow': '#ffff80',
106
+ 'bright blue': '#8080ff',
107
+ 'bright magenta': '#ff80ff',
108
+ 'bright cyan': '#80ffff',
109
+ 'bright white': '#ffffff',
110
+ 'orange': '#ff8000',
111
+ 'purple': '#8000ff',
112
+ 'pink': '#ff80ff',
113
+ 'brown': '#804000',
114
+ 'dark gray': '#404040',
115
+ 'light gray': '#c0c0c0',
116
+ }
117
+
118
+ # Available fonts
119
+ self.available_fonts = font.families()
120
+
121
+ # ANSI style configuration
122
+ self.ansi_styles = {
123
+ '0': {'font': ('Courier', 10, 'normal'), 'fg': 'white', 'bg': 'black'}, # Reset
124
+ '1': {'font': ('Courier', 10, 'bold')}, # Bold
125
+ '3': {'font': ('Courier', 10, 'italic')}, # Italic
126
+ '4': {'underline': True}, # Underline
127
+ '7': {'fg': 'black', 'bg': 'white'}, # Reverse video
128
+ '9': {'strikethrough': True}, # Strikethrough
129
+ }
130
+
131
+ # Current text style state
132
+ self.current_style = {
133
+ 'font': ('Courier', 10, 'normal'),
134
+ 'foreground': 'white',
135
+ 'background': 'black',
136
+ 'underline': False,
137
+ 'strikethrough': False
138
+ }
139
+
140
+ # Defined text tags
141
+ self.tag_names = set()
142
+
143
+ # Handle window closure
144
+ self.root.protocol("WM_DELETE_WINDOW", self.exit)
145
+
146
+ # Main frame to hold everything
147
+ self.main_frame = Frame(self.root)
148
+ self.main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
149
+
150
+ # Output area
151
+ self.text_area = scrolledtext.ScrolledText(
152
+ self.main_frame,
153
+ wrap=tk.WORD,
154
+ width=width,
155
+ height=height,
156
+ font=("Courier", 10),
157
+ bg="black",
158
+ fg="white",
159
+ insertbackground="white"
160
+ )
161
+ self.text_area.pack(padx=5, pady=5, fill=tk.BOTH, expand=True)
162
+ self.text_area.config(state=tk.DISABLED)
163
+
164
+ # Configure default tag
165
+ self.text_area.tag_configure("default",
166
+ font=("Courier", 10, "normal"),
167
+ foreground="white",
168
+ background="black"
169
+ )
170
+
171
+ # Configure color tags with hex color codes (excluding disabled colors)
172
+ for code, color_hex in self.ansi_colors.items():
173
+ # Skip disabled colors
174
+ if 'disabled_colors' in self.config and code in self.config['disabled_colors']:
175
+ continue
176
+
177
+ tag_name = f"ansi_{code}"
178
+ if (code.startswith('3') and ';' not in code) or code.startswith('38'):
179
+ # Foreground colors
180
+ self.text_area.tag_configure(tag_name, foreground=color_hex)
181
+ elif code.startswith('4') or code.startswith('48'):
182
+ # Background colors
183
+ self.text_area.tag_configure(tag_name, background=color_hex)
184
+
185
+ # Configure style tags
186
+ self.text_area.tag_configure("bold", font=("Courier", 10, "bold"))
187
+ self.text_area.tag_configure("italic", font=("Courier", 10, "italic"))
188
+ self.text_area.tag_configure("underline", underline=True)
189
+ self.text_area.tag_configure("strikethrough", overstrike=True)
190
+ self.text_area.tag_configure("reverse", foreground="black", background="white")
191
+
192
+ # Terminal-style input area frame
193
+ self.input_frame = Frame(self.main_frame)
194
+ self.input_frame.pack(fill=tk.X, padx=5, pady=5)
195
+
196
+ # Input label
197
+ self.input_label = tk.Label(self.input_frame, text=">> ", font=("Courier", 10), fg="white", bg="black")
198
+ self.input_label.pack(side=tk.LEFT, padx=(0, 5))
199
+
200
+ # Terminal-style input entry
201
+ self.input_var = StringVar()
202
+ self.input_entry = Entry(
203
+ self.input_frame,
204
+ textvariable=self.input_var,
205
+ font=("Courier", 10),
206
+ bg="black",
207
+ fg="white",
208
+ insertbackground="white"
209
+ )
210
+ self.input_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5))
211
+
212
+ # Send button
213
+ self.send_button = Button(
214
+ self.input_frame,
215
+ text="Send",
216
+ command=self._on_send_input,
217
+ font=("Courier", 9)
218
+ )
219
+ self.send_button.pack(side=tk.LEFT)
220
+
221
+ # Input queue for user_write
222
+ self.input_queue = queue.Queue()
223
+
224
+ # Input queue for user_type_in
225
+ self.type_in_queue = queue.Queue()
226
+
227
+ # Bind Enter key to send input
228
+ self.input_entry.bind('<Return>', self._on_enter_pressed)
229
+
230
+ # Menus
231
+ self._setup_menus()
232
+
233
+ def _load_config(self) -> Dict:
234
+ """Load configuration from JSON file"""
235
+ default_config = {
236
+ "disabled_menus": [],
237
+ "disabled_views": [],
238
+ "disabled_colors": [],
239
+ "show_clear_button": True,
240
+ "show_demo_button": True
241
+ }
242
+
243
+ try:
244
+ if os.path.exists(self.config_file):
245
+ with open(self.config_file, 'r') as f:
246
+ config = json.load(f)
247
+ # Merge with default config
248
+ for key, value in default_config.items():
249
+ if key not in config:
250
+ config[key] = value
251
+ return config
252
+ except Exception as e:
253
+ print(f"Error loading config file: {e}")
254
+
255
+ return default_config
256
+
257
+ def _setup_menus(self) -> None:
258
+ menubar = Menu(self.root)
259
+ self.root.config(menu=menubar)
260
+
261
+ # File menu
262
+ if 'disabled_menus' not in self.config or 'File' not in self.config['disabled_menus']:
263
+ file_menu = Menu(menubar, tearoff=0)
264
+ menubar.add_cascade(label="File", menu=file_menu)
265
+ file_menu.add_command(label="Exit", command=self.exit)
266
+
267
+ # Edit menu
268
+ if 'disabled_menus' not in self.config or 'Edit' not in self.config['disabled_menus']:
269
+ edit_menu = Menu(menubar, tearoff=0)
270
+ menubar.add_cascade(label="Edit", menu=edit_menu)
271
+ edit_menu.add_command(label="Copy", command=self.copy_text)
272
+ edit_menu.add_command(label="Select All", command=self.select_all)
273
+
274
+ # View menu
275
+ if 'disabled_menus' not in self.config or 'View' not in self.config['disabled_menus']:
276
+ view_menu = Menu(menubar, tearoff=0)
277
+ menubar.add_cascade(label="View", menu=view_menu)
278
+
279
+ # Add view menu items based on configuration
280
+ if 'disabled_views' not in self.config or 'Focus Input' not in self.config['disabled_views']:
281
+ view_menu.add_command(label="Focus Input", command=self.focus_input)
282
+
283
+ if 'disabled_views' not in self.config or 'Clear Output' not in self.config['disabled_views']:
284
+ view_menu.add_command(label="Clear Output", command=self.clear)
285
+
286
+ if 'disabled_views' not in self.config or 'Demo ANSI Colors' not in self.config['disabled_views']:
287
+ view_menu.add_command(label="Demo ANSI Colors", command=self._demo_colors)
288
+
289
+ # Colors menu
290
+ if 'disabled_menus' not in self.config or 'Colors' not in self.config['disabled_menus']:
291
+ colors_menu = Menu(menubar, tearoff=0)
292
+ menubar.add_cascade(label="Colors", menu=colors_menu)
293
+ colors_menu.add_command(label="Default Theme", command=lambda: self.set_theme("default"))
294
+ colors_menu.add_command(label="Dark Theme", command=lambda: self.set_theme("dark"))
295
+ colors_menu.add_command(label="Light Theme", command=lambda: self.set_theme("light"))
296
+ colors_menu.add_command(label="Green on Black", command=lambda: self.set_theme("matrix"))
297
+
298
+ def _parse_ansi_codes(self, text: str) -> List[Tuple[str, List[str]]]:
299
+ """Parse ANSI escape sequences in text"""
300
+ # ANSI escape sequence regex
301
+ ansi_pattern = re.compile(r'(\033\[[\d;]*m)')
302
+
303
+ parts = []
304
+ last_end = 0
305
+ current_codes = []
306
+
307
+ for match in ansi_pattern.finditer(text):
308
+ # Add normal text
309
+ if match.start() > last_end:
310
+ normal_text = text[last_end:match.start()]
311
+ if normal_text:
312
+ parts.append((normal_text, current_codes.copy()))
313
+
314
+ # Parse ANSI code
315
+ ansi_code = match.group(0)
316
+ code_str = ansi_code[2:-1] # Remove \033[ and m
317
+
318
+ if code_str == '':
319
+ # Reset all attributes
320
+ current_codes = ['0']
321
+ else:
322
+ codes = code_str.split(';')
323
+ for code in codes:
324
+ if code == '0':
325
+ # Reset
326
+ current_codes = ['0']
327
+ elif code in ['1', '3', '4', '7', '9']:
328
+ # Style codes
329
+ if code in current_codes:
330
+ # Remove duplicate style codes
331
+ pass
332
+ else:
333
+ if code == '1' and '22' in current_codes:
334
+ current_codes.remove('22')
335
+ current_codes.append(code)
336
+ elif code in ['22', '23', '24', '27', '29']:
337
+ # Reset specific styles
338
+ reset_map = {'22': '1', '23': '3', '24': '4', '27': '7', '29': '9'}
339
+ if reset_map[code] in current_codes:
340
+ current_codes.remove(reset_map[code])
341
+ elif code in self.ansi_colors or code.startswith('38;') or code.startswith('48;'):
342
+ # Color codes
343
+ # Skip disabled colors
344
+ if 'disabled_colors' in self.config and code in self.config['disabled_colors']:
345
+ continue
346
+
347
+ # Remove same type of color codes
348
+ if code in ['30', '31', '32', '33', '34', '35', '36', '37',
349
+ '90', '91', '92', '93', '94', '95', '96', '97']:
350
+ # Remove other basic foreground colors
351
+ for c in list(current_codes):
352
+ if c in ['30', '31', '32', '33', '34', '35', '36', '37',
353
+ '90', '91', '92', '93', '94', '95', '96', '97']:
354
+ current_codes.remove(c)
355
+ elif code in ['40', '41', '42', '43', '44', '45', '46', '47']:
356
+ # Remove other basic background colors
357
+ for c in list(current_codes):
358
+ if c in ['40', '41', '42', '43', '44', '45', '46', '47']:
359
+ current_codes.remove(c)
360
+ elif code.startswith('38;'):
361
+ # Remove other foreground colors
362
+ for c in list(current_codes):
363
+ if c.startswith('38;'):
364
+ current_codes.remove(c)
365
+ elif code.startswith('48;'):
366
+ # Remove other background colors
367
+ for c in list(current_codes):
368
+ if c.startswith('48;'):
369
+ current_codes.remove(c)
370
+ current_codes.append(code)
371
+
372
+ # Add the last part of text
373
+ if last_end < len(text):
374
+ remaining_text = text[last_end:]
375
+ if remaining_text:
376
+ parts.append((remaining_text, current_codes.copy()))
377
+
378
+ return parts
379
+
380
+ def _get_tags_for_codes(self, codes: List[str]) -> List[str]:
381
+ """Get corresponding tag list based on ANSI codes"""
382
+ tags = []
383
+
384
+ for code in codes:
385
+ if code == '0':
386
+ # Reset all styles
387
+ tags = ['default']
388
+ break
389
+ elif code in ['1', '3', '4', '7', '9']:
390
+ # Style tags
391
+ style_map = {'1': 'bold', '3': 'italic', '4': 'underline',
392
+ '7': 'reverse', '9': 'strikethrough'}
393
+ if code in style_map:
394
+ tags.append(style_map[code])
395
+ elif code in self.ansi_colors or code.startswith('38;') or code.startswith('48;'):
396
+ # Skip disabled colors
397
+ if 'disabled_colors' in self.config and code in self.config['disabled_colors']:
398
+ continue
399
+ # Color tags
400
+ tags.append(f"ansi_{code}")
401
+
402
+ return tags
403
+
404
+ def _process_escape_sequences(self, text: str) -> str:
405
+ """Process escape sequences like \n, \t, etc."""
406
+ # Replace common escape sequences
407
+ replacements = {
408
+ '\\n': '\n',
409
+ '\\t': '\t',
410
+ '\\r': '\r',
411
+ '\\b': '\b',
412
+ '\\f': '\f',
413
+ '\\v': '\v',
414
+ '\\\\': '\\',
415
+ '\\"': '"',
416
+ "\\'": "'"
417
+ }
418
+
419
+ for esc_seq, char in replacements.items():
420
+ text = text.replace(esc_seq, char)
421
+
422
+ return text
423
+
424
+ def display_paragraph(self, text: str, parse_ansi: bool = True, font_family: Optional[str] = None,
425
+ font_size: Optional[int] = None, font_style: Optional[str] = None) -> None:
426
+ """Thread-safe display of paragraphs with escape sequence processing and no auto newline"""
427
+ def _update():
428
+ self.text_area.config(state=tk.NORMAL)
429
+
430
+ # Process escape sequences
431
+ text_processed = self._process_escape_sequences(text)
432
+
433
+ # Check for custom font settings
434
+ font_tags = []
435
+ if font_family or font_size or font_style:
436
+ # Create a unique tag for this font combination
437
+ font_family_val = font_family or "Courier"
438
+ font_size_val = font_size or 10
439
+ font_style_val = font_style or "normal"
440
+ font_key = f"font_{font_family_val}_{font_size_val}_{font_style_val}"
441
+
442
+ if font_key not in self.tag_names:
443
+ self.text_area.tag_configure(font_key, font=(font_family_val, font_size_val, font_style_val))
444
+ self.tag_names.add(font_key)
445
+ font_tags.append(font_key)
446
+
447
+ if parse_ansi and ('\033[' in text_processed or '\x1b[' in text_processed):
448
+ # Parse and apply ANSI colors
449
+ parts = self._parse_ansi_codes(text_processed)
450
+
451
+ for part_text, codes in parts:
452
+ tags = self._get_tags_for_codes(codes)
453
+ if not tags:
454
+ tags = ['default']
455
+
456
+ # Add font tags if specified
457
+ if font_tags:
458
+ tags = font_tags + tags
459
+
460
+ # Insert text and apply tags
461
+ self.text_area.insert(tk.END, part_text, tuple(tags))
462
+ else:
463
+ # Plain text
464
+ tags = ['default']
465
+ if font_tags:
466
+ tags = font_tags
467
+ self.text_area.insert(tk.END, text_processed, tuple(tags))
468
+
469
+ self.text_area.config(state=tk.DISABLED)
470
+ self.text_area.see(tk.END)
471
+
472
+ self.root.after(0, _update)
473
+
474
+ def display(self, text: str, parse_ansi: bool = True, font_family: Optional[str] = None,
475
+ font_size: Optional[int] = None, font_style: Optional[str] = None) -> None:
476
+ """Thread-safe display with ANSI color support and font customization"""
477
+ def _update():
478
+ self.text_area.config(state=tk.NORMAL)
479
+
480
+ # Check for custom font settings
481
+ font_tags = []
482
+ if font_family or font_size or font_style:
483
+ # Create a unique tag for this font combination
484
+ font_family_val = font_family or "Courier"
485
+ font_size_val = font_size or 10
486
+ font_style_val = font_style or "normal"
487
+ font_key = f"font_{font_family_val}_{font_size_val}_{font_style_val}"
488
+
489
+ if font_key not in self.tag_names:
490
+ self.text_area.tag_configure(font_key, font=(font_family_val, font_size_val, font_style_val))
491
+ self.tag_names.add(font_key)
492
+ font_tags.append(font_key)
493
+
494
+ if parse_ansi and ('\033[' in text or '\x1b[' in text):
495
+ # Parse and apply ANSI colors
496
+ parts = self._parse_ansi_codes(text)
497
+
498
+ for part_text, codes in parts:
499
+ tags = self._get_tags_for_codes(codes)
500
+ if not tags:
501
+ tags = ['default']
502
+
503
+ # Add font tags if specified
504
+ if font_tags:
505
+ tags = font_tags + tags
506
+
507
+ # Insert text and apply tags
508
+ self.text_area.insert(tk.END, part_text, tuple(tags))
509
+
510
+ # Add newline
511
+ if font_tags:
512
+ self.text_area.insert(tk.END, "\n", tuple(font_tags))
513
+ else:
514
+ self.text_area.insert(tk.END, "\n", 'default')
515
+ else:
516
+ # Plain text
517
+ tags = ['default']
518
+ if font_tags:
519
+ tags = font_tags
520
+ self.text_area.insert(tk.END, str(text) + "\n", tuple(tags))
521
+
522
+ self.text_area.config(state=tk.DISABLED)
523
+ self.text_area.see(tk.END)
524
+
525
+ self.root.after(0, _update)
526
+
527
+ def display_colored(self, text: str, fg_color: Optional[str] = None, bg_color: Optional[str] = None,
528
+ bold: bool = False, underline: bool = False, italic: bool = False,
529
+ strikethrough: bool = False, reverse: bool = False,
530
+ font_family: Optional[str] = None, font_size: Optional[int] = None,
531
+ font_style: Optional[str] = None) -> None:
532
+ """Directly display colored text with full color and font support"""
533
+ def _update():
534
+ self.text_area.config(state=tk.NORMAL)
535
+
536
+ tags = ['default']
537
+
538
+ # Handle foreground color - FIXED: Check for empty strings
539
+ if fg_color is not None and fg_color != "":
540
+ # Check if it's a color name that needs conversion
541
+ if fg_color.lower() in self.color_name_to_hex:
542
+ color_value = self.color_name_to_hex[fg_color.lower()]
543
+ elif fg_color.isdigit():
544
+ # It's an ANSI code
545
+ # Skip disabled colors
546
+ if 'disabled_colors' in self.config and fg_color in self.config['disabled_colors']:
547
+ # Use default color for disabled colors
548
+ pass
549
+ elif fg_color in self.ansi_colors:
550
+ tags.append(f"ansi_{fg_color}")
551
+ else:
552
+ # Default to white if unknown ANSI code
553
+ color_value = '#ffffff'
554
+ custom_fg_tag = f"custom_fg_{color_value}"
555
+ tags.append(custom_fg_tag)
556
+ if custom_fg_tag not in self.tag_names:
557
+ self.text_area.tag_configure(custom_fg_tag, foreground=color_value)
558
+ self.tag_names.add(custom_fg_tag)
559
+ elif fg_color.startswith('#') and len(fg_color) in [4, 5, 7, 9]:
560
+ # It's a hex color
561
+ color_value = fg_color
562
+ custom_fg_tag = f"custom_fg_{color_value}"
563
+ tags.append(custom_fg_tag)
564
+ if custom_fg_tag not in self.tag_names:
565
+ self.text_area.tag_configure(custom_fg_tag, foreground=color_value)
566
+ self.tag_names.add(custom_fg_tag)
567
+ elif ';' in fg_color and fg_color.startswith('38;'):
568
+ # Extended ANSI color code
569
+ if fg_color in self.ansi_colors:
570
+ tags.append(f"ansi_{fg_color}")
571
+ else:
572
+ # Try to use as a named color, but only if it's not empty
573
+ try:
574
+ if fg_color.strip(): # Check it's not just whitespace
575
+ self.text_area.tag_configure(f"custom_fg_{fg_color}", foreground=fg_color)
576
+ tags.append(f"custom_fg_{fg_color}")
577
+ self.tag_names.add(f"custom_fg_{fg_color}")
578
+ except tk.TclError:
579
+ # Fall back to default
580
+ pass
581
+
582
+ # Handle background color - FIXED: Check for empty strings
583
+ if bg_color is not None and bg_color != "":
584
+ # Check if it's a color name that needs conversion
585
+ if bg_color.lower() in self.color_name_to_hex:
586
+ color_value = self.color_name_to_hex[bg_color.lower()]
587
+ elif bg_color.isdigit():
588
+ # It's an ANSI code
589
+ # Skip disabled colors
590
+ if 'disabled_colors' in self.config and bg_color in self.config['disabled_colors']:
591
+ # Use default color for disabled colors
592
+ pass
593
+ elif bg_color in self.ansi_colors:
594
+ tags.append(f"ansi_{bg_color}")
595
+ else:
596
+ # Default to black if unknown ANSI code
597
+ color_value = '#000000'
598
+ custom_bg_tag = f"custom_bg_{color_value}"
599
+ tags.append(custom_bg_tag)
600
+ if custom_bg_tag not in self.tag_names:
601
+ self.text_area.tag_configure(custom_bg_tag, background=color_value)
602
+ self.tag_names.add(custom_bg_tag)
603
+ elif bg_color.startswith('#') and len(bg_color) in [4, 5, 7, 9]:
604
+ # It's a hex color
605
+ color_value = bg_color
606
+ custom_bg_tag = f"custom_bg_{color_value}"
607
+ tags.append(custom_bg_tag)
608
+ if custom_bg_tag not in self.tag_names:
609
+ self.text_area.tag_configure(custom_bg_tag, background=color_value)
610
+ self.tag_names.add(custom_bg_tag)
611
+ elif ';' in bg_color and bg_color.startswith('48;'):
612
+ # Extended ANSI color code
613
+ if bg_color in self.ansi_colors:
614
+ tags.append(f"ansi_{bg_color}")
615
+ else:
616
+ # Try to use as a named color, but only if it's not empty
617
+ try:
618
+ if bg_color.strip(): # Check it's not just whitespace
619
+ self.text_area.tag_configure(f"custom_bg_{bg_color}", background=bg_color)
620
+ tags.append(f"custom_bg_{bg_color}")
621
+ self.tag_names.add(f"custom_bg_{bg_color}")
622
+ except tk.TclError:
623
+ # Fall back to default
624
+ pass
625
+
626
+ # Handle font
627
+ if font_family or font_size or font_style:
628
+ # Create a unique tag for this font combination
629
+ font_family_val = font_family or "Courier"
630
+ font_size_val = font_size or 10
631
+ font_style_val = font_style or "normal"
632
+ font_key = f"font_{font_family_val}_{font_size_val}_{font_style_val}"
633
+
634
+ if font_key not in self.tag_names:
635
+ self.text_area.tag_configure(font_key, font=(font_family_val, font_size_val, font_style_val))
636
+ self.tag_names.add(font_key)
637
+ tags.append(font_key)
638
+
639
+ # Handle styles
640
+ if bold:
641
+ tags.append('bold')
642
+ if underline:
643
+ tags.append('underline')
644
+ if italic:
645
+ tags.append('italic')
646
+ if strikethrough:
647
+ tags.append('strikethrough')
648
+ if reverse:
649
+ tags.append('reverse')
650
+
651
+ self.text_area.insert(tk.END, str(text) + "\n", tuple(tags))
652
+ self.text_area.config(state=tk.DISABLED)
653
+ self.text_area.see(tk.END)
654
+
655
+ self.root.after(0, _update)
656
+
657
+ def _demo_colors(self) -> None:
658
+ """Display ANSI color demo"""
659
+ demo_texts = [
660
+ ("\033[1mANSI Color Demo\033[0m\n", False), # Don't parse ANSI
661
+ ("\033[1;37mBasic Colors:\033[0m\n", True),
662
+ (" \033[30mBlack\033[0m \033[31mRed\033[0m \033[32mGreen\033[0m \033[33mYellow\033[0m\n", True),
663
+ (" \033[34mBlue\033[0m \033[35mMagenta\033[0m \033[36mCyan\033[0m \033[37mWhite\033[0m\n", True),
664
+ ("\n\033[1;37mBright Colors:\033[0m\n", True),
665
+ (" \033[90mGray\033[0m \033[91mBright Red\033[0m \033[92mBright Green\033[0m\n", True),
666
+ (" \033[93mBright Yellow\033[0m \033[94mBright Blue\033[0m \033[95mBright Magenta\033[0m\n", True),
667
+ ("\n\033[1;37mBackground Colors:\033[0m\n", True),
668
+ (" \033[40;37mBlack BG\033[0m \033[41mRed BG\033[0m \033[42mGreen BG\033[0m\n", True),
669
+ (" \033[43mYellow BG\033[0m \033[44mBlue BG\033[0m \033[45mMagenta BG\033[0m\n", True),
670
+ ("\n\033[1;37mExtended Colors:\033[0m\n", True),
671
+ (" \033[38;5;1mDark Red\033[0m \033[38;5;9mRed\033[0m \033[38;5;10mGreen\033[0m \033[38;5;12mBlue\033[0m\n", True),
672
+ (" \033[48;5;1mDark Red BG\033[0m \033[48;5;9mRed BG\033[0m\n", True),
673
+ ("\n\033[1;37mTrue Colors (RGB):\033[0m\n", True),
674
+ (" \033[38;2;255;0;0mRed\033[0m \033[38;2;0;255;0mGreen\033[0m \033[38;2;0;0;255mBlue\033[0m\n", True),
675
+ ("\n\033[1;37mText Styles:\033[0m\n", True),
676
+ (" \033[1mBold\033[0m \033[3mItalic\033[0m \033[4mUnderline\033[0m \033[9mStrikethrough\033[0m\n", True),
677
+ ("\n\033[1;37mCombined Styles:\033[0m\n", True),
678
+ (" \033[1;31mBold Red\033[0m \033[1;4;32mBold Underlined Green\033[0m\n", True),
679
+ (" \033[1;33;44mBold Yellow on Blue\033[0m \033[1;37;41mBold White on Red\033[0m\n", True),
680
+ ]
681
+
682
+ for text, parse_ansi in demo_texts:
683
+ self.display(text, parse_ansi)
684
+
685
+ def set_theme(self, theme_name: str) -> None:
686
+ """Set theme"""
687
+ def _set_theme():
688
+ if theme_name == "dark":
689
+ self.text_area.config(bg="black", fg="white", insertbackground="white")
690
+ self.text_area.tag_configure("default", foreground="white", background="black")
691
+ elif theme_name == "light":
692
+ self.text_area.config(bg="white", fg="black", insertbackground="black")
693
+ self.text_area.tag_configure("default", foreground="black", background="white")
694
+ elif theme_name == "matrix":
695
+ self.text_area.config(bg="black", fg="#00ff00", insertbackground="#00ff00")
696
+ self.text_area.tag_configure("default", foreground="#00ff00", background="black")
697
+ else: # default
698
+ self.text_area.config(bg="black", fg="white", insertbackground="white")
699
+ self.text_area.tag_configure("default", foreground="white", background="black")
700
+
701
+ self.text_area.see(tk.END)
702
+
703
+ self.root.after(0, _set_theme)
704
+
705
+ def user_write(self, prompt: str = "Input:") -> Optional[str]:
706
+ """Thread-safe input dialog (opens in a new window)"""
707
+ if not self.running:
708
+ return None
709
+ def _ask():
710
+ try:
711
+ result = simpledialog.askstring("Input", prompt, parent=self.root)
712
+ self.input_queue.put(result)
713
+ except tk.TclError:
714
+ self.input_queue.put(None)
715
+ self.root.after(0, _ask)
716
+
717
+ # Wait for input with timeout to check if still running
718
+ while self.running:
719
+ try:
720
+ return self.input_queue.get(timeout=0.1)
721
+ except queue.Empty:
722
+ continue
723
+ return None
724
+
725
+ def user_type_in(self, prompt: str = ">> ") -> Optional[str]:
726
+ """
727
+ Thread-safe terminal-style input (embedded in the main window)
728
+ User types in the terminal-like input field and presses Enter to send
729
+ """
730
+ if not self.running:
731
+ return None
732
+
733
+ def _prepare_input():
734
+ try:
735
+ # Update the prompt in the label
736
+ self.input_label.config(text=prompt)
737
+
738
+ # Clear any previous input
739
+ self.input_var.set("")
740
+
741
+ # Focus the input field
742
+ self.input_entry.focus_set()
743
+
744
+ # Enable the input field
745
+ self.input_entry.config(state=tk.NORMAL)
746
+ except tk.TclError:
747
+ pass
748
+
749
+ # Clear the queue in case there are stale values
750
+ while not self.type_in_queue.empty():
751
+ try:
752
+ self.type_in_queue.get_nowait()
753
+ except queue.Empty:
754
+ break
755
+
756
+ # Prepare the input field
757
+ try:
758
+ self.root.after(0, _prepare_input)
759
+ except tk.TclError:
760
+ return None
761
+
762
+ # Block and wait for user input
763
+ while self.running:
764
+ try:
765
+ return self.type_in_queue.get(timeout=0.1)
766
+ except queue.Empty:
767
+ continue
768
+ return None
769
+
770
+ def _on_enter_pressed(self, event: Optional[tk.Event] = None) -> str:
771
+ """Handle Enter key press in the input field"""
772
+ self._on_send_input()
773
+ return "break" # Prevent default behavior
774
+
775
+ def _on_send_input(self) -> None:
776
+ """Send the input from the terminal-style input field"""
777
+ user_input = self.input_var.get().strip()
778
+
779
+ if user_input:
780
+ # Put the input in the queue
781
+ self.type_in_queue.put(user_input)
782
+
783
+ # Display the user input in the output area
784
+ self.text_area.config(state=tk.NORMAL)
785
+ self.text_area.insert(tk.END, f"{self.input_label.cget('text')}{user_input}\n", 'default')
786
+ self.text_area.config(state=tk.DISABLED)
787
+ self.text_area.see(tk.END)
788
+
789
+ # Clear the input field
790
+ self.input_var.set("")
791
+
792
+ def _clear_input(self) -> None:
793
+ """Clear the terminal-style input field"""
794
+ self.input_var.set("")
795
+ self.input_entry.focus_set()
796
+
797
+ def focus_input(self) -> None:
798
+ """Set focus to the terminal-style input field"""
799
+ self.input_entry.focus_set()
800
+
801
+ def clear(self) -> None:
802
+ self.text_area.config(state=tk.NORMAL)
803
+ self.text_area.delete(1.0, tk.END)
804
+ self.text_area.config(state=tk.DISABLED)
805
+
806
+ def copy_text(self) -> None:
807
+ try:
808
+ selected = self.text_area.selection_get()
809
+ self.root.clipboard_clear()
810
+ self.root.clipboard_append(selected)
811
+ except tk.TclError:
812
+ pass
813
+
814
+ def select_all(self) -> None:
815
+ self.text_area.config(state=tk.NORMAL)
816
+ self.text_area.tag_add(tk.SEL, "1.0", tk.END)
817
+ self.text_area.config(state=tk.DISABLED)
818
+ self.text_area.mark_set(tk.INSERT, "1.0")
819
+ self.text_area.see(tk.INSERT)
820
+
821
+ def exit(self) -> None:
822
+ self.running = False
823
+ try:
824
+ self.root.quit()
825
+ self.root.destroy()
826
+ except tk.TclError:
827
+ pass # Already destroyed
828
+
829
+ def run(self, func: Optional[Callable] = None, *args, **kwargs) -> Any:
830
+ """
831
+ Run the GUI and optionally a worker function.
832
+ All Tkinter operations stay on main thread; your logic runs in a thread.
833
+ """
834
+ result_queue = queue.Queue()
835
+
836
+ if func:
837
+ def worker():
838
+ try:
839
+ result = func(*args, **kwargs)
840
+ result_queue.put(result)
841
+ except Exception as e:
842
+ self.display(f"Error: {e}\n{traceback.format_exc()}")
843
+ result_queue.put(e)
844
+ threading.Thread(target=worker, daemon=True).start()
845
+
846
+ try:
847
+ self.root.mainloop()
848
+ except KeyboardInterrupt:
849
+ self.exit()
850
+
851
+ if func:
852
+ # Wait for function to complete, but make it interruptible
853
+ while self.running:
854
+ try:
855
+ return result_queue.get(timeout=0.1)
856
+ except queue.Empty:
857
+ continue
858
+ return None
859
+
860
+
861
+ # Global instance and helper functions
862
+ _gui_instance = Py2GUI()
863
+
864
+ display = _gui_instance.display
865
+ display_colored = _gui_instance.display_colored
866
+ display_paragraph = _gui_instance.display_paragraph
867
+ user_write = _gui_instance.user_write
868
+ user_type_in = _gui_instance.user_type_in
869
+ clear = _gui_instance.clear
870
+ copy_text = _gui_instance.copy_text
871
+ select_all = _gui_instance.select_all
872
+ exit_gui = _gui_instance.exit
873
+ run = _gui_instance.run
874
+ focus_input = _gui_instance.focus_input
875
+ set_theme = _gui_instance.set_theme