config-cli-gui 0.0.2__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.
@@ -0,0 +1,542 @@
1
+ """GUI interface for config-cli-gui using tkinter with integrated logging.
2
+
3
+ This module provides a graphical user interface for the config-cli-gui
4
+ with settings dialog, file management, and centralized logging capabilities.
5
+
6
+ run gui: python -m config_cli_gui.gui
7
+ """
8
+
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ import threading
13
+ import tkinter as tk
14
+ import traceback
15
+ import webbrowser
16
+ from functools import partial
17
+ from pathlib import Path
18
+ from tkinter import filedialog, messagebox, ttk
19
+
20
+ from config_cli_gui.config.config import ConfigParameterManager
21
+ from config_cli_gui.core.base import BaseGPXProcessor
22
+ from config_cli_gui.core.logging import (
23
+ connect_gui_logging,
24
+ disconnect_gui_logging,
25
+ get_logger,
26
+ initialize_logging,
27
+ )
28
+ from config_cli_gui.gui_generator import SettingsDialogGenerator
29
+
30
+
31
+ class GuiLogWriter:
32
+ """Log writer that handles GUI text widget updates in a thread-safe way."""
33
+
34
+ def __init__(self, text_widget):
35
+ self.text_widget = text_widget
36
+ self.root = text_widget.winfo_toplevel()
37
+ self.hyperlink_tags = {} # To store clickable links
38
+
39
+ def write(self, text):
40
+ """Write text to the widget in a thread-safe manner."""
41
+ # Schedule the GUI update in the main thread
42
+ self.root.after(0, self._update_text, text)
43
+
44
+ def _update_text(self, text):
45
+ """Update the text widget (must be called from main thread)."""
46
+ try:
47
+ current_end = self.text_widget.index(tk.END)
48
+ self.text_widget.insert(tk.END, text)
49
+
50
+ # Check for a directory path (simplified regex for common path formats)
51
+ # This regex looks for paths that start with a drive letter (C:\), a forward slash (/)
52
+ # or a backslash (\) followed by word characters, and ends with a word character.
53
+ # This is a basic approach; more robust path detection might be needed for edge cases.
54
+ import re
55
+
56
+ path_match = re.search(
57
+ r"([A-Za-z]:[\\/][\S ]*|[\\][\\/][\S ]*|[\w/.-]+[/][\S ]*)\b", text
58
+ )
59
+ if path_match:
60
+ path = path_match.group(0).strip()
61
+ # Ensure the path exists and is a directory to make it clickable
62
+ if Path(path).is_dir():
63
+ start_index = self.text_widget.search(path, current_end, tk.END)
64
+ if start_index:
65
+ end_index = f"{start_index}+{len(path)}c"
66
+ tag_name = f"link_{len(self.hyperlink_tags)}"
67
+ self.text_widget.tag_config(tag_name, foreground="blue", underline=True)
68
+ self.text_widget.tag_bind(
69
+ tag_name, "<Button-1>", lambda e, p=path: self._open_path_in_explorer(p)
70
+ )
71
+ self.text_widget.tag_bind(
72
+ tag_name, "<Enter>", lambda e: self.text_widget.config(cursor="hand2")
73
+ )
74
+ self.text_widget.tag_bind(
75
+ tag_name, "<Leave>", lambda e: self.text_widget.config(cursor="")
76
+ )
77
+ self.text_widget.tag_add(tag_name, start_index, end_index)
78
+ self.hyperlink_tags[tag_name] = path
79
+
80
+ self.text_widget.see(tk.END)
81
+ self.text_widget.update_idletasks()
82
+ except tk.TclError:
83
+ # Widget might be destroyed
84
+ pass
85
+
86
+ def _open_path_in_explorer(self, path):
87
+ """Opens the given path in the file explorer."""
88
+ try:
89
+ if sys.platform == "win32":
90
+ os.startfile(path)
91
+ elif sys.platform == "darwin":
92
+ subprocess.Popen(["open", path])
93
+ else:
94
+ subprocess.Popen(["xdg-open", path])
95
+ except Exception as e:
96
+ get_logger("gui.main").error(f"Failed to open path {path}: {e}")
97
+
98
+ def flush(self):
99
+ """Flush method for compatibility."""
100
+ pass
101
+
102
+
103
+ class MainGui:
104
+ """Main GUI application class."""
105
+
106
+ processing_modes = [
107
+ ("compress_files", "Compress"),
108
+ ("merge_files", "Merge"),
109
+ ("extract_pois", "Extract POIs"),
110
+ ]
111
+
112
+ def __init__(self, root):
113
+ self.root = root
114
+ self.root.title("config-cli-gui")
115
+ self.root.geometry("1200x600") # Increased width for new layout
116
+
117
+ # Initialize configuration
118
+ self.config_manager = ConfigParameterManager("config.yaml")
119
+
120
+ # Initialize logging system
121
+ self.logger_manager = initialize_logging(self.config_manager)
122
+ self.logger = get_logger("gui.main")
123
+
124
+ # File lists
125
+ self.input_files = []
126
+ self.output_files = []
127
+
128
+ self._build_widgets()
129
+ self._create_menu()
130
+
131
+ # Setup GUI logging after widgets are created
132
+ self._setup_gui_logging()
133
+
134
+ # Handle window closing
135
+ self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
136
+
137
+ self.logger.info("GUI application started")
138
+ self.logger_manager.log_config_summary()
139
+
140
+ def _build_widgets(self):
141
+ """Build the main GUI widgets."""
142
+ # Main container
143
+ main_frame = ttk.Frame(self.root)
144
+ main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
145
+
146
+ # Top frame for file lists and buttons
147
+ top_frame = ttk.Frame(main_frame)
148
+ top_frame.pack(fill=tk.BOTH, expand=True)
149
+
150
+ # Left side - Input File list
151
+ input_file_frame = ttk.LabelFrame(top_frame, text="Input Files")
152
+ input_file_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5))
153
+
154
+ self.input_file_listbox = tk.Listbox(input_file_frame, selectmode=tk.EXTENDED)
155
+ input_file_scrollbar = ttk.Scrollbar(
156
+ input_file_frame, orient="vertical", command=self.input_file_listbox.yview
157
+ )
158
+ self.input_file_listbox.configure(yscrollcommand=input_file_scrollbar.set)
159
+
160
+ self.input_file_listbox.pack(side="left", fill="both", expand=True, padx=5, pady=5)
161
+ input_file_scrollbar.pack(side="right", fill="y", pady=5)
162
+ self.input_file_listbox.bind(
163
+ "<Double-Button-1>", lambda event: self._open_selected_file(event, self.input_files)
164
+ )
165
+
166
+ # Middle - Output File list
167
+ output_file_frame = ttk.LabelFrame(top_frame, text="Generated Files")
168
+ output_file_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(5, 5))
169
+
170
+ self.output_file_listbox = tk.Listbox(output_file_frame)
171
+ output_file_scrollbar = ttk.Scrollbar(
172
+ output_file_frame, orient="vertical", command=self.output_file_listbox.yview
173
+ )
174
+ self.output_file_listbox.configure(yscrollcommand=output_file_scrollbar.set)
175
+
176
+ self.output_file_listbox.pack(side="left", fill="both", expand=True, padx=5, pady=5)
177
+ output_file_scrollbar.pack(side="right", fill="y", pady=5)
178
+ self.output_file_listbox.bind(
179
+ "<Double-Button-1>", lambda event: self._open_selected_file(event, self.output_files)
180
+ )
181
+
182
+ # Right side - Buttons
183
+ button_frame = ttk.Frame(top_frame)
184
+ button_frame.pack(side=tk.RIGHT, fill=tk.Y, padx=(5, 0))
185
+
186
+ open_button = ttk.Button(button_frame, text="Open Files", command=self._open_files)
187
+ open_button.pack(pady=8, fill=tk.X)
188
+
189
+ remove_selected_button = ttk.Button(
190
+ button_frame, text="Remove Selected", command=self._remove_selected_input_files
191
+ )
192
+ remove_selected_button.pack(pady=1, fill=tk.X)
193
+
194
+ # Create buttons dynamically
195
+ self.run_buttons = {}
196
+ for mode, label in self.processing_modes:
197
+ button = ttk.Button(
198
+ button_frame, text=label, command=partial(self._run_processing, mode=mode)
199
+ )
200
+ button.pack(pady=1, fill=tk.X)
201
+ # Save buttons in dictionary for later access
202
+ self.run_buttons[mode] = button
203
+
204
+ # Clear files button
205
+ self.clear_input_button = ttk.Button(
206
+ button_frame, text="Clear Input Files", command=self._clear_input_files
207
+ )
208
+ self.clear_input_button.pack(pady=8, fill=tk.X)
209
+
210
+ self.clear_output_button = ttk.Button(
211
+ button_frame, text="Clear Generated Files", command=self._clear_output_files
212
+ )
213
+ self.clear_output_button.pack(pady=1, fill=tk.X)
214
+
215
+ # Progress bar
216
+ self.progress = ttk.Progressbar(button_frame, mode="indeterminate")
217
+ self.progress.pack(pady=5, fill=tk.X)
218
+
219
+ # Bottom frame - Log output
220
+ log_frame = ttk.LabelFrame(main_frame, text="Log Output")
221
+ log_frame.pack(fill=tk.BOTH, expand=True, pady=(5, 0))
222
+
223
+ # Log text widget with scrollbar
224
+ log_text_frame = ttk.Frame(log_frame)
225
+ log_text_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
226
+
227
+ self.log_text = tk.Text(log_text_frame, height=10, wrap=tk.WORD)
228
+ log_text_scrollbar = ttk.Scrollbar(
229
+ log_text_frame, orient="vertical", command=self.log_text.yview
230
+ )
231
+ self.log_text.configure(yscrollcommand=log_text_scrollbar.set)
232
+
233
+ self.log_text.pack(side="left", fill="both", expand=True)
234
+ log_text_scrollbar.pack(side="right", fill="y")
235
+
236
+ # Log controls
237
+ log_controls = ttk.Frame(log_frame)
238
+ log_controls.pack(fill=tk.X, padx=5, pady=(0, 5))
239
+
240
+ ttk.Button(log_controls, text="Clear Log", command=self._clear_log).pack(side=tk.LEFT)
241
+
242
+ # Log level selector
243
+ ttk.Label(log_controls, text="Log Level:").pack(side=tk.LEFT, padx=(10, 5))
244
+ self.log_level_var = tk.StringVar(
245
+ value=self.config_manager.get_category("app").log_level.default
246
+ )
247
+ log_level_combo = ttk.Combobox(
248
+ log_controls,
249
+ textvariable=self.log_level_var,
250
+ values=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
251
+ state="readonly",
252
+ width=10,
253
+ )
254
+ log_level_combo.pack(side=tk.LEFT)
255
+ log_level_combo.bind("<<ComboboxSelected>>", self._on_log_level_changed)
256
+
257
+ def _create_menu(self):
258
+ """Create the application menu."""
259
+ menubar = tk.Menu(self.root)
260
+ self.root.config(menu=menubar)
261
+
262
+ # File menu
263
+ file_menu = tk.Menu(menubar, tearoff=0)
264
+ menubar.add_cascade(label="File", menu=file_menu)
265
+ file_menu.add_command(label="Open...", command=self._open_files)
266
+ file_menu.add_separator()
267
+
268
+ # Create Run menu options dynamically
269
+ for mode, label in self.processing_modes:
270
+ file_menu.add_command(label=label, command=partial(self._run_processing, mode=mode))
271
+
272
+ file_menu.add_separator()
273
+ file_menu.add_command(label="Exit", command=self._on_closing)
274
+
275
+ # Options menu
276
+ options_menu = tk.Menu(menubar, tearoff=0)
277
+ menubar.add_cascade(label="Options", menu=options_menu)
278
+ options_menu.add_command(label="Settings", command=self._open_settings)
279
+
280
+ # Help menu
281
+ help_menu = tk.Menu(menubar, tearoff=0)
282
+ menubar.add_cascade(label="Help", menu=help_menu)
283
+ help_menu.add_command(label="User help", command=self._open_help)
284
+ help_menu.add_separator()
285
+ help_menu.add_command(label="About", command=self._show_about)
286
+
287
+ def _setup_gui_logging(self):
288
+ """Setup GUI logging integration."""
289
+ # Create GUI log writer
290
+ self.gui_log_writer = GuiLogWriter(self.log_text)
291
+
292
+ # Connect to logging system
293
+ connect_gui_logging(self.gui_log_writer)
294
+
295
+ def _on_log_level_changed(self, event=None):
296
+ """Handle log level change."""
297
+ new_level = self.log_level_var.get()
298
+ self.logger_manager.set_log_level(new_level)
299
+ self.logger.info(f"Log level changed to {new_level}")
300
+
301
+ def _clear_log(self):
302
+ """Clear the log text widget."""
303
+ self.log_text.delete(1.0, tk.END)
304
+ self.logger.debug("Log display cleared")
305
+
306
+ def _clear_input_files(self):
307
+ """Clear the input file list."""
308
+ self.input_files.clear()
309
+ self.input_file_listbox.delete(0, tk.END)
310
+ self.logger.info("Input file list cleared")
311
+
312
+ def _clear_output_files(self):
313
+ """Clear the output file list."""
314
+ self.output_files.clear()
315
+ self.output_file_listbox.delete(0, tk.END)
316
+ self.logger.info("Generated file list cleared")
317
+
318
+ def _remove_selected_input_files(self):
319
+ """Remove selected files from the input file list."""
320
+ selected_indices = self.input_file_listbox.curselection()
321
+ if not selected_indices:
322
+ messagebox.showwarning("Warning", "No files selected to remove!")
323
+ return
324
+
325
+ # Delete from listbox from end to start to avoid index issues
326
+ for i in reversed(selected_indices):
327
+ self.input_file_listbox.delete(i)
328
+ del self.input_files[i]
329
+ self.logger.info(f"Removed {len(selected_indices)} selected input files.")
330
+
331
+ def _open_selected_file(self, event, file_list_source):
332
+ """Opens the selected file in the system's default application or explorer."""
333
+ selection_index = event.widget.nearest(event.y)
334
+ if selection_index == -1: # No item clicked
335
+ return
336
+
337
+ file_path_str = file_list_source[selection_index]["path"]
338
+ file_path = Path(file_path_str)
339
+
340
+ if not file_path.exists():
341
+ self.logger.error(f"File not found: {file_path}")
342
+ messagebox.showerror("Error", f"File not found: {file_path}")
343
+ return
344
+
345
+ try:
346
+ if sys.platform == "win32":
347
+ os.startfile(file_path)
348
+ elif sys.platform == "darwin":
349
+ subprocess.Popen(["open", file_path])
350
+ else:
351
+ subprocess.Popen(["xdg-open", file_path])
352
+ self.logger.info(f"Opened file: {file_path}")
353
+ except Exception as e:
354
+ self.logger.error(f"Could not open file {file_path}: {e}")
355
+ messagebox.showerror("Error", f"Could not open file {file_path}: {e}")
356
+
357
+ def _open_files(self):
358
+ """Open file dialog and add files to list."""
359
+ files = filedialog.askopenfilenames(
360
+ title="Select input files",
361
+ filetypes=[
362
+ ("GPX/KML files", "*.gpx *.kml *.zip"), # Added KML support
363
+ ("GPX files", "*.gpx"),
364
+ ("KML files", "*.kml"),
365
+ ("ZIP files", "*.zip"),
366
+ ("All files", "*.*"),
367
+ ],
368
+ )
369
+
370
+ new_files = 0
371
+ for file_path_str in files:
372
+ file_path = Path(file_path_str)
373
+ if file_path_str not in [f["path"] for f in self.input_files]:
374
+ try:
375
+ file_size_kb = file_path.stat().st_size / 1024
376
+ self.input_files.append({"path": file_path_str, "size": file_size_kb})
377
+ self.input_file_listbox.insert(
378
+ tk.END, f"{file_path.name} ({file_size_kb:.2f} KB)"
379
+ )
380
+ new_files += 1
381
+ except Exception as e:
382
+ self.logger.warning(f"Could not get size for {file_path_str}: {e}")
383
+ self.input_files.append({"path": file_path_str, "size": 0})
384
+ self.input_file_listbox.insert(tk.END, f"{file_path.name} (N/A KB)")
385
+
386
+ if new_files > 0:
387
+ self.logger.info(f"Added {new_files} new files to processing list")
388
+ else:
389
+ self.logger.debug("No new files selected")
390
+
391
+ def _update_output_listbox(self, generated_files_info):
392
+ """Updates the output file listbox with newly generated files."""
393
+ self.output_file_listbox.delete(0, tk.END) # Clear current list
394
+ self.output_files.clear() # Clear internal list
395
+ for file_path_str in generated_files_info:
396
+ file_path = Path(file_path_str)
397
+ try:
398
+ file_size_kb = file_path.stat().st_size / 1024
399
+ self.output_files.append({"path": file_path_str, "size": file_size_kb})
400
+ self.output_file_listbox.insert(tk.END, f"{file_path.name} ({file_size_kb:.2f} KB)")
401
+ except Exception as e:
402
+ self.logger.warning(f"Could not get size for generated file {file_path_str}: {e}")
403
+ self.output_files.append({"path": file_path_str, "size": 0})
404
+ self.output_file_listbox.insert(tk.END, f"{file_path.name} (N/A KB)")
405
+
406
+ if generated_files_info:
407
+ output_dir = Path(generated_files_info[0]).parent
408
+ self.logger.info(f"Generated files saved in: {output_dir}") # Log directory
409
+
410
+ def _run_processing(self, mode="compress_files"):
411
+ """Run the processing in a separate thread."""
412
+ selected_indices = self.input_file_listbox.curselection()
413
+ files_to_process = []
414
+
415
+ if selected_indices:
416
+ for i in selected_indices:
417
+ files_to_process.append(self.input_files[i]["path"])
418
+ else:
419
+ files_to_process = [f["path"] for f in self.input_files]
420
+
421
+ if not files_to_process:
422
+ self.logger.warning("No input files selected or all are deselected.")
423
+ messagebox.showwarning("Warning", "No input files selected or all are deselected!")
424
+ return
425
+
426
+ self.logger.info(f"Starting processing of {len(files_to_process)} files in mode: {mode}")
427
+
428
+ # Disable all buttons during processing
429
+ for button in self.run_buttons.values():
430
+ button.config(state="disabled")
431
+ self.clear_input_button.config(state="disabled")
432
+ self.clear_output_button.config(state="disabled")
433
+ self.progress.start()
434
+
435
+ # Run in separate thread to avoid blocking GUI
436
+ thread = threading.Thread(
437
+ target=self._process_files,
438
+ args=(
439
+ mode,
440
+ files_to_process,
441
+ ),
442
+ daemon=True,
443
+ )
444
+ thread.start()
445
+
446
+ def _process_files(self, mode="compress_files", files_to_process=None):
447
+ """Process the selected files."""
448
+ generated_files_paths = []
449
+ try:
450
+ self.logger.info("=== Processing Started ===")
451
+ self.logger.info("Processing files...")
452
+
453
+ if files_to_process is None:
454
+ files_to_process = [] # Should not happen with the check in _run_processing
455
+
456
+ # Create and run project
457
+ project = BaseGPXProcessor(
458
+ files_to_process, # Pass selected files
459
+ self.config_manager.get_category("cli").output.default,
460
+ self.config_manager.get_category("cli").min_dist.default,
461
+ self.config_manager.get_category("app").date_format.default,
462
+ self.config_manager.get_category("cli").elevation.default,
463
+ self.logger,
464
+ )
465
+ # implement switch case for different processing modes
466
+ if mode == "compress_files":
467
+ generated_files_paths = project.compress_files()
468
+ elif mode == "merge_files":
469
+ generated_files_paths = project.merge_files()
470
+ elif mode == "extract_pois":
471
+ generated_files_paths = project.extract_pois()
472
+ else:
473
+ self.logger.warning(f"Unknown mode: {mode}")
474
+
475
+ self.logger.info(f"Completed: {len(files_to_process)} files processed")
476
+ self.logger.info("=== All files processed successfully! ===")
477
+
478
+ self.root.after(0, self._update_output_listbox, generated_files_paths)
479
+
480
+ except Exception as err:
481
+ self.logger.error(f"Processing failed: {err}", exc_info=True)
482
+ # Show error dialog in main thread
483
+ self.root.after(
484
+ 0, lambda e=err: messagebox.showerror("Error", f"Processing failed: {e}")
485
+ )
486
+
487
+ finally:
488
+ # Re-enable controls in main thread
489
+ self.root.after(0, self._processing_finished)
490
+
491
+ def _processing_finished(self):
492
+ """Re-enable controls after processing is finished."""
493
+ for button in self.run_buttons.values():
494
+ button.config(state="normal")
495
+ self.clear_input_button.config(state="normal")
496
+ self.clear_output_button.config(state="normal")
497
+ self.progress.stop()
498
+
499
+ def _open_settings(self):
500
+ """Open the settings dialog."""
501
+ self.logger.debug("Opening settings dialog")
502
+ settings_dialog_generator = SettingsDialogGenerator(self.config_manager)
503
+ dialog = settings_dialog_generator.create_settings_dialog(self.root)
504
+ self.root.wait_window(dialog.dialog)
505
+
506
+ if dialog.result == "ok":
507
+ self.logger.info("Settings updated successfully")
508
+ # Update log level selector if it changed
509
+ self.log_level_var.set(self.config_manager.get_category("app").log_level.default)
510
+
511
+ def _open_help(self):
512
+ """Open help documentation in browser."""
513
+ self.logger.debug("Opening help documentation")
514
+ webbrowser.open("https://config-cli-gui.readthedocs.io/en/stable/")
515
+
516
+ def _show_about(self):
517
+ """Show about dialog."""
518
+ self.logger.debug("Showing about dialog")
519
+ messagebox.showinfo("About", "config-cli-gui\n\nCopyright by Paul")
520
+
521
+ def _on_closing(self):
522
+ """Handle application closing."""
523
+ self.logger.info("Closing GUI application")
524
+ disconnect_gui_logging()
525
+ self.root.quit()
526
+ self.root.destroy()
527
+
528
+
529
+ def main():
530
+ """Main entry point for the GUI application."""
531
+ root = tk.Tk()
532
+ try:
533
+ MainGui(root)
534
+ root.mainloop()
535
+ except Exception as e:
536
+ print(f"GUI startup failed: {e}")
537
+ traceback.print_exc()
538
+ sys.exit(1)
539
+
540
+
541
+ if __name__ == "__main__":
542
+ main()
main.py ADDED
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Unified entry point for CLI and GUI application.
4
+ Automatically detects whether to run CLI or GUI based on how the application is started.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+
10
+
11
+ def is_console_attached():
12
+ """
13
+ Check if the application is running in a console environment.
14
+ Returns True if launched from console, False if launched via double-click.
15
+ """
16
+ try:
17
+ # On Windows, check if we have a console attached
18
+ if os.name == "nt":
19
+ import ctypes
20
+
21
+ kernel32 = ctypes.windll.kernel32
22
+ # GetConsoleWindow returns 0 if no console is attached
23
+ console_window = kernel32.GetConsoleWindow()
24
+ if console_window == 0:
25
+ return False
26
+
27
+ # Check if the console was created by this process
28
+ # (indicates double-click launch with console auto-created)
29
+ process_list = kernel32.GetConsoleProcessList(None, 0)
30
+ if process_list <= 1:
31
+ return False
32
+
33
+ return True
34
+ else:
35
+ # On Unix-like systems (macOS, Linux), check multiple indicators
36
+ # Check if stdout is a terminal
37
+ if not sys.stdout.isatty():
38
+ return False
39
+
40
+ # Additional check for macOS: Check if we're running in a .app bundle
41
+ if sys.platform == "darwin":
42
+ # If we're in a .app bundle, we're likely launched via double-click
43
+ executable_path = sys.executable
44
+ if ".app/" in executable_path:
45
+ # We're in an app bundle, check if we have a real terminal
46
+ # by checking if TERM environment variable is set
47
+ return os.environ.get("TERM") is not None
48
+
49
+ return True
50
+ except Exception as ex:
51
+ # Fallback: assume GUI if we can't determine
52
+ print(ex)
53
+ return False
54
+
55
+
56
+ def has_command_line_args():
57
+ """Check if command line arguments (beyond script name) are provided."""
58
+ return len(sys.argv) > 1
59
+
60
+
61
+ def main():
62
+ """Main entry point that decides between CLI and GUI."""
63
+
64
+ # Force CLI mode if command line arguments are provided
65
+ if has_command_line_args():
66
+ run_cli()
67
+ return
68
+
69
+ # Check if we're in a console environment
70
+ if is_console_attached():
71
+ # We're in a console - offer choice or default to CLI
72
+ print("Python Template Project")
73
+ print("=" * 50)
74
+ print("Detected console environment.")
75
+ print("Options:")
76
+ print(" 1. Run CLI interface (default)")
77
+ print(" 2. Run GUI interface")
78
+ print(" 3. Show help")
79
+ print()
80
+
81
+ try:
82
+ choice = input("Select option [1]: ").strip()
83
+ if choice == "2":
84
+ run_gui()
85
+ elif choice == "3":
86
+ show_help()
87
+ else:
88
+ run_cli()
89
+ except (KeyboardInterrupt, EOFError):
90
+ print("\nExiting...")
91
+ sys.exit(0)
92
+ else:
93
+ # Launched via double-click - start GUI
94
+ run_gui()
95
+
96
+
97
+ def run_cli():
98
+ """Launch the CLI interface."""
99
+ try:
100
+ from config_cli_gui.cli.cli import main as cli_main
101
+
102
+ cli_main()
103
+ except ImportError as e:
104
+ print(f"Error importing CLI module: {e}")
105
+ sys.exit(1)
106
+ except Exception as e:
107
+ print(f"Error running CLI: {e}")
108
+ sys.exit(1)
109
+
110
+
111
+ def run_gui():
112
+ """Launch the GUI interface."""
113
+ try:
114
+ from config_cli_gui.gui.gui import main as gui_main
115
+
116
+ gui_main()
117
+ except ImportError as e:
118
+ print(f"Error importing GUI module: {e}")
119
+ # If GUI fails, fallback to CLI
120
+ print("Falling back to CLI interface...")
121
+ run_cli()
122
+ except Exception as e:
123
+ print(f"Error running GUI: {e}")
124
+ print("Falling back to CLI interface...")
125
+ run_cli()
126
+
127
+
128
+ def show_help():
129
+ """Show help information."""
130
+ print("""
131
+ Unified Application
132
+
133
+ Usage:
134
+ When launched from console:
135
+ - Without arguments: Interactive mode selection
136
+ - With arguments: Direct CLI mode
137
+
138
+ When launched via double-click:
139
+ - Automatically starts GUI interface
140
+
141
+ Command Line Arguments:
142
+ Run with any CLI arguments to force CLI mode.
143
+
144
+ Examples:
145
+ python main.py --help # Shows CLI help
146
+ python main.py # Interactive mode selection
147
+ double-click main.exe # Starts GUI
148
+
149
+ """)
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()