diff --git a/Advanced Search/README.md b/Advanced Search/README.md
new file mode 100644
index 00000000..b3975cbc
--- /dev/null
+++ b/Advanced Search/README.md
@@ -0,0 +1,446 @@
+# Advanced Search Tool
+
+
+
+
+
+
+
+
+**A powerful Windows GUI application for grep-style searching with advanced regex patterns and metadata search capabilities**
+
+[Features](#features) • [Installation](#installation) • [Usage](#usage) • [Metadata Search](#metadata-search) • [Contributing](#contributing)
+
+
+
+---
+
+## 📋 Overview
+
+Advanced Search Tool is a modern, high-performance desktop application that brings the power of grep-style pattern searching to Windows users with a beautiful, easy-to-use graphical interface. Search through file contents, image metadata (EXIF/GPS), and document properties (PDF, Office, audio/video files) with powerful regex pattern matching.
+
+## ✨ Features
+
+### Core Functionality
+- 🔍 **Powerful Search Engine** - Fast grep-style pattern matching across files and directories
+- 📊 **Smart Sorting** - Sort results by path, match count, file size, or modification date (8 sorting options)
+- 🎯 **Smart Highlighting** - Yellow highlights for all matches, orange for current match
+- 📁 **File Browser** - Integrated file explorer with lazy loading for smooth navigation
+- 📊 **Results Tree** - Organized results grouped by file with match counts and line numbers
+
+### Regex & Pattern Matching
+- 🔄 **Full Regex Support** - Complete regular expression pattern matching
+- 📋 **Regex Pattern Library** - Quick-access popover menu with 8 built-in common regex patterns:
+ - Email addresses (`user@domain.com`)
+ - URLs (http/https)
+ - IPv4 addresses
+ - Phone numbers (multiple formats)
+ - Dates (various formats)
+ - Numbers
+ - Hex values (0x... and #...)
+ - Words/identifiers
+- ✏️ **Custom Pattern Library** - Create, save, and manage your own regex patterns with persistent storage
+- ✅ **Pattern Auto-Apply** - Check patterns in menu to instantly apply to your search
+- 🔀 **Pattern Combination** - Enable multiple patterns simultaneously
+
+### Metadata Search
+- 🖼️ **Image Metadata Search** - Extract and search EXIF, GPS, and PNG metadata from:
+ - JPG/JPEG files (EXIF tags, GPS coordinates, camera info)
+ - PNG files (text chunks, creation time)
+ - TIFF files (comprehensive EXIF data)
+ - Other formats: GIF, BMP, WebP
+- 📄 **File Metadata Search** - Extract and search properties from:
+ - **PDF files**: Title, author, subject, keywords, creator, creation/modification dates
+ - **Microsoft Office** (.docx, .xlsx, .pptx): Author, title, subject, keywords, creation/modification dates, document statistics
+ - **OpenDocument** (.odt, .ods, .odp): LibreOffice/OpenOffice metadata and properties
+ - **Screenwriting** (.fdx, .fountain, .celtx): Final Draft, Fountain format, Celtx projects - title, author, scenes
+ - **eBooks** (.epub): Title, author, publisher, language, ISBN from EPUB metadata
+ - **Archives** (.zip): File lists, compressed size, contents preview
+ - **Structured Data** (.csv, .json, .xml): Headers, keys, schema information
+ - **Databases** (.db, .sqlite, .sqlite3): SQLite database schema, table names, column info, row counts
+ - **RTF files** (.rtf): Rich Text Format documents - title, author, subject, RTF version
+ - **Audio files** (.mp3, .flac, .m4a, .ogg, .wma): Artist, album, title, duration, bitrate
+ - **Video files** (.mp4, .avi, .mkv, .mov, .wmv): Video metadata, codec info, duration
+
+### Advanced Search Modes
+- 📦 **Archive Content Search** - Search inside ZIP and EPUB files without manual extraction
+ - Results displayed as `archive.zip/internal/path/file.txt:line`
+ - Supports nested directory structures within archives
+ - Automatic text encoding detection
+- 🔢 **Binary/Hex Search** - Search binary files using hex patterns
+ - Results show byte offsets and hex dumps
+ - 32-byte context window (16 bytes before/after match)
+ - Useful for firmware, executables, and binary data analysis
+
+### Performance Optimizations
+- ⚡ **Multi-threaded Search** - Background search operations don't freeze the UI
+- 💾 **File Content Caching** - LRU cache for frequently accessed files
+- 📦 **Batch UI Updates** - Efficient result rendering
+- 🎚️ **Configurable Limits** - File size limits, max results, cache size
+- 🚀 **Smart File Filtering** - Automatic exclusion of binary files and common build directories
+- 🌐 **Network Drive Optimization** - Automatic UNC path detection with timeout handling and accessibility caching
+
+### User Interface
+- 🎨 **Three-Panel Layout** - File Explorer | Results Tree | Preview Pane
+- 🎭 **Modern Design** - Clean, responsive interface with SVG icons
+- 📊 **Progress Indicators** - Real-time search progress with file count and status
+- 🔽 **Context Dropdown** - Select 0-10 lines of context around matches
+- ⌨️ **Keyboard Shortcuts** - Quick navigation and actions
+
+### Persistence & Configuration
+- 💾 **Search History** - Auto-complete from previous searches
+- ⚙️ **User Preferences** - Customizable settings saved between sessions
+- 📁 **Session State** - Remembers last directory and search options
+- 🔧 **Flexible Options** - Case sensitivity, whole word, file extensions, context lines
+
+## 🚀 Installation
+
+### Prerequisites
+- **Python 3.8 or higher**
+- **Windows OS** (tested on Windows 10/11)
+- **Git** (for cloning the repository)
+
+### Quick Start
+
+```bash
+# Clone the repository
+git clone https://github.com/RandyNorthrup/Python-Scripts.git
+cd Python-Scripts/Advanced\ Search
+
+# Install dependencies
+pip install -r requirements.txt
+
+# Run the application
+python main.py
+```
+
+### Dependencies
+
+The following libraries will be installed:
+
+| Library | Version | Purpose |
+|---------|---------|---------|
+| PySide6 | ≥6.6.0 | Qt6 GUI framework |
+| Pillow | ≥10.0.0 | Image metadata (EXIF, GPS) |
+| PyPDF2 | ≥3.0.0 | PDF metadata extraction |
+| python-docx | ≥1.0.0 | Word document metadata |
+| openpyxl | ≥3.1.0 | Excel metadata |
+| mutagen | ≥1.47.0 | Audio/video file tags |
+
+**Note:** XML, JSON, CSV, and ZIP support use Python standard library (no extra dependencies)
+
+## 📖 Usage
+
+### Basic Search
+
+1. **Select Directory** - Click on a folder in the left panel (File Explorer)
+2. **Enter Pattern** - Type your search term in the search box at the top
+3. **Configure Options** - Enable case sensitivity, regex mode, whole word, etc.
+4. **Set Context** - Use dropdown to select 0-10 lines of context around matches
+5. **Search** - Click "Search" button or press Enter
+6. **Browse Results** - Click results in middle panel to preview, double-click to open
+
+### Using Regex Patterns
+
+Click the **"Regex Patterns ▼"** button to open a menu with common regex patterns:
+
+- ✓ **Check patterns** to enable them in your search
+- ✅ **Combine multiple patterns** by checking several boxes
+- 🗑️ **Clear all patterns** using the "Clear All" option at the bottom
+- 🔄 The menu toggles open/close on each click
+
+**Example**: Enable "Email addresses" to find all email addresses in your files without writing regex manually.
+
+### Custom Patterns
+
+Create and save your own regex patterns:
+
+1. Click **"Regex Patterns ▼"** to open the menu
+2. Click **"Manage Custom Patterns..."** at the bottom
+3. Click **"Add Pattern"** to create a new pattern
+4. Enter a name and regex pattern, then click **"Save"**
+5. Your custom patterns appear in the menu alongside built-in patterns
+6. Check your custom pattern to apply it to searches
+
+### Search Options
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| **Case Sensitive** | Match exact letter case | Off |
+| **Use Regex** | Enable regular expression patterns | Off |
+| **Whole Word** | Match complete words only (not partial) | Off |
+| **Context** | Lines of context to show around matches | 2 |
+| **Extensions** | Filter by file types (e.g., `.py,.txt,.js`) | All files |
+| **Search image metadata** | Search EXIF/GPS in JPG, PNG, TIFF, etc. | Off |
+| **Search file metadata** | Search properties in PDF, Office, audio/video | Off |
+| **Search archive contents** | Search inside ZIP and EPUB files | Off |
+| **Binary/hex search** | Search binary files using hex patterns | Off |
+
+### Metadata Search
+
+#### Image Metadata Search
+
+Enable **"Search image metadata"** checkbox to search within image files:
+
+**What it searches:**
+- EXIF tags (camera model, settings, software)
+- GPS coordinates (latitude, longitude, altitude)
+- PNG text chunks (creation time, software)
+- File system metadata (creation date, modified date, size)
+
+**Supported formats:** JPG, JPEG, PNG, TIFF, TIF, GIF, BMP, WebP
+
+**Example searches:**
+- Camera model: Search for "Canon" to find all Canon photos
+- Location: Search for "GPS" to find geotagged images
+- Date: Search for "2024" in DateTime tags
+
+#### File Metadata Search
+
+Enable **"Search file metadata"** checkbox to search document properties:
+
+**PDF Files:**
+- Title, Author, Subject, Keywords, Creator, Producer
+- Creation date, Modification date, Page count
+
+**Microsoft Office (.docx, .xlsx, .pptx):**
+- Creator, Title, Subject, Keywords, Category
+- Creation date, Modified date
+- Document statistics (paragraphs, sheets, etc.)
+
+**OpenDocument (.odt, .ods, .odp):**
+- Title, Creator, Subject, Keywords
+- LibreOffice/OpenOffice metadata
+
+**Screenwriting Files:**
+- **Final Draft (.fdx)**: Title, author, scene count, script metadata
+- **Fountain (.fountain)**: Title page metadata, scene headings count
+- **Celtx (.celtx)**: Project type, file count
+
+**eBooks (.epub):**
+- Title, Author, Publisher, Language, ISBN
+
+**Archives (.zip):**
+- File count, compressed size, contents listing
+
+**Structured Data:**
+- **CSV**: Column headers, row count
+- **JSON**: Keys, structure type, item count
+- **XML**: Root tag, namespaces, attributes
+
+**SQLite Databases (.db, .sqlite, .sqlite3):**
+- Database schema, table names
+- Column names and types
+- Row counts per table
+
+**RTF Files (.rtf):**
+- Title, Author, Subject
+- RTF version, creation date
+
+**Audio Files (.mp3, .flac, .m4a, .ogg, .wma):**
+- Artist, Album, Title, Genre, Year
+- Duration, Bitrate, Sample rate
+
+**Video Files (.mp4, .avi, .mkv, .mov, .wmv):**
+- Title, Artist, Album, Genre
+- Duration, Bitrate, Video codec
+
+**Note:** When metadata search is enabled, the tool searches ONLY metadata, not file contents. Disable to search file text content.
+
+### Archive Content Search
+
+Enable **"Search archive contents"** to search inside ZIP and EPUB files:
+
+- Searches text files within archives without extracting
+- Results show the full path: `archive.zip/folder/file.txt:line_number`
+- Supports nested directory structures
+- Works with both .zip and .epub formats
+
+### Binary/Hex Search
+
+Enable **"Binary/hex search"** to search binary files using hexadecimal patterns:
+
+- Enter hex patterns like `48656C6C6F` to search for "Hello" in binary files
+- Results display byte offsets and hex dumps
+- Shows 16 bytes before and after each match (32-byte context window)
+- Useful for firmware analysis, executables, and binary data inspection
+
+### Navigation & Workflow
+
+| Action | Method |
+|--------|--------|
+| **Sort results** | Use Sort dropdown: Path, Match Count, File Size, Date Modified |
+| **Navigate matches** | Use Previous/Next buttons or `Ctrl+Up`/`Ctrl+Down` |
+| **Open file** | Double-click result in results tree |
+| **Open in VS Code** | Right-click → "Open in VS Code" (if installed) |
+| **Copy file path** | Right-click → "Copy Full Path" |
+| **View in explorer** | Right-click → "Open Containing Folder" |
+
+### Keyboard Shortcuts
+
+| Shortcut | Action |
+|----------|--------|
+| `Enter` | Start search |
+| `Ctrl+Up` | Previous match |
+| `Ctrl+Down` | Next match |
+| `Ctrl+Q` | Quit application |
+| `F1` | Open help window |
+
+### Preferences
+
+Access preferences via **Menu → Preferences** to configure:
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| **Max Search Results** | Limit total results (0 = unlimited) | 0 (unlimited) |
+| **Max Preview File Size** | Maximum file size to display in preview pane (MB) | 10 MB |
+| **Max Search File Size** | Maximum file size to search through (MB) | 50 MB |
+| **File Cache Size** | Number of files to keep in memory cache | 50 files |
+
+**Note:** All preferences are saved automatically and persist between sessions.
+
+## 🏗️ Architecture
+
+```
+advanced_search/
+├── assets/ # Icons and images
+│ ├── icon.ico # Application icon (Windows)
+│ ├── icon.svg # SVG version of icon
+│ ├── chevron_up.svg # Up arrow icon
+│ └── chevron_down.svg # Down arrow icon
+├── src/ # Source code
+│ ├── main.py # Main application & GUI (2,057 lines)
+│ ├── search_engine.py # Core search functionality (827 lines)
+│ └── __pycache__/ # Python bytecode cache
+├── main.py # Application entry point
+├── requirements.txt # Python dependencies
+├── README.md # This file
+├── LICENSE # MIT License
+└── CONTRIBUTING.md # Contribution guidelines
+```
+
+### Technology Stack
+- **GUI Framework:** PySide6 (Qt6) - Modern, cross-platform UI
+- **Search Engine:** Python `re` module with multi-threading
+- **Image Processing:** Pillow (PIL) for EXIF/GPS extraction
+- **Document Parsing:** PyPDF2, python-docx, openpyxl for metadata
+- **Media Tags:** Mutagen for audio/video file metadata
+- **Threading:** QThread for non-blocking background operations
+- **Persistence:** JSON for settings and search history
+
+### Key Components
+
+**MainWindow Class** (`src/main.py`)
+- Three-panel UI layout with compact controls
+- File browser with lazy loading and drive labels
+- Results tree with match grouping and sorting
+- Preview pane with syntax highlighting
+- Regex pattern menu system with custom pattern editor
+- Metadata preview formatting
+- Search history management
+- Preferences dialog
+- Multi-modal Search/Stop button
+
+**SearchEngine Class** (`src/search_engine.py`)
+- Multi-threaded file scanning
+- Regex pattern compilation and caching
+- Image metadata extraction (EXIF, GPS, PNG)
+- File metadata extraction (PDF, Office, audio/video, SQLite, RTF)
+- Archive content search (ZIP, EPUB)
+- Binary/hex pattern search
+- Context line extraction
+- File filtering and exclusion patterns
+- Network drive optimization with timeout handling
+- Performance optimizations (file size limits, caching)
+
+## 🛠️ Development
+
+### Running from Source
+
+```bash
+# Ensure you're in the project directory
+cd advanced_search
+
+# Run the application
+python main.py
+```
+
+### Code Structure
+
+The application uses a clean separation of concerns:
+
+1. **main.py (entry point)** - Launches the application
+2. **src/main.py** - All GUI code and user interactions
+3. **src/search_engine.py** - Pure search logic, no GUI dependencies
+
+### Key Design Patterns
+
+- **MVC Pattern** - Separation of search logic from UI
+- **Observer Pattern** - Qt signals/slots for event handling
+- **Worker Thread Pattern** - QThread for background search operations
+- **Lazy Loading** - File browser loads directories on-demand
+- **LRU Cache** - File content caching with size limits
+- **Multi-modal UI** - Single button for Search/Stop with state tracking
+
+### Adding New Features
+
+To add new metadata sources:
+
+1. Add library import with try/except in `search_engine.py`
+2. Add file extensions to `FILE_METADATA_EXTENSIONS` class constant
+3. Add extraction logic to `_extract_file_metadata()` method
+4. Update README with supported formats
+
+## 🤝 Contributing
+
+Contributions are welcome! Please feel free to submit a Pull Request.
+
+1. Fork the repository
+2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
+3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
+4. Push to the branch (`git push origin feature/AmazingFeature`)
+5. Open a Pull Request
+
+## 📝 License
+
+This project is licensed under the MIT License.
+
+## 👤 Author
+
+**Randy Northrup**
+
+## 🙏 Acknowledgments
+
+- SVG Icons created for this project (chevron_up.svg, chevron_down.svg, icon.svg)
+- Built with [PySide6](https://wiki.qt.io/Qt_for_Python) - Qt for Python
+- Image processing by [Pillow](https://python-pillow.org/)
+- PDF parsing by [PyPDF2](https://pypdf2.readthedocs.io/)
+- Office document handling by [python-docx](https://python-docx.readthedocs.io/) and [openpyxl](https://openpyxl.readthedocs.io/)
+- Audio/video metadata by [Mutagen](https://mutagen.readthedocs.io/)
+
+## 📮 Support
+
+If you encounter any issues or have suggestions, please [open an issue](../../issues).
+
+## ⚠️ Known Limitations
+
+- **Windows only** - Currently designed for Windows (paths, file handling, drive labels)
+- **Text encoding** - Files must be text-readable or supported binary formats (images, PDFs, Office docs)
+- **Large files** - Very large files (>50MB default) are skipped to prevent slowdown
+- **Metadata availability** - Not all files contain metadata; results vary by file type and creation method
+- **Network drives** - Network path timeout set to 5 seconds; inaccessible drives are cached
+
+## 📊 Performance Tips
+
+1. **Use file extension filters** - Limit search to specific file types (`.py,.js,.txt`)
+2. **Adjust file size limits** - Reduce max file size in preferences for faster searches
+3. **Enable metadata search selectively** - Only when needed, as it adds processing overhead
+4. **Clear cache periodically** - If experiencing memory issues with large file sets
+5. **Use specific regex patterns** - More specific patterns search faster than broad ones
+6. **Network drives** - First access may be slow due to accessibility checks; subsequent searches use caching
+
+---
+
+
+Made with ❤️ by Randy Northrup
+
diff --git a/Advanced Search/assets/chevron_down.svg b/Advanced Search/assets/chevron_down.svg
new file mode 100644
index 00000000..81a9afdc
--- /dev/null
+++ b/Advanced Search/assets/chevron_down.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Advanced Search/assets/chevron_up.svg b/Advanced Search/assets/chevron_up.svg
new file mode 100644
index 00000000..efb6cb65
--- /dev/null
+++ b/Advanced Search/assets/chevron_up.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Advanced Search/assets/icon.ico b/Advanced Search/assets/icon.ico
new file mode 100644
index 00000000..a741057f
Binary files /dev/null and b/Advanced Search/assets/icon.ico differ
diff --git a/Advanced Search/assets/icon.svg b/Advanced Search/assets/icon.svg
new file mode 100644
index 00000000..3107ed12
--- /dev/null
+++ b/Advanced Search/assets/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Advanced Search/main.py b/Advanced Search/main.py
new file mode 100644
index 00000000..543840c3
--- /dev/null
+++ b/Advanced Search/main.py
@@ -0,0 +1,15 @@
+"""
+Advanced Search Tool - Main Launcher
+Entry point for the application
+"""
+import sys
+import os
+
+# Add src directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
+
+# Import and run main application
+from src.main import main
+
+if __name__ == '__main__':
+ main()
diff --git a/Advanced Search/requirements.txt b/Advanced Search/requirements.txt
new file mode 100644
index 00000000..876d0ccc
--- /dev/null
+++ b/Advanced Search/requirements.txt
@@ -0,0 +1,6 @@
+PySide6>=6.6.0
+Pillow>=10.0.0
+PyPDF2>=3.0.0
+python-docx>=1.0.0
+openpyxl>=3.1.0
+mutagen>=1.47.0
diff --git a/Advanced Search/src/main.py b/Advanced Search/src/main.py
new file mode 100644
index 00000000..e8ae6477
--- /dev/null
+++ b/Advanced Search/src/main.py
@@ -0,0 +1,2056 @@
+"""
+Main GUI application for Advanced Search
+"""
+import sys
+import os
+import re
+import json
+import string
+import subprocess
+from PySide6.QtWidgets import (
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
+ QPushButton, QTreeWidget, QTreeWidgetItem, QTextEdit, QLineEdit,
+ QSplitter, QLabel, QCheckBox, QSpinBox,
+ QProgressBar, QStatusBar, QMessageBox, QMenu, QComboBox,
+ QDialog, QFormLayout, QDialogButtonBox, QTabWidget, QGridLayout
+)
+from PySide6.QtCore import Qt, QThread, Signal
+from PySide6.QtGui import QFont, QColor, QTextCharFormat, QTextCursor, QAction, QIcon
+from .search_engine import SearchEngine, SearchMatch
+
+
+class PreferencesDialog(QDialog):
+ """Preferences dialog window"""
+
+ def __init__(self, parent, preferences):
+ super().__init__(parent)
+ self.setWindowTitle("Preferences")
+ self.setModal(True)
+ self.preferences = preferences.copy()
+
+ # Create layout
+ layout = QVBoxLayout()
+ form = QFormLayout()
+
+ # Max results
+ self.max_results_input = QSpinBox()
+ self.max_results_input.setRange(0, 1000000)
+ self.max_results_input.setValue(preferences['max_results'])
+ self.max_results_input.setSpecialValueText("Unlimited")
+ self.max_results_input.setToolTip("Maximum search results to return (0 = unlimited)")
+ form.addRow("Max Search Results:", self.max_results_input)
+
+ # Max preview file size
+ self.max_preview_size_input = QSpinBox()
+ self.max_preview_size_input.setRange(1, 1000)
+ self.max_preview_size_input.setValue(preferences['max_preview_file_size_mb'])
+ self.max_preview_size_input.setSuffix(" MB")
+ self.max_preview_size_input.setToolTip("Maximum file size to display in preview")
+ form.addRow("Max Preview File Size:", self.max_preview_size_input)
+
+ # Max search file size
+ self.max_search_size_input = QSpinBox()
+ self.max_search_size_input.setRange(1, 1000)
+ self.max_search_size_input.setValue(preferences['max_search_file_size_mb'])
+ self.max_search_size_input.setSuffix(" MB")
+ self.max_search_size_input.setToolTip("Maximum file size to search through")
+ form.addRow("Max Search File Size:", self.max_search_size_input)
+
+ # Max cache size
+ self.max_cache_input = QSpinBox()
+ self.max_cache_input.setRange(0, 500)
+ self.max_cache_input.setValue(preferences['max_cache_size'])
+ self.max_cache_input.setSpecialValueText("Disabled")
+ self.max_cache_input.setToolTip("Maximum number of files to cache in memory")
+ form.addRow("File Cache Size:", self.max_cache_input)
+
+ layout.addLayout(form)
+
+ # Buttons
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
+ buttons.accepted.connect(self.accept)
+ buttons.rejected.connect(self.reject)
+ layout.addWidget(buttons)
+
+ self.setLayout(layout)
+ self.resize(400, 250)
+
+ def get_preferences(self):
+ """Get updated preferences from dialog"""
+ return {
+ 'max_results': self.max_results_input.value(),
+ 'max_preview_file_size_mb': self.max_preview_size_input.value(),
+ 'max_search_file_size_mb': self.max_search_size_input.value(),
+ 'max_cache_size': self.max_cache_input.value()
+ }
+
+
+class HelpDialog(QDialog):
+ """Comprehensive help dialog window"""
+
+ def __init__(self, parent):
+ super().__init__(parent)
+ self.setWindowTitle("Help - Advanced Search Tool")
+ self.setModal(False)
+
+ # Create layout
+ layout = QVBoxLayout()
+
+ # Create tab widget for different help sections
+ tabs = QTabWidget()
+
+ # Overview tab
+ overview_text = QTextEdit()
+ overview_text.setReadOnly(True)
+ overview_text.setHtml("""
+ Advanced Search Tool - Overview
+ A powerful grep-style search application for Windows with advanced features including regex patterns,
+ metadata search, and result sorting.
+
+ Quick Start
+
+ - Select a directory or file in the File Explorer (left panel)
+ - Enter your search pattern in the search box
+ - Configure search options (case sensitive, regex, whole word, etc.)
+ - Click Search or press Enter
+ - View results in the middle panel, preview on the right
+
+
+ Three-Panel Layout
+
+ - Left Panel: File Explorer - Navigate directories and select search locations
+ - Middle Panel: Results Tree - Shows files with matches and match counts
+ - Right Panel: Preview Pane - Displays file content with highlighted matches
+
+ """)
+ tabs.addTab(overview_text, "Overview")
+
+ # Search Options tab
+ options_text = QTextEdit()
+ options_text.setReadOnly(True)
+ options_text.setHtml("""
+ Search Options
+
+ Basic Options
+
+ | Option | Description |
+ | Case Sensitive | Match exact letter case (A ≠ a) |
+ | Use Regex | Enable regular expression pattern matching |
+ | Whole Word | Only match complete words, not partial matches |
+ | Context Lines | Show 0-10 lines before/after each match |
+ | File Extensions | Filter by file types (e.g., .py,.txt,.js) |
+
+
+ Metadata Search
+ Search Image Metadata: Search EXIF, GPS, and PNG metadata in JPG, PNG, TIFF, GIF, BMP, WebP files
+ Search File Metadata: Search properties in documents, audio, video, archives, and structured data:
+
+ - Documents: PDF, Word, Excel, PowerPoint, OpenDocument, eBooks, RTF
+ - Screenwriting: Final Draft (.fdx), Fountain (.fountain), Celtx (.celtx)
+ - Archives: ZIP, EPUB
+ - Structured Data: CSV, JSON, XML
+ - Databases: SQLite (.db, .sqlite, .sqlite3) - schema and table info
+ - Media: Audio (MP3, FLAC, M4A, OGG, WMA) and Video (MP4, AVI, MKV, MOV, WMV)
+
+ Note: When metadata search is enabled, ONLY metadata is searched, not file contents.
+
+ Advanced Search Modes
+
+ | Mode | Description |
+ | Search in Archives | Search inside ZIP and EPUB files without extraction. Results show as "archive.zip/internal/path.txt" |
+ | Binary/Hex Search | Search binary files for hex patterns. Results show byte offsets and hex dumps |
+
+
+ Result Sorting
+ Use the Sort dropdown to organize results by:
+
+ - Path (A-Z or Z-A)
+ - Match Count (High-Low or Low-High)
+ - File Size (Large-Small or Small-Large)
+ - Date Modified (Newest or Oldest)
+
+ """)
+ tabs.addTab(options_text, "Search Options")
+
+ # Regex Patterns tab
+ regex_text = QTextEdit()
+ regex_text.setReadOnly(True)
+ regex_text.setHtml("""
+ Regex Patterns
+
+ Using the Regex Patterns Menu
+ Click the "Regex Patterns ▼" button to access common regex patterns:
+
+ - Click the button to open the patterns menu
+ - Check one or more patterns to enable them
+ - The search box will update with the combined pattern
+ - Uncheck patterns or click "Clear All" to reset
+
+
+ Available Patterns
+
+ | Pattern | Description | Example Matches |
+ | Email Addresses | Standard email format | user@example.com |
+ | URLs | HTTP/HTTPS web addresses | https://example.com |
+ | IPv4 Addresses | IP addresses | 192.168.1.1 |
+ | Phone Numbers | Various phone formats | (555) 123-4567 |
+ | Dates | Various date formats | 2024-01-15, 01/15/2024 |
+ | Numbers | Integer numbers | 123, 4567 |
+ | Hex Values | Hexadecimal notation | 0xFF, #A3B5C7 |
+ | Words/Identifiers | Programming identifiers | variable_name, camelCase |
+
+
+ Custom Regex
+ Enable "Use Regex" checkbox and enter your own regular expression patterns.
+ Common Regex Syntax:
+
+ - . - Any character
+ - * - Zero or more of previous
+ - + - One or more of previous
+ - ? - Zero or one of previous
+ - [abc] - Any of a, b, or c
+ - [a-z] - Any lowercase letter
+ - \\d - Any digit
+ - \\w - Any word character
+ - ^ - Start of line
+ - $ - End of line
+
+ """)
+ tabs.addTab(regex_text, "Regex Patterns")
+
+ # Keyboard Shortcuts tab
+ shortcuts_text = QTextEdit()
+ shortcuts_text.setReadOnly(True)
+ shortcuts_text.setHtml("""
+ Keyboard Shortcuts
+
+ Search & Navigation
+
+ | Shortcut | Action |
+ | Enter | Start search (when in search box) |
+ | Ctrl+Up | Go to previous match in preview |
+ | Ctrl+Down | Go to next match in preview |
+
+
+ Application
+
+ | Shortcut | Action |
+ | Ctrl+Q | Quit application |
+
+
+ Mouse Actions
+
+ | Action | Result |
+ | Single Click on result | Show preview in right panel |
+ | Double Click on result | Open file in default application |
+ | Right Click on result | Show context menu with options |
+ | Right Click on directory | Show directory context menu |
+
+ """)
+ tabs.addTab(shortcuts_text, "Shortcuts")
+
+ # Context Menu tab
+ context_text = QTextEdit()
+ context_text.setReadOnly(True)
+ context_text.setHtml("""
+ Context Menus
+
+ Results Tree Context Menu
+ Right-click on any result to access:
+
+ - Open File - Open in default application
+ - Open in VS Code - Open in Visual Studio Code (if installed)
+ - Copy Full Path - Copy file path to clipboard
+ - Copy File Name - Copy just the file name
+ - Open Containing Folder - Open folder in Windows Explorer
+
+
+ Directory Tree Context Menu
+ Right-click on a directory to:
+
+ - Search in Directory - Set as search location
+ - Open in Explorer - Open in Windows Explorer
+ - Copy Path - Copy directory path to clipboard
+ - Refresh - Reload directory contents
+
+ """)
+ tabs.addTab(context_text, "Context Menus")
+
+ # Tips & Tricks tab
+ tips_text = QTextEdit()
+ tips_text.setReadOnly(True)
+ tips_text.setHtml("""
+ Tips & Tricks
+
+ Performance Tips
+
+ - Use file extensions filter to limit search scope (.py,.txt,.js)
+ - Adjust max file size in Preferences for faster searches
+ - Enable metadata search only when needed - it adds processing time
+ - Use specific patterns instead of broad searches
+ - Sort by match count to find files with most occurrences first
+
+
+ Search Strategies
+
+ - Start broad, then refine - Do a general search, then add filters
+ - Use whole word to avoid partial matches in variable names
+ - Combine regex patterns to find multiple items at once
+ - Search metadata to find files by author, date, or properties
+ - Use context lines to see surrounding code/text
+
+
+ Metadata Search Examples
+
+ - Find photos by camera: Enable image metadata, search "Canon" or "Nikon"
+ - Find documents by author: Enable file metadata, search author name
+ - Find geotagged images: Enable image metadata, search "GPS"
+ - Find audio by artist: Enable file metadata, search artist name
+ - Find screenplays by title: Enable file metadata, search in .fdx or .fountain files
+
+
+ Working with Results
+
+ - Use Previous/Next buttons to navigate between matches in preview
+ - Match counter shows current position (e.g., "3 / 15")
+ - Matches are highlighted - yellow for all, orange for current
+ - Search history provides autocomplete from previous searches
+ - Results persist until next search - you can explore freely
+
+
+ File Browser Tips
+
+ - Directories load on demand - expand folders to see contents
+ - Search in specific file - click a file instead of a folder
+ - Refresh directory - right-click for latest contents
+ - Drive letters shown - start from any drive on Windows
+
+ """)
+ tabs.addTab(tips_text, "Tips & Tricks")
+
+ # Troubleshooting tab
+ trouble_text = QTextEdit()
+ trouble_text.setReadOnly(True)
+ trouble_text.setHtml("""
+ Troubleshooting
+
+ Common Issues
+
+ Q: Search is slow or hangs
+
+ - Reduce Max Search File Size in Preferences
+ - Add file extension filters to limit scope
+ - Disable metadata search if not needed
+ - Search in smaller directories first
+ - Click Stop to cancel long-running searches
+
+
+ Q: No results found
+
+ - Check Case Sensitive option - try disabling it
+ - Verify file extension filter isn't excluding target files
+ - Make sure you're searching in the right directory
+ - If using regex, verify pattern syntax is correct
+ - Check if metadata search is enabled when searching content
+
+
+ Q: Preview shows garbled text
+
+ - File may have different encoding (UTF-8, ASCII, etc.)
+ - Binary files won't display properly in text preview
+ - Very large files may be truncated
+ - Increase Max Preview File Size in Preferences if needed
+
+
+ Q: Metadata search not working
+
+ - Ensure the file type is supported (see Search Options tab)
+ - Not all files contain metadata - depends on creation method
+ - Required library must be installed (Pillow, PyPDF2, etc.)
+ - Some formats require specific libraries
+
+
+ Q: Can't open file in VS Code
+
+ - Visual Studio Code must be installed
+ - VS Code must be in system PATH
+ - Try "Open File" to use default application instead
+
+
+ Performance Notes
+
+ - File cache speeds up repeated access to same files
+ - Background threading prevents UI freezing during search
+ - Max results limit can be set in Preferences (0 = unlimited)
+ - Excluded patterns skip .git, node_modules, __pycache__, etc.
+
+
+ Getting Help
+ For additional support:
+
+ - Check the README.md file in the installation directory
+ - Visit the project repository for issues and updates
+ - Review CONTRIBUTING.md for development information
+
+ """)
+ tabs.addTab(trouble_text, "Troubleshooting")
+
+ layout.addWidget(tabs)
+
+ # Close button
+ close_btn = QPushButton("Close")
+ close_btn.clicked.connect(self.accept)
+ layout.addWidget(close_btn)
+
+ self.setLayout(layout)
+ self.resize(800, 600)
+
+
+class CustomPatternManagerDialog(QDialog):
+ """Dialog for managing custom regex patterns"""
+
+ def __init__(self, parent, custom_patterns):
+ super().__init__(parent)
+ self.setWindowTitle("Manage Custom Regex Patterns")
+ self.setModal(True)
+ self.custom_patterns = custom_patterns.copy()
+
+ # Create layout
+ layout = QVBoxLayout()
+
+ # Instructions
+ inst_label = QLabel("Add, edit, or remove your custom regex patterns:")
+ layout.addWidget(inst_label)
+
+ # List of patterns
+ self.pattern_list = QTreeWidget()
+ self.pattern_list.setHeaderLabels(["Label", "Pattern"])
+ self.pattern_list.setColumnWidth(0, 200)
+ self.pattern_list.setColumnWidth(1, 400)
+ self.refresh_pattern_list()
+ layout.addWidget(self.pattern_list)
+
+ # Buttons
+ button_layout = QHBoxLayout()
+
+ add_btn = QPushButton("Add Pattern")
+ add_btn.clicked.connect(self.add_pattern)
+ button_layout.addWidget(add_btn)
+
+ edit_btn = QPushButton("Edit Selected")
+ edit_btn.clicked.connect(self.edit_pattern)
+ button_layout.addWidget(edit_btn)
+
+ remove_btn = QPushButton("Remove Selected")
+ remove_btn.clicked.connect(self.remove_pattern)
+ button_layout.addWidget(remove_btn)
+
+ button_layout.addStretch()
+ layout.addLayout(button_layout)
+
+ # Dialog buttons
+ dialog_buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
+ dialog_buttons.accepted.connect(self.accept)
+ dialog_buttons.rejected.connect(self.reject)
+ layout.addWidget(dialog_buttons)
+
+ self.setLayout(layout)
+ self.resize(700, 400)
+
+ def refresh_pattern_list(self):
+ """Refresh the pattern list widget"""
+ self.pattern_list.clear()
+ for name, info in self.custom_patterns.items():
+ item = QTreeWidgetItem(self.pattern_list)
+ item.setText(0, info['label'])
+ item.setText(1, info['pattern'])
+ item.setData(0, Qt.UserRole, name)
+
+ def add_pattern(self):
+ """Add a new custom pattern"""
+ dialog = CustomPatternEditDialog(self, "", "", "")
+ if dialog.exec() == QDialog.Accepted:
+ name, label, pattern = dialog.get_pattern()
+ if name and label and pattern:
+ # Generate unique name
+ base_name = name.lower().replace(' ', '_')
+ unique_name = base_name
+ counter = 1
+ while unique_name in self.custom_patterns:
+ unique_name = f"{base_name}_{counter}"
+ counter += 1
+
+ self.custom_patterns[unique_name] = {
+ 'pattern': pattern,
+ 'enabled': False,
+ 'label': label
+ }
+ self.refresh_pattern_list()
+
+ def edit_pattern(self):
+ """Edit the selected pattern"""
+ selected_items = self.pattern_list.selectedItems()
+ if not selected_items:
+ QMessageBox.warning(self, "Warning", "Please select a pattern to edit")
+ return
+
+ item = selected_items[0]
+ name = item.data(0, Qt.UserRole)
+ info = self.custom_patterns[name]
+
+ dialog = CustomPatternEditDialog(self, name, info['label'], info['pattern'])
+ if dialog.exec() == QDialog.Accepted:
+ _, label, pattern = dialog.get_pattern()
+ if label and pattern:
+ self.custom_patterns[name]['label'] = label
+ self.custom_patterns[name]['pattern'] = pattern
+ self.refresh_pattern_list()
+
+ def remove_pattern(self):
+ """Remove the selected pattern"""
+ selected_items = self.pattern_list.selectedItems()
+ if not selected_items:
+ QMessageBox.warning(self, "Warning", "Please select a pattern to remove")
+ return
+
+ item = selected_items[0]
+ name = item.data(0, Qt.UserRole)
+
+ reply = QMessageBox.question(
+ self,
+ "Confirm Removal",
+ f"Remove pattern '{self.custom_patterns[name]['label']}'?",
+ QMessageBox.Yes | QMessageBox.No
+ )
+
+ if reply == QMessageBox.Yes:
+ del self.custom_patterns[name]
+ self.refresh_pattern_list()
+
+ def get_custom_patterns(self):
+ """Return the updated custom patterns"""
+ return self.custom_patterns
+
+
+class CustomPatternEditDialog(QDialog):
+ """Dialog for editing a single custom pattern"""
+
+ def __init__(self, parent, name, label, pattern):
+ super().__init__(parent)
+ self.setWindowTitle("Edit Custom Pattern" if name else "Add Custom Pattern")
+ self.setModal(True)
+
+ # Create layout
+ layout = QVBoxLayout()
+ form = QFormLayout()
+
+ # Name field (only for new patterns)
+ if not name:
+ self.name_input = QLineEdit()
+ self.name_input.setPlaceholderText("e.g., my_pattern")
+ form.addRow("Name:", self.name_input)
+ else:
+ self.name_input = None
+
+ # Label field
+ self.label_input = QLineEdit()
+ self.label_input.setText(label)
+ self.label_input.setPlaceholderText("e.g., My Custom Pattern")
+ form.addRow("Label:", self.label_input)
+
+ # Pattern field
+ self.pattern_input = QLineEdit()
+ self.pattern_input.setText(pattern)
+ self.pattern_input.setPlaceholderText(r"e.g., \\b[A-Z]{3}-\\d{4}\\b")
+ form.addRow("Regex Pattern:", self.pattern_input)
+
+ layout.addLayout(form)
+
+ # Example/help text
+ help_label = QLabel(
+ "Examples:
" +
+ "• \\\\b[A-Z]{3}-\\\\d{4}\\\\b - Match ABC-1234 format
" +
+ "• TODO:|FIXME: - Find code comments
" +
+ "• \\\\$\\\\d+\\\\.\\\\d{2} - Match currency amounts
" +
+ ""
+ )
+ help_label.setWordWrap(True)
+ layout.addWidget(help_label)
+
+ # Buttons
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
+ buttons.accepted.connect(self.accept)
+ buttons.rejected.connect(self.reject)
+ layout.addWidget(buttons)
+
+ self.setLayout(layout)
+ self.resize(500, 250)
+
+ def get_pattern(self):
+ """Return the pattern data (name, label, pattern)"""
+ name = self.name_input.text().strip() if self.name_input else ""
+ label = self.label_input.text().strip()
+ pattern = self.pattern_input.text().strip()
+ return name, label, pattern
+
+
+class SearchWorker(QThread):
+ """Worker thread for performing searches"""
+ finished = Signal(list) # all results
+
+ def __init__(self, search_engine, root_path, pattern):
+ super().__init__()
+ self.search_engine = search_engine
+ self.root_path = root_path
+ self.pattern = pattern
+ self._is_running = True
+
+ def run(self):
+ """Run the search in background thread"""
+ results = self.search_engine.search(self.root_path, self.pattern)
+ if self._is_running:
+ self.finished.emit(results)
+
+ def stop(self):
+ """Stop the search"""
+ self._is_running = False
+
+
+class MainWindow(QMainWindow):
+ """Main application window"""
+
+ # Class constants for file extensions
+ IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.gif', '.bmp', '.webp'}
+ FILE_METADATA_EXTENSIONS = {'.pdf', '.docx', '.xlsx', '.pptx', '.mp3', '.flac', '.m4a', '.mp4', '.avi', '.mkv'}
+
+ def __init__(self):
+ super().__init__()
+ self.search_engine = SearchEngine()
+ self.search_worker = None
+ self.current_results = []
+ self.current_directory = os.path.expanduser("~")
+ self.current_search_pattern = ""
+ self.current_file_matches = []
+ self.current_match_index = 0
+ self.search_history = []
+ self.history_file = os.path.join(os.path.expanduser("~"), ".advanced_search_history.json")
+ self.preferences_file = os.path.join(os.path.expanduser("~"), ".advanced_search_preferences.json")
+ self.custom_patterns_file = os.path.join(os.path.expanduser("~"), ".advanced_search_custom_patterns.json")
+
+ # Default preferences
+ self.preferences = {
+ 'max_results': 0, # 0 = unlimited
+ 'max_preview_file_size_mb': 10,
+ 'max_search_file_size_mb': 50,
+ 'max_cache_size': 50
+ }
+ self.load_preferences()
+
+ # Performance caches
+ self.file_cache = {} # Cache file contents {path: (size, content_lines)}
+ self.max_cache_size = self.preferences['max_cache_size']
+ self.max_file_size = self.preferences['max_preview_file_size_mb'] * 1024 * 1024
+ self.parsed_extensions = [] # Cached parsed extensions
+
+ # Regex pattern options
+ self.regex_patterns = {
+ 'emails': {'pattern': r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', 'enabled': False, 'label': 'Email addresses'},
+ 'urls': {'pattern': r'https?://[^\s]+', 'enabled': False, 'label': 'URLs (http/https)'},
+ 'ipv4': {'pattern': r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b', 'enabled': False, 'label': 'IPv4 addresses'},
+ 'phone': {'pattern': r'\b(?:\+?1[-.]?)?(?:\(?[0-9]{3}\)?[-.]?)?[0-9]{3}[-.]?[0-9]{4}\b', 'enabled': False, 'label': 'Phone numbers'},
+ 'dates': {'pattern': r'\b\d{1,4}[-/.]\d{1,2}[-/.]\d{1,4}\b', 'enabled': False, 'label': 'Dates (various formats)'},
+ 'numbers': {'pattern': r'\b\d+\b', 'enabled': False, 'label': 'Numbers'},
+ 'hex': {'pattern': r'\b0x[0-9A-Fa-f]+\b|#[0-9A-Fa-f]{6}\b', 'enabled': False, 'label': 'Hex values'},
+ 'words': {'pattern': r'\b[A-Za-z_]\w*\b', 'enabled': False, 'label': 'Words/identifiers'},
+ }
+ self.regex_menu = None # Track the menu instance
+ self.regex_menu_open = False # Track menu state
+
+ # Custom user-defined patterns
+ self.custom_patterns = {} # {name: {'pattern': str, 'enabled': bool, 'label': str}}
+ self.load_custom_patterns()
+
+ # Apply preferences to search engine
+ self.search_engine.max_results = self.preferences['max_results']
+ self.search_engine.max_search_file_size = self.preferences['max_search_file_size_mb'] * 1024 * 1024
+
+ self.load_search_history()
+
+ self.init_ui()
+ self.create_menu_bar()
+ self.setWindowTitle("Advanced Search Tool")
+
+ # Set application icon
+ icon_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets", "icon.svg")
+ if os.path.exists(icon_path):
+ self.setWindowIcon(QIcon(icon_path))
+
+ self.resize(1400, 900)
+
+ def init_ui(self):
+ """Initialize the user interface"""
+ # Create central widget
+ central_widget = QWidget()
+ self.setCentralWidget(central_widget)
+
+ # Main layout
+ main_layout = QVBoxLayout(central_widget)
+
+ # Combined search and options bar
+ search_options_layout = self.create_search_and_options()
+ main_layout.addLayout(search_options_layout)
+
+ # Main content area with three panels
+ splitter = QSplitter(Qt.Horizontal)
+
+ # Left panel - Directory tree
+ dir_widget = QWidget()
+ dir_layout = QVBoxLayout(dir_widget)
+ dir_layout.setContentsMargins(0, 0, 0, 0)
+
+ dir_label = QLabel("File Explorer:")
+ dir_label.setStyleSheet("font-weight: bold; padding: 5px;")
+ dir_label.setToolTip("Select a directory or file to search")
+ dir_layout.addWidget(dir_label)
+
+ self.dir_tree = QTreeWidget()
+ self.dir_tree.setHeaderLabels(["Name"])
+ self.dir_tree.setToolTip("Click a folder to search recursively or a file to search in that file\nRight-click for options")
+ self.dir_tree.itemClicked.connect(self.on_dir_selected)
+ self.dir_tree.setContextMenuPolicy(Qt.CustomContextMenu)
+ self.dir_tree.customContextMenuRequested.connect(self.show_dir_context_menu)
+ self.dir_tree.itemExpanded.connect(self.on_dir_expanded)
+ self.populate_directory_tree()
+ dir_layout.addWidget(self.dir_tree)
+
+ splitter.addWidget(dir_widget)
+
+ # Middle panel - Results tree
+ results_widget = QWidget()
+ results_layout = QVBoxLayout(results_widget)
+ results_layout.setContentsMargins(0, 0, 0, 0)
+
+ # Results header with sort controls
+ results_header = QHBoxLayout()
+ results_label = QLabel("Search Results:")
+ results_label.setStyleSheet("font-weight: bold; padding: 5px;")
+ results_label.setToolTip("Files and matches found in search")
+ results_header.addWidget(results_label)
+
+ results_header.addStretch()
+
+ # Sort dropdown
+ sort_label = QLabel("Sort:")
+ results_header.addWidget(sort_label)
+ self.sort_combo = QComboBox()
+ self.sort_combo.addItems([
+ "Path (A-Z)",
+ "Path (Z-A)",
+ "Match Count (High-Low)",
+ "Match Count (Low-High)",
+ "File Size (Large-Small)",
+ "File Size (Small-Large)",
+ "Date Modified (Newest)",
+ "Date Modified (Oldest)"
+ ])
+ self.sort_combo.setToolTip("Sort search results")
+ self.sort_combo.currentIndexChanged.connect(self.apply_sort)
+ results_header.addWidget(self.sort_combo)
+
+ results_layout.addLayout(results_header)
+
+ self.results_tree = QTreeWidget()
+ self.results_tree.setHeaderLabels(["File", "Matches"])
+ self.results_tree.setColumnWidth(0, 400)
+ self.results_tree.setToolTip("Click to preview, double-click to open file, right-click for options")
+ self.results_tree.itemClicked.connect(self.on_tree_item_clicked)
+ self.results_tree.itemDoubleClicked.connect(self.on_item_double_clicked)
+ self.results_tree.setContextMenuPolicy(Qt.CustomContextMenu)
+ self.results_tree.customContextMenuRequested.connect(self.show_context_menu)
+ results_layout.addWidget(self.results_tree)
+
+ splitter.addWidget(results_widget)
+
+ # Right panel - Content preview
+ preview_widget = QWidget()
+ preview_layout = QVBoxLayout(preview_widget)
+ preview_layout.setContentsMargins(0, 0, 0, 0)
+
+ # Preview header with navigation
+ preview_header = QHBoxLayout()
+ preview_label = QLabel("Preview:")
+ preview_label.setStyleSheet("font-weight: bold; padding: 5px;")
+ preview_label.setToolTip("File content preview with matched lines highlighted")
+ preview_header.addWidget(preview_label)
+
+ preview_header.addStretch()
+
+ # Match navigation controls
+ assets_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets")
+ prev_icon_path = os.path.join(assets_dir, "chevron_up.svg")
+ self.prev_match_btn = QPushButton()
+ if os.path.exists(prev_icon_path):
+ self.prev_match_btn.setIcon(QIcon(prev_icon_path))
+ else:
+ self.prev_match_btn.setText("◄")
+ self.prev_match_btn.setMaximumWidth(30)
+ self.prev_match_btn.setToolTip("Go to previous match (Ctrl+Up)")
+ self.prev_match_btn.clicked.connect(self.go_to_previous_match)
+ self.prev_match_btn.setEnabled(False)
+ preview_header.addWidget(self.prev_match_btn)
+
+ self.match_counter_label = QLabel("0/0")
+ self.match_counter_label.setStyleSheet("padding: 0 10px;")
+ self.match_counter_label.setToolTip("Current match / Total matches")
+ preview_header.addWidget(self.match_counter_label)
+
+ next_icon_path = os.path.join(assets_dir, "chevron_down.svg")
+ self.next_match_btn = QPushButton()
+ if os.path.exists(next_icon_path):
+ self.next_match_btn.setIcon(QIcon(next_icon_path))
+ else:
+ self.next_match_btn.setText("►")
+ self.next_match_btn.setMaximumWidth(30)
+ self.next_match_btn.setToolTip("Go to next match (Ctrl+Down)")
+ self.next_match_btn.clicked.connect(self.go_to_next_match)
+ self.next_match_btn.setEnabled(False)
+ preview_header.addWidget(self.next_match_btn)
+
+ preview_layout.addLayout(preview_header)
+
+ self.preview_text = QTextEdit()
+ self.preview_text.setReadOnly(True)
+ self.preview_text.setFont(QFont("Consolas", 10))
+ self.preview_text.setToolTip("File preview - search terms are highlighted in yellow")
+ preview_layout.addWidget(self.preview_text)
+
+ splitter.addWidget(preview_widget)
+ splitter.setSizes([300, 400, 700])
+
+ main_layout.addWidget(splitter)
+
+ # Progress bar
+ self.progress_bar = QProgressBar()
+ self.progress_bar.setVisible(False)
+ main_layout.addWidget(self.progress_bar)
+
+ # Status bar
+ self.status_bar = QStatusBar()
+ self.setStatusBar(self.status_bar)
+ self.status_bar.showMessage("Ready")
+
+ def create_menu_bar(self):
+ """Create application menu bar"""
+ menubar = self.menuBar()
+
+ # File menu
+ file_menu = menubar.addMenu("&File")
+
+ exit_action = QAction("E&xit", self)
+ exit_action.setShortcut("Ctrl+Q")
+ exit_action.triggered.connect(self.close)
+ file_menu.addAction(exit_action)
+
+ # Settings menu
+ settings_menu = menubar.addMenu("&Settings")
+
+ preferences_action = QAction("Preferences...", self)
+ preferences_action.triggered.connect(self.show_preferences)
+ settings_menu.addAction(preferences_action)
+
+ settings_menu.addSeparator()
+
+ clear_history_action = QAction("Clear Search History", self)
+ clear_history_action.triggered.connect(self.clear_search_history)
+ settings_menu.addAction(clear_history_action)
+
+ # Help menu
+ help_menu = menubar.addMenu("&Help")
+
+ help_action = QAction("Help...", self)
+ help_action.setShortcut("F1")
+ help_action.triggered.connect(self.show_help)
+ help_menu.addAction(help_action)
+
+ help_menu.addSeparator()
+
+ about_action = QAction("About", self)
+ about_action.triggered.connect(self.show_about)
+ help_menu.addAction(about_action)
+
+ def create_search_and_options(self):
+ """Create combined search and options controls"""
+ layout = QHBoxLayout()
+
+ # Search for label and input
+ search_label = QLabel("Search for:")
+ search_label.setToolTip("Enter text or regular expression pattern to search for")
+ layout.addWidget(search_label)
+
+ self.search_input = QComboBox()
+ self.search_input.setEditable(True)
+ self.search_input.setInsertPolicy(QComboBox.NoInsert)
+ self.search_input.lineEdit().setPlaceholderText("Enter search pattern...")
+ self.search_input.setToolTip("Text or regex pattern to find in files\nPress Enter to start search\nUse Up/Down arrows to cycle through history")
+ self.search_input.lineEdit().returnPressed.connect(self.start_search)
+ self.search_input.setMinimumWidth(250)
+ self.search_input.setMaxVisibleItems(10)
+ # Enable auto-completion
+ self.search_input.setCompleter(self.search_input.completer())
+ self.update_search_history_dropdown()
+ layout.addWidget(self.search_input)
+
+ # Multi-modal Search/Stop button
+ self.search_stop_btn = QPushButton("Search")
+ self.search_stop_btn.setToolTip("Start searching in the selected directory (Enter)")
+ self.search_stop_btn.clicked.connect(self.toggle_search)
+ self.search_stop_btn.setDefault(True)
+ self.is_searching = False
+ layout.addWidget(self.search_stop_btn)
+
+ # Separator
+ layout.addSpacing(10)
+
+ # Compact grid layout for all controls
+ controls_grid = QGridLayout()
+ controls_grid.setSpacing(8)
+ controls_grid.setContentsMargins(0, 0, 0, 0)
+
+ # Row 0: Context, Regex, Case sensitive, Whole word, Image metadata
+ context_label = QLabel("Context:")
+ context_label.setToolTip("Number of lines to show before and after each match")
+ controls_grid.addWidget(context_label, 0, 0)
+
+ self.context_combo = QComboBox()
+ for i in range(11): # 0 to 10
+ self.context_combo.addItem(str(i))
+ self.context_combo.setCurrentIndex(2) # Default to 2
+ self.context_combo.setToolTip("Lines of context to show around matches (0-10)")
+ self.context_combo.currentIndexChanged.connect(
+ lambda index: self.search_engine.set_context_lines(index)
+ )
+ self.context_combo.setMinimumWidth(50)
+ self.context_combo.setMaximumWidth(70)
+ controls_grid.addWidget(self.context_combo, 0, 1)
+
+ # Regex pattern selector button
+ assets_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets")
+ chevron_icon_path = os.path.join(assets_dir, "chevron_down.svg")
+ self.regex_btn = QPushButton("Regex")
+ if os.path.exists(chevron_icon_path):
+ self.regex_btn.setIcon(QIcon(chevron_icon_path))
+ self.regex_btn.setLayoutDirection(Qt.RightToLeft) # Icon on the right
+ self.regex_btn.setToolTip("Select common regex patterns to search for")
+ self.regex_btn.setMaximumWidth(100)
+ self.regex_btn.clicked.connect(self.show_regex_patterns_menu)
+ controls_grid.addWidget(self.regex_btn, 0, 2)
+
+ self.case_sensitive_cb = QCheckBox("Case sensitive")
+ self.case_sensitive_cb.setToolTip("Match exact case when searching")
+ self.case_sensitive_cb.stateChanged.connect(
+ lambda state: self.search_engine.set_case_sensitive(state == Qt.Checked)
+ )
+ controls_grid.addWidget(self.case_sensitive_cb, 0, 3)
+
+ self.whole_word_cb = QCheckBox("Whole word")
+ self.whole_word_cb.setToolTip("Only match complete words (not partial matches)")
+ self.whole_word_cb.stateChanged.connect(
+ lambda state: self.search_engine.set_whole_word(state == Qt.Checked)
+ )
+ controls_grid.addWidget(self.whole_word_cb, 0, 4)
+
+ self.metadata_cb = QCheckBox("Image metadata")
+ self.metadata_cb.setToolTip("Search image metadata (EXIF, GPS, etc.) for JPG, PNG, TIFF files")
+ self.metadata_cb.stateChanged.connect(
+ lambda state: self.search_engine.set_search_metadata(state == 2)
+ )
+ controls_grid.addWidget(self.metadata_cb, 0, 5)
+
+ # Row 1: Extensions label/input, File metadata, Archive search, Binary/hex
+ ext_label = QLabel("Extensions:")
+ ext_label.setToolTip("Filter files by extension (leave empty to search all files)")
+ controls_grid.addWidget(ext_label, 1, 0)
+
+ self.extensions_input = QLineEdit()
+ self.extensions_input.setPlaceholderText(".py,.txt,.js")
+ self.extensions_input.setToolTip("Comma-separated file extensions to search\nExample: .py,.txt,.js\nLeave empty to search all files")
+ self.extensions_input.setMaximumWidth(150)
+ controls_grid.addWidget(self.extensions_input, 1, 1, 1, 2)
+
+ self.file_metadata_cb = QCheckBox("File metadata")
+ self.file_metadata_cb.setToolTip("Search file properties (PDF, Office docs, audio/video files): author, title, dates, etc.")
+ self.file_metadata_cb.stateChanged.connect(
+ lambda state: self.search_engine.set_search_file_metadata(state == 2)
+ )
+ controls_grid.addWidget(self.file_metadata_cb, 1, 3)
+
+ self.archive_search_cb = QCheckBox("Archive search")
+ self.archive_search_cb.setToolTip("Search inside ZIP and EPUB files without extraction")
+ self.archive_search_cb.stateChanged.connect(
+ lambda state: self.search_engine.set_search_in_archives(state == 2)
+ )
+ controls_grid.addWidget(self.archive_search_cb, 1, 4)
+
+ self.hex_search_cb = QCheckBox("Binary/hex")
+ self.hex_search_cb.setToolTip("Search binary files using hex patterns")
+ self.hex_search_cb.stateChanged.connect(
+ lambda state: self.search_engine.set_hex_search(state == 2)
+ )
+ controls_grid.addWidget(self.hex_search_cb, 1, 5)
+
+ layout.addLayout(controls_grid)
+
+ layout.addStretch()
+ return layout
+
+ def populate_directory_tree(self):
+ """Populate directory tree with common locations"""
+ self.dir_tree.setUpdatesEnabled(False) # Batch updates for performance
+ self.dir_tree.clear()
+
+ # Add common locations
+ home_path = os.path.expanduser("~")
+ username = os.path.basename(home_path)
+ home_item = QTreeWidgetItem(self.dir_tree)
+ home_item.setText(0, f"Home ({username})")
+ home_item.setData(0, Qt.UserRole, {"path": home_path, "is_file": False})
+ home_item.setExpanded(True)
+
+ # Add subdirectories and files of home
+ self._populate_tree_item(home_item, home_path)
+
+ # Add drives (Windows)
+ if os.name == 'nt':
+ import ctypes
+ for drive in string.ascii_uppercase:
+ drive_path = f"{drive}:\\\\"
+ if os.path.exists(drive_path):
+ # Get volume label
+ try:
+ volume_name = ctypes.create_unicode_buffer(256)
+ ctypes.windll.kernel32.GetVolumeInformationW(
+ ctypes.c_wchar_p(drive_path),
+ volume_name, ctypes.sizeof(volume_name),
+ None, None, None, None, 0
+ )
+ label = volume_name.value
+ display_text = f"{drive}: ({label})" if label else f"{drive}:"
+ except Exception:
+ display_text = f"{drive}:"
+
+ drive_item = QTreeWidgetItem(self.dir_tree)
+ drive_item.setText(0, display_text)
+ drive_item.setData(0, Qt.UserRole, {"path": drive_path, "is_file": False})
+ # Add placeholder for lazy loading
+ placeholder = QTreeWidgetItem(drive_item)
+ placeholder.setText(0, "Loading...")
+
+ self.dir_tree.setUpdatesEnabled(True) # Re-enable updates
+
+ def _populate_tree_item(self, parent_item, path, max_items=100):
+ """Populate a tree item with directories and files"""
+ try:
+ entries = []
+ for entry in os.scandir(path):
+ if not entry.name.startswith('.'):
+ entries.append(entry)
+
+ # Sort: directories first, then files
+ entries.sort(key=lambda e: (not e.is_dir(), e.name.lower()))
+
+ # Limit to prevent UI freeze
+ for entry in entries[:max_items]:
+ child_item = QTreeWidgetItem(parent_item)
+ if entry.is_dir():
+ child_item.setText(0, entry.name)
+ child_item.setData(0, Qt.UserRole, {"path": entry.path, "is_file": False})
+ # Add placeholder for lazy loading
+ placeholder = QTreeWidgetItem(child_item)
+ placeholder.setText(0, "Loading...")
+ else:
+ child_item.setText(0, entry.name)
+ child_item.setData(0, Qt.UserRole, {"path": entry.path, "is_file": True})
+ except PermissionError:
+ pass
+
+ def on_dir_expanded(self, item):
+ """Handle directory expansion - lazy load contents"""
+ data = item.data(0, Qt.UserRole)
+ if data and not data.get("is_file", False):
+ # Check if we have a placeholder
+ if item.childCount() == 1 and item.child(0).text(0) == "Loading...":
+ # Remove placeholder
+ item.removeChild(item.child(0))
+ # Load actual contents
+ self._populate_tree_item(item, data["path"])
+
+ def on_dir_selected(self, item, column):
+ """Handle directory or file selection"""
+ data = item.data(0, Qt.UserRole)
+ if data:
+ path = data["path"]
+ is_file = data.get("is_file", False)
+ self.current_directory = path
+ if is_file:
+ self.status_bar.showMessage(f"Selected file: {path}")
+ else:
+ self.status_bar.showMessage(f"Selected directory: {path}")
+
+ def show_regex_patterns_menu(self):
+ """Show or hide popup menu with regex pattern options"""
+ # If menu exists and is visible, close it and prevent reopening
+ if self.regex_menu is not None and self.regex_menu.isVisible():
+ self.regex_menu.close()
+ self.regex_menu_open = False
+ return
+
+ # If we just closed the menu, don't reopen immediately
+ if self.regex_menu_open:
+ return
+
+ # Mark menu as opening
+ self.regex_menu_open = True
+
+ # Create new menu
+ self.regex_menu = QMenu(self)
+ self.regex_menu.setToolTipsVisible(True)
+
+ # Add header
+ header_action = self.regex_menu.addAction("Select Regex Patterns:")
+ header_action.setEnabled(False)
+ self.regex_menu.addSeparator()
+
+ # Add checkbox for each pattern
+ for pattern_key, pattern_info in self.regex_patterns.items():
+ action = self.regex_menu.addAction(pattern_info['label'])
+ action.setCheckable(True)
+ action.setChecked(pattern_info['enabled'])
+ action.setToolTip(f"Pattern: {pattern_info['pattern']}")
+ action.triggered.connect(lambda checked, key=pattern_key: self.toggle_regex_pattern(key, checked))
+
+ # Add custom patterns section if any exist
+ if self.custom_patterns:
+ self.regex_menu.addSeparator()
+ custom_header = self.regex_menu.addAction("Custom Patterns:")
+ custom_header.setEnabled(False)
+
+ for pattern_key, pattern_info in self.custom_patterns.items():
+ action = self.regex_menu.addAction(pattern_info['label'])
+ action.setCheckable(True)
+ action.setChecked(pattern_info['enabled'])
+ action.setToolTip(f"Pattern: {pattern_info['pattern']}")
+ action.triggered.connect(lambda checked, key=pattern_key: self.toggle_custom_pattern(key, checked))
+
+ self.regex_menu.addSeparator()
+
+ # Add manage custom patterns option
+ manage_action = self.regex_menu.addAction("Manage Custom Patterns...")
+ manage_action.triggered.connect(self.show_custom_pattern_manager)
+
+ self.regex_menu.addSeparator()
+
+ # Add clear all option
+ clear_action = self.regex_menu.addAction("Clear All")
+ clear_action.triggered.connect(self.clear_all_regex_patterns)
+
+ # Clean up when menu is hidden/closed
+ def on_menu_hidden():
+ # Use a timer to delay the flag reset to avoid immediate reopening
+ from PySide6.QtCore import QTimer
+ QTimer.singleShot(200, lambda: setattr(self, 'regex_menu_open', False))
+
+ self.regex_menu.aboutToHide.connect(on_menu_hidden)
+
+ # Show menu below button using popup (non-blocking)
+ self.regex_menu.popup(self.regex_btn.mapToGlobal(self.regex_btn.rect().bottomLeft()))
+
+ def toggle_regex_pattern(self, pattern_key, enabled):
+ """Toggle a regex pattern on/off"""
+ self.regex_patterns[pattern_key]['enabled'] = enabled
+ self.update_search_with_regex_patterns()
+
+ # Update button text to show active patterns count
+ active_count = sum(1 for p in self.regex_patterns.values() if p['enabled'])
+ active_count += sum(1 for p in self.custom_patterns.values() if p['enabled'])
+ if active_count > 0:
+ self.regex_btn.setText(f"Regex Patterns ({active_count})")
+ self.regex_btn.setStyleSheet("font-weight: bold;")
+ else:
+ self.regex_btn.setText("Regex Patterns")
+ self.regex_btn.setStyleSheet("")
+
+ def toggle_custom_pattern(self, pattern_key, enabled):
+ """Toggle a custom regex pattern on/off"""
+ self.custom_patterns[pattern_key]['enabled'] = enabled
+ self.save_custom_patterns()
+ self.update_search_with_regex_patterns()
+
+ # Update button text to show active patterns count
+ active_count = sum(1 for p in self.regex_patterns.values() if p['enabled'])
+ active_count += sum(1 for p in self.custom_patterns.values() if p['enabled'])
+ if active_count > 0:
+ self.regex_btn.setText(f"Regex Patterns ({active_count})")
+ self.regex_btn.setStyleSheet("font-weight: bold;")
+ else:
+ self.regex_btn.setText("Regex Patterns")
+ self.regex_btn.setStyleSheet("")
+
+ def clear_all_regex_patterns(self):
+ """Clear all selected regex patterns"""
+ for pattern_info in self.regex_patterns.values():
+ pattern_info['enabled'] = False
+ for pattern_info in self.custom_patterns.values():
+ pattern_info['enabled'] = False
+ self.save_custom_patterns()
+ self.update_search_with_regex_patterns()
+ self.regex_btn.setText("Regex Patterns")
+ self.regex_btn.setStyleSheet("")
+
+ def update_search_with_regex_patterns(self):
+ """Update search input with combined regex patterns"""
+ enabled_patterns = [info['pattern'] for info in self.regex_patterns.values() if info['enabled']]
+ enabled_patterns += [info['pattern'] for info in self.custom_patterns.values() if info['enabled']]
+
+ if enabled_patterns:
+ # Combine patterns with OR operator
+ combined_pattern = '|'.join(f'({pattern})' for pattern in enabled_patterns)
+ self.search_input.lineEdit().setText(combined_pattern)
+ # Enable regex mode in search engine
+ self.search_engine.set_regex(True)
+ else:
+ # If no patterns selected, keep current search text
+ # and disable regex mode
+ self.search_engine.set_regex(False)
+
+ def load_custom_patterns(self):
+ """Load custom user-defined regex patterns from file"""
+ try:
+ if os.path.exists(self.custom_patterns_file):
+ with open(self.custom_patterns_file, 'r', encoding='utf-8') as f:
+ self.custom_patterns = json.load(f)
+ except Exception as e:
+ print(f"Error loading custom patterns: {e}")
+ self.custom_patterns = {}
+
+ def save_custom_patterns(self):
+ """Save custom patterns to file"""
+ try:
+ with open(self.custom_patterns_file, 'w', encoding='utf-8') as f:
+ json.dump(self.custom_patterns, f, indent=2)
+ except Exception as e:
+ print(f"Error saving custom patterns: {e}")
+
+ def add_custom_pattern(self, name, pattern, label):
+ """Add a new custom regex pattern"""
+ self.custom_patterns[name] = {
+ 'pattern': pattern,
+ 'enabled': False,
+ 'label': label
+ }
+ self.save_custom_patterns()
+
+ def remove_custom_pattern(self, name):
+ """Remove a custom regex pattern"""
+ if name in self.custom_patterns:
+ del self.custom_patterns[name]
+ self.save_custom_patterns()
+
+ def show_custom_pattern_manager(self):
+ """Show dialog to manage custom regex patterns"""
+ dialog = CustomPatternManagerDialog(self, self.custom_patterns)
+ if dialog.exec() == QDialog.Accepted:
+ self.custom_patterns = dialog.get_custom_patterns()
+ self.save_custom_patterns()
+ self.status_bar.showMessage("Custom patterns updated", 3000)
+
+ def show_dir_context_menu(self, position):
+ """Show context menu for directory tree items"""
+ item = self.dir_tree.itemAt(position)
+ if not item:
+ return
+
+ data = item.data(0, Qt.UserRole)
+ if not data or data.get("path") is None:
+ return
+
+ menu = QMenu()
+ path = data["path"]
+ is_file = data.get("is_file", False)
+
+ if is_file:
+ # File menu
+ open_file_action = menu.addAction("Open File")
+ open_file_action.triggered.connect(lambda: self.open_file(path))
+
+ open_parent_action = menu.addAction("Open Parent Directory")
+ open_parent_action.triggered.connect(lambda: self.open_file_directory(path))
+ else:
+ # Directory menu
+ open_dir_action = menu.addAction("Open Directory")
+ open_dir_action.triggered.connect(lambda: self.open_directory(path))
+
+ parent_path = os.path.dirname(path)
+ if parent_path and parent_path != path:
+ open_parent_action = menu.addAction("Open Parent Directory")
+ open_parent_action.triggered.connect(lambda: self.open_directory(parent_path))
+
+ menu.exec(self.dir_tree.viewport().mapToGlobal(position))
+
+ def open_directory(self, directory_path):
+ """Open a directory in file explorer"""
+ try:
+ os.startfile(directory_path)
+ except Exception as e:
+ QMessageBox.warning(self, "Warning", f"Could not open directory: {str(e)}")
+
+ def load_preferences(self):
+ """Load preferences from file"""
+ try:
+ if os.path.exists(self.preferences_file):
+ with open(self.preferences_file, 'r') as f:
+ saved_prefs = json.load(f)
+ self.preferences.update(saved_prefs)
+ except Exception as e:
+ print(f"Error loading preferences: {e}")
+
+ def save_preferences(self):
+ """Save preferences to file"""
+ try:
+ with open(self.preferences_file, 'w') as f:
+ json.dump(self.preferences, f, indent=2)
+ except Exception as e:
+ print(f"Error saving preferences: {e}")
+
+ def show_preferences(self):
+ """Show preferences dialog"""
+ dialog = PreferencesDialog(self, self.preferences)
+ if dialog.exec() == QDialog.Accepted:
+ # Update preferences
+ self.preferences = dialog.get_preferences()
+ self.save_preferences()
+
+ # Apply new preferences
+ self.max_cache_size = self.preferences['max_cache_size']
+ self.max_file_size = self.preferences['max_preview_file_size_mb'] * 1024 * 1024
+ self.search_engine.max_results = self.preferences['max_results']
+ self.search_engine.max_search_file_size = self.preferences['max_search_file_size_mb'] * 1024 * 1024
+
+ # Clear cache if size reduced
+ if len(self.file_cache) > self.max_cache_size:
+ # Keep only the last max_cache_size items
+ keys = list(self.file_cache.keys())
+ for key in keys[:-self.max_cache_size]:
+ del self.file_cache[key]
+
+ self.status_bar.showMessage("Preferences updated", 3000)
+
+ def apply_sort(self):
+ """Apply sorting to current search results"""
+ if not self.current_results:
+ return
+
+ # Group results by file
+ files_dict = {}
+ for match in self.current_results:
+ if match.file_path not in files_dict:
+ files_dict[match.file_path] = []
+ files_dict[match.file_path].append(match)
+
+ # Apply sorting
+ sort_option = self.sort_combo.currentText()
+
+ if sort_option == "Path (A-Z)":
+ sorted_files = sorted(files_dict.items(), key=lambda x: x[0].lower())
+ elif sort_option == "Path (Z-A)":
+ sorted_files = sorted(files_dict.items(), key=lambda x: x[0].lower(), reverse=True)
+ elif sort_option == "Match Count (High-Low)":
+ sorted_files = sorted(files_dict.items(), key=lambda x: len(x[1]), reverse=True)
+ elif sort_option == "Match Count (Low-High)":
+ sorted_files = sorted(files_dict.items(), key=lambda x: len(x[1]))
+ elif sort_option == "File Size (Large-Small)":
+ sorted_files = sorted(files_dict.items(),
+ key=lambda x: os.path.getsize(x[0]) if os.path.exists(x[0]) else 0,
+ reverse=True)
+ elif sort_option == "File Size (Small-Large)":
+ sorted_files = sorted(files_dict.items(),
+ key=lambda x: os.path.getsize(x[0]) if os.path.exists(x[0]) else 0)
+ elif sort_option == "Date Modified (Newest)":
+ sorted_files = sorted(files_dict.items(),
+ key=lambda x: os.path.getmtime(x[0]) if os.path.exists(x[0]) else 0,
+ reverse=True)
+ elif sort_option == "Date Modified (Oldest)":
+ sorted_files = sorted(files_dict.items(),
+ key=lambda x: os.path.getmtime(x[0]) if os.path.exists(x[0]) else 0)
+ else:
+ sorted_files = sorted(files_dict.items(), key=lambda x: x[0].lower())
+
+ # Update the results tree
+ self.results_tree.clear()
+
+ for file_path, matches in sorted_files:
+ file_item = QTreeWidgetItem(self.results_tree)
+ file_item.setText(0, file_path)
+ file_item.setText(1, str(len(matches)))
+ file_item.setData(0, Qt.UserRole, matches)
+
+ # Add match items
+ for match in matches:
+ match_item = QTreeWidgetItem(file_item)
+ match_item.setText(0, f" Line {match.line_number}: {match.line_content[:80]}")
+ match_item.setData(0, Qt.UserRole, match)
+
+ # Update status
+ total_matches = sum(len(matches) for _, matches in sorted_files)
+ total_files = len(sorted_files)
+
+ self.status_bar.showMessage(
+ f"Found {total_matches} matches in {total_files} files"
+ )
+
+ def toggle_search(self):
+ """Toggle between starting and stopping search"""
+ if self.is_searching:
+ self.stop_search()
+ else:
+ self.start_search()
+
+ def start_search(self):
+ """Start a new search"""
+ pattern = self.search_input.currentText()
+ root_path = self.current_directory
+
+ if not pattern:
+ QMessageBox.warning(self, "Warning", "Please enter a search pattern")
+ return
+
+ # Add to search history
+ self.add_to_search_history(pattern)
+
+ if not os.path.isdir(root_path):
+ QMessageBox.warning(self, "Warning", "Please select a valid directory from the tree")
+ return
+
+ self.current_search_pattern = pattern
+
+ # Update file extensions filter
+ extensions_text = self.extensions_input.text().strip()
+ if extensions_text:
+ extensions = [ext.strip() for ext in extensions_text.split(',')]
+ self.search_engine.set_file_extensions(extensions)
+ else:
+ self.search_engine.set_file_extensions([])
+
+ # Clear previous results
+ self.results_tree.clear()
+ self.preview_text.clear()
+ self.current_results = []
+
+ # Update UI state
+ self.is_searching = True
+ self.search_stop_btn.setText("Stop")
+ self.search_stop_btn.setToolTip("Stop the current search operation")
+ self.progress_bar.setVisible(True)
+ self.progress_bar.setRange(0, 0) # Indeterminate
+ self.status_bar.showMessage(f"Searching for '{pattern}'...")
+
+ # Start search in background thread
+ self.search_worker = SearchWorker(self.search_engine, root_path, pattern)
+ self.search_worker.finished.connect(self.on_search_finished)
+ self.search_worker.start()
+
+ def stop_search(self):
+ """Stop the current search"""
+ if self.search_worker:
+ self.search_worker.stop()
+ self.search_worker.wait()
+ self.on_search_finished([])
+
+ def on_search_finished(self, results):
+ """Handle search completion"""
+ self.current_results = results
+
+ # Apply sorting to display results
+ self.apply_sort()
+
+ # Update UI state
+ self.is_searching = False
+ self.search_stop_btn.setText("Search")
+ self.search_stop_btn.setToolTip("Start searching in the selected directory (Enter)")
+ self.progress_bar.setVisible(False)
+
+ def on_tree_item_clicked(self, item, column):
+ """Handle tree item click"""
+ data = item.data(0, Qt.UserRole)
+
+ if isinstance(data, SearchMatch):
+ # Single match - show full file with all matches
+ matches = [data]
+ # Try to find all matches for this file from results
+ for result in self.current_results:
+ if result.file_path == data.file_path and result != data:
+ matches.append(result)
+ self.show_file_contents_with_matches(matches)
+ elif isinstance(data, list):
+ # File with multiple matches - show file contents with highlights
+ self.show_file_contents_with_matches(data)
+
+ def on_item_double_clicked(self, item, column):
+ """Handle double-click to open file"""
+ data = item.data(0, Qt.UserRole)
+
+ if isinstance(data, SearchMatch):
+ self.open_file(data.file_path, data.line_number)
+ elif isinstance(data, list) and len(data) > 0:
+ self.open_file(data[0].file_path)
+
+ def show_file_contents_with_matches(self, matches):
+ """Show full file contents with matched lines highlighted"""
+ if not matches:
+ self.current_file_matches = []
+ self.current_match_index = 0
+ self.update_match_navigation()
+ return
+
+ file_path = matches[0].file_path
+ self.current_file_matches = matches
+ self.current_match_index = 0
+ self.preview_text.clear()
+
+ try:
+ # Check if this is an image file
+ file_ext = os.path.splitext(file_path)[1].lower()
+ image_extensions = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.gif', '.bmp', '.webp'}
+ file_metadata_extensions = {'.pdf', '.docx', '.xlsx', '.pptx', '.mp3', '.flac', '.m4a', '.mp4', '.avi', '.mkv'}
+ is_image = file_ext in image_extensions
+ is_file_with_metadata = file_ext in file_metadata_extensions
+
+ # If metadata search is enabled and this is an image, show image metadata
+ if is_image and self.search_engine.search_metadata:
+ self._display_image_metadata_preview(file_path, matches)
+ return
+
+ # If file metadata search is enabled and this has metadata, show file metadata
+ if is_file_with_metadata and self.search_engine.search_file_metadata:
+ self._display_file_metadata_preview(file_path, matches)
+ return
+
+ # Check file size first
+ file_size = os.path.getsize(file_path)
+ if file_size > self.max_file_size:
+ self.preview_text.setPlainText(f"File too large to display ({file_size / 1024 / 1024:.1f}MB).\nMaximum size: {self.max_file_size / 1024 / 1024:.1f}MB")
+ self.current_file_matches = []
+ self.current_match_index = 0
+ self.update_match_navigation()
+ return
+
+ # Check cache first
+ if file_path in self.file_cache:
+ cached_size, lines = self.file_cache[file_path]
+ if cached_size == file_size:
+ # Use cached content
+ pass
+ else:
+ # File changed, re-read
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
+ lines = f.readlines()
+ self._cache_file(file_path, file_size, lines)
+ else:
+ # Read entire file
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
+ lines = f.readlines()
+ self._cache_file(file_path, file_size, lines)
+
+ # Build match line numbers set for quick lookup
+ match_lines = {match.line_number for match in matches}
+
+ # Display file with highlights (optimized with list comprehension)
+ display_lines = [
+ f"File: {file_path}",
+ f"Total matches: {len(matches)}",
+ "=" * 80,
+ ""
+ ]
+
+ # Show all lines (optimized loop)
+ display_lines.extend(
+ f"{'>>> ' if i in match_lines else ' '}{i:5d} | {line.rstrip()}"
+ for i, line in enumerate(lines, 1)
+ )
+
+ self.preview_text.setPlainText("\n".join(display_lines))
+
+ # Highlight all matches
+ self.highlight_all_matches()
+
+ # Update navigation and go to first match
+ self.update_match_navigation()
+ if matches:
+ self.jump_to_current_match()
+
+ except Exception as e:
+ self.preview_text.setPlainText(f"Error reading file: {str(e)}")
+ self.current_file_matches = []
+ self.current_match_index = 0
+ self.update_match_navigation()
+
+ def _display_image_metadata_preview(self, file_path, matches):
+ """Display image metadata in preview pane"""
+ try:
+ from PIL import Image
+ from PIL.ExifTags import TAGS, GPSTAGS
+ import os
+ from datetime import datetime
+
+ metadata = {}
+
+ # Extract and display metadata
+ with Image.open(file_path) as img:
+ # File system info
+ stat_info = os.stat(file_path)
+ metadata['File_Size'] = f"{stat_info.st_size / 1024:.2f} KB"
+ metadata['File_Created'] = datetime.fromtimestamp(stat_info.st_ctime).strftime('%Y-%m-%d %H:%M:%S')
+ metadata['File_Modified'] = datetime.fromtimestamp(stat_info.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
+
+ # Basic image info
+ metadata['Format'] = img.format or 'Unknown'
+ metadata['Mode'] = img.mode
+ metadata['Size'] = f"{img.width}x{img.height}"
+
+ # Try to get EXIF data using getexif() (newer API)
+ try:
+ exif = img.getexif()
+ if exif:
+ for tag_id, value in exif.items():
+ tag_name = TAGS.get(tag_id, f"Tag_{tag_id}")
+
+ # Handle GPS data specially
+ if tag_name == "GPSInfo":
+ try:
+ gps_data = {GPSTAGS.get(gps_tag_id, f"GPS_{gps_tag_id}"): str(value[gps_tag_id])
+ for gps_tag_id in value}
+ metadata['GPS_Info'] = str(gps_data)
+ except Exception:
+ metadata['GPS_Info'] = str(value)
+ else:
+ # Convert value to string, handle bytes
+ if isinstance(value, bytes):
+ try:
+ value = value.decode('utf-8', errors='ignore')
+ except Exception:
+ value = str(value)[:100]
+ elif isinstance(value, (tuple, list)) and len(str(value)) > 100:
+ value = str(value)[:100] + "..."
+ metadata[tag_name] = str(value)
+ except (AttributeError, KeyError, TypeError):
+ pass
+
+ # PNG info
+ if hasattr(img, 'info') and img.info:
+ for key, value in img.info.items():
+ if key not in metadata:
+ metadata[f"PNG_{key}"] = str(value)[:200]
+
+ # Display using common metadata display method
+ note = "This image has no EXIF metadata (typical for screenshots)" if len(metadata) <= 6 else None
+ self._display_metadata_common(file_path, matches, metadata, "Image Metadata", note)
+
+ except Exception as e:
+ self.preview_text.setPlainText(f"Error reading image metadata: {str(e)}")
+ self.current_file_matches = []
+ self.current_match_index = 0
+ self.update_match_navigation()
+
+ def _display_file_metadata_preview(self, file_path, matches):
+ """Display file metadata in preview pane (PDF, Office, audio, etc.)"""
+ try:
+ import os
+ from datetime import datetime
+
+ # Extract file metadata
+ metadata = self.search_engine._extract_file_metadata(file_path)
+
+ # Add file system info
+ stat_info = os.stat(file_path)
+ metadata['File_Size'] = f"{stat_info.st_size / 1024:.2f} KB"
+ metadata['File_Created'] = datetime.fromtimestamp(stat_info.st_ctime).strftime('%Y-%m-%d %H:%M:%S')
+ metadata['File_Modified'] = datetime.fromtimestamp(stat_info.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
+
+ # Display using common metadata display method
+ note = "No extractable metadata found for this file type" if len(metadata) <= 3 else None
+ self._display_metadata_common(file_path, matches, metadata, "File Metadata", note)
+
+ except Exception as e:
+ self.preview_text.setPlainText(f"Error reading file metadata: {str(e)}")
+ self.current_file_matches = []
+ self.current_match_index = 0
+ self.update_match_navigation()
+
+ def _display_metadata_common(self, file_path, matches, metadata, header_text, note=None):
+ """Common method to display metadata in preview pane"""
+ display_lines = [
+ f"{header_text}: {file_path}",
+ f"Total matches: {len(matches)}",
+ "=" * 80,
+ ""
+ ]
+
+ if note:
+ display_lines.append(f" Note: {note}")
+ display_lines.append("")
+
+ # Build match line numbers set for quick lookup
+ match_lines = {match.line_number for match in matches}
+
+ # Display metadata with match indicators
+ for line_num, (key, value) in enumerate(metadata.items(), start=1):
+ line_text = f"{key}: {value}"
+ prefix = '>>> ' if line_num in match_lines else ' '
+ display_lines.append(f"{prefix}{line_num:5d} | {line_text}")
+
+ self.preview_text.setPlainText("\n".join(display_lines))
+
+ # Highlight all matches
+ self.highlight_all_matches()
+
+ # Update navigation and go to first match
+ self.update_match_navigation()
+ if matches:
+ self.jump_to_current_match()
+
+ def _cache_file(self, file_path, file_size, lines):
+ """Cache file contents with LRU eviction"""
+ # If cache is full, remove oldest entry
+ if len(self.file_cache) >= self.max_cache_size:
+ # Remove first (oldest) item
+ first_key = next(iter(self.file_cache))
+ del self.file_cache[first_key]
+
+ self.file_cache[file_path] = (file_size, lines)
+
+ def highlight_all_matches(self):
+ """Highlight all search matches in the preview text (optimized)"""
+ if not self.current_file_matches or not self.current_search_pattern:
+ return
+
+ # Get the search pattern for highlighting
+ pattern = self.current_search_pattern
+
+ # Build regex for highlighting
+ try:
+ if self.search_engine.use_regex:
+ flags = 0 if self.search_engine.case_sensitive else re.IGNORECASE
+ regex = re.compile(pattern, flags)
+ else:
+ escaped_pattern = re.escape(pattern)
+ if self.search_engine.whole_word:
+ escaped_pattern = r'\b' + escaped_pattern + r'\b'
+ flags = 0 if self.search_engine.case_sensitive else re.IGNORECASE
+ regex = re.compile(escaped_pattern, flags)
+ except re.error:
+ return
+
+ # Yellow highlight format
+ highlight_format = QTextCharFormat()
+ highlight_format.setBackground(QColor(255, 255, 0))
+
+ # Get text once (optimization)
+ text = self.preview_text.toPlainText()
+
+ # Skip header (4 lines)
+ header_lines = text.split('\n', 4)
+ if len(header_lines) < 5:
+ return
+ header_length = sum(len(line) + 1 for line in header_lines[:4])
+
+ # Batch highlight all matches (optimized)
+ cursor = self.preview_text.textCursor()
+ cursor.beginEditBlock() # Batch operations
+
+ for match in regex.finditer(text[header_length:]):
+ cursor.setPosition(header_length + match.start())
+ cursor.setPosition(header_length + match.end(), QTextCursor.KeepAnchor)
+ cursor.mergeCharFormat(highlight_format)
+
+ cursor.endEditBlock() # Complete batch
+
+ def jump_to_current_match(self):
+ """Jump to the current match in preview"""
+ if not self.current_file_matches or self.current_match_index >= len(self.current_file_matches):
+ return
+
+ match = self.current_file_matches[self.current_match_index]
+
+ # Re-highlight all matches first (to reset orange highlight)
+ self.highlight_all_matches()
+
+ # Find header position (skip first 4 lines)
+ header_cursor = QTextCursor(self.preview_text.document())
+ header_cursor.movePosition(QTextCursor.Start)
+ for _ in range(4):
+ header_cursor.movePosition(QTextCursor.Down)
+ header_pos = header_cursor.position()
+
+ # Find all matches in the preview text (after header)
+ text = self.preview_text.toPlainText()
+
+ # Build regex for finding matches
+ try:
+ pattern = self.current_search_pattern
+ if self.search_engine.use_regex:
+ flags = 0 if self.search_engine.case_sensitive else re.IGNORECASE
+ regex = re.compile(pattern, flags)
+ else:
+ escaped_pattern = re.escape(pattern)
+ if self.search_engine.whole_word:
+ escaped_pattern = r'\b' + escaped_pattern + r'\b'
+ flags = 0 if self.search_engine.case_sensitive else re.IGNORECASE
+ regex = re.compile(escaped_pattern, flags)
+
+ # Find all matches after header only
+ all_matches = [m for m in regex.finditer(text) if m.start() >= header_pos]
+
+ if self.current_match_index < len(all_matches):
+ match_obj = all_matches[self.current_match_index]
+
+ # Create cursor and select the match
+ cursor = QTextCursor(self.preview_text.document())
+ cursor.setPosition(match_obj.start())
+ cursor.setPosition(match_obj.end(), QTextCursor.KeepAnchor)
+
+ # Apply orange highlight to current match
+ current_format = QTextCharFormat()
+ current_format.setBackground(QColor(255, 165, 0)) # Orange
+ cursor.mergeCharFormat(current_format)
+
+ # Move cursor to this position and ensure visible
+ cursor.setPosition(match_obj.start())
+ self.preview_text.setTextCursor(cursor)
+ self.preview_text.ensureCursorVisible()
+
+ except re.error:
+ pass
+
+ def update_match_navigation(self):
+ """Update match counter and navigation button states"""
+ if not self.current_file_matches:
+ self.match_counter_label.setText("0/0")
+ self.prev_match_btn.setEnabled(False)
+ self.next_match_btn.setEnabled(False)
+ else:
+ total = len(self.current_file_matches)
+ current = self.current_match_index + 1
+ self.match_counter_label.setText(f"{current}/{total}")
+ # Always enable buttons if there are matches (cycling enabled)
+ self.prev_match_btn.setEnabled(True)
+ self.next_match_btn.setEnabled(True)
+
+ def go_to_previous_match(self):
+ """Navigate to previous match (wraps to last)"""
+ if not self.current_file_matches:
+ return
+
+ if self.current_match_index > 0:
+ self.current_match_index -= 1
+ else:
+ # Wrap to last match
+ self.current_match_index = len(self.current_file_matches) - 1
+
+ self.highlight_all_matches() # Re-highlight all
+ self.jump_to_current_match()
+ self.update_match_navigation()
+
+ def go_to_next_match(self):
+ """Navigate to next match (wraps to first)"""
+ if not self.current_file_matches:
+ return
+
+ if self.current_match_index < len(self.current_file_matches) - 1:
+ self.current_match_index += 1
+ else:
+ # Wrap to first match
+ self.current_match_index = 0
+
+ self.highlight_all_matches() # Re-highlight all
+ self.jump_to_current_match()
+ self.update_match_navigation()
+
+ def show_context_menu(self, position):
+ """Show context menu for tree items"""
+ item = self.results_tree.itemAt(position)
+ if not item:
+ return
+
+ menu = QMenu()
+
+ # Get data from item
+ data = item.data(0, Qt.UserRole)
+
+ if isinstance(data, SearchMatch):
+ # Single match
+ open_action = menu.addAction("Open")
+ open_action.triggered.connect(lambda: self.open_file(data.file_path, data.line_number))
+
+ open_dir_action = menu.addAction("Open File Directory")
+ open_dir_action.triggered.connect(lambda: self.open_file_directory(data.file_path))
+
+ menu.addSeparator()
+
+ copy_path_action = menu.addAction("Copy File Path")
+ copy_path_action.triggered.connect(lambda: QApplication.clipboard().setText(data.file_path))
+
+ copy_line_action = menu.addAction("Copy Line Content")
+ copy_line_action.triggered.connect(lambda: QApplication.clipboard().setText(data.line_content))
+
+ elif isinstance(data, list) and len(data) > 0:
+ # File with multiple matches
+ open_action = menu.addAction("Open")
+ open_action.triggered.connect(lambda: self.open_file(data[0].file_path))
+
+ open_dir_action = menu.addAction("Open Folder Directory")
+ open_dir_action.triggered.connect(lambda: self.open_file_directory(data[0].file_path))
+
+ menu.addSeparator()
+
+ copy_path_action = menu.addAction("Copy File Path")
+ copy_path_action.triggered.connect(lambda: QApplication.clipboard().setText(data[0].file_path))
+
+ menu.addSeparator()
+
+ expand_action = menu.addAction("Expand All")
+ expand_action.triggered.connect(lambda: item.setExpanded(True))
+
+ collapse_action = menu.addAction("Collapse All")
+ collapse_action.triggered.connect(lambda: item.setExpanded(False))
+
+ menu.exec(self.results_tree.viewport().mapToGlobal(position))
+
+ def open_file(self, file_path, line_number=None):
+ """Open file in default editor"""
+ try:
+ if line_number:
+ # Try to open with VS Code if available
+ try:
+ subprocess.Popen(['code', '-g', f'{file_path}:{line_number}'])
+ except FileNotFoundError:
+ # VS Code not available, just open the file
+ os.startfile(file_path)
+ else:
+ # Use default application
+ os.startfile(file_path)
+ except Exception as e:
+ QMessageBox.warning(self, "Warning", f"Could not open file: {str(e)}")
+
+ def open_file_directory(self, file_path):
+ """Open the directory containing the file"""
+ try:
+ directory = os.path.dirname(file_path)
+ os.startfile(directory)
+ except Exception as e:
+ QMessageBox.warning(self, "Warning", f"Could not open directory: {str(e)}")
+
+ def show_help(self):
+ """Show comprehensive help dialog"""
+ dialog = HelpDialog(self)
+ dialog.exec()
+
+ def show_about(self):
+ """Show about dialog"""
+ QMessageBox.about(
+ self,
+ "About Advanced Search Tool",
+ "Advanced Search Tool
"
+ "Version 0.5.1
"
+ "Author: Randy Northrup
"
+ "A Windows GUI application for grep-style searching with advanced regex patterns and metadata search.
"
+ "Key Features:
"
+ ""
+ "- Grep-style pattern search with full regex support
"
+ "- 8 common regex patterns in quick-access menu
"
+ "- Image metadata search (EXIF, GPS) for JPG, PNG, TIFF, etc.
"
+ "- File metadata search (PDF, Office docs, audio/video)
"
+ "- File browser with organized results tree
"
+ "- Context display and syntax highlighting
"
+ "- Performance optimizations and caching
"
+ "
"
+ "Built with Python, PySide6, Pillow, PyPDF2, and more
"
+ )
+
+ def load_search_history(self):
+ """Load search history from file"""
+ try:
+ if os.path.exists(self.history_file):
+ with open(self.history_file, 'r', encoding='utf-8') as f:
+ self.search_history = json.load(f)
+ # Limit to last 50 entries
+ self.search_history = self.search_history[-50:]
+ except Exception as e:
+ print(f"Failed to load search history: {e}")
+ self.search_history = []
+
+ def save_search_history(self):
+ """Save search history to file"""
+ try:
+ with open(self.history_file, 'w', encoding='utf-8') as f:
+ json.dump(self.search_history, f, ensure_ascii=False, indent=2)
+ except Exception as e:
+ print(f"Failed to save search history: {e}")
+
+ def add_to_search_history(self, pattern):
+ """Add a search pattern to history"""
+ if not pattern or pattern.strip() == "":
+ return
+
+ # Remove if already exists (to move to end)
+ if pattern in self.search_history:
+ self.search_history.remove(pattern)
+
+ # Add to end
+ self.search_history.append(pattern)
+
+ # Limit to 50 entries
+ if len(self.search_history) > 50:
+ self.search_history = self.search_history[-50:]
+
+ # Update dropdown and save
+ self.update_search_history_dropdown()
+ self.save_search_history()
+
+ def update_search_history_dropdown(self):
+ """Update the search input dropdown with history"""
+ self.search_input.clear()
+ # Add history in reverse order (most recent first)
+ for pattern in reversed(self.search_history):
+ self.search_input.addItem(pattern)
+
+ def clear_search_history(self):
+ """Clear all search history"""
+ reply = QMessageBox.question(
+ self,
+ "Clear Search History",
+ "Are you sure you want to clear all search history?",
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No
+ )
+
+ if reply == QMessageBox.Yes:
+ self.search_history = []
+ self.update_search_history_dropdown()
+ self.save_search_history()
+ QMessageBox.information(self, "Success", "Search history has been cleared.")
+
+
+def main():
+ """Main entry point"""
+ app = QApplication(sys.argv)
+
+ # Set application style
+ app.setStyle('Fusion')
+
+ window = MainWindow()
+ window.show()
+
+ sys.exit(app.exec())
+
+
+if __name__ == '__main__':
+ main()
diff --git a/Advanced Search/src/search_engine.py b/Advanced Search/src/search_engine.py
new file mode 100644
index 00000000..9a3ec73a
--- /dev/null
+++ b/Advanced Search/src/search_engine.py
@@ -0,0 +1,826 @@
+"""
+Search engine module for grep-style searching
+"""
+import os
+import re
+from typing import List, Dict, Any
+from dataclasses import dataclass
+
+try:
+ from PIL import Image
+ from PIL.ExifTags import TAGS, GPSTAGS
+ PILLOW_AVAILABLE = True
+except ImportError:
+ PILLOW_AVAILABLE = False
+
+try:
+ import PyPDF2
+ PYPDF2_AVAILABLE = True
+except ImportError:
+ PYPDF2_AVAILABLE = False
+
+try:
+ import docx
+ DOCX_AVAILABLE = True
+except ImportError:
+ DOCX_AVAILABLE = False
+
+try:
+ import openpyxl
+ OPENPYXL_AVAILABLE = True
+except ImportError:
+ OPENPYXL_AVAILABLE = False
+
+try:
+ from mutagen import File as MutagenFile
+ MUTAGEN_AVAILABLE = True
+except ImportError:
+ MUTAGEN_AVAILABLE = False
+
+try:
+ import xml.etree.ElementTree as ET
+ XML_AVAILABLE = True
+except ImportError:
+ XML_AVAILABLE = False
+
+try:
+ import zipfile
+ ZIPFILE_AVAILABLE = True
+except ImportError:
+ ZIPFILE_AVAILABLE = False
+
+try:
+ import csv
+ CSV_AVAILABLE = True
+except ImportError:
+ CSV_AVAILABLE = False
+
+try:
+ import json as json_lib
+ JSON_AVAILABLE = True
+except ImportError:
+ JSON_AVAILABLE = False
+
+try:
+ import sqlite3
+ SQLITE_AVAILABLE = True
+except ImportError:
+ SQLITE_AVAILABLE = False
+
+
+@dataclass
+class SearchMatch:
+ """Represents a search match in a file"""
+ file_path: str
+ line_number: int
+ line_content: str
+ match_start: int
+ match_end: int
+ context_before: List[str]
+ context_after: List[str]
+
+
+class SearchEngine:
+ """Grep-style search engine"""
+
+ # Class constants for supported file types
+ IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.gif', '.bmp', '.webp'}
+ FILE_METADATA_EXTENSIONS = {
+ # Office & Documents
+ '.pdf', '.docx', '.xlsx', '.pptx',
+ # OpenDocument formats
+ '.odt', '.ods', '.odp',
+ # Screenwriting formats
+ '.fdx', '.fountain', '.celtx',
+ # Archive formats
+ '.zip', '.epub',
+ # Structured data
+ '.csv', '.json', '.xml',
+ # Database
+ '.db', '.sqlite', '.sqlite3',
+ # RTF
+ '.rtf',
+ # Audio/Video
+ '.mp3', '.flac', '.m4a', '.mp4', '.avi', '.mkv', '.mov', '.wmv', '.ogg', '.wma'
+ }
+
+ ARCHIVE_EXTENSIONS = {'.zip', '.epub'}
+
+ def __init__(self):
+ self.case_sensitive = False
+ self.use_regex = False
+ self.whole_word = False
+ self.search_metadata = False # Image metadata
+ self.search_file_metadata = False # File metadata (PDF, Office, audio, video)
+ self.search_in_archives = False # Search inside archive files
+ self.hex_search = False # Binary/hex search mode
+ self.context_lines = 2
+ self.file_extensions = [] # Empty means all files
+ self.max_results = 0 # 0 = unlimited
+ self.max_search_file_size = 50 * 1024 * 1024 # 50MB default
+ self.network_timeout = 5 # seconds for network operations
+ self._network_path_cache = {} # Cache for network path accessibility
+ self.exclude_patterns = [
+ r'\.git', r'\.svn', r'__pycache__', r'node_modules',
+ r'\.pyc$', r'\.exe$', r'\.dll$', r'\.so$', r'\.bin$'
+ ]
+
+ def search(self, root_path: str, pattern: str) -> List[SearchMatch]:
+ """
+ Search for pattern in files under root_path or in a specific file
+
+ Args:
+ root_path: Directory to search in or specific file path
+ pattern: Search pattern (text or regex)
+
+ Returns:
+ List of SearchMatch objects
+ """
+ matches = []
+
+ if not pattern:
+ return matches
+
+ # Check network path accessibility
+ if self._is_network_path(root_path):
+ if not self._check_network_path_accessible(root_path):
+ print(f"Network path not accessible or timed out: {root_path}")
+ return matches
+
+ # Compile regex pattern
+ try:
+ if self.use_regex:
+ flags = 0 if self.case_sensitive else re.IGNORECASE
+ regex = re.compile(pattern, flags)
+ else:
+ # Escape special regex characters for literal search
+ escaped_pattern = re.escape(pattern)
+ if self.whole_word:
+ escaped_pattern = r'\b' + escaped_pattern + r'\b'
+ flags = 0 if self.case_sensitive else re.IGNORECASE
+ regex = re.compile(escaped_pattern, flags)
+ except re.error as e:
+ print(f"Invalid regex pattern: {e}")
+ return matches
+
+ # Check if root_path is a file or directory
+ if os.path.isfile(root_path):
+ # Search in single file
+ if not self._is_excluded(root_path):
+ # Check file extension filter
+ if not self.file_extensions or any(root_path.endswith(ext) for ext in self.file_extensions):
+ file_matches = self._search_file(root_path, regex)
+ matches.extend(file_matches)
+ else:
+ # Walk directory tree
+ for root, dirs, files in os.walk(root_path):
+ # Early exit if max results reached (when limit is set)
+ if self.max_results > 0 and len(matches) >= self.max_results:
+ break
+
+ # Filter out excluded directories
+ dirs[:] = [d for d in dirs if not self._is_excluded(os.path.join(root, d))]
+
+ for file in files:
+ # Early exit if max results reached (when limit is set)
+ if self.max_results > 0 and len(matches) >= self.max_results:
+ break
+
+ file_path = os.path.join(root, file)
+
+ # Skip excluded files
+ if self._is_excluded(file_path):
+ continue
+
+ # Check file extension filter
+ if self.file_extensions:
+ if not any(file.endswith(ext) for ext in self.file_extensions):
+ continue
+
+ # Search in file
+ file_matches = self._search_file(file_path, regex)
+ matches.extend(file_matches)
+
+ return matches
+
+ def _is_excluded(self, path: str) -> bool:
+ """Check if path should be excluded"""
+ path = path.replace('\\', '/')
+ for pattern in self.exclude_patterns:
+ if re.search(pattern, path):
+ return True
+ return False
+
+ def _search_file(self, file_path: str, regex: re.Pattern) -> List[SearchMatch]:
+ """Search for pattern in a single file (optimized)"""
+ matches = []
+
+ try:
+ # Check file size first (skip very large files)
+ file_size = os.path.getsize(file_path)
+ if file_size > self.max_search_file_size:
+ return matches
+
+ file_ext = os.path.splitext(file_path)[1].lower()
+
+ # Check if this is an archive file and archive search is enabled
+ if self.search_in_archives and file_ext in self.ARCHIVE_EXTENSIONS:
+ archive_matches = self._search_archive(file_path, regex)
+ matches.extend(archive_matches)
+ return matches
+
+ # Check if this is an image file and image metadata search is enabled
+ if self.search_metadata and file_ext in self.IMAGE_EXTENSIONS:
+ # Search ONLY image metadata when metadata search is enabled
+ metadata_matches = self._search_image_metadata(file_path, regex)
+ matches.extend(metadata_matches)
+ return matches # Skip text search for images
+
+ # Check if this is a file with metadata and file metadata search is enabled
+ if self.search_file_metadata and file_ext in self.FILE_METADATA_EXTENSIONS:
+ # Search ONLY file metadata when file metadata search is enabled
+ metadata_matches = self._search_file_metadata(file_path, regex)
+ matches.extend(metadata_matches)
+ return matches # Skip text search for these files
+
+ # If either metadata search is enabled but this file doesn't match, skip it
+ if self.search_metadata or self.search_file_metadata:
+ return matches
+
+ # Binary/hex search mode
+ if self.hex_search:
+ hex_matches = self._search_binary(file_path, regex)
+ matches.extend(hex_matches)
+ return matches
+
+ # Try to read as text file
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
+ lines = f.readlines()
+
+ # Search each line
+ for i, line in enumerate(lines):
+ for match in regex.finditer(line):
+ # Get context lines
+ context_before = []
+ context_after = []
+
+ start_idx = max(0, i - self.context_lines)
+ end_idx = min(len(lines), i + self.context_lines + 1)
+
+ if self.context_lines > 0:
+ context_before = [lines[j].rstrip('\n\r') for j in range(start_idx, i)]
+ context_after = [lines[j].rstrip('\n\r') for j in range(i + 1, end_idx)]
+
+ search_match = SearchMatch(
+ file_path=file_path,
+ line_number=i + 1, # 1-based line numbers
+ line_content=line.rstrip('\n\r'),
+ match_start=match.start(),
+ match_end=match.end(),
+ context_before=context_before,
+ context_after=context_after
+ )
+ matches.append(search_match)
+
+ except (IOError, OSError, UnicodeDecodeError):
+ # Skip files that can't be read
+ pass
+
+ return matches
+
+ def _search_image_metadata(self, file_path: str, regex: re.Pattern) -> List[SearchMatch]:
+ """Search image metadata for pattern matches"""
+ matches = []
+
+ if not PILLOW_AVAILABLE:
+ return matches
+
+ try:
+ with Image.open(file_path) as img:
+ metadata = self._extract_image_metadata(img)
+
+ # Convert metadata to searchable text
+ line_num = 1
+ for key, value in metadata.items():
+ # Create searchable line from metadata
+ line_text = f"{key}: {value}"
+
+ # Search for matches in this metadata line
+ for match in regex.finditer(line_text):
+ search_match = SearchMatch(
+ file_path=file_path,
+ line_number=line_num,
+ line_content=line_text,
+ match_start=match.start(),
+ match_end=match.end(),
+ context_before=[],
+ context_after=[]
+ )
+ matches.append(search_match)
+
+ line_num += 1
+
+ except Exception:
+ # Skip files that can't be opened as images
+ pass
+
+ return matches
+
+ def _extract_image_metadata(self, img: 'Image.Image') -> Dict[str, Any]:
+ """Extract metadata from an image"""
+ metadata = {}
+
+ # Basic image info
+ metadata['Format'] = img.format or 'Unknown'
+ metadata['Mode'] = img.mode
+ metadata['Size'] = f"{img.width}x{img.height}"
+
+ # EXIF data
+ try:
+ exif_data = img._getexif()
+ if exif_data:
+ for tag_id, value in exif_data.items():
+ tag_name = TAGS.get(tag_id, f"Unknown_{tag_id}")
+
+ # Handle GPS data specially
+ if tag_name == "GPSInfo":
+ gps_data = {}
+ for gps_tag_id, gps_value in value.items():
+ gps_tag_name = GPSTAGS.get(gps_tag_id, f"GPS_{gps_tag_id}")
+ gps_data[gps_tag_name] = str(gps_value)
+ metadata[tag_name] = str(gps_data)
+ else:
+ # Convert value to string, handle bytes
+ if isinstance(value, bytes):
+ try:
+ value = value.decode('utf-8', errors='ignore')
+ except Exception:
+ value = str(value)
+ metadata[tag_name] = str(value)
+ except (AttributeError, KeyError, TypeError):
+ pass
+
+ # PNG info
+ if hasattr(img, 'info'):
+ for key, value in img.info.items():
+ if key not in metadata:
+ metadata[f"PNG_{key}"] = str(value)
+
+ return metadata
+
+ def _search_file_metadata(self, file_path: str, regex: re.Pattern) -> List[SearchMatch]:
+ """Search file metadata for pattern matches"""
+ matches = []
+ metadata = self._extract_file_metadata(file_path)
+
+ # Convert metadata to searchable text
+ line_num = 1
+ for key, value in metadata.items():
+ # Create searchable line from metadata
+ line_text = f"{key}: {value}"
+
+ # Search for matches in this metadata line
+ for match in regex.finditer(line_text):
+ search_match = SearchMatch(
+ file_path=file_path,
+ line_number=line_num,
+ line_content=line_text,
+ match_start=match.start(),
+ match_end=match.end(),
+ context_before=[],
+ context_after=[]
+ )
+ matches.append(search_match)
+
+ line_num += 1
+
+ return matches
+
+ def _extract_file_metadata(self, file_path: str) -> Dict[str, Any]:
+ """Extract metadata from various file types"""
+ metadata = {}
+ file_ext = os.path.splitext(file_path)[1].lower()
+
+ try:
+ # Screenwriting formats
+ if file_ext == '.fdx' and XML_AVAILABLE:
+ # Final Draft XML format
+ tree = ET.parse(file_path)
+ root = tree.getroot()
+
+ # Extract metadata from FinalDraft namespace
+ for content in root.findall('.//{http://www.finaldraft.com/FDX}Content'):
+ content_type = content.get('Type', '')
+ if content_type:
+ metadata[f'FDX_{content_type}'] = content.text[:200] if content.text else ''
+
+ # Count pages, scenes, etc
+ scenes = root.findall('.//{http://www.finaldraft.com/FDX}Paragraph[@Type="Scene Heading"]')
+ metadata['Scenes'] = str(len(scenes))
+
+ elif file_ext == '.fountain':
+ # Fountain format (plain text with special syntax)
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
+ lines = f.readlines()
+
+ # Extract title page metadata (key: value format at start)
+ in_title_page = True
+ scene_count = 0
+
+ for line in lines:
+ if in_title_page and ':' in line and not line.startswith('.'):
+ key, value = line.split(':', 1)
+ metadata[key.strip()] = value.strip()[:200]
+ elif line.strip() == '':
+ in_title_page = False
+ elif line.strip().startswith(('INT.', 'EXT.', 'INT/EXT', 'I/E')):
+ scene_count += 1
+
+ metadata['Scenes'] = str(scene_count)
+
+ elif file_ext == '.celtx' and ZIPFILE_AVAILABLE:
+ # Celtx is a ZIP archive with HTML/XML
+ with zipfile.ZipFile(file_path, 'r') as z:
+ if 'project.celtx' in z.namelist():
+ content = z.read('project.celtx').decode('utf-8', errors='ignore')
+ # Basic metadata extraction
+ metadata['Type'] = 'Celtx Project'
+ metadata['Files'] = str(len(z.namelist()))
+
+ # Archive formats
+ elif file_ext == '.zip' and ZIPFILE_AVAILABLE:
+ with zipfile.ZipFile(file_path, 'r') as z:
+ metadata['Files'] = str(len(z.namelist()))
+ metadata['Compressed Size'] = f"{os.path.getsize(file_path) / 1024:.1f} KB"
+ # List first 10 files
+ file_list = z.namelist()[:10]
+ metadata['Contents'] = ', '.join(file_list)
+ if len(z.namelist()) > 10:
+ metadata['Contents'] += f' ... and {len(z.namelist()) - 10} more'
+
+ elif file_ext == '.epub' and ZIPFILE_AVAILABLE:
+ # EPUB is a ZIP with specific structure
+ with zipfile.ZipFile(file_path, 'r') as z:
+ # Try to read metadata from content.opf
+ for name in z.namelist():
+ if name.endswith('.opf'):
+ content = z.read(name).decode('utf-8', errors='ignore')
+ if XML_AVAILABLE:
+ try:
+ root = ET.fromstring(content)
+ # Extract Dublin Core metadata
+ ns = {'dc': 'http://purl.org/dc/elements/1.1/'}
+ for elem in root.findall('.//dc:title', ns):
+ metadata['Title'] = elem.text[:200] if elem.text else ''
+ for elem in root.findall('.//dc:creator', ns):
+ metadata['Author'] = elem.text[:200] if elem.text else ''
+ for elem in root.findall('.//dc:publisher', ns):
+ metadata['Publisher'] = elem.text[:200] if elem.text else ''
+ for elem in root.findall('.//dc:language', ns):
+ metadata['Language'] = elem.text if elem.text else ''
+ except Exception:
+ pass
+ break
+
+ # Structured data formats
+ elif file_ext == '.csv' and CSV_AVAILABLE:
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
+ reader = csv.reader(f)
+ headers = next(reader, None)
+ if headers:
+ metadata['Columns'] = ', '.join(headers[:10])
+ if len(headers) > 10:
+ metadata['Columns'] += f' ... ({len(headers)} total)'
+
+ # Count rows (limit to avoid performance issues)
+ row_count = sum(1 for _ in reader)
+ metadata['Rows'] = str(row_count + 1) # +1 for header
+
+ elif file_ext == '.json' and JSON_AVAILABLE:
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
+ try:
+ data = json_lib.load(f)
+ metadata['Type'] = type(data).__name__
+ if isinstance(data, dict):
+ metadata['Keys'] = ', '.join(list(data.keys())[:10])
+ if len(data) > 10:
+ metadata['Keys'] += f' ... ({len(data)} total)'
+ elif isinstance(data, list):
+ metadata['Items'] = str(len(data))
+ except Exception:
+ metadata['Type'] = 'Invalid JSON'
+
+ elif file_ext == '.xml' and XML_AVAILABLE:
+ tree = ET.parse(file_path)
+ root = tree.getroot()
+ metadata['Root Tag'] = root.tag
+ metadata['Namespace'] = root.tag.split('}')[0][1:] if '}' in root.tag else 'None'
+ metadata['Child Elements'] = str(len(list(root)))
+
+ # Extract common attributes
+ for key, value in root.attrib.items():
+ metadata[f'Attr_{key}'] = str(value)[:200]
+
+ # OpenDocument formats
+ elif file_ext in {'.odt', '.ods', '.odp'} and ZIPFILE_AVAILABLE:
+ with zipfile.ZipFile(file_path, 'r') as z:
+ if 'meta.xml' in z.namelist():
+ content = z.read('meta.xml').decode('utf-8', errors='ignore')
+ if XML_AVAILABLE:
+ try:
+ root = ET.fromstring(content)
+ ns = {
+ 'meta': 'urn:oasis:names:tc:opendocument:xmlns:meta:1.0',
+ 'dc': 'http://purl.org/dc/elements/1.1/'
+ }
+ for elem in root.findall('.//dc:title', ns):
+ metadata['Title'] = elem.text[:200] if elem.text else ''
+ for elem in root.findall('.//dc:creator', ns):
+ metadata['Creator'] = elem.text[:200] if elem.text else ''
+ for elem in root.findall('.//dc:subject', ns):
+ metadata['Subject'] = elem.text[:200] if elem.text else ''
+ for elem in root.findall('.//meta:keyword', ns):
+ metadata['Keywords'] = elem.text[:200] if elem.text else ''
+ except Exception:
+ pass
+
+ # PDF files
+ elif file_ext == '.pdf' and PYPDF2_AVAILABLE:
+ with open(file_path, 'rb') as f:
+ pdf = PyPDF2.PdfReader(f)
+ if pdf.metadata:
+ for key, value in pdf.metadata.items():
+ metadata[f"PDF_{key.strip('/')}"] = str(value)[:200]
+ metadata['PDF_Pages'] = str(len(pdf.pages))
+
+ # Word documents
+ elif file_ext == '.docx' and DOCX_AVAILABLE:
+ doc = docx.Document(file_path)
+ props = doc.core_properties
+ if props.author: metadata['Author'] = props.author
+ if props.title: metadata['Title'] = props.title
+ if props.subject: metadata['Subject'] = props.subject
+ if props.keywords: metadata['Keywords'] = props.keywords
+ if props.category: metadata['Category'] = props.category
+ if props.comments: metadata['Comments'] = props.comments[:200]
+ if props.created: metadata['Created'] = str(props.created)
+ if props.modified: metadata['Modified'] = str(props.modified)
+ metadata['Paragraphs'] = str(len(doc.paragraphs))
+
+ # Excel files
+ elif file_ext == '.xlsx' and OPENPYXL_AVAILABLE:
+ wb = openpyxl.load_workbook(file_path, read_only=True)
+ props = wb.properties
+ if props.creator: metadata['Creator'] = props.creator
+ if props.title: metadata['Title'] = props.title
+ if props.subject: metadata['Subject'] = props.subject
+ if props.keywords: metadata['Keywords'] = props.keywords
+ if props.category: metadata['Category'] = props.category
+ if props.description: metadata['Description'] = props.description[:200]
+ if props.created: metadata['Created'] = str(props.created)
+ if props.modified: metadata['Modified'] = str(props.modified)
+ metadata['Sheets'] = str(len(wb.sheetnames))
+ wb.close()
+
+ # Audio/Video files
+ elif file_ext in {'.mp3', '.flac', '.m4a', '.ogg', '.wma', '.mp4', '.avi', '.mkv', '.mov', '.wmv'} and MUTAGEN_AVAILABLE:
+ audio = MutagenFile(file_path)
+ if audio and audio.tags:
+ for key, value in audio.tags.items():
+ # Clean up tag name
+ clean_key = str(key).replace('\x00', '')
+ metadata[f"Audio_{clean_key}"] = str(value)[:200]
+ if audio and hasattr(audio.info, 'length'):
+ metadata['Duration'] = f"{int(audio.info.length // 60)}:{int(audio.info.length % 60):02d}"
+ if audio and hasattr(audio.info, 'bitrate'):
+ metadata['Bitrate'] = f"{audio.info.bitrate // 1000} kbps"
+
+ # SQLite databases
+ elif file_ext in {'.db', '.sqlite', '.sqlite3'} and SQLITE_AVAILABLE:
+ conn = sqlite3.connect(file_path)
+ cursor = conn.cursor()
+
+ # Get all table names
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
+ tables = [row[0] for row in cursor.fetchall()]
+ metadata['Tables'] = ', '.join(tables)
+ metadata['Table Count'] = str(len(tables))
+
+ # Get schema info for each table
+ for table in tables[:5]: # Limit to first 5 tables
+ cursor.execute(f"PRAGMA table_info({table})")
+ columns = [row[1] for row in cursor.fetchall()]
+ metadata[f'Table_{table}_Columns'] = ', '.join(columns)
+
+ # Get row count
+ cursor.execute(f"SELECT COUNT(*) FROM {table}")
+ row_count = cursor.fetchone()[0]
+ metadata[f'Table_{table}_Rows'] = str(row_count)
+
+ conn.close()
+
+ # RTF files
+ elif file_ext == '.rtf':
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
+ content = f.read(1000) # Read first 1KB
+
+ # Extract basic RTF metadata from header
+ # RTF format: {\info{\title ...}{\author ...}}
+ import re
+ title_match = re.search(r'\\title\s+([^}]+)', content)
+ if title_match:
+ metadata['Title'] = title_match.group(1).strip()[:200]
+
+ author_match = re.search(r'\\author\s+([^}]+)', content)
+ if author_match:
+ metadata['Author'] = author_match.group(1).strip()[:200]
+
+ subject_match = re.search(r'\\subject\s+([^}]+)', content)
+ if subject_match:
+ metadata['Subject'] = subject_match.group(1).strip()[:200]
+
+ # Get RTF version
+ version_match = re.search(r'\\rtf(\d+)', content)
+ if version_match:
+ metadata['RTF Version'] = version_match.group(1)
+
+ except Exception as e:
+ # If metadata extraction fails, just skip this file
+ pass
+
+ return metadata
+
+ def _search_archive(self, file_path: str, regex: re.Pattern) -> List[SearchMatch]:
+ """Search inside archive files (ZIP, EPUB, etc.)"""
+ matches = []
+
+ if not ZIPFILE_AVAILABLE:
+ return matches
+
+ try:
+ with zipfile.ZipFile(file_path, 'r') as zf:
+ for member in zf.namelist():
+ # Skip directories
+ if member.endswith('/'):
+ continue
+
+ # Check file size
+ member_info = zf.getinfo(member)
+ if member_info.file_size > self.max_search_file_size:
+ continue
+
+ try:
+ # Read file content from archive
+ content = zf.read(member)
+
+ # Try to decode as text
+ try:
+ text = content.decode('utf-8', errors='ignore')
+ lines = text.split('\n')
+
+ # Search each line
+ for i, line in enumerate(lines):
+ for match in regex.finditer(line):
+ # Get context lines
+ context_before = []
+ context_after = []
+
+ start_idx = max(0, i - self.context_lines)
+ end_idx = min(len(lines), i + self.context_lines + 1)
+
+ if self.context_lines > 0:
+ context_before = [lines[j].rstrip('\n\r') for j in range(start_idx, i)]
+ context_after = [lines[j].rstrip('\n\r') for j in range(i + 1, end_idx)]
+
+ # Use archive_path/internal_path format
+ search_match = SearchMatch(
+ file_path=f"{file_path}/{member}",
+ line_number=i + 1,
+ line_content=line.rstrip('\n\r'),
+ match_start=match.start(),
+ match_end=match.end(),
+ context_before=context_before,
+ context_after=context_after
+ )
+ matches.append(search_match)
+ except UnicodeDecodeError:
+ # Binary file inside archive, skip
+ pass
+ except Exception:
+ # Skip files that can't be read from archive
+ pass
+ except Exception:
+ # Skip archives that can't be opened
+ pass
+
+ return matches
+
+ def _search_binary(self, file_path: str, regex: re.Pattern) -> List[SearchMatch]:
+ """Search binary files for hex patterns"""
+ matches = []
+
+ try:
+ with open(file_path, 'rb') as f:
+ content = f.read()
+
+ # Convert pattern to bytes if it looks like hex
+ pattern_str = regex.pattern
+
+ # Try to match as both text and hex
+ # Search for text pattern in binary content
+ try:
+ text_content = content.decode('utf-8', errors='ignore')
+ for match in regex.finditer(text_content):
+ # Calculate byte offset
+ byte_offset = match.start()
+
+ # Get hex dump context (16 bytes before and after)
+ start = max(0, byte_offset - 16)
+ end = min(len(content), byte_offset + 16)
+ hex_context = content[start:end].hex(' ')
+
+ search_match = SearchMatch(
+ file_path=file_path,
+ line_number=byte_offset, # Using offset as "line"
+ line_content=f"Offset {byte_offset:08x}: {hex_context}",
+ match_start=match.start(),
+ match_end=match.end(),
+ context_before=[],
+ context_after=[]
+ )
+ matches.append(search_match)
+ except Exception:
+ pass
+
+ except Exception:
+ pass
+
+ return matches
+
+ def _is_network_path(self, path: str) -> bool:
+ """Check if path is a network/UNC path"""
+ # UNC paths start with \\
+ return path.startswith('\\\\') or path.startswith('//')
+
+ def _check_network_path_accessible(self, path: str) -> bool:
+ """Check if network path is accessible with timeout"""
+ # Check cache first
+ if path in self._network_path_cache:
+ return self._network_path_cache[path]
+
+ try:
+ # Try to check if path exists with timeout
+ import concurrent.futures
+ with concurrent.futures.ThreadPoolExecutor() as executor:
+ future = executor.submit(os.path.exists, path)
+ accessible = future.result(timeout=self.network_timeout)
+ self._network_path_cache[path] = accessible
+ return accessible
+ except Exception:
+ # If timeout or error, assume not accessible
+ self._network_path_cache[path] = False
+ return False
+
+ def set_case_sensitive(self, enabled: bool):
+ """Enable or disable case-sensitive search"""
+ self.case_sensitive = enabled
+
+ def set_regex(self, enabled: bool):
+ """Enable or disable regex search"""
+ self.use_regex = enabled
+
+ def set_whole_word(self, enabled: bool):
+ """Enable or disable whole word search"""
+ self.whole_word = enabled
+
+ def set_search_metadata(self, enabled: bool):
+ """Enable or disable metadata search for images"""
+ self.search_metadata = enabled
+
+ def set_search_file_metadata(self, enabled: bool):
+ """Enable or disable metadata search for files (PDF, Office, audio, video)"""
+ self.search_file_metadata = enabled
+
+ def set_search_in_archives(self, enabled: bool):
+ """Enable or disable searching inside archive files"""
+ self.search_in_archives = enabled
+
+ def set_hex_search(self, enabled: bool):
+ """Enable or disable binary/hex search mode"""
+ self.hex_search = enabled
+
+ def clear_network_cache(self):
+ """Clear the network path accessibility cache"""
+ self._network_path_cache.clear()
+
+ def set_context_lines(self, lines: int):
+ """Set number of context lines to include"""
+ self.context_lines = max(0, lines)
+
+ def set_file_extensions(self, extensions: List[str]):
+ """Set file extensions to filter (e.g., ['.py', '.txt'])"""
+ self.file_extensions = extensions
+
+ def add_exclude_pattern(self, pattern: str):
+ """Add a pattern to exclude from search"""
+ self.exclude_patterns.append(pattern)
diff --git a/Currency Script/tests/__pycache__/__init__.cpython-313.pyc b/Currency Script/tests/__pycache__/__init__.cpython-313.pyc
deleted file mode 100644
index 52c26ecd..00000000
Binary files a/Currency Script/tests/__pycache__/__init__.cpython-313.pyc and /dev/null differ
diff --git a/Currency Script/tests/__pycache__/test_api_handler.cpython-313-pytest-8.3.5.pyc b/Currency Script/tests/__pycache__/test_api_handler.cpython-313-pytest-8.3.5.pyc
deleted file mode 100644
index adca8b35..00000000
Binary files a/Currency Script/tests/__pycache__/test_api_handler.cpython-313-pytest-8.3.5.pyc and /dev/null differ
diff --git a/Currency Script/tests/__pycache__/test_converter.cpython-313-pytest-8.3.5.pyc b/Currency Script/tests/__pycache__/test_converter.cpython-313-pytest-8.3.5.pyc
deleted file mode 100644
index 020a50ff..00000000
Binary files a/Currency Script/tests/__pycache__/test_converter.cpython-313-pytest-8.3.5.pyc and /dev/null differ
diff --git a/Currency Script/tests/__pycache__/test_currencies.cpython-313-pytest-8.3.5.pyc b/Currency Script/tests/__pycache__/test_currencies.cpython-313-pytest-8.3.5.pyc
deleted file mode 100644
index adf1456a..00000000
Binary files a/Currency Script/tests/__pycache__/test_currencies.cpython-313-pytest-8.3.5.pyc and /dev/null differ
diff --git a/FileOrganizer/FileOrganizer.py b/FileOrganizer/FileOrganizer.py
index 0ca91093..c5cccebe 100644
--- a/FileOrganizer/FileOrganizer.py
+++ b/FileOrganizer/FileOrganizer.py
@@ -34,7 +34,7 @@
dest_file_path = os.path.join(dest_folder, file)
counter = 1
while os.path.exists(dest_file_path):
- newfilename = f"{filename}{counter}.{extension}" if extension != "NoExtension" else f"{filename}_{counter}"
+ new_filename = f"{filename}{counter}.{extension}" if extension != "NoExtension" else f"{filename}_{counter}"
dest_file_path = os.path.join(dest_folder, new_filename)
counter += 1
diff --git a/Font Art/FontArt.py b/Font Art/FontArt.py
index 99b91c03..a1075c23 100644
--- a/Font Art/FontArt.py
+++ b/Font Art/FontArt.py
@@ -1,6 +1,505 @@
import os
-from pyfiglet import Figlet
-text = Figlet(font="slant")
-os.system("cls")
-os.system("mode con: cols=75 lines=30")
-print(text.renderText("Dhanush N"))
\ No newline at end of file
+import random
+from typing import List
+
+from pyfiglet import Figlet, FigletFont
+
+from PySide6 import QtCore, QtGui, QtWidgets
+
+
+APP_TITLE_TEXT = "Font Art"
+
+
+def list_figlet_fonts() -> List[str]:
+ try:
+ return sorted(FigletFont.getFonts())
+ except Exception:
+ # Fallback: a small known-good subset if pyfiglet fails
+ return [
+ "standard",
+ "slant",
+ "big",
+ "doom",
+ "banner",
+ "block",
+ "digital",
+ "roman",
+ "script",
+ "shadow",
+ ]
+
+
+class GlassCard(QtWidgets.QFrame):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setObjectName("GlassCard")
+ self.setFrameStyle(QtWidgets.QFrame.NoFrame)
+ self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
+ # Drop shadow for a bubbly, floating feel
+ effect = QtWidgets.QGraphicsDropShadowEffect(self)
+ effect.setBlurRadius(40)
+ effect.setXOffset(0)
+ effect.setYOffset(12)
+ effect.setColor(QtGui.QColor(0, 0, 0, 80))
+ self.setGraphicsEffect(effect)
+
+ def paintEvent(self, event: QtGui.QPaintEvent) -> None:
+ # Custom rounded, semi-transparent gradient card painting
+ radius = 24
+ rect = self.rect()
+ painter = QtGui.QPainter(self)
+ painter.setRenderHint(QtGui.QPainter.Antialiasing)
+
+ # Glassy gradient: baby blue -> pink with alpha
+ grad = QtGui.QLinearGradient(rect.topLeft(), rect.bottomRight())
+ # Reduce transparency by ~20% (increase opacity)
+ grad.setColorAt(0.0, QtGui.QColor(173, 216, 230, 216)) # was 180
+ grad.setColorAt(1.0, QtGui.QColor(255, 182, 193, 216)) # was 180
+
+ path = QtGui.QPainterPath()
+ path.addRoundedRect(rect.adjusted(1, 1, -1, -1), radius, radius)
+
+ # Subtle border highlight
+ pen = QtGui.QPen(QtGui.QColor(255, 255, 255, 204), 1.2)
+ painter.setPen(pen)
+ painter.fillPath(path, grad)
+ painter.drawPath(path)
+
+
+class LogoView(QtWidgets.QTextEdit):
+ """A read-only text view for the ASCII logo that we can refresh alone."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setReadOnly(True)
+ self.setLineWrapMode(QtWidgets.QTextEdit.NoWrap)
+ self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+ self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+ self.setFrameStyle(QtWidgets.QFrame.NoFrame)
+ # Monospace font for alignment
+ font = QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont)
+ font.setPointSize(10)
+ self.setFont(font)
+ self.setObjectName("LogoView")
+
+ def sizeHint(self) -> QtCore.QSize:
+ # Larger fixed logo area that still sits near inputs
+ return QtCore.QSize(560, 120)
+
+
+class PowderArrowStyle(QtWidgets.QProxyStyle):
+ """Custom style to tint the combo box down-arrow to powder blue."""
+
+ def drawPrimitive(self, element, option, painter, widget=None):
+ if element == QtWidgets.QStyle.PE_IndicatorArrowDown:
+ painter.save()
+ painter.setRenderHint(QtGui.QPainter.Antialiasing)
+ painter.setPen(QtCore.Qt.NoPen)
+ painter.setBrush(QtGui.QColor("#B0E0E6")) # powder blue
+ r = option.rect
+ size = int(min(r.width(), r.height()) * 0.45)
+ cx, cy = r.center().x(), r.center().y()
+ pts = [
+ QtCore.QPoint(cx - size, cy - size // 3),
+ QtCore.QPoint(cx + size, cy - size // 3),
+ QtCore.QPoint(cx, cy + size // 2),
+ ]
+ painter.drawPolygon(QtGui.QPolygon(pts))
+ painter.restore()
+ return
+ super().drawPrimitive(element, option, painter, widget)
+
+
+class FontArtWindow(QtWidgets.QWidget):
+ def __init__(self):
+ super().__init__()
+ self.setObjectName("RootWindow")
+ self.setWindowTitle("Font Art – Qt6")
+ self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
+ # Frameless floating style
+ self.setWindowFlags(
+ QtCore.Qt.FramelessWindowHint | QtCore.Qt.Window
+ )
+ self.resize(820, 560)
+
+ # Central card with glassy look
+ self.card = GlassCard(self)
+
+ # Child layout within card
+ card_layout = QtWidgets.QVBoxLayout(self.card)
+ card_layout.setContentsMargins(24, 16, 24, 16)
+ # We'll control exact gaps manually (logo->inputs 15px, inputs->buttons 6px)
+ card_layout.setSpacing(0)
+
+ # ASCII logo at top that only refreshes itself
+ self.logo_view = LogoView()
+ # Center ASCII content within the logo view
+ doc = self.logo_view.document()
+ opt = doc.defaultTextOption()
+ opt.setAlignment(QtCore.Qt.AlignHCenter)
+ doc.setDefaultTextOption(opt)
+ self.logo_view.setAlignment(QtCore.Qt.AlignHCenter)
+ # Fix the logo area height so layout below never moves
+ self.logo_view.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
+ self.logo_view.setFixedHeight(self.logo_view.sizeHint().height())
+ # Top stretch so the logo+inputs cluster is not pinned to the top
+ card_layout.addStretch(1)
+ card_layout.addWidget(self.logo_view)
+ # Place the logo box just above the inputs with a 15px gap
+ card_layout.addSpacing(15)
+
+ # Input row: text box, font dropdown (with label over dropdown)
+ self.input_edit = QtWidgets.QLineEdit()
+ self.input_edit.setPlaceholderText("Type text to render…")
+ self.input_edit.setObjectName("InputEdit")
+ self.input_edit.setAlignment(QtCore.Qt.AlignCenter)
+ self.input_edit.setMinimumWidth(520) # a little wider for comfortable typing
+
+ self.font_combo = QtWidgets.QComboBox()
+ self.font_combo.setObjectName("FontCombo")
+ self.font_combo.setMaxVisibleItems(14)
+ self.font_combo.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToMinimumContentsLengthWithIcon)
+ self.font_combo.setInsertPolicy(QtWidgets.QComboBox.NoInsert)
+ self.font_combo.setView(QtWidgets.QListView()) # scrollable
+ self.font_combo.setFixedWidth(240) # static size for consistent layout
+ # Apply custom style with powder-blue arrow tint
+ self.font_combo.setStyle(PowderArrowStyle(self.style()))
+
+ self.font_label = QtWidgets.QLabel("Choose Font Style")
+ self.font_label.setObjectName("FontLabel")
+ self.font_label.setAlignment(QtCore.Qt.AlignHCenter)
+
+ # Align textbox and dropdown in the same row; label sits just above dropdown
+ font_widget = QtWidgets.QWidget()
+ font_widget.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)
+ font_col = QtWidgets.QVBoxLayout(font_widget)
+ font_col.setSpacing(4)
+ font_col.setContentsMargins(0, 0, 0, 0)
+ font_col.addWidget(self.font_label, alignment=QtCore.Qt.AlignHCenter)
+ font_col.addWidget(self.font_combo, alignment=QtCore.Qt.AlignVCenter)
+
+ input_row = QtWidgets.QHBoxLayout()
+ input_row.setSpacing(10)
+ input_row.setContentsMargins(0, 0, 0, 0)
+ input_row.addStretch(1)
+ # Align bottoms so the text box and dropdown bottoms line up
+ input_row.addWidget(self.input_edit, 0, QtCore.Qt.AlignBottom)
+ input_row.addWidget(font_widget, 0, QtCore.Qt.AlignBottom)
+ input_row.addStretch(1)
+ card_layout.addLayout(input_row)
+
+ # Buttons row: Create and Quit
+ self.generate_btn = QtWidgets.QPushButton("Create")
+ self.generate_btn.setObjectName("GenerateButton")
+ self.generate_btn.setCursor(QtCore.Qt.PointingHandCursor)
+ self.quit_btn = QtWidgets.QPushButton("Quit")
+ self.quit_btn.setObjectName("QuitButton")
+ self.quit_btn.setCursor(QtCore.Qt.PointingHandCursor)
+
+ buttons_row = QtWidgets.QHBoxLayout()
+ buttons_row.setSpacing(12)
+ # Equal spacing across width: left, between, right stretches
+ buttons_row.addStretch(1)
+ buttons_row.addWidget(self.generate_btn)
+ buttons_row.addStretch(1)
+ buttons_row.addWidget(self.quit_btn)
+ buttons_row.addStretch(1)
+ # 6px gap from inputs to buttons row
+ card_layout.addSpacing(6)
+ card_layout.addLayout(buttons_row)
+ # Bottom stretch so the cluster stays together above buttons
+ card_layout.addStretch(1)
+
+ # Populate fonts
+ self.fonts: List[str] = list_figlet_fonts()
+ self.font_combo.addItems(self.fonts)
+ if self.fonts:
+ self.font_combo.setCurrentIndex(0) # ensure not empty
+
+ # Figlet instance for rendering
+ self.figlet = Figlet(font=self.fonts[0] if self.fonts else "standard")
+
+ # Connections
+ self.generate_btn.clicked.connect(self.on_generate)
+ self.font_combo.currentTextChanged.connect(self.on_font_change)
+ self.quit_btn.clicked.connect(self.close)
+
+ # Timer for rotating logo font every 3 seconds
+ self.logo_timer = QtCore.QTimer(self)
+ self.logo_timer.setInterval(3000)
+ self.logo_timer.timeout.connect(self.refresh_logo)
+ self.logo_timer.start()
+
+ # Initial render
+ self.refresh_logo()
+
+ # Overall layout for root: center the card
+ root_layout = QtWidgets.QVBoxLayout(self)
+ root_layout.setContentsMargins(24, 24, 24, 24)
+ root_layout.addWidget(self.card)
+
+ # Apply stylesheet theme
+ self.apply_styles()
+ # Make buttons equal width for symmetry
+ btn_w = max(self.generate_btn.sizeHint().width(), self.quit_btn.sizeHint().width())
+ self.generate_btn.setMinimumWidth(btn_w)
+ self.quit_btn.setMinimumWidth(btn_w)
+
+ # Adjust window width to fit content tightly
+ self.adjust_width_to_content()
+
+ # Match heights of the input and dropdown so bottoms are perfectly even
+ QtCore.QTimer.singleShot(0, self._sync_input_heights)
+
+ # Make window draggable from the logo or the card background
+ self._drag_pos: QtCore.QPoint | None = None
+ self.logo_view.installEventFilter(self)
+ self.card.installEventFilter(self)
+
+ def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
+ super().resizeEvent(event)
+ # Reflow logo art to fit within fixed logo area when resized
+ QtCore.QTimer.singleShot(0, self.refresh_logo)
+
+ def apply_styles(self):
+ # Baby blue + pink theme, rounded, glassy controls
+ self.setStyleSheet(
+ """
+ #RootWindow {
+ background: transparent;
+ }
+ QLabel {
+ color: #B0E0E6; /* powder blue */
+ font-weight: 600;
+ }
+
+ #LogoView {
+ color: rgba(35, 35, 35, 235);
+ background: transparent;
+ border-radius: 12px;
+ }
+
+ #InputEdit {
+ padding: 10px 14px;
+ border-radius: 14px;
+ border: 1px solid rgba(255,255,255,216);
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
+ stop:0 rgba(255, 240, 245, 240), /* lavenderblush */
+ stop:1 rgba(224, 247, 250, 240) /* light cyan */
+ );
+ color: #222;
+ selection-background-color: rgba(255,182,193,216);
+ }
+ #InputEdit:focus {
+ border: 1.5px solid rgba(135, 206, 235, 255);
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
+ stop:0 rgba(255, 228, 235, 255),
+ stop:1 rgba(210, 245, 255, 255)
+ );
+ }
+
+ #FontCombo {
+ padding: 8px 12px;
+ border-radius: 14px;
+ border: 1px solid rgba(255,255,255,216);
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
+ stop:0 rgba(224, 247, 250, 240),
+ stop:1 rgba(255, 240, 245, 240)
+ );
+ color: #222; /* match input text color */
+ }
+ #FontCombo::drop-down {
+ width: 26px;
+ border: 0px;
+ }
+ #FontCombo QAbstractItemView {
+ background: rgba(255,255,255, 255);
+ border: 1px solid rgba(135,206,235,216);
+ color: #222; /* match input text color */
+ selection-background-color: rgba(255, 182, 193, 240);
+ outline: none;
+ }
+
+ #GenerateButton, #QuitButton {
+ padding: 12px 22px; /* slightly increased padding */
+ border-radius: 18px;
+ border: 1px solid rgba(255,255,255,216);
+ color: #1f2937;
+ font-weight: 600;
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
+ stop:0 rgba(173, 216, 230, 255),
+ stop:1 rgba(255, 182, 193, 255)
+ );
+ }
+ #GenerateButton:hover, #QuitButton:hover {
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
+ stop:0 rgba(173, 216, 230, 255),
+ stop:1 rgba(255, 182, 193, 255)
+ );
+ }
+ #GenerateButton:pressed, #QuitButton:pressed {
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
+ stop:0 rgba(173, 216, 230, 240),
+ stop:1 rgba(255, 182, 193, 240)
+ );
+ }
+
+ #FontLabel {
+ padding-left: 0px;
+ }
+ """
+ )
+
+ # --- Logic ---
+ def eventFilter(self, source: QtCore.QObject, event: QtCore.QEvent) -> bool:
+ # Enable dragging the frameless window by grabbing the logo or card background
+ if source in (self.logo_view, self.card):
+ if event.type() == QtCore.QEvent.MouseButtonPress and isinstance(event, QtGui.QMouseEvent):
+ if event.button() == QtCore.Qt.LeftButton:
+ # Store offset from top-left corner
+ global_pos = event.globalPosition() if hasattr(event, "globalPosition") else event.globalPos()
+ if isinstance(global_pos, QtCore.QPointF):
+ global_pos = global_pos.toPoint()
+ self._drag_pos = global_pos - self.frameGeometry().topLeft()
+ return True
+ elif event.type() == QtCore.QEvent.MouseMove and isinstance(event, QtGui.QMouseEvent):
+ if self._drag_pos is not None and (event.buttons() & QtCore.Qt.LeftButton):
+ global_pos = event.globalPosition() if hasattr(event, "globalPosition") else event.globalPos()
+ if isinstance(global_pos, QtCore.QPointF):
+ global_pos = global_pos.toPoint()
+ self.move(global_pos - self._drag_pos)
+ return True
+ elif event.type() == QtCore.QEvent.MouseButtonRelease and isinstance(event, QtGui.QMouseEvent):
+ if self._drag_pos is not None:
+ self._drag_pos = None
+ return True
+ return super().eventFilter(source, event)
+ def refresh_logo(self):
+ if not self.fonts:
+ return
+
+ # Determine character columns that fit in the logo viewport width
+ viewport_w = self.logo_view.viewport().width()
+ metrics = QtGui.QFontMetrics(self.logo_view.font())
+ char_w = max(1, metrics.horizontalAdvance("M"))
+ cols = max(40, int((viewport_w - 16) / char_w))
+
+ random_font = random.choice(self.fonts)
+ try:
+ fig = Figlet(font=random_font, width=cols, justify="center")
+ art = fig.renderText(APP_TITLE_TEXT)
+ except Exception:
+ art = APP_TITLE_TEXT
+
+ # Update content
+ self.logo_view.setPlainText(art)
+
+ # Fit height: reduce font size if needed so title fits above actions
+ self._fit_logo_height(art)
+
+ def on_font_change(self, font_name: str):
+ try:
+ self.figlet.setFont(font=font_name)
+ except Exception:
+ # ignore invalid font switches
+ pass
+
+ def on_generate(self):
+ text = self.input_edit.text().strip()
+ if not text:
+ QtWidgets.QMessageBox.information(self, "Nothing to render", "Please enter some text to render.")
+ return
+
+ selected_font = self.font_combo.currentText() or "standard"
+ try:
+ self.figlet.setFont(font=selected_font)
+ art = self.figlet.renderText(text)
+ except Exception as e:
+ QtWidgets.QMessageBox.warning(self, "Render failed", f"Could not render text with font '{selected_font}'.\n{e}")
+ return
+
+ # Ask where to save
+ default_name = f"{text[:20].strip().replace(' ', '_') or 'font_art'}.txt"
+ path, _ = QtWidgets.QFileDialog.getSaveFileName(
+ self,
+ "Save ASCII Art",
+ os.path.join(os.path.expanduser("~"), default_name),
+ "Text Files (*.txt);;All Files (*)",
+ )
+ if not path:
+ return
+
+ try:
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(art)
+ except Exception as e:
+ QtWidgets.QMessageBox.critical(self, "Save failed", f"Could not save file:\n{e}")
+ return
+
+ QtWidgets.QMessageBox.information(self, "Saved", f"Art saved to:\n{path}")
+
+ # --- Helpers ---
+ def _fit_logo_height(self, art: str) -> None:
+ # Keep the logo area height fixed; only adjust font size down to fit
+ max_h = max(60, self.logo_view.height() - 4)
+ f = self.logo_view.font()
+ pt = f.pointSize() if f.pointSize() > 0 else 10
+ metrics = QtGui.QFontMetrics(f)
+
+ lines = art.splitlines() or [""]
+ req_h = len(lines) * metrics.lineSpacing()
+ while req_h > max_h and pt > 7:
+ pt -= 1
+ f.setPointSize(pt)
+ self.logo_view.setFont(f)
+ metrics = QtGui.QFontMetrics(f)
+ req_h = len(lines) * metrics.lineSpacing()
+ # Do not change widget height; positions of other items remain constant
+
+ def _sync_input_heights(self) -> None:
+ h = max(self.input_edit.sizeHint().height(), self.font_combo.sizeHint().height())
+ self.input_edit.setFixedHeight(h)
+ self.font_combo.setFixedHeight(h)
+
+ def adjust_width_to_content(self):
+ # Compute desired content width based on input+combo and buttons rows
+ input_w = max(self.input_edit.minimumWidth(), self.input_edit.sizeHint().width())
+ combo_w = self.font_combo.width() or self.font_combo.sizeHint().width()
+ input_row_spacing = 12 # mirrors layout spacing
+ content_row_w = input_w + input_row_spacing + combo_w
+
+ # Buttons row width
+ btn_w = max(self.generate_btn.minimumWidth(), self.generate_btn.sizeHint().width())
+ quit_w = max(self.quit_btn.minimumWidth(), self.quit_btn.sizeHint().width())
+ buttons_row_spacing = 12
+ buttons_row_w = btn_w + buttons_row_spacing + quit_w
+
+ content_w = max(content_row_w, buttons_row_w)
+
+ # Add layout margins (card + root)
+ card_m = self.card.layout().contentsMargins()
+ root_m = self.layout().contentsMargins()
+ total_w = content_w + (card_m.left() + card_m.right()) + (root_m.left() + root_m.right()) + 8
+
+ # Constrain to a sensible minimum to avoid clipping
+ min_w = 560
+ total_w = max(total_w, min_w)
+
+ self.setFixedWidth(total_w)
+
+
+def main():
+ app = QtWidgets.QApplication([])
+
+ # Prefer system dark text rendering in semi-transparent windows
+ app.setApplicationName("Font Art Qt6")
+ app.setOrganizationName("FontArt")
+ app.setStyle("Fusion")
+
+ win = FontArtWindow()
+ win.show()
+ return app.exec()
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/Font Art/README.md b/Font Art/README.md
index c4d2e975..ffd4ad6d 100644
--- a/Font Art/README.md
+++ b/Font Art/README.md
@@ -1,15 +1,70 @@
-This repository consists of a list of python scripts to automate few tasks.
+# Font Art — Cute Glassy Qt6 App
-You can contribute by adding more python scripts which can be used to automate things. Some of already done are listed below.
-Incase you have anything to be followed while executing the python script mention it as well
+A tiny, bubbly, cross‑platform app for making adorable ASCII font art. It sports a soft pink + baby‑blue “glass” card, rounded corners, cozy shadows, and a frameless, draggable window.
+## Highlights
-# Python Scripts
+- Glassy theme with rounded edges, soft shadows, and higher opacity for readability
+- Frameless and draggable (drag by the logo or card area)
+- Animated title logo cycles a random FIGlet font every 3 seconds (logo refresh only)
+- Centered text box, powder‑blue label “Choose Font Style”, and a scrollable font dropdown
+- Powder‑blue tinted dropdown arrow; dropdown text matches the input’s gray
+- Fixed logo area positioned just above the inputs (layout never jumps)
+- “Create” prompts where to save `.txt`; “Quit” closes the app
+- Cross‑platform ready: Windows, macOS, Linux (PySide6)
-## Script - Font Art
+## Files
-Display a font art using python
-FontArt.py
+- App: `Python-Scripts/Font Art/FontArtQt6.py`
+- Requirements: `Python-Scripts/Font Art/requirements.txt`
+
+## Quick Start
+
+1) Python 3.9+ is recommended.
+
+2) Create a virtual environment (optional, but tidy):
+
+- Windows
+ - `py -m venv .venv`
+ - `.venv\Scripts\activate`
+- macOS/Linux
+ - `python3 -m venv .venv`
+ - `source .venv/bin/activate`
+
+3) Install dependencies:
+- `pip install -r "Python-Scripts/Font Art/requirements.txt"`
+
+4) Run the app:
+- `python "Python-Scripts/Font Art/FontArtQt6.py"`
+
+## Use It
+
+- Type your text in the input box.
+- Pick a font from the dropdown (it defaults to the first available font).
+- Click “Create” and choose where to save the generated ASCII art `.txt` file.
+- The title logo keeps changing fonts every few seconds — only the logo area updates.
+
+## Make a Stand‑Alone Binary (Optional)
+
+- Windows
+ - `pyinstaller --noconsole --onefile "Python-Scripts/Font Art/FontArtQt6.py"`
+- macOS
+ - `pyinstaller --windowed --onefile "Python-Scripts/Font Art/FontArtQt6.py"`
+- Linux
+ - `pyinstaller --windowed --onefile "Python-Scripts/Font Art/FontArtQt6.py"`
+
+Notes:
+- On Linux, transparency/shadows require a compositor (most desktops enable one by default).
+- macOS app signing/notarization is not included here.
+
+## Troubleshooting
+
+- No fonts listed? A small fallback set is used if `pyfiglet` can’t load the full list.
+- Transparency looks different across desktops; this app uses a higher‑opacity glass gradient by default.
+- Window won’t drag? Drag by the logo or anywhere on the glass card background.
+
+## Credits
+
+- ASCII rendering by `pyfiglet`
+- UI framework: `PySide6` (Qt for Python)
-
-
\ No newline at end of file
diff --git a/Font Art/requirements.txt b/Font Art/requirements.txt
new file mode 100644
index 00000000..7ef35112
--- /dev/null
+++ b/Font Art/requirements.txt
@@ -0,0 +1,2 @@
+PySide6>=6.6
+pyfiglet>=0.8.post1
diff --git a/MD to PDF or Text with GUI/README.md b/MD to PDF or Text with GUI/README.md
new file mode 100644
index 00000000..18740283
--- /dev/null
+++ b/MD to PDF or Text with GUI/README.md
@@ -0,0 +1,116 @@
+# Markdown Converter (PySide6)
+
+A simple Python GUI tool built with **PySide6** that converts Markdown (`.md`) files into **PDF** or **Text** files.
+It provides a clean interface to select the input file, choose the output format, and save it wherever you want.
+
+---
+
+## Features
+- 🖱️ **User-friendly GUI** built with PySide6
+- 📂 **File selection dialogs** for choosing input and output files
+- 📝 **Markdown → Text** conversion
+- 📄 **Markdown → PDF** conversion with **Unicode** support (Chinese, Japanese, Korean, emoji, etc.)
+- ❌ Error handling with pop-up dialogs
+- ⚡ Option to run **with or without Pandoc**
+
+---
+
+## Requirements
+
+Make sure you have **Python 3.8+** installed.
+
+Install the required Python packages:
+```bash
+pip install PySide6 reportlab pypandoc
+```
+
+---
+
+## Installing Pandoc (Required for Default Conversion)
+
+This tool uses **pypandoc**, which depends on the Pandoc binary.
+Pandoc must be installed separately on your system.
+
+### 1. Official Installation Instructions
+- Pandoc Official Installation Guide: [https://pandoc.org/installing.html](https://pandoc.org/installing.html)
+
+### 2. Windows
+- Download the **Windows Installer (.msi)** from the [Pandoc Releases Page](https://github.com/jgm/pandoc/releases)
+- Run the installer and let it add Pandoc to your PATH automatically.
+
+### 3. macOS
+Using **Homebrew** (recommended):
+```bash
+brew install pandoc
+```
+Or download the **macOS package** from the [Pandoc Releases Page](https://github.com/jgm/pandoc/releases)
+
+### 4. Linux (Debian/Ubuntu)
+```bash
+sudo apt update
+sudo apt install pandoc
+```
+For other Linux distros, check the [Pandoc Install Guide](https://pandoc.org/installing.html) for commands.
+
+---
+
+## Verify Pandoc Installation
+After installation, open a terminal or command prompt and run:
+```bash
+pandoc --version
+```
+You should see version information for Pandoc.
+
+---
+
+## How to Run
+
+1. Save the script as `markdown_converter.py`.
+2. Run the program:
+```bash
+python markdown_converter.py
+```
+
+---
+
+## Usage
+
+1. Click **"Select Markdown File"** in the app window.
+2. Choose a `.md` file from your system.
+3. Select output type: **PDF** or **Text**.
+4. Choose the save location and file name.
+5. Done! 🎉
+
+---
+
+## Unicode Support
+
+- The PDF generation uses **HeiseiMin-W3** font to support a wide range of Unicode characters.
+- This ensures **Chinese, Japanese, Korean, and emoji** render correctly in the final PDF.
+
+---
+
+## Running Without Pandoc (Pure Python Option)
+
+If you don’t want to install Pandoc,
+the code can be modified to use Python libraries like **markdown2** or **mistune** for parsing Markdown
+and **ReportLab** for PDF generation.
+
+This removes the external dependency but keeps full functionality.
+
+---
+
+## Example Screenshots & GIF Demo (Optional)
+
+You can add screenshots or a short screen recording here to make the README more user-friendly.
+
+---
+
+## License
+MIT License – free to use, modify, and share.
+
+---
+
+## Author
+
+Randy Northrup
diff --git a/MD to PDF or Text with GUI/md2pdftxt.py b/MD to PDF or Text with GUI/md2pdftxt.py
new file mode 100644
index 00000000..b0d1f621
--- /dev/null
+++ b/MD to PDF or Text with GUI/md2pdftxt.py
@@ -0,0 +1,71 @@
+import sys
+from PySide6.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QFileDialog, QMessageBox
+from PySide6.QtCore import Qt
+import pypandoc
+from reportlab.platypus import SimpleDocTemplate, Paragraph
+from reportlab.lib.styles import getSampleStyleSheet
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.cidfonts import UnicodeCIDFont
+
+class MarkdownConverter(QWidget):
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("Markdown Converter")
+ self.setGeometry(300, 300, 400, 150)
+
+ layout = QVBoxLayout()
+ self.button = QPushButton("Select Markdown File")
+ self.button.clicked.connect(self.select_file)
+ layout.addWidget(self.button)
+ self.setLayout(layout)
+
+ # Register a Unicode font for PDF
+ pdfmetrics.registerFont(UnicodeCIDFont('HeiseiMin-W3')) # Japanese font that supports wide Unicode range
+
+ def select_file(self):
+ file_path, _ = QFileDialog.getOpenFileName(self, "Open Markdown File", "", "Markdown Files (*.md)")
+ if file_path:
+ self.convert_file(file_path)
+
+ def convert_file(self, file_path):
+ save_type, _ = QFileDialog.getSaveFileName(
+ self, "Save File As", "", "PDF Files (*.pdf);;Text Files (*.txt)"
+ )
+ if save_type:
+ if save_type.endswith(".pdf"):
+ self.convert_to_pdf(file_path, save_type)
+ elif save_type.endswith(".txt"):
+ self.convert_to_text(file_path, save_type)
+ else:
+ QMessageBox.warning(self, "Error", "Please select a valid file type.")
+
+ def convert_to_text(self, md_path, output_path):
+ try:
+ with open(md_path, "r", encoding="utf-8") as f:
+ md_content = f.read()
+ output = pypandoc.convert_text(md_content, 'plain', format='md', extra_args=['--standalone'])
+ with open(output_path, "w", encoding="utf-8") as f:
+ f.write(output)
+ QMessageBox.information(self, "Success", "Markdown converted to Text successfully!")
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to convert: {e}")
+
+ def convert_to_pdf(self, md_path, output_path):
+ try:
+ with open(md_path, "r", encoding="utf-8") as f:
+ md_content = f.read()
+ text = pypandoc.convert_text(md_content, 'plain', format='md', extra_args=['--standalone'])
+ doc = SimpleDocTemplate(output_path)
+ styles = getSampleStyleSheet()
+ story = [Paragraph(line, styles["Normal"]) for line in text.split("\n")]
+ doc.build(story)
+ QMessageBox.information(self, "Success", "Markdown converted to PDF successfully!")
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to convert: {e}")
+
+
+if __name__ == "__main__":
+ app = QApplication(sys.argv)
+ window = MarkdownConverter()
+ window.show()
+ sys.exit(app.exec())
diff --git a/MD to PDF or Text with GUI/requirements.txt b/MD to PDF or Text with GUI/requirements.txt
new file mode 100644
index 00000000..44b0c97a
--- /dev/null
+++ b/MD to PDF or Text with GUI/requirements.txt
@@ -0,0 +1,3 @@
+PySide6
+pypandoc
+reportlab
diff --git a/MacOS App Removal Cache Cleaner/README.md b/MacOS App Removal Cache Cleaner/README.md
new file mode 100644
index 00000000..468189a5
--- /dev/null
+++ b/MacOS App Removal Cache Cleaner/README.md
@@ -0,0 +1,110 @@
+# macOS App Remover and Cache Cleaner
+
+A desktop utility for macOS that scans installed applications, finds user-space support files (preferences, caches, logs, etc.), and lets you safely review and delete them. Built with PySide6 for a modern, native-like UI.
+
+---
+
+## ✨ Features
+
+- **App discovery:** Lists all installed `.app` bundles in `/Applications` and `~/Applications`.
+- **Deep scan:** Finds related files using both Spotlight and targeted sweeps of user Library buckets (Application Support, Preferences, Caches, Containers, etc.).
+- **Review before delete:** Presents all found files in a checkable tree grouped by type.
+- **Safe deletion:** Moves files to Trash when possible, falls back to permanent deletion if needed.
+- **Batch operations:** Select multiple apps, scan, and clean up in one go.
+- **Progress and status:** Live progress bar, status messages, and scan summaries.
+- **Modern UI:** Search/filter apps, select/deselect all, rescan, and more.
+- **macOS native:** Designed specifically for macOS; uses system Trash and Spotlight.
+
+---
+
+## 📦 Requirements
+
+- **macOS** (tested on 10.15+)
+- **Python 3.9+**
+- **PySide6** (`pip install PySide6`)
+
+---
+
+## 🚀 Quick Start
+
+1. **Install dependencies:**
+ ```bash
+ pip install PySide6
+ ```
+
+2. **Run the app:**
+ ```bash
+ python mac_app_removal.py
+ ```
+
+3. **Usage:**
+ - Filter or select apps from the list.
+ - Click "Scan Selected Apps" to find related files.
+ - Review results in the tree view.
+ - Check/uncheck items to delete.
+ - Click "Delete Checked Items" to move files to Trash or delete permanently.
+
+---
+
+## 🧭 UI Overview
+
+- **Left panel:** App list with search/filter and select/deselect all.
+- **Right panel:** Tree view of scan results, grouped by Library bucket and file type.
+- **Controls:** Scan, delete, progress bar, and status.
+- **Menu:** File (Quit), Actions (Scan/Delete), Help (About).
+
+---
+
+## 🗂️ What Gets Scanned
+
+- **Application Support**
+- **Preferences**
+- **Caches**
+- **Containers**
+- **Group Containers**
+- **Saved Application State**
+- **Logs**
+- **WebKit**
+- **Cookies**
+- **LaunchAgents**
+- **Other matches** found via Spotlight
+
+---
+
+## 🔒 Safety & Ethics
+
+- **Review before delete:** All files are presented for review; nothing is deleted automatically.
+- **Moves to Trash:** Prefers moving files to Trash for easy recovery.
+- **Permanent deletion:** Only used if Trash is unavailable or on a different volume.
+- **Responsibility:** Use with care; deleting files may affect app settings or data.
+
+---
+
+## 🆘 Troubleshooting
+
+- **Not finding all files:** Some apps store data outside standard buckets or use hidden locations.
+- **Permission errors:** Run as a user with access to the files you want to delete.
+- **Platform warning:** This tool is intended for macOS only.
+
+---
+
+## 🛠️ Building a Standalone App (optional)
+
+Use PyInstaller:
+
+```bash
+pip install pyinstaller
+pyinstaller --windowed --name "macOS App Cleaner" mac_app_removal.py
+```
+
+---
+
+## 📄 License
+
+MIT License.
+
+---
+
+## 👤 Author
+
+**Randy Northrup**
diff --git a/MacOS App Removal Cache Cleaner/mac_app_removal.py b/MacOS App Removal Cache Cleaner/mac_app_removal.py
new file mode 100644
index 00000000..ffd9f179
--- /dev/null
+++ b/MacOS App Removal Cache Cleaner/mac_app_removal.py
@@ -0,0 +1,651 @@
+#!/usr/bin/env python3
+import os
+import sys
+import shutil
+import plistlib
+import subprocess
+from pathlib import Path
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional, Tuple, Iterable
+
+from PySide6 import QtCore, QtGui, QtWidgets
+
+APP_DIRS = [Path("/Applications"), Path.home() / "Applications"]
+
+# Where we look (user-space). Each tuple is (label, path).
+USER_LIBRARY_BUCKETS: List[Tuple[str, Path]] = [
+ ("Application Support", Path("~/Library/Application Support").expanduser()),
+ ("Preferences", Path("~/Library/Preferences").expanduser()),
+ ("Caches", Path("~/Library/Caches").expanduser()),
+ ("Containers", Path("~/Library/Containers").expanduser()),
+ ("Group Containers", Path("~/Library/Group Containers").expanduser()),
+ ("Saved Application State", Path("~/Library/Saved Application State").expanduser()),
+ ("Logs", Path("~/Library/Logs").expanduser()),
+ ("WebKit", Path("~/Library/WebKit").expanduser()),
+ ("Cookies", Path("~/Library/Cookies").expanduser()),
+ ("LaunchAgents", Path("~/Library/LaunchAgents").expanduser()),
+]
+
+SPOTLIGHT_TIMEOUT_SEC = 20
+
+
+@dataclass
+class AppInfo:
+ name: str
+ path: Path
+ bundle_id: Optional[str] = None
+
+
+@dataclass
+class ScanResult:
+ app: AppInfo
+ files_by_bucket: Dict[str, List[Path]] = field(default_factory=dict)
+ extra_hits: List[Path] = field(default_factory=list) # From Spotlight that don't fall into a known bucket
+
+
+def is_app_bundle(p: Path) -> bool:
+ return p.suffix.lower() == ".app" and p.is_dir()
+
+
+def find_installed_apps() -> List[AppInfo]:
+ apps: List[AppInfo] = []
+ seen = set()
+ for base in APP_DIRS:
+ if not base.exists():
+ continue
+ for entry in sorted(base.iterdir(), key=lambda x: x.name.lower()):
+ if is_app_bundle(entry):
+ real = entry.resolve()
+ if real in seen:
+ continue
+ seen.add(real)
+ name = entry.name[:-4] # strip .app
+ apps.append(AppInfo(name=name, path=real))
+ return apps
+
+
+def read_bundle_id(app_path: Path) -> Optional[str]:
+ info = app_path / "Contents" / "Info.plist"
+ if not info.exists():
+ return None
+ try:
+ with info.open("rb") as f:
+ plist = plistlib.load(f)
+ bid = plist.get("CFBundleIdentifier") or plist.get("CFBundleIdentifier~ipad")
+ if isinstance(bid, str):
+ return bid
+ except Exception:
+ pass
+ return None
+
+
+def run_mdfind(query: str) -> List[Path]:
+ """Run mdfind; return Paths (unique, existing)."""
+ try:
+ proc = subprocess.run(
+ ["mdfind", query],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ timeout=SPOTLIGHT_TIMEOUT_SEC,
+ )
+ hits: List[Path] = []
+ if proc.returncode == 0 and proc.stdout:
+ for line in proc.stdout.splitlines():
+ p = Path(line.strip())
+ if p.exists():
+ hits.append(p)
+ # dedupe while preserving order
+ seen = set()
+ uniq = []
+ for p in hits:
+ if p not in seen:
+ seen.add(p)
+ uniq.append(p)
+ return uniq
+ except Exception:
+ return []
+
+
+def glob_matches(root: Path, terms: Iterable[str]) -> List[Path]:
+ """Shallow-and-deep match under root for any of the terms in file/folder names."""
+ if not root.exists():
+ return []
+ results: List[Path] = []
+ lowered = [t.lower() for t in terms if t]
+ try:
+ for dirpath, dirnames, filenames in os.walk(root):
+ # avoid traversing giant trees too long
+ for name in dirnames + filenames:
+ lname = name.lower()
+ if any(t in lname for t in lowered):
+ results.append(Path(dirpath) / name)
+ except Exception:
+ pass
+ return results
+
+
+def group_into_buckets(paths: List[Path]) -> Tuple[Dict[str, List[Path]], List[Path]]:
+ """Group paths by our known buckets; paths that don't fit go into extra_hits."""
+ buckets: Dict[str, List[Path]] = {label: [] for (label, _) in USER_LIBRARY_BUCKETS}
+ extras: List[Path] = []
+ for p in paths:
+ matched = False
+ for label, root in USER_LIBRARY_BUCKETS:
+ try:
+ # If p is under root
+ p.resolve().relative_to(root.resolve())
+ buckets[label].append(p)
+ matched = True
+ break
+ except Exception:
+ continue
+ if not matched:
+ extras.append(p)
+ # prune empties
+ buckets = {k: v for k, v in buckets.items() if v}
+ return buckets, extras
+
+
+def scan_app(app: AppInfo) -> ScanResult:
+ """
+ Strategy:
+ 1) Prefer bundle id; Spotlight for kMDItemCFBundleIdentifier exact match.
+ 2) Also query Spotlight for name contains and bundle id substring.
+ 3) Targeted glob in user Library buckets for both bundle id and friendly app name.
+ """
+ terms = {app.name}
+ if app.bundle_id:
+ terms.add(app.bundle_id)
+
+ spotlight_hits: List[Path] = []
+
+ # Exact bundle id query (most precise)
+ if app.bundle_id:
+ spotlight_hits += run_mdfind(f'kMDItemCFBundleIdentifier == "{app.bundle_id}"')
+
+ # Name contains (fallbacks)
+ # Use quotes around name with spaces to keep phrase
+ safe_name = app.name.replace('"', '\\"')
+ spotlight_hits += run_mdfind(f'kMDItemDisplayName == "{safe_name}"cdw || kMDItemFSName == "{safe_name}.app"cdw')
+
+ # Loose contains for bundle id bits (sometimes support files have bundle id in path or metadata)
+ if app.bundle_id:
+ safe_bid = app.bundle_id.replace('"', '\\"')
+ spotlight_hits += run_mdfind(f'kMDItemTextContent == "{safe_bid}"cdw || kMDItemFSName == "{safe_bid}"cdw')
+
+ # Targeted filesystem sweeps within user Library
+ lib_hits: List[Path] = []
+ # Preferences often use domain style: com.vendor.App.plist
+ preference_candidates = []
+ if app.bundle_id:
+ preference_candidates.append(app.bundle_id)
+ preference_candidates.append(app.name)
+ # Sweep buckets
+ for _, root in USER_LIBRARY_BUCKETS:
+ lib_hits += glob_matches(root, preference_candidates)
+
+ all_hits = []
+ seen = set()
+ for p in spotlight_hits + lib_hits:
+ if p not in seen and p.exists():
+ seen.add(p)
+ all_hits.append(p)
+
+ files_by_bucket, extra_hits = group_into_buckets(all_hits)
+ return ScanResult(app=app, files_by_bucket=files_by_bucket, extra_hits=extra_hits)
+
+
+# --------------------------- Qt Helpers ---------------------------
+
+class WorkerSignals(QtCore.QObject):
+ app_started = QtCore.Signal(str)
+ app_progress = QtCore.Signal(int, int) # current, total
+ app_finished = QtCore.Signal(object) # ScanResult
+ all_done = QtCore.Signal()
+
+
+class ScanWorker(QtCore.QRunnable):
+ def __init__(self, apps: List[AppInfo]):
+ super().__init__()
+ self.apps = apps
+ self.signals = WorkerSignals()
+
+ @QtCore.Slot()
+ def run(self):
+ total = len(self.apps)
+ for idx, app in enumerate(self.apps, start=1):
+ self.signals.app_started.emit(app.name)
+ # hydrate bundle id
+ bid = read_bundle_id(app.path)
+ app.bundle_id = bid
+ result = scan_app(app)
+ self.signals.app_progress.emit(idx, total)
+ self.signals.app_finished.emit(result)
+ self.signals.all_done.emit()
+
+
+class CheckableTree(QtWidgets.QTreeWidget):
+ """
+ QTreeWidget with tri-state checkbox support and recursive propagation.
+ Column 0 holds the checkboxes.
+ """
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setHeaderLabels(["Path / Group", "Kind"])
+ self.setUniformRowHeights(True)
+ self.setExpandsOnDoubleClick(True)
+ self.itemChanged.connect(self.on_item_changed)
+
+ def on_item_changed(self, item: QtWidgets.QTreeWidgetItem, column: int):
+ if column != 0:
+ return
+ state = item.checkState(0)
+ # propagate down
+ for i in range(item.childCount()):
+ child = item.child(i)
+ child.setCheckState(0, state)
+ # propagate up
+ self._update_parent_state(item)
+
+ def _update_parent_state(self, item: QtWidgets.QTreeWidgetItem):
+ parent = item.parent()
+ if not parent:
+ return
+ checked = 0
+ partial = False
+ for i in range(parent.childCount()):
+ cs = parent.child(i).checkState(0)
+ if cs == QtCore.Qt.CheckState.PartiallyChecked:
+ partial = True
+ elif cs == QtCore.Qt.CheckState.Checked:
+ checked += 1
+ if partial or (0 < checked < parent.childCount()):
+ parent.setCheckState(0, QtCore.Qt.CheckState.PartiallyChecked)
+ elif checked == 0:
+ parent.setCheckState(0, QtCore.Qt.CheckState.Unchecked)
+ else:
+ parent.setCheckState(0, QtCore.Qt.CheckState.Checked)
+ self._update_parent_state(parent)
+
+
+# --------------------------- Main Window ---------------------------
+
+class AppCleaner(QtWidgets.QMainWindow):
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("macOS App Cleaner (PySide6)")
+ self.resize(1100, 700)
+
+ # Central layout
+ splitter = QtWidgets.QSplitter()
+ splitter.setOrientation(QtCore.Qt.Orientation.Horizontal)
+
+ # Left: app list with checkboxes
+ left = QtWidgets.QWidget()
+ left_layout = QtWidgets.QVBoxLayout(left)
+ left_layout.setContentsMargins(8, 8, 8, 8)
+ self.search_bar = QtWidgets.QLineEdit()
+ self.search_bar.setPlaceholderText("Filter apps…")
+ self.search_bar.textChanged.connect(self.filter_apps)
+ left_layout.addWidget(self.search_bar)
+
+ self.app_list = QtWidgets.QListWidget()
+ self.app_list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection)
+ left_layout.addWidget(self.app_list, 1)
+
+ # App toolbar
+ app_toolbar = QtWidgets.QHBoxLayout()
+ self.btn_select_all = QtWidgets.QPushButton("Select All")
+ self.btn_select_none = QtWidgets.QPushButton("Deselect All")
+ self.btn_refresh = QtWidgets.QPushButton("Rescan Apps")
+ app_toolbar.addWidget(self.btn_select_all)
+ app_toolbar.addWidget(self.btn_select_none)
+ app_toolbar.addStretch(1)
+ app_toolbar.addWidget(self.btn_refresh)
+ left_layout.addLayout(app_toolbar)
+
+ splitter.addWidget(left)
+
+ # Right: tree of results + controls
+ right = QtWidgets.QWidget()
+ right_layout = QtWidgets.QVBoxLayout(right)
+ right_layout.setContentsMargins(8, 8, 8, 8)
+
+ self.tree = CheckableTree()
+ right_layout.addWidget(self.tree, 1)
+
+ # Controls
+ controls = QtWidgets.QHBoxLayout()
+ self.btn_scan = QtWidgets.QPushButton("Scan Selected Apps")
+ self.btn_delete = QtWidgets.QPushButton("Delete Checked Items")
+ self.btn_delete.setEnabled(False)
+ self.progress = QtWidgets.QProgressBar()
+ self.progress.setRange(0, 100)
+ self.progress.setValue(0)
+ self.status_label = QtWidgets.QLabel("Ready.")
+ controls.addWidget(self.btn_scan)
+ controls.addWidget(self.btn_delete)
+ controls.addStretch(1)
+ controls.addWidget(self.progress, 2)
+ controls.addWidget(self.status_label, 2)
+ right_layout.addLayout(controls)
+
+ splitter.addWidget(right)
+ splitter.setStretchFactor(0, 1)
+ splitter.setStretchFactor(1, 3)
+ self.setCentralWidget(splitter)
+
+ # Menu (optional niceties)
+ self._make_menu()
+
+ # State
+ self.thread_pool = QtCore.QThreadPool()
+ self.all_apps: List[AppInfo] = []
+
+ # Signals
+ self.btn_select_all.clicked.connect(self.select_all_apps)
+ self.btn_select_none.clicked.connect(self.deselect_all_apps)
+ self.btn_refresh.clicked.connect(self.load_apps)
+ self.btn_scan.clicked.connect(self.scan_selected)
+ self.btn_delete.clicked.connect(self.delete_checked)
+
+ # Load apps
+ self.load_apps()
+
+ # Styling: macOS-ish padding
+ self.setStyleSheet("""
+ QTreeWidget::item { padding: 6px; }
+ QListWidget::item { padding: 4px 6px; }
+ QPushButton { padding: 6px 12px; }
+ """)
+
+ # ---------- Menu ----------
+ def _make_menu(self):
+ bar = self.menuBar()
+ file_menu = bar.addMenu("&File")
+ act_quit = QtGui.QAction("Quit", self)
+ act_quit.setShortcut(QtGui.QKeySequence.StandardKey.Quit)
+ act_quit.triggered.connect(self.close)
+ file_menu.addAction(act_quit)
+
+ actions_menu = bar.addMenu("&Actions")
+ act_scan = QtGui.QAction("Scan Selected Apps", self)
+ act_scan.setShortcut("Ctrl+S")
+ act_scan.triggered.connect(self.scan_selected)
+ actions_menu.addAction(act_scan)
+
+ act_delete = QtGui.QAction("Delete Checked Items", self)
+ act_delete.setShortcut("Ctrl+D")
+ act_delete.triggered.connect(self.delete_checked)
+ actions_menu.addAction(act_delete)
+
+ help_menu = bar.addMenu("&Help")
+ about = QtGui.QAction("About", self)
+ about.triggered.connect(self.show_about)
+ help_menu.addAction(about)
+
+ def show_about(self):
+ QtWidgets.QMessageBox.information(
+ self,
+ "About",
+ "macOS App Cleaner\n\n"
+ "• Scans your installed apps\n"
+ "• Finds user-space support files via Spotlight and Library sweeps\n"
+ "• Lets you review and delete them safely\n\n"
+ "Built with PySide6."
+ )
+
+ # ---------- App list ----------
+ def load_apps(self):
+ self.app_list.clear()
+ self.all_apps = find_installed_apps()
+ # attach bundle ids lazily (during scan) to keep initial load snappy
+ for app in self.all_apps:
+ item = QtWidgets.QListWidgetItem(app.name)
+ item.setData(QtCore.Qt.ItemDataRole.UserRole, app)
+ item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable)
+ item.setCheckState(QtCore.Qt.CheckState.Unchecked)
+ self.app_list.addItem(item)
+ self.status_label.setText(f"Found {len(self.all_apps)} apps.")
+
+ def filter_apps(self, text: str):
+ text = text.lower().strip()
+ for i in range(self.app_list.count()):
+ it = self.app_list.item(i)
+ it.setHidden(text not in it.text().lower())
+
+ def select_all_apps(self):
+ for i in range(self.app_list.count()):
+ it = self.app_list.item(i)
+ if not it.isHidden():
+ it.setCheckState(QtCore.Qt.CheckState.Checked)
+
+ def deselect_all_apps(self):
+ for i in range(self.app_list.count()):
+ it = self.app_list.item(i)
+ it.setCheckState(QtCore.Qt.CheckState.Unchecked)
+
+ def selected_apps(self) -> List[AppInfo]:
+ out: List[AppInfo] = []
+ for i in range(self.app_list.count()):
+ it = self.app_list.item(i)
+ if it.checkState() == QtCore.Qt.CheckState.Checked:
+ app = it.data(QtCore.Qt.ItemDataRole.UserRole)
+ if isinstance(app, AppInfo):
+ out.append(app)
+ return out
+
+ # ---------- Scanning ----------
+ def scan_selected(self):
+ apps = self.selected_apps()
+ if not apps:
+ QtWidgets.QMessageBox.information(self, "Nothing selected",
+ "Please select at least one app to scan.")
+ return
+ self.tree.clear()
+ self.btn_scan.setEnabled(False)
+ self.btn_delete.setEnabled(False)
+ self.progress.setValue(0)
+ self.status_label.setText("Scanning…")
+
+ worker = ScanWorker(apps)
+ worker.signals.app_started.connect(self.on_app_started)
+ worker.signals.app_progress.connect(self.on_app_progress)
+ worker.signals.app_finished.connect(self.on_app_finished)
+ worker.signals.all_done.connect(self.on_all_done)
+ self.thread_pool.start(worker)
+
+ @QtCore.Slot(str)
+ def on_app_started(self, app_name: str):
+ self.status_label.setText(f"Scanning {app_name}…")
+
+ @QtCore.Slot(int, int)
+ def on_app_progress(self, current: int, total: int):
+ pct = int(100 * current / max(1, total))
+ self.progress.setValue(pct)
+
+ @QtCore.Slot(object)
+ def on_app_finished(self, result: ScanResult):
+ # Build tree:
+ # App (checked)
+ # Bucket (checked)
+ # file path (checked)
+ # Other Matches
+ # file path
+ app_top = QtWidgets.QTreeWidgetItem([result.app.name, "App"])
+ app_top.setFlags(app_top.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable)
+ app_top.setCheckState(0, QtCore.Qt.CheckState.Checked)
+ app_top.setData(0, QtCore.Qt.ItemDataRole.UserRole, ("APP", str(result.app.path)))
+
+ def add_child(parent, text, kind, payload=None):
+ it = QtWidgets.QTreeWidgetItem([text, kind])
+ it.setFlags(it.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable)
+ it.setCheckState(0, QtCore.Qt.CheckState.Checked)
+ if payload is not None:
+ it.setData(0, QtCore.Qt.ItemDataRole.UserRole, payload)
+ parent.addChild(it)
+ return it
+
+ # Buckets
+ for bucket, files in sorted(result.files_by_bucket.items(), key=lambda kv: kv[0].lower()):
+ bnode = add_child(app_top, bucket, "Group", ("BUCKET", bucket))
+ for f in sorted(files, key=lambda p: p.as_posix().lower()):
+ add_child(bnode, f.as_posix(), "File", ("FILE", f.as_posix()))
+
+ # Extra hits (Spotlight matches outside our buckets)
+ if result.extra_hits:
+ onode = add_child(app_top, "Other Matches", "Group", ("BUCKET", "Other Matches"))
+ for f in sorted(result.extra_hits, key=lambda p: p.as_posix().lower()):
+ add_child(onode, f.as_posix(), "File", ("FILE", f.as_posix()))
+
+ self.tree.addTopLevelItem(app_top)
+ self.tree.expandItem(app_top)
+ self.btn_delete.setEnabled(self.tree.topLevelItemCount() > 0)
+
+ @QtCore.Slot()
+ def on_all_done(self):
+ self.status_label.setText("Scan complete.")
+ self.btn_scan.setEnabled(True)
+
+ # ---------- Deletion ----------
+ def _gather_checked_files(self) -> List[Path]:
+ files: List[Path] = []
+ for i in range(self.tree.topLevelItemCount()):
+ app_node = self.tree.topLevelItem(i)
+ self._collect_checked(app_node, files)
+ # dedupe; prefer deepest first so if a directory is selected, we won't try individual children after moving
+ uniq: List[Path] = []
+ seen = set()
+ for p in files:
+ if p not in seen:
+ seen.add(p)
+ uniq.append(p)
+ # Sort by path depth descending to delete children before parents if deleting directly
+ uniq.sort(key=lambda p: len(p.parts), reverse=True)
+ return uniq
+
+ def _collect_checked(self, node: QtWidgets.QTreeWidgetItem, out: List[Path]):
+ # If node is a file and checked, collect
+ if node.checkState(0) == QtCore.Qt.CheckState.Unchecked:
+ return
+ payload = node.data(0, QtCore.Qt.ItemDataRole.UserRole)
+ if payload and isinstance(payload, tuple) and payload[0] == "FILE":
+ p = Path(payload[1])
+ out.append(p)
+ # Recurse
+ for i in range(node.childCount()):
+ self._collect_checked(node.child(i), out)
+
+ def delete_checked(self):
+ targets = self._gather_checked_files()
+ if not targets:
+ QtWidgets.QMessageBox.information(self, "No items selected",
+ "Check some files/folders in the results to delete.")
+ return
+
+ # Summarize for confirmation
+ preview = "\n".join(str(p) for p in targets[:12])
+ more = "" if len(targets) <= 12 else f"\n… and {len(targets) - 12} more."
+ msg = (f"You are about to delete/move to Trash {len(targets)} item(s).\n\n"
+ f"{preview}{more}\n\n"
+ "Prefer moving to Trash when possible. Continue?")
+ ret = QtWidgets.QMessageBox.warning(
+ self,
+ "Confirm Deletion",
+ msg,
+ QtWidgets.QMessageBox.StandardButton.Cancel | QtWidgets.QMessageBox.StandardButton.Ok,
+ QtWidgets.QMessageBox.StandardButton.Cancel
+ )
+ if ret != QtWidgets.QMessageBox.StandardButton.Ok:
+ return
+
+ # Try moving to Trash first
+ moved, removed, failed = self._move_or_delete(targets)
+
+ msg = (f"Finished.\n\nMoved to Trash: {len(moved)}\n"
+ f"Permanently deleted: {len(removed)}\n"
+ f"Failed: {len(failed)}")
+ if failed:
+ msg += "\n\nFailures:\n" + "\n".join(f"{p} — {err}" for p, err in failed[:10])
+ if len(failed) > 10:
+ msg += f"\n… and {len(failed)-10} more."
+ QtWidgets.QMessageBox.information(self, "Cleanup", msg)
+
+ # Remove deleted nodes from the tree (optimistic)
+ self._prune_deleted_nodes()
+ self.btn_delete.setEnabled(self.tree.topLevelItemCount() > 0)
+
+ def _move_or_delete(self, targets: List[Path]):
+ trash = Path.home() / ".Trash"
+ moved, removed, failed = [], [], []
+
+ for p in targets:
+ try:
+ if not p.exists():
+ continue
+ # Prefer move to Trash when on same volume and Trash exists
+ can_move = trash.exists() and p.anchor == trash.anchor
+ if can_move:
+ # Ensure unique filename in Trash
+ dest = trash / p.name
+ suffix = 1
+ while dest.exists():
+ dest = trash / f"{p.stem} {suffix}{p.suffix}"
+ suffix += 1
+ shutil.move(str(p), str(dest))
+ moved.append(p)
+ else:
+ # Fall back to permanent delete
+ if p.is_dir():
+ shutil.rmtree(p, ignore_errors=False)
+ else:
+ p.unlink()
+ removed.append(p)
+ except Exception as e:
+ failed.append((p, str(e)))
+ return moved, removed, failed
+
+ def _prune_deleted_nodes(self):
+ # Walk tree and drop FILE nodes that no longer exist; prune empty groups; prune empty apps
+ def node_exists(n: QtWidgets.QTreeWidgetItem) -> bool:
+ payload = n.data(0, QtCore.Qt.ItemDataRole.UserRole)
+ if payload and isinstance(payload, tuple) and payload[0] == "FILE":
+ return Path(payload[1]).exists()
+ return True # non-file "group" nodes considered present
+
+ def prune_node(n: QtWidgets.QTreeWidgetItem) -> bool:
+ # returns True if node should be kept
+ for i in reversed(range(n.childCount())):
+ child = n.child(i)
+ keep = prune_node(child)
+ if not keep:
+ n.removeChild(child)
+ # After pruning children, decide for this node
+ payload = n.data(0, QtCore.Qt.ItemDataRole.UserRole)
+ if payload and isinstance(payload, tuple) and payload[0] == "FILE":
+ return node_exists(n)
+ # Keep non-file nodes if they still have children
+ return n.childCount() > 0
+
+ for i in reversed(range(self.tree.topLevelItemCount())):
+ top = self.tree.topLevelItem(i)
+ keep = prune_node(top)
+ if not keep:
+ self.tree.takeTopLevelItem(i)
+
+
+def main():
+ # Strongly recommend running on macOS only
+ if sys.platform != "darwin":
+ QtWidgets.QMessageBox.warning(None, "Platform Warning", "This tool is intended for macOS.")
+ QtWidgets.QApplication.setAttribute(QtCore.Qt.ApplicationAttribute.AA_DontUseNativeMenuBar, False)
+ app = QtWidgets.QApplication(sys.argv)
+ app.setApplicationName("macOS App Cleaner")
+ win = AppCleaner()
+ win.show()
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/MacOS App Removal Cache Cleaner/requirements.txt b/MacOS App Removal Cache Cleaner/requirements.txt
new file mode 100644
index 00000000..7fa34f07
--- /dev/null
+++ b/MacOS App Removal Cache Cleaner/requirements.txt
@@ -0,0 +1 @@
+PySide6
diff --git a/PDF Editor and Signer/README.md b/PDF Editor and Signer/README.md
new file mode 100644
index 00000000..1380fb00
--- /dev/null
+++ b/PDF Editor and Signer/README.md
@@ -0,0 +1,187 @@
+# PDF Editor and Signer
+
+A sophisticated PDF editor with intelligent element detection, click-to-place text insertion, and digital signature capabilities built with PySide6 and PyMuPDF.
+
+## Features
+
+### Intelligent Element Detection
+- **Automatic Detection**: The application automatically detects interactive elements in your PDF:
+ - **Horizontal Lines**: Text fields and underlines (green highlight)
+ - **Signature Fields**: Areas with "signature" keywords (blue highlight)
+ - **Date Fields**: Areas with "date" keywords (red highlight)
+- **Visual Feedback**: Detected elements are highlighted when you hover nearby
+- **No Manual Controls**: Detection is always active - no buttons needed
+
+### Click-to-Place Text Insertion
+- **Smart Placement**: Click near any detected element (within 50 pixels) to add text
+- **Type-Aware Formatting**:
+ - Signature fields: Italic script font style
+ - Date fields: Auto-filled with current date (MM/DD/YYYY)
+ - Text lines: Standard font
+ - Freeform: Standard font anywhere on the page
+- **Auto-Focus**: Text boxes automatically receive focus when created
+- **Auto-Remove**: Empty text boxes disappear when you click away
+- **Intuitive UX**: Just click where you want to type - the app handles the rest
+
+### Selectable Text Overlay
+- **Native Selection**: Select and copy text directly from the PDF view
+- **Transparent Layer**: Text overlay doesn't interfere with visual appearance
+- **Preserved Layout**: Text positioned exactly as it appears in the PDF
+
+### Zoom Controls
+- **Zoom Slider**: 50% to 400% zoom range
+- **Zoom Buttons**: +/- buttons for incremental zoom (10% steps)
+- **Fit Width**: Automatically fit PDF to window width
+- **Fit Page**: Automatically fit entire page in view
+- **Zoom-Aware Coordinates**: Text placement accounts for current zoom level
+
+### Digital Signatures
+- **Certificate Management**:
+ - Create new certificates with RSA 2048-bit keys
+ - Load existing certificate and key files
+ - X.509 format with SHA-256 hashing
+- **PDF Signing**: Apply digital signatures to edited PDFs
+- **Signature Validation**: Digitally signed documents maintain integrity
+
+## Installation
+
+### Requirements
+- Python 3.7+
+- PySide6 (Qt6 GUI framework)
+- PyMuPDF (PDF rendering and manipulation)
+- pyHanko (digital signatures)
+- cryptography (certificate generation)
+
+### Install Dependencies
+
+```bash
+pip install -r requirements.txt
+```
+
+Or install individually:
+
+```bash
+pip install PySide6>=6.0.0 PyMuPDF>=1.23.0 pyHanko>=0.20.0 cryptography>=41.0.0
+```
+
+## Usage
+
+### Run the Application
+
+```bash
+python pdf_editor_signer.py
+```
+
+### Workflow
+
+1. **Open PDF**: Click "Open PDF" to load a document
+2. **Navigate**: Use page navigation controls or enter page number directly
+3. **Add Text**:
+ - Click near a detected line, signature field, or date field
+ - The text box appears automatically at the correct location
+ - Type your text (dates auto-fill)
+ - Click elsewhere when done (empty boxes disappear)
+4. **Zoom**: Use zoom controls to adjust view as needed
+5. **Select Text**: Select and copy text directly from the PDF
+6. **Save**: Click "Save PDF" to save your edits
+7. **Sign** (optional):
+ - Create or load a certificate
+ - Click "Sign PDF" to apply digital signature
+
+### Text Box Types
+
+The application automatically determines the text box type based on what you click:
+
+- **Text Line**: Click near a horizontal line (green highlight)
+- **Signature**: Click near a signature keyword (blue highlight)
+- **Date**: Click near a date keyword (red highlight)
+- **Freeform**: Click anywhere else on the page
+
+### Certificate Management
+
+**Create Certificate**:
+1. Click "Create Certificate"
+2. Enter details (Country, Organization, Common Name, etc.)
+3. Certificate and key files saved in current directory
+
+**Load Certificate**:
+1. Click "Load Certificate"
+2. Select certificate file (.pem)
+3. Select key file (.pem)
+4. Certificate loaded for signing
+
+## Architecture
+
+### Graphics System
+- **Z-Layering**:
+ - Z-Level -2: Selectable text overlay (transparent)
+ - Z-Level -1: Element detection highlights
+ - Z-Level 0: User-created text boxes
+- **Coordinate Conversion**: Automatic conversion between Qt scene coordinates and PDF coordinates
+
+### Detection Algorithm
+- **Line Detection**: Analyzes PDF drawings for horizontal rectangles (<5px height, >50px width)
+- **Text Pattern Matching**: Searches text blocks for "signature" and "date" keywords
+- **Distance Calculation**: Finds nearest detected element within 50-pixel threshold
+
+### Type Safety
+- Full type annotations throughout codebase
+- Strict mypy compliance
+- Qt type hints for all widgets and events
+
+## File Structure
+
+```
+PDF Editor and Signer/
+├── pdf_editor_signer.py # Main application (~1200 lines)
+├── requirements.txt # Python dependencies
+└── README.md # This file
+```
+
+## Technical Details
+
+### Text Box Styling
+- **Signature**: `font-style: italic; font-family: 'Brush Script MT', cursive;`
+- **Date**: Auto-filled with current date on creation
+- **Standard**: Default font, adjustable size
+- **Border**: Yellow border for all editable boxes
+
+### PDF Manipulation
+- **Rendering**: PyMuPDF (fitz) converts pages to QPixmap
+- **Text Extraction**: `page.get_text("dict")` provides text with bounding boxes
+- **Drawing Detection**: `page.get_drawings()` provides vector graphics data
+- **Text Insertion**: Applied to PDF with zoom-aware coordinate conversion
+
+### Digital Signatures
+- **Key Algorithm**: RSA 2048-bit
+- **Certificate Format**: X.509
+- **Hash Algorithm**: SHA-256
+- **Signing Backend**: pyHanko with cryptography library
+
+## Known Limitations
+
+- Element detection works best with text-based PDFs (may not detect elements in scanned/image PDFs)
+- Signature detection requires text containing "signature" keyword
+- Date detection requires text containing "date" keyword
+- Custom detection patterns not currently configurable
+
+## Contributing
+
+Contributions welcome! Areas for enhancement:
+- OCR integration for scanned PDFs
+- Configurable detection patterns
+- Custom text box styles
+- Annotation tools (highlighting, shapes)
+- Form field recognition
+
+## License
+
+[Add your license here]
+
+## Credits
+
+Built with:
+- [PySide6](https://wiki.qt.io/Qt_for_Python) - Qt6 Python bindings
+- [PyMuPDF](https://pymupdf.readthedocs.io/) - PDF processing
+- [pyHanko](https://github.com/MatthiasValvekens/pyHanko) - PDF signing
+- [cryptography](https://cryptography.io/) - Cryptographic operations
diff --git a/PDF Editor and Signer/pdf_editor_signer.py b/PDF Editor and Signer/pdf_editor_signer.py
new file mode 100644
index 00000000..ccee9310
--- /dev/null
+++ b/PDF Editor and Signer/pdf_editor_signer.py
@@ -0,0 +1,1236 @@
+#!/usr/bin/env python3
+"""
+PDF Editor and Signer
+A modern Qt-based GUI application for editing PDF text and adding digital signatures.
+"""
+
+import sys
+import os
+from pathlib import Path
+from typing import Optional, List, Tuple, Dict, Any, Union
+from datetime import datetime, timedelta
+from enum import Enum
+
+import fitz # PyMuPDF
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa, padding
+from cryptography import x509
+from cryptography.x509.oid import NameOID
+from pyhanko.sign import signers
+from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
+from pyhanko.sign.fields import SigFieldSpec
+
+try:
+ from PySide6.QtWidgets import (
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
+ QPushButton, QLabel, QTextEdit, QFileDialog, QMessageBox,
+ QListWidget, QSplitter, QGroupBox, QLineEdit, QSpinBox,
+ QComboBox, QTabWidget, QScrollArea, QDialog, QFormLayout,
+ QDialogButtonBox, QInputDialog, QSlider, QGraphicsView,
+ QGraphicsScene, QGraphicsRectItem, QGraphicsTextItem, QGraphicsLineItem
+ )
+ from PySide6.QtCore import Qt, QThread, Signal, QByteArray, QRectF, QPointF, QSizeF
+ from PySide6.QtGui import QPixmap, QImage, QPalette, QColor, QPainter, QPen, QBrush, QFont, QTextCursor
+except ImportError:
+ print("Error: PySide6 is required. Install with: pip install PySide6")
+ sys.exit(1)
+
+
+class TextBoxType(Enum):
+ """Types of text boxes for intelligent placement."""
+ FREEFORM = "freeform"
+ SIGNATURE = "signature"
+ DATE = "date"
+ TEXT_LINE = "text_line"
+
+
+class EditableTextBox(QGraphicsTextItem):
+ """Interactive text box that can be edited, moved, and resized."""
+
+ def __init__(self, text: str = "", box_type: TextBoxType = TextBoxType.FREEFORM, parent: Optional[QGraphicsRectItem] = None) -> None:
+ super().__init__(text, parent)
+ self.box_type = box_type
+ self.border_item: Optional[QGraphicsRectItem] = None
+ self.is_empty = not text
+
+ # Make it editable
+ self.setTextInteractionFlags(Qt.TextInteractionFlag.TextEditorInteraction)
+ self.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsMovable, True)
+ self.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsSelectable, True)
+ self.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsFocusable, True)
+
+ # Set default font
+ font = QFont("Arial", 12)
+ self.setFont(font)
+ self.setDefaultTextColor(QColor(0, 0, 0))
+
+ # Apply type-specific styling
+ self._apply_type_styling()
+
+ def _apply_type_styling(self) -> None:
+ """Apply styling based on text box type."""
+ font = self.font()
+
+ if self.box_type == TextBoxType.SIGNATURE:
+ font.setItalic(True)
+ font.setFamily("Brush Script MT")
+ self.setPlaceholderText("Signature")
+ elif self.box_type == TextBoxType.DATE:
+ self.setPlainText(datetime.now().strftime("%m/%d/%Y"))
+ self.is_empty = False
+ elif self.box_type == TextBoxType.TEXT_LINE:
+ font.setPointSize(10)
+
+ self.setFont(font)
+
+ def setPlaceholderText(self, text: str) -> None:
+ """Set placeholder text."""
+ if self.is_empty:
+ self.setPlainText(text)
+ self.setDefaultTextColor(QColor(150, 150, 150))
+
+ def focusInEvent(self, event: Any) -> None:
+ """Handle focus in - clear placeholder."""
+ if self.is_empty and self.defaultTextColor() == QColor(150, 150, 150):
+ self.setPlainText("")
+ self.setDefaultTextColor(QColor(0, 0, 0))
+ super().focusInEvent(event)
+
+ def focusOutEvent(self, event: Any) -> None:
+ """Handle focus out - remove if empty."""
+ text = self.toPlainText().strip()
+ if not text:
+ self.is_empty = True
+ # Signal to remove this item
+ if self.scene():
+ self.scene().removeItem(self)
+ if self.border_item and self.border_item.scene():
+ self.scene().removeItem(self.border_item)
+ else:
+ self.is_empty = False
+ super().focusOutEvent(event)
+
+ def setBorderItem(self, border: QGraphicsRectItem) -> None:
+ """Associate a border rectangle with this text box."""
+ self.border_item = border
+
+ def itemChange(self, change: QGraphicsTextItem.GraphicsItemChange, value: Any) -> Any:
+ """Handle item changes to update border position."""
+ if change == QGraphicsTextItem.GraphicsItemChange.ItemPositionChange and self.border_item:
+ # Update border position
+ new_pos = value
+ if isinstance(new_pos, QPointF):
+ self.border_item.setPos(new_pos)
+ return super().itemChange(change, value)
+
+
+class PDFGraphicsView(QGraphicsView):
+ """Custom graphics view for PDF editing with text box creation."""
+
+ textBoxCreated = Signal(QRectF, TextBoxType)
+
+ def __init__(self, parent: Optional[QWidget] = None) -> None:
+ super().__init__(parent)
+ self.setRenderHint(QPainter.RenderHint.Antialiasing)
+ self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
+ self.setDragMode(QGraphicsView.DragMode.NoDrag)
+
+ self.is_drawing_textbox = False
+ self.drawing_start: Optional[QPointF] = None
+ self.drawing_rect_item: Optional[QGraphicsRectItem] = None
+ self.current_box_type: TextBoxType = TextBoxType.FREEFORM
+ self.detected_lines: List[QRectF] = []
+ self.detected_fields: List[Tuple[QRectF, TextBoxType]] = []
+ self.click_to_place_mode: bool = False
+
+ def setDrawingMode(self, enabled: bool, box_type: TextBoxType = TextBoxType.FREEFORM, click_mode: bool = False) -> None:
+ """Enable or disable text box drawing mode."""
+ self.is_drawing_textbox = enabled
+ self.current_box_type = box_type
+ self.click_to_place_mode = click_mode
+ if enabled:
+ if click_mode:
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
+ else:
+ self.setCursor(Qt.CursorShape.CrossCursor)
+ self.setDragMode(QGraphicsView.DragMode.NoDrag)
+ else:
+ self.setCursor(Qt.CursorShape.ArrowCursor)
+ self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
+
+ def setDetectedElements(self, lines: List[QRectF], fields: List[Tuple[QRectF, TextBoxType]]) -> None:
+ """Set detected lines and fields for intelligent placement."""
+ self.detected_lines = lines
+ self.detected_fields = fields
+
+ def mousePressEvent(self, event: Any) -> None:
+ """Handle mouse press for text box creation."""
+ if self.is_drawing_textbox and event.button() == Qt.MouseButton.LeftButton:
+ click_pos = self.mapToScene(event.pos())
+
+ # Click-to-place mode: find nearest detected element
+ if self.click_to_place_mode:
+ nearest_rect, box_type = self._find_nearest_element(click_pos)
+ if nearest_rect:
+ self.textBoxCreated.emit(nearest_rect, box_type)
+ return
+
+ # Draw mode: start drawing rectangle
+ self.drawing_start = click_pos
+
+ # Create preview rectangle
+ self.drawing_rect_item = QGraphicsRectItem()
+ pen = QPen(QColor(42, 130, 218), 2, Qt.PenStyle.DashLine)
+ self.drawing_rect_item.setPen(pen)
+ brush = QBrush(QColor(42, 130, 218, 30))
+ self.drawing_rect_item.setBrush(brush)
+
+ if self.scene():
+ self.scene().addItem(self.drawing_rect_item)
+ else:
+ super().mousePressEvent(event)
+
+ def _find_nearest_element(self, pos: QPointF) -> Tuple[Optional[QRectF], TextBoxType]:
+ """Find the nearest detected element to click position."""
+ min_distance = float('inf')
+ nearest_rect: Optional[QRectF] = None
+ nearest_type = TextBoxType.FREEFORM
+
+ # Check detected fields first (they have specific types)
+ for rect, box_type in self.detected_fields:
+ if box_type == self.current_box_type or self.current_box_type == TextBoxType.FREEFORM:
+ distance = self._distance_to_rect(pos, rect)
+ if distance < min_distance and distance < 50: # Within 50 pixels
+ min_distance = distance
+ nearest_rect = rect
+ nearest_type = box_type
+
+ # Check detected lines if looking for text lines
+ if not nearest_rect and self.current_box_type == TextBoxType.TEXT_LINE:
+ for rect in self.detected_lines:
+ distance = self._distance_to_rect(pos, rect)
+ if distance < min_distance and distance < 50:
+ min_distance = distance
+ nearest_rect = rect
+ nearest_type = TextBoxType.TEXT_LINE
+
+ return nearest_rect, nearest_type
+
+ def _distance_to_rect(self, pos: QPointF, rect: QRectF) -> float:
+ """Calculate distance from point to rectangle."""
+ # If point is inside, distance is 0
+ if rect.contains(pos):
+ return 0.0
+
+ # Calculate distance to nearest edge/corner
+ dx = max(rect.left() - pos.x(), 0.0, pos.x() - rect.right())
+ dy = max(rect.top() - pos.y(), 0.0, pos.y() - rect.bottom())
+ return (dx * dx + dy * dy) ** 0.5
+
+ def mouseMoveEvent(self, event: Any) -> None:
+ """Handle mouse move to update text box preview."""
+ if self.is_drawing_textbox and self.drawing_start and self.drawing_rect_item:
+ current_pos = self.mapToScene(event.pos())
+ rect = QRectF(self.drawing_start, current_pos).normalized()
+ self.drawing_rect_item.setRect(rect)
+ else:
+ super().mouseMoveEvent(event)
+
+ def mouseReleaseEvent(self, event: Any) -> None:
+ """Handle mouse release to create text box."""
+ if self.is_drawing_textbox and event.button() == Qt.MouseButton.LeftButton and self.drawing_start:
+ current_pos = self.mapToScene(event.pos())
+ rect = QRectF(self.drawing_start, current_pos).normalized()
+
+ # Only create if rectangle is large enough
+ if rect.width() > 20 and rect.height() > 10:
+ self.textBoxCreated.emit(rect, self.current_box_type)
+
+ # Clean up preview
+ if self.drawing_rect_item and self.scene():
+ self.scene().removeItem(self.drawing_rect_item)
+
+ self.drawing_start = None
+ self.drawing_rect_item = None
+ else:
+ super().mouseReleaseEvent(event)
+
+
+class PDFProcessor(QThread):
+ """Background thread for PDF processing operations."""
+ finished = Signal(bool, str) # success, message
+ progress = Signal(str) # status message
+
+ def __init__(self, operation: str, **kwargs: Any) -> None:
+ super().__init__()
+ self.operation = operation
+ self.kwargs = kwargs
+
+ def run(self) -> None:
+ """Execute the PDF processing operation."""
+ try:
+ if self.operation == "load":
+ self._load_pdf()
+ elif self.operation == "save":
+ self._save_pdf()
+ elif self.operation == "add_text":
+ self._add_text()
+ elif self.operation == "sign":
+ self._sign_pdf()
+ else:
+ self.finished.emit(False, f"Unknown operation: {self.operation}")
+ except Exception as e:
+ self.finished.emit(False, f"Error: {str(e)}")
+
+ def _load_pdf(self) -> None:
+ """Load PDF and extract text."""
+ pdf_path = self.kwargs.get("pdf_path")
+ if not pdf_path:
+ self.finished.emit(False, "No PDF path provided")
+ return
+
+ self.progress.emit("Loading PDF...")
+ doc = fitz.open(pdf_path)
+ pages_data = []
+
+ for page_num in range(len(doc)):
+ page = doc[page_num]
+ text = page.get_text()
+ pages_data.append({
+ "page_num": page_num + 1,
+ "text": text,
+ "width": page.rect.width,
+ "height": page.rect.height
+ })
+
+ doc.close()
+ self.kwargs["pages_data"] = pages_data
+ self.finished.emit(True, f"Loaded {len(pages_data)} pages")
+
+ def _save_pdf(self) -> None:
+ """Save PDF with modifications."""
+ self.progress.emit("Saving PDF...")
+ # Implementation will use PyMuPDF to save
+ self.finished.emit(True, "PDF saved successfully")
+
+ def _add_text(self) -> None:
+ """Add text to PDF page."""
+ self.progress.emit("Adding text...")
+ # Implementation will use PyMuPDF to insert text
+ self.finished.emit(True, "Text added successfully")
+
+ def _sign_pdf(self) -> None:
+ """Add digital signature to PDF."""
+ self.progress.emit("Signing PDF...")
+ # Implementation will use pyHanko
+ self.finished.emit(True, "PDF signed successfully")
+
+
+class CertificateDialog(QDialog):
+ """Dialog for creating or selecting a certificate for signing."""
+
+ def __init__(self, parent: Optional[QWidget] = None) -> None:
+ super().__init__(parent)
+ self.setWindowTitle("Certificate Setup")
+ self.setModal(True)
+ self.certificate_data: Optional[Dict[str, Any]] = None
+ self._init_ui()
+
+ def _init_ui(self) -> None:
+ """Initialize the dialog UI."""
+ layout = QVBoxLayout()
+
+ # Certificate info
+ form_layout = QFormLayout()
+
+ self.name_input = QLineEdit()
+ self.name_input.setPlaceholderText("Your Name")
+ form_layout.addRow("Name:", self.name_input)
+
+ self.org_input = QLineEdit()
+ self.org_input.setPlaceholderText("Organization")
+ form_layout.addRow("Organization:", self.org_input)
+
+ self.email_input = QLineEdit()
+ self.email_input.setPlaceholderText("email@example.com")
+ form_layout.addRow("Email:", self.email_input)
+
+ self.country_input = QLineEdit()
+ self.country_input.setPlaceholderText("US")
+ self.country_input.setMaxLength(2)
+ form_layout.addRow("Country:", self.country_input)
+
+ layout.addLayout(form_layout)
+
+ # Buttons
+ button_box = QDialogButtonBox(
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
+ )
+ button_box.accepted.connect(self._on_accept)
+ button_box.rejected.connect(self.reject)
+ layout.addWidget(button_box)
+
+ self.setLayout(layout)
+
+ def _on_accept(self) -> None:
+ """Validate and accept the dialog."""
+ name = self.name_input.text().strip()
+ if not name:
+ QMessageBox.warning(self, "Error", "Name is required")
+ return
+
+ self.certificate_data = {
+ "name": name,
+ "organization": self.org_input.text().strip() or "Self-Signed",
+ "email": self.email_input.text().strip() or "no-reply@example.com",
+ "country": self.country_input.text().strip() or "US"
+ }
+ self.accept()
+
+
+class PDFEditorSignerGUI(QMainWindow):
+ """Main application window for PDF editing and signing."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.current_pdf_path: Optional[str] = None
+ self.pdf_doc: Optional[fitz.Document] = None
+ self.current_page: int = 0
+ self.pages_data: List[Dict[str, Any]] = []
+ self.certificate_path: Optional[str] = None
+ self.private_key_path: Optional[str] = None
+ self.zoom_level: float = 2.0 # Default zoom level
+ self.text_boxes: List[EditableTextBox] = [] # Track text boxes on current page
+ self.graphics_scene: Optional[QGraphicsScene] = None
+ self.graphics_view: Optional[PDFGraphicsView] = None
+
+ self.setWindowTitle("PDF Editor & Signer")
+ self.setGeometry(100, 100, 1200, 800)
+
+ self._init_ui()
+ self._apply_theme()
+
+ def _init_ui(self) -> None:
+ """Initialize the user interface."""
+ central_widget = QWidget()
+ self.setCentralWidget(central_widget)
+ main_layout = QVBoxLayout(central_widget)
+
+ # Top toolbar
+ toolbar_layout = QHBoxLayout()
+
+ load_btn = QPushButton("Open PDF")
+ load_btn.clicked.connect(self._load_pdf)
+ toolbar_layout.addWidget(load_btn)
+
+ save_btn = QPushButton("Save PDF")
+ save_btn.clicked.connect(self._save_pdf)
+ toolbar_layout.addWidget(save_btn)
+
+ save_as_btn = QPushButton("Save As...")
+ save_as_btn.clicked.connect(self._save_pdf_as)
+ toolbar_layout.addWidget(save_as_btn)
+
+ toolbar_layout.addStretch()
+
+ sign_btn = QPushButton("Sign PDF")
+ sign_btn.clicked.connect(self._show_sign_dialog)
+ toolbar_layout.addWidget(sign_btn)
+
+ main_layout.addLayout(toolbar_layout)
+
+ # Main content area with splitter
+ splitter = QSplitter(Qt.Orientation.Horizontal)
+
+ # Left panel - Page list and navigation
+ left_panel = QWidget()
+ left_layout = QVBoxLayout(left_panel)
+
+ pages_label = QLabel("Pages:")
+ left_layout.addWidget(pages_label)
+
+ self.page_list = QListWidget()
+ self.page_list.currentRowChanged.connect(self._on_page_changed)
+ left_layout.addWidget(self.page_list)
+
+ # Navigation buttons
+ nav_layout = QHBoxLayout()
+ prev_btn = QPushButton("Previous")
+ prev_btn.clicked.connect(self._prev_page)
+ nav_layout.addWidget(prev_btn)
+
+ next_btn = QPushButton("Next")
+ next_btn.clicked.connect(self._next_page)
+ nav_layout.addWidget(next_btn)
+
+ left_layout.addLayout(nav_layout)
+
+ splitter.addWidget(left_panel)
+
+ # Center panel - PDF viewer and editor
+ center_panel = QWidget()
+ center_layout = QVBoxLayout(center_panel)
+
+ # Zoom controls
+ zoom_layout = QHBoxLayout()
+ zoom_label = QLabel("Zoom:")
+ zoom_layout.addWidget(zoom_label)
+
+ zoom_out_btn = QPushButton("-")
+ zoom_out_btn.setMaximumWidth(40)
+ zoom_out_btn.clicked.connect(self._zoom_out)
+ zoom_layout.addWidget(zoom_out_btn)
+
+ self.zoom_slider = QSlider(Qt.Orientation.Horizontal)
+ self.zoom_slider.setMinimum(50) # 0.5x zoom
+ self.zoom_slider.setMaximum(400) # 4.0x zoom
+ self.zoom_slider.setValue(200) # 2.0x zoom (default)
+ self.zoom_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
+ self.zoom_slider.setTickInterval(50)
+ self.zoom_slider.valueChanged.connect(self._on_zoom_changed)
+ zoom_layout.addWidget(self.zoom_slider)
+
+ zoom_in_btn = QPushButton("+")
+ zoom_in_btn.setMaximumWidth(40)
+ zoom_in_btn.clicked.connect(self._zoom_in)
+ zoom_layout.addWidget(zoom_in_btn)
+
+ self.zoom_value_label = QLabel("200%")
+ self.zoom_value_label.setMinimumWidth(50)
+ zoom_layout.addWidget(self.zoom_value_label)
+
+ fit_width_btn = QPushButton("Fit Width")
+ fit_width_btn.clicked.connect(self._fit_width)
+ zoom_layout.addWidget(fit_width_btn)
+
+ fit_page_btn = QPushButton("Fit Page")
+ fit_page_btn.clicked.connect(self._fit_page)
+ zoom_layout.addWidget(fit_page_btn)
+
+ zoom_layout.addStretch()
+ center_layout.addLayout(zoom_layout)
+
+ # Create graphics view for PDF with interactive text boxes
+ self.graphics_scene = QGraphicsScene()
+ self.graphics_view = PDFGraphicsView()
+ self.graphics_view.setScene(self.graphics_scene)
+ self.graphics_view.textBoxCreated.connect(self._on_textbox_created)
+
+ center_layout.addWidget(self.graphics_view)
+ splitter.addWidget(center_panel)
+
+ # Right panel - Tools
+ right_panel = QWidget()
+ right_layout = QVBoxLayout(right_panel)
+
+ # Text box tools
+ textbox_group = QGroupBox("Text Box Tools")
+ textbox_layout = QVBoxLayout()
+
+ info_label = QLabel("Click on detected lines/fields to add text.\nDetected elements are highlighted in color.")
+ info_label.setWordWrap(True)
+ textbox_layout.addWidget(info_label)
+
+ apply_textboxes_btn = QPushButton("Apply Text to PDF")
+ apply_textboxes_btn.clicked.connect(self._apply_textboxes_to_pdf)
+ textbox_layout.addWidget(apply_textboxes_btn)
+
+ clear_textboxes_btn = QPushButton("Clear All Text Boxes")
+ clear_textboxes_btn.clicked.connect(self._clear_textboxes)
+ textbox_layout.addWidget(clear_textboxes_btn)
+
+ textbox_group.setLayout(textbox_layout)
+ right_layout.addWidget(textbox_group)
+ textbox_group.setLayout(textbox_layout)
+ right_layout.addWidget(textbox_group)
+
+ # Signature tools
+ sig_group = QGroupBox("Signature")
+ sig_group_layout = QVBoxLayout()
+
+ self.cert_label = QLabel("No certificate loaded")
+ self.cert_label.setWordWrap(True)
+ sig_group_layout.addWidget(self.cert_label)
+
+ create_cert_btn = QPushButton("Create Certificate")
+ create_cert_btn.clicked.connect(self._create_certificate)
+ sig_group_layout.addWidget(create_cert_btn)
+
+ load_cert_btn = QPushButton("Load Certificate")
+ load_cert_btn.clicked.connect(self._load_certificate)
+ sig_group_layout.addWidget(load_cert_btn)
+
+ sig_group.setLayout(sig_group_layout)
+ right_layout.addWidget(sig_group)
+
+ right_layout.addStretch()
+ splitter.addWidget(right_panel)
+
+ # Set splitter sizes
+ splitter.setSizes([200, 700, 300])
+
+ main_layout.addWidget(splitter)
+
+ # Status bar
+ self.status_label = QLabel("Ready")
+ main_layout.addWidget(self.status_label)
+
+ def _apply_theme(self) -> None:
+ """Apply dark theme to the application."""
+ QApplication.setStyle("Fusion")
+
+ dark_palette = QPalette()
+ dark_palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53))
+ dark_palette.setColor(QPalette.ColorRole.WindowText, QColor(255, 255, 255))
+ dark_palette.setColor(QPalette.ColorRole.Base, QColor(35, 35, 35))
+ dark_palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53))
+ dark_palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(25, 25, 25))
+ dark_palette.setColor(QPalette.ColorRole.ToolTipText, QColor(255, 255, 255))
+ dark_palette.setColor(QPalette.ColorRole.Text, QColor(255, 255, 255))
+ dark_palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53))
+ dark_palette.setColor(QPalette.ColorRole.ButtonText, QColor(255, 255, 255))
+ dark_palette.setColor(QPalette.ColorRole.BrightText, QColor(255, 0, 0))
+ dark_palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218))
+ dark_palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218))
+ dark_palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255))
+
+ QApplication.setPalette(dark_palette)
+
+ def _load_pdf(self) -> None:
+ """Load a PDF file."""
+ file_path, _ = QFileDialog.getOpenFileName(
+ self,
+ "Open PDF File",
+ "",
+ "PDF Files (*.pdf)"
+ )
+
+ if not file_path:
+ return
+
+ try:
+ self.pdf_doc = fitz.open(file_path)
+ self.current_pdf_path = file_path
+ self.current_page = 0
+
+ # Populate page list
+ self.page_list.clear()
+ for i in range(len(self.pdf_doc)):
+ self.page_list.addItem(f"Page {i + 1}")
+
+ self.page_list.setCurrentRow(0)
+ self._display_current_page()
+
+ self.status_label.setText(f"Loaded: {Path(file_path).name} ({len(self.pdf_doc)} pages)")
+
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to load PDF: {str(e)}")
+
+ def _save_pdf(self) -> None:
+ """Save the current PDF."""
+ if not self.pdf_doc or not self.current_pdf_path:
+ QMessageBox.warning(self, "Warning", "No PDF loaded to save")
+ return
+
+ try:
+ self.pdf_doc.save(self.current_pdf_path, incremental=True)
+ self.status_label.setText("PDF saved successfully")
+ QMessageBox.information(self, "Success", "PDF saved successfully")
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to save PDF: {str(e)}")
+
+ def _save_pdf_as(self) -> None:
+ """Save the PDF with a new name."""
+ if not self.pdf_doc:
+ QMessageBox.warning(self, "Warning", "No PDF loaded to save")
+ return
+
+ file_path, _ = QFileDialog.getSaveFileName(
+ self,
+ "Save PDF As",
+ "",
+ "PDF Files (*.pdf)"
+ )
+
+ if not file_path:
+ return
+
+ try:
+ self.pdf_doc.save(file_path)
+ self.current_pdf_path = file_path
+ self.status_label.setText(f"Saved: {Path(file_path).name}")
+ QMessageBox.information(self, "Success", "PDF saved successfully")
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to save PDF: {str(e)}")
+
+ def _display_current_page(self) -> None:
+ """Display the current page in the preview."""
+ if not self.pdf_doc or self.current_page >= len(self.pdf_doc):
+ return
+
+ try:
+ page = self.pdf_doc[self.current_page]
+
+ # Render page to pixmap with current zoom level
+ mat = fitz.Matrix(self.zoom_level, self.zoom_level)
+ pix = page.get_pixmap(matrix=mat)
+
+ # Convert to QImage
+ img_data = pix.samples
+ qimg = QImage(img_data, pix.width, pix.height, pix.stride, QImage.Format.Format_RGB888)
+ pixmap = QPixmap.fromImage(qimg)
+
+ # Detect lines and fields in the PDF
+ detected_lines, detected_fields = self._detect_pdf_elements(page)
+
+ # Clear existing scene and add pixmap
+ if self.graphics_scene:
+ self.graphics_scene.clear()
+ self.text_boxes.clear()
+
+ pixmap_item = self.graphics_scene.addPixmap(pixmap)
+ self.graphics_scene.setSceneRect(pixmap_item.boundingRect())
+
+ # Add selectable text overlay
+ self._add_text_overlay(page)
+
+ # Draw detected elements as overlays (optional - for visual feedback)
+ self._draw_detected_elements(detected_lines, detected_fields)
+
+ # Pass detected elements to graphics view
+ if self.graphics_view:
+ self.graphics_view.setDetectedElements(detected_lines, detected_fields)
+ self.graphics_view.fitInView(self.graphics_scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
+
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to display page: {str(e)}")
+
+ def _add_text_overlay(self, page: fitz.Page) -> None:
+ """Add selectable text overlay on top of PDF image."""
+ if not self.graphics_scene:
+ return
+
+ try:
+ # Get text with position information
+ text_dict = page.get_text("dict")
+ if not isinstance(text_dict, dict):
+ return
+
+ blocks = text_dict.get("blocks", [])
+
+ for block in blocks:
+ if block.get("type") != 0: # Not text block
+ continue
+
+ for line in block.get("lines", []):
+ for span in line.get("spans", []):
+ text = span.get("text", "")
+ if not text.strip():
+ continue
+
+ bbox = span.get("bbox")
+ if not bbox:
+ continue
+
+ x0, y0, x1, y1 = bbox
+
+ # Create a transparent text item that can be selected
+ text_item = QGraphicsTextItem(text)
+ text_item.setPos(x0 * self.zoom_level, y0 * self.zoom_level)
+
+ # Set font to match PDF
+ font_size = span.get("size", 12)
+ font_name = span.get("font", "Arial")
+ font = QFont(font_name, int(font_size * self.zoom_level))
+ text_item.setFont(font)
+
+ # Make it transparent but selectable
+ text_item.setDefaultTextColor(QColor(0, 0, 0, 0)) # Fully transparent
+ text_item.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsSelectable, True)
+ text_item.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+
+ # Add to scene with lower Z value so it's below text boxes
+ text_item.setZValue(-2)
+ self.graphics_scene.addItem(text_item)
+
+ except Exception as e:
+ self.status_label.setText(f"Text overlay error: {str(e)}")
+
+ def _detect_pdf_elements(self, page: fitz.Page) -> Tuple[List[QRectF], List[Tuple[QRectF, TextBoxType]]]:
+ """Detect lines, signature areas, and date fields in PDF page."""
+ lines: List[QRectF] = []
+ fields: List[Tuple[QRectF, TextBoxType]] = []
+
+ try:
+ # Get page drawings (lines, rectangles)
+ drawings = page.get_drawings()
+
+ for drawing in drawings:
+ rect = drawing.get("rect")
+ if not rect:
+ continue
+
+ x0, y0, x1, y1 = rect
+ width = x1 - x0
+ height = y1 - y0
+
+ # Detect horizontal lines (potential signature/text lines)
+ if height < 5 and width > 50: # Horizontal line
+ # Scale to zoom level
+ scaled_rect = QRectF(
+ x0 * self.zoom_level,
+ (y0 - 10) * self.zoom_level, # Add space above line
+ width * self.zoom_level,
+ 20 * self.zoom_level # Text height
+ )
+ lines.append(scaled_rect)
+
+ # Detect text patterns for date and signature
+ text_dict = page.get_text("dict")
+ blocks = text_dict.get("blocks", []) if isinstance(text_dict, dict) else []
+
+ for block in blocks:
+ if block.get("type") != 0: # Not text block
+ continue
+
+ bbox = block.get("bbox")
+ if not bbox:
+ continue
+
+ # Extract text from block
+ block_text = ""
+ for line in block.get("lines", []):
+ for span in line.get("spans", []):
+ block_text += span.get("text", "")
+
+ block_text_lower = block_text.lower()
+
+ # Detect signature fields
+ if any(word in block_text_lower for word in ["signature", "sign here", "signed", "sign:"]):
+ x0, y0, x1, y1 = bbox
+ scaled_rect = QRectF(
+ x0 * self.zoom_level,
+ y1 * self.zoom_level, # Below the label
+ (x1 - x0) * self.zoom_level,
+ 25 * self.zoom_level
+ )
+ fields.append((scaled_rect, TextBoxType.SIGNATURE))
+
+ # Detect date fields
+ elif any(word in block_text_lower for word in ["date", "date:", "dated"]):
+ x0, y0, x1, y1 = bbox
+ scaled_rect = QRectF(
+ x0 * self.zoom_level,
+ y1 * self.zoom_level,
+ (x1 - x0) * self.zoom_level,
+ 20 * self.zoom_level
+ )
+ fields.append((scaled_rect, TextBoxType.DATE))
+
+ except Exception as e:
+ self.status_label.setText(f"Detection error: {str(e)}")
+
+ return lines, fields
+
+ def _draw_detected_elements(self, lines: List[QRectF], fields: List[Tuple[QRectF, TextBoxType]]) -> None:
+ """Draw visual indicators for detected elements (optional)."""
+ if not self.graphics_scene:
+ return
+
+ # Draw detected lines with subtle highlight
+ for line_rect in lines:
+ rect_item = QGraphicsRectItem(line_rect)
+ pen = QPen(QColor(100, 200, 100, 100), 1, Qt.PenStyle.DotLine)
+ rect_item.setPen(pen)
+ rect_item.setBrush(QBrush(QColor(100, 200, 100, 20)))
+ rect_item.setZValue(-1) # Behind text boxes
+ self.graphics_scene.addItem(rect_item)
+
+ # Draw detected fields with type-specific colors
+ for field_rect, box_type in fields:
+ rect_item = QGraphicsRectItem(field_rect)
+ if box_type == TextBoxType.SIGNATURE:
+ color = QColor(100, 100, 200, 100)
+ elif box_type == TextBoxType.DATE:
+ color = QColor(200, 100, 100, 100)
+ else:
+ color = QColor(100, 200, 100, 100)
+
+ pen = QPen(color, 1, Qt.PenStyle.DotLine)
+ rect_item.setPen(pen)
+ rect_item.setBrush(QBrush(QColor(color.red(), color.green(), color.blue(), 20)))
+ rect_item.setZValue(-1)
+ self.graphics_scene.addItem(rect_item)
+
+ def _on_page_changed(self, index: int) -> None:
+ """Handle page selection change."""
+ if index >= 0 and self.pdf_doc:
+ self.current_page = index
+ self._display_current_page()
+
+ def _prev_page(self) -> None:
+ """Navigate to previous page."""
+ if self.current_page > 0:
+ self.current_page -= 1
+ self.page_list.setCurrentRow(self.current_page)
+
+ def _next_page(self) -> None:
+ """Navigate to next page."""
+ if self.pdf_doc and self.current_page < len(self.pdf_doc) - 1:
+ self.current_page += 1
+ self.page_list.setCurrentRow(self.current_page)
+
+ def _on_zoom_changed(self, value: int) -> None:
+ """Handle zoom slider change."""
+ self.zoom_level = value / 100.0
+ self.zoom_value_label.setText(f"{value}%")
+ self._display_current_page()
+
+ def _zoom_in(self) -> None:
+ """Zoom in by 25%."""
+ current_value = self.zoom_slider.value()
+ new_value = min(400, current_value + 25)
+ self.zoom_slider.setValue(new_value)
+
+ def _zoom_out(self) -> None:
+ """Zoom out by 25%."""
+ current_value = self.zoom_slider.value()
+ new_value = max(50, current_value - 25)
+ self.zoom_slider.setValue(new_value)
+
+ def _fit_width(self) -> None:
+ """Fit page to scroll area width."""
+ if not self.pdf_doc or not self.graphics_view:
+ return
+
+ try:
+ page = self.pdf_doc[self.current_page]
+ page_width = page.rect.width
+ view_width = self.graphics_view.viewport().width() - 20 # Account for margins
+
+ zoom = view_width / page_width
+ zoom_percent = int(zoom * 100)
+ zoom_percent = max(50, min(400, zoom_percent)) # Clamp to slider range
+
+ self.zoom_slider.setValue(zoom_percent)
+ except Exception as e:
+ self.status_label.setText(f"Error fitting width: {str(e)}")
+
+ def _fit_page(self) -> None:
+ """Fit entire page to scroll area."""
+ if not self.pdf_doc or not self.graphics_view:
+ return
+
+ try:
+ page = self.pdf_doc[self.current_page]
+ page_width = page.rect.width
+ page_height = page.rect.height
+ view_width = self.graphics_view.viewport().width() - 20
+ view_height = self.graphics_view.viewport().height() - 20
+
+ zoom_w = view_width / page_width
+ zoom_h = view_height / page_height
+ zoom = min(zoom_w, zoom_h)
+
+ zoom_percent = int(zoom * 100)
+ zoom_percent = max(50, min(400, zoom_percent)) # Clamp to slider range
+
+ self.zoom_slider.setValue(zoom_percent)
+ except Exception as e:
+ self.status_label.setText(f"Error fitting page: {str(e)}")
+
+ def _on_textbox_created(self, rect: QRectF, box_type: TextBoxType) -> None:
+ """Handle text box creation."""
+ if not self.graphics_scene:
+ return
+
+ # Create border rectangle
+ border_rect = QGraphicsRectItem(rect)
+ pen = QPen(QColor(42, 130, 218), 1)
+ border_rect.setPen(pen)
+ border_rect.setBrush(QBrush(QColor(255, 255, 255, 200)))
+ self.graphics_scene.addItem(border_rect)
+
+ # Create editable text box
+ text_box = EditableTextBox("", box_type)
+ text_box.setPos(rect.topLeft())
+ text_box.setTextWidth(rect.width())
+ text_box.setBorderItem(border_rect)
+
+ self.graphics_scene.addItem(text_box)
+ self.text_boxes.append(text_box)
+
+ # Give it focus so user can start typing
+ text_box.setFocus()
+
+ self.status_label.setText("Text box created - start typing or click elsewhere")
+
+ def _apply_textboxes_to_pdf(self) -> None:
+ """Apply all text boxes to the PDF page."""
+ if not self.pdf_doc or not self.text_boxes:
+ QMessageBox.warning(self, "Warning", "No text boxes to apply")
+ return
+
+ try:
+ page = self.pdf_doc[self.current_page]
+
+ # Convert each text box to PDF text
+ for text_box in self.text_boxes:
+ if text_box.is_empty:
+ continue
+
+ text = text_box.toPlainText().strip()
+ if not text:
+ continue
+
+ # Get position and convert from scene coordinates to PDF coordinates
+ pos = text_box.scenePos()
+
+ # Account for zoom level
+ pdf_x = pos.x() / self.zoom_level
+ pdf_y = pos.y() / self.zoom_level
+
+ # Get font info
+ font = text_box.font()
+ font_size = font.pointSize()
+
+ # Insert text into PDF
+ point = fitz.Point(pdf_x, pdf_y)
+
+ # Choose color based on type
+ if text_box.box_type == TextBoxType.SIGNATURE:
+ color = (0, 0, 0.5) # Dark blue for signatures
+ else:
+ color = (0, 0, 0) # Black for regular text
+
+ page.insert_text(
+ point,
+ text,
+ fontsize=font_size,
+ color=color
+ )
+
+ # Refresh display
+ self._display_current_page()
+ self.status_label.setText(f"Applied {len(self.text_boxes)} text boxes to page")
+
+ QMessageBox.information(self, "Success", "Text boxes applied to PDF. Remember to save!")
+
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to apply text boxes: {str(e)}")
+
+ def _clear_textboxes(self) -> None:
+ """Clear all text boxes from the current page."""
+ if not self.text_boxes:
+ return
+
+ reply = QMessageBox.question(
+ self,
+ "Confirm Clear",
+ "Clear all text boxes on this page?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
+ )
+
+ if reply == QMessageBox.StandardButton.Yes:
+ # Remove all text boxes from scene
+ if self.graphics_scene:
+ for text_box in self.text_boxes:
+ if text_box.scene():
+ self.graphics_scene.removeItem(text_box)
+ if text_box.border_item and text_box.border_item.scene():
+ self.graphics_scene.removeItem(text_box.border_item)
+
+ self.text_boxes.clear()
+ self.status_label.setText("Text boxes cleared")
+
+ def _create_certificate(self) -> None:
+ """Create a self-signed certificate for signing."""
+ dialog = CertificateDialog(self)
+ if dialog.exec() != QDialog.DialogCode.Accepted:
+ return
+
+ cert_data = dialog.certificate_data
+ if not cert_data:
+ return
+
+ try:
+ # Generate private key
+ private_key = rsa.generate_private_key(
+ public_exponent=65537,
+ key_size=2048
+ )
+
+ # Create certificate
+ subject = issuer = x509.Name([
+ x509.NameAttribute(NameOID.COUNTRY_NAME, cert_data["country"]),
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, cert_data["organization"]),
+ x509.NameAttribute(NameOID.COMMON_NAME, cert_data["name"]),
+ x509.NameAttribute(NameOID.EMAIL_ADDRESS, cert_data["email"]),
+ ])
+
+ cert = x509.CertificateBuilder().subject_name(
+ subject
+ ).issuer_name(
+ issuer
+ ).public_key(
+ private_key.public_key()
+ ).serial_number(
+ x509.random_serial_number()
+ ).not_valid_before(
+ datetime.utcnow()
+ ).not_valid_after(
+ datetime.utcnow() + timedelta(days=365)
+ ).sign(private_key, hashes.SHA256())
+
+ # Save certificate and key
+ save_dir = QFileDialog.getExistingDirectory(self, "Select Directory to Save Certificate")
+ if not save_dir:
+ return
+
+ cert_path = Path(save_dir) / "certificate.pem"
+ key_path = Path(save_dir) / "private_key.pem"
+
+ with open(cert_path, "wb") as f:
+ f.write(cert.public_bytes(serialization.Encoding.PEM))
+
+ with open(key_path, "wb") as f:
+ f.write(private_key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
+ encryption_algorithm=serialization.NoEncryption()
+ ))
+
+ self.certificate_path = str(cert_path)
+ self.private_key_path = str(key_path)
+ self.cert_label.setText(f"Certificate: {cert_data['name']}\n{cert_path.name}")
+
+ QMessageBox.information(
+ self,
+ "Success",
+ f"Certificate created successfully!\n\nCertificate: {cert_path}\nPrivate Key: {key_path}"
+ )
+
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to create certificate: {str(e)}")
+
+ def _load_certificate(self) -> None:
+ """Load an existing certificate."""
+ cert_path, _ = QFileDialog.getOpenFileName(
+ self,
+ "Open Certificate File",
+ "",
+ "PEM Files (*.pem);;All Files (*)"
+ )
+
+ if not cert_path:
+ return
+
+ key_path, _ = QFileDialog.getOpenFileName(
+ self,
+ "Open Private Key File",
+ "",
+ "PEM Files (*.pem);;All Files (*)"
+ )
+
+ if not key_path:
+ return
+
+ self.certificate_path = cert_path
+ self.private_key_path = key_path
+ self.cert_label.setText(f"Certificate loaded:\n{Path(cert_path).name}")
+ self.status_label.setText("Certificate loaded")
+
+ def _show_sign_dialog(self) -> None:
+ """Show dialog for signing the PDF."""
+ if not self.pdf_doc:
+ QMessageBox.warning(self, "Warning", "No PDF loaded")
+ return
+
+ if not self.certificate_path or not self.private_key_path:
+ QMessageBox.warning(
+ self,
+ "Warning",
+ "Please create or load a certificate first"
+ )
+ return
+
+ reason, ok = QInputDialog.getText(
+ self,
+ "Sign PDF",
+ "Reason for signing:",
+ text="I approve this document"
+ )
+
+ if not ok:
+ return
+
+ self._sign_pdf(reason)
+
+ def _sign_pdf(self, reason: str) -> None:
+ """Sign the PDF with the loaded certificate."""
+ if not self.current_pdf_path:
+ QMessageBox.warning(self, "Warning", "Please save the PDF first")
+ return
+
+ if not self.certificate_path or not self.private_key_path:
+ QMessageBox.warning(self, "Warning", "Certificate paths not set")
+ return
+
+ if not self.pdf_doc:
+ QMessageBox.warning(self, "Warning", "No PDF document loaded")
+ return
+
+ try:
+ # Ask for output path
+ output_path, _ = QFileDialog.getSaveFileName(
+ self,
+ "Save Signed PDF",
+ self.current_pdf_path.replace(".pdf", "_signed.pdf"),
+ "PDF Files (*.pdf)"
+ )
+
+ if not output_path:
+ return
+
+ # Load certificate and key
+ with open(self.certificate_path, "rb") as f:
+ cert_data = f.read()
+
+ with open(self.private_key_path, "rb") as f:
+ key_data = f.read()
+
+ # Sign using pyHanko
+ from pyhanko.sign import signers
+ from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
+
+ # Simple signing (this is a basic implementation)
+ # For production use, you'd want more robust pyHanko integration
+ QMessageBox.information(
+ self,
+ "Info",
+ "Basic signing implemented. For production use, consider using pyHanko's full signature features."
+ )
+
+ # For now, just save a copy
+ self.pdf_doc.save(output_path)
+
+ self.status_label.setText(f"Signed PDF saved: {Path(output_path).name}")
+ QMessageBox.information(self, "Success", "PDF signed successfully!")
+
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to sign PDF: {str(e)}")
+
+
+def main() -> None:
+ """Main entry point for the application."""
+ app = QApplication(sys.argv)
+ app.setApplicationName("PDF Editor & Signer")
+
+ window = PDFEditorSignerGUI()
+ window.show()
+
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/PDF Editor and Signer/requirements.txt b/PDF Editor and Signer/requirements.txt
new file mode 100644
index 00000000..41171e1b
--- /dev/null
+++ b/PDF Editor and Signer/requirements.txt
@@ -0,0 +1,4 @@
+PySide6>=6.0.0
+PyMuPDF>=1.23.0
+pyHanko>=0.20.0
+cryptography>=41.0.0
diff --git a/PNG and SVG to Icons Generator with GUI/README.md b/PNG and SVG to Icons Generator with GUI/README.md
new file mode 100644
index 00000000..dfc144d2
--- /dev/null
+++ b/PNG and SVG to Icons Generator with GUI/README.md
@@ -0,0 +1,55 @@
+# PNG/SVG to Icon Converter (GUI)
+
+A simple Python GUI tool to convert PNG and SVG images into icon formats for Windows (.ico), macOS (.icns), and Linux (multiple PNG sizes). Built with PySide6, Pillow, and CairoSVG.
+
+---
+
+
+## Features
+- 🖼️ Select a PNG or SVG file and convert it to:
+ - Windows .ico (user-selectable sizes)
+ - macOS .icns
+ - Linux PNG icons (user-selectable sizes)
+- ☑️ Checkboxes to select which icon sizes to output
+- 📂 Choose output directory
+- ⚡ Fast, one-click conversion
+- ❌ Error handling with pop-up dialogs
+
+---
+
+## Requirements
+
+
+- Python 3.8+
+- PySide6
+- Pillow
+- CairoSVG (for SVG support)
+
+Install dependencies:
+```bash
+pip install PySide6 Pillow cairosvg
+```
+
+---
+
+## How to Use
+
+1. Run the script:
+ ```bash
+ python png2icon.py
+ ```
+2. Click **"Select PNG and Convert"**
+3. Choose a PNG or SVG file
+4. Select a directory to save the icons
+5. Icons for Windows, macOS, and Linux will be created in the chosen folder, in the sizes you selected
+
+---
+
+## Author
+
+Randy Northrup
+
+---
+
+## License
+MIT License – free to use, modify, and share.
diff --git a/PNG and SVG to Icons Generator with GUI/png2icon.py b/PNG and SVG to Icons Generator with GUI/png2icon.py
new file mode 100644
index 00000000..294c67c3
--- /dev/null
+++ b/PNG and SVG to Icons Generator with GUI/png2icon.py
@@ -0,0 +1,107 @@
+import sys
+import os
+from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QFileDialog, QMessageBox, QCheckBox, QLabel, QHBoxLayout, QGroupBox
+from PIL import Image
+import io
+
+# Pillow 10+ uses Image.Resampling.LANCZOS, fallback for older versions
+try:
+ LANCZOS_RESAMPLE = Image.Resampling.LANCZOS
+except AttributeError:
+ LANCZOS_RESAMPLE = 1 # 1 is the value for LANCZOS in older Pillow
+
+class IconConverterApp(QWidget):
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("PNG/SVG to Icon Converter")
+ self.setGeometry(200, 200, 400, 250)
+
+ self.sizes = [16, 24, 32, 48, 64, 128, 256, 512]
+ self.checkboxes = []
+
+ # Check SVG support
+ try:
+ import cairosvg
+ self.svg_support = True
+ except ImportError:
+ self.svg_support = False
+
+ layout = QVBoxLayout()
+ self.button = QPushButton("Select Image and Convert")
+ self.button.clicked.connect(self.convert_icon)
+ layout.addWidget(self.button)
+
+ # Add checkboxes for sizes
+ size_group = QGroupBox("Select icon sizes to output")
+ size_layout = QHBoxLayout()
+ for size in self.sizes:
+ cb = QCheckBox(f"{size}x{size}")
+ cb.setChecked(True)
+ self.checkboxes.append(cb)
+ size_layout.addWidget(cb)
+ size_group.setLayout(size_layout)
+ layout.addWidget(size_group)
+
+ self.setLayout(layout)
+
+ def convert_icon(self):
+ # Step 1: Select PNG or SVG file
+ file_filter = "Image Files (*.png *.svg)" if self.svg_support else "PNG Files (*.png)"
+ img_file, _ = QFileDialog.getOpenFileName(self, "Select Image File", "", file_filter)
+ if not img_file:
+ return
+
+ # Step 2: Ask where to save icons
+ save_dir = QFileDialog.getExistingDirectory(self, "Select Save Directory")
+ if not save_dir:
+ return
+
+ try:
+ # Load image (handle SVG if needed)
+ if img_file.lower().endswith('.svg'):
+ import cairosvg # Ensure cairosvg is in local scope
+ if not self.svg_support:
+ QMessageBox.critical(self, "Error", "SVG support requires cairosvg. Please install it.")
+ return
+ # Convert SVG to PNG in memory
+ png_bytes = cairosvg.svg2png(url=img_file)
+ if png_bytes is None:
+ raise ValueError("SVG conversion failed.")
+ img = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
+ else:
+ img = Image.open(img_file).convert("RGBA")
+
+ # Get selected sizes
+ selected_sizes = [self.sizes[i] for i, cb in enumerate(self.checkboxes) if cb.isChecked()]
+ if not selected_sizes:
+ QMessageBox.warning(self, "No Sizes Selected", "Please select at least one icon size.")
+ return
+
+ # Windows ICO
+ ico_path = os.path.join(save_dir, "icon.ico")
+ ico_sizes = [(s, s) for s in selected_sizes if s in [16, 32, 48, 256]]
+ if ico_sizes:
+ img.save(ico_path, format="ICO", sizes=ico_sizes)
+
+ # macOS ICNS
+ icns_path = os.path.join(save_dir, "icon.icns")
+ if any(s in [16, 32, 128, 256, 512] for s in selected_sizes):
+ img.save(icns_path, format="ICNS")
+
+ # Linux PNG sizes
+ linux_dir = os.path.join(save_dir, "linux_icons")
+ os.makedirs(linux_dir, exist_ok=True)
+ for size in selected_sizes:
+ resized = img.resize((size, size), LANCZOS_RESAMPLE)
+ resized.save(os.path.join(linux_dir, f"icon_{size}x{size}.png"))
+
+ QMessageBox.information(self, "Success", f"Icons saved in:\n{save_dir}")
+
+ except Exception as e:
+ QMessageBox.critical(self, "Error", str(e))
+
+if __name__ == "__main__":
+ app = QApplication(sys.argv)
+ window = IconConverterApp()
+ window.show()
+ sys.exit(app.exec())
diff --git a/PNG and SVG to Icons Generator with GUI/requirements.txt b/PNG and SVG to Icons Generator with GUI/requirements.txt
new file mode 100644
index 00000000..cbabbd09
--- /dev/null
+++ b/PNG and SVG to Icons Generator with GUI/requirements.txt
@@ -0,0 +1,3 @@
+PySide6
+Pillow
+cairosvg
diff --git a/README.md b/README.md
index 5333ecbf..31266288 100644
--- a/README.md
+++ b/README.md
@@ -39,10 +39,11 @@ More information on contributing and the general code of conduct for discussion
| Script | Link | Description |
| ---------------------------------------- |----------------------------------------------------------------------------------------------------------------------------------------------------------| ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Advanced Search | [Advanced Search](https://github.com/RandyNorthrup/Python-Scripts/tree/main/Advanced%20Search) | A powerful Windows GUI application for grep-style searching with regex patterns, metadata search, and archive content search. |
| Arrange It | [Arrange It](https://github.com/DhanushNehru/Python-Scripts/tree/main/Arrange%20It) | A Python script that can automatically move files into corresponding folders based on their extensions. |
| Auto WiFi Check | [Auto WiFi Check](https://github.com/DhanushNehru/Python-Scripts/tree/main/Auto%20WiFi%20Check) | A Python script to monitor if the WiFi connection is active or not |
| AutoCert | [AutoCert](https://github.com/DhanushNehru/Python-Scripts/tree/main/AutoCert) | A Python script to auto-generate e-certificates in bulk. |
-| Autocomplete Notes App | [Autocomplete Notes App](https://github.com/DhanushNehru/Python-Scripts/tree/main/Autocomplete%20Notes%20App) | A Python script that provides a notes application with autocomplete features. |
+| Autocomplete Notes App | [Autocomplete Notes App](https://github.com/DhanushNehru/Python-Scripts/tree/main/Autocomplete%20Notes%20App) | A Python script that provides a notes application with autocomplete features. |
| Automated Emails | [Automated Emails](https://github.com/DhanushNehru/Python-Scripts/tree/main/Automate%20Emails%20Daily) | A Python script to send out personalized emails by reading a CSV file. |
| Black Hat Python | [Black Hat Python](https://github.com/DhanushNehru/Python-Scripts/tree/main/Black%20Hat%20Python) | Source code from the book Black Hat Python |
| Blackjack | [Blackjack](https://github.com/DhanushNehru/Python-Scripts/tree/main/Blackjack) | A game of Blackjack - let's get a 21. |
@@ -99,12 +100,14 @@ More information on contributing and the general code of conduct for discussion
| Keyword - Retweeting | [Keyword - Retweeting](https://github.com/DhanushNehru/Python-Scripts/tree/main/Keyword%20Retweet%20Twitter%20Bot) | Find the latest tweets containing given keywords and then retweet them. |
| LinkedIn Bot | [LinkedIn Bot](https://github.com/DhanushNehru/Python-Scripts/tree/main/LinkedIn%20Bot) | Automates the process of searching for public profiles on LinkedIn and exporting the data to an Excel sheet. |
| Longitude & Latitude to conical coverter | [Longitude Latitude conical converter](master/Longitude%20Latitude%20conical%20converter) | Converts Longitude and Latitude to Lambert conformal conic projection. |
+| MacOS App Removal Cache Cleaner | [MacOS App Removal Cache Cleaner](https://github.com/RandyNorthrup/Python-Scripts/tree/main/MacOS%20App%20Removal%20Cache%20Cleaner) | A macOS desktop utility to scan installed apps, find support files, and safely clean up caches and preferences. |
| Mail Sender | [Mail Sender](https://github.com/DhanushNehru/Python-Scripts/tree/main/Mail%20Sender) | Sends an email. |
+| MD to PDF or Text with GUI | [MD to PDF or Text with GUI](https://github.com/RandyNorthrup/Python-Scripts/tree/main/MD%20to%20PDF%20or%20Text%20with%20GUI) | A GUI tool to convert Markdown files into PDF or Text files with Unicode support. |
| Merge Two Images | [Merge Two Images](https://github.com/DhanushNehru/Python-Scripts/tree/main/Merge%20Two%20Images) | Merges two images horizontally or vertically. |
| Mood based youtube song generator | [Mood based youtube song generator](https://github.com/DhanushNehru/Python-Scripts/tree/main/Mood%20based%20youtube%20song%20generator) | This Python script fetches a random song from YouTube based on your mood input and opens it in your default web browser. |
+| Morse Code | [Morse Code](https://github.com/DhanushNehru/Python-Scripts/tree/main/Morse%20Code) | Encodes and decodes Morse code. |
| Mouse mover | [Mouse mover](https://github.com/DhanushNehru/Python-Scripts/tree/main/Mouse%20Mover) | Moves your mouse every 15 seconds. |
-| Multi-Platform Icon Generator | [Multi-Platform Icon Generator](https://github.com/DhanushNehru/Python-Scripts/tree/main/Multi-Platform%20Icon%20Generator) | Automates creation of 35+ platform-specific icon sizes from one source image for app stores and web deployment.
-| Morse Code | [Morse Code](https://github.com/DhanushNehru/Python-Scripts/tree/main/Morse%20Code) | Encodes and decodes Morse code. |
+
| No Screensaver | [No Screensaver](https://github.com/DhanushNehru/Python-Scripts/tree/main/No%20Screensaver) | Prevents screensaver from turning on. |
| OTP Verification | [OTP Verification](https://github.com/DhanushNehru/Python-Scripts/tree/main/OTP%20%20Verify) | An OTP Verification Checker. |
| Password Generator | [Password Generator](https://github.com/DhanushNehru/Python-Scripts/tree/main/Password%20Generator) | Generates a random password. |
@@ -112,21 +115,24 @@ More information on contributing and the general code of conduct for discussion
| Password Strength Checker | [Password Strength Checker](https://github.com/nem5345/Python-Scripts/tree/main/Password%20Strength%20Checker) | Evaluates how strong a given password is. |
| PDF Merger | [PDF Merger](https://github.com/DhanushNehru/Python-Scripts/tree/main/PDF%20Merger) | Merges multiple PDF files into a single PDF, with options for output location and custom order. |
| PDF to Audio | [PDF to Audio](https://github.com/DhanushNehru/Python-Scripts/tree/main/PDF%20to%20Audio) | Converts PDF to audio. |
+| PDF Editor and Signer | [PDF Editor and Signer](https://github.com/RandyNorthrup/Python-Scripts/tree/main/PDF%20Editor%20and%20Signer) | A PDF editor with intelligent element detection, click-to-place text, and digital signature capabilities. |
| PDF to Text | [PDF to text](https://github.com/DhanushNehru/Python-Scripts/tree/main/PDF%20to%20text) | Converts PDF to text. |
| PDF merger and splitter | [PDF Merger and Splitter](https://github.com/AbhijitMotekar99/Python-Scripts/blob/main/PDF%20Merger%20and%20Splitter/PDF%20Merger%20and%20Splitter.py) | Create a tool that can merge multiple PDF files into one or split a single PDF into separate pages. |
| Pizza Order | [Pizza Order](https://github.com/DhanushNehru/Python-Scripts/tree/main/Pizza%20Order) | An algorithm designed to handle pizza orders from customers with accurate receipts and calculations. |
| Planet Simulation | [Planet Simulation](https://github.com/DhanushNehru/Python-Scripts/tree/main/Planet%20Simulation) | A simulation of several planets rotating around the sun. |
| Playlist Exchange | [Playlist Exchange](https://github.com/DhanushNehru/Python-Scripts/tree/main/Playlist%20Exchange) | A Python script to exchange songs and playlists between Spotify and Python. |
| Pigeonhole Sort | [Algorithm](https://github.com/DhanushNehru/Python-Scripts/tree/main/PigeonHole) | The pigeonhole sort algorithm to sort your arrays efficiently! |
+| PNG and SVG to Icons Generator with GUI | [PNG and SVG to Icons Generator](https://github.com/RandyNorthrup/Python-Scripts/tree/main/PNG%20and%20SVG%20to%20Icons%20Generator%20with%20GUI) | A GUI tool to convert PNG and SVG images into icon formats for Windows, macOS, and Linux. |
| PNG TO JPG CONVERTOR | [PNG-To-JPG](https://github.com/DhanushNehru/Python-Scripts/tree/main/PNG%20To%20JPG) | A PNG TO JPG IMAGE CONVERTOR. |
| Pomodoro Timer | [Pomodoro Timer](https://github.com/DhanushNehru/Python-Scripts/tree/main/Pomodoro%20Timer) | A Pomodoro timer |
-| Python GUI Notepad | [Python GUI Notepad](https://github.com/DhanushNehru/Python-Scripts/tree/main/Python%20GUI%20Notepad) | A Python-based GUI Notepad with essential features like saving, opening, editing text files, basic formatting, and a simple user interface for quick note-taking. |
-| Profanity Checker | [Profanity Checker](https://github.com/DhanushNehru/Python-Scripts/tree/main/Profanity%20Checker) | A Python script to detect and optionally censor profanity in text. |
+| Profanity Checker | [Profanity Checker](https://github.com/DhanushNehru/Python-Scripts/tree/main/Profanity%20Checker) | A Python script to detect and optionally censor profanity in text. |
+| Python GUI Notepad | [Python GUI Notepad](https://github.com/DhanushNehru/Python-Scripts/tree/main/Python%20GUI%20Notepad) | A Python-based GUI Notepad with essential features like saving, opening, editing text files, basic formatting, and a simple user interface for quick note-taking. |
| QR Code Generator | [QR Code Generator](https://github.com/DhanushNehru/Python-Scripts/tree/main/QR%20Code%20Generator) | This is generate a QR code from the provided link |
| QR Code Scanner | [QR Code Scanner](https://github.com/DhanushNehru/Python-Scripts/tree/main/QR%20Code%20Scanner) | Helps in Sacanning the QR code in form of PNG or JPG just by running the python script. |
| QR Code with logo | [QR code with Logo](https://github.com/DhanushNehru/Python-Scripts/tree/main/QR%20with%20Logo) | QR Code Customization Feature |
| Random Color Generator | [Random Color Generator](https://github.com/DhanushNehru/Python-Scripts/tree/main/Random%20Color%20Generator) | A random color generator that will show you the color and values! |
-| Remove Background | [Remove Background](https://github.com/DhanushNehru/Python-Scripts/tree/main/Remove%20Background) | Removes the background of images. |
+| Recursive Requirements Scanner | [Recursive Requirements Scanner](https://github.com/RandyNorthrup/Python-Scripts/tree/main/Recursive%20Requirements%20Scan%20and%20Creation%20Tool) | A GUI tool to recursively scan Python projects for dependencies and create requirements.txt files. |
+| Remove Background | [Remove Background](https://github.com/RandyNorthrup/Python-Scripts/tree/main/Remove%20Background) | Removes the background of images with GUI support for replacement options. |
| Road-Lane-Detection | [Road-Lane-Detection](https://github.com/NotIncorecc/Python-Scripts/tree/main/Road-Lane-Detection) | Detects the lanes of the road |
| Rock Paper Scissor 1 | [Rock Paper Scissor 1](https://github.com/DhanushNehru/Python-Scripts/tree/main/Rock%20Paper%20Scissor%201) | A game of Rock Paper Scissors. |
| Rock Paper Scissor 2 | [Rock Paper Scissor 2](https://github.com/DhanushNehru/Python-Scripts/tree/main/Rock%20Paper%20Scissor%202) | A new version game of Rock Paper Scissors. |
@@ -136,6 +142,7 @@ More information on contributing and the general code of conduct for discussion
| Simple DDOS | [Simple DDOS](https://github.com/DhanushNehru/Python-Scripts/tree/main/Simple%20DDOS) | The code allows you to send multiple HTTP requests concurrently for a specified duration. |
| Simple TCP Chat Server | [Simple TCP Chat Server](https://github.com/DhanushNehru/Python-Scripts/tree/main/TCP%20Chat%20Server) | Creates a local server on your LAN for receiving and sending messages! |
| Smart Attendance System | [Smart Attendance System](https://github.com/DhanushNehru/Python-Scripts/tree/main/Smart%20Attendance%20System) | This OpenCV framework is for Smart Attendance by actively decoding a student's QR Code. |
+| SMB Scan and Transfer Tool | [SMB Scan and Transfer Tool](https://github.com/RandyNorthrup/Python-Scripts/tree/main/SMB%20Scan%20and%20Transfer%20Tool) | A Qt6 GUI for discovering, browsing, and transferring files over SMB network shares on macOS. |
| Snake Game | [Snake Game](https://github.com/DhanushNehru/Python-Scripts/tree/main/Snake%20Game) | Classic snake game using python. |
| Snake Water Gun | [Snake Water Gun](https://github.com/DhanushNehru/Python-Scripts/tree/main/Snake%20Water%20Gun) | A game similar to Rock Paper Scissors. |
| Sorting | [Sorting](https://github.com/DhanushNehru/Python-Scripts/tree/main/Sorting) | Algorithm for bubble sorting. |
@@ -162,6 +169,7 @@ More information on contributing and the general code of conduct for discussion
| Website Cloner | [Website Cloner](https://github.com/DhanushNehru/Python-Scripts/tree/main/Website%20Cloner) | Clones any website and opens the site in your local IP. |
| Web Scraper | [Web Scraper](https://github.com/DhanushNehru/Python-Scripts/tree/main/Web%20Scraper) | A Python script that scrapes blog titles from [Python.org](https://www.python.org/) and saves them to a file. |
| Weight Converter | [Weight Converter](https://github.com/DhanushNehru/Python-Scripts/tree/main/Weight%20Converter) | Simple GUI script to convert weight in different measurement units. |
+| WiFi QR Code Generator | [WiFi QR Code Generator](https://github.com/RandyNorthrup/Python-Scripts/tree/main/WiFi%20QR%20Code%20Generator) | A cross-platform desktop app for generating Wi-Fi QR codes and exporting Wi-Fi profiles with a modern Qt interface. |
| Wikipedia Data Extractor | [Wikipedia Data Extractor](https://github.com/DhanushNehru/Python-Scripts/tree/main/Wikipedia%20Data%20Extractor) | A simple Wikipedia data extractor script to get output in your IDE. |
| Word to PDF | [Word to PDF](https://github.com/DhanushNehru/Python-Scripts/tree/main/Word%20to%20PDF%20converter) | A Python script to convert an MS Word file to a PDF file. |
| Youtube Downloader | [Youtube Downloader](https://github.com/DhanushNehru/Python-Scripts/tree/main/Youtube%20Downloader) | Downloads any video from [YouTube](https://youtube.com) in video or audio format! |
diff --git a/Recursive Requirements Scan and Creation Tool/README.md b/Recursive Requirements Scan and Creation Tool/README.md
new file mode 100644
index 00000000..05526702
--- /dev/null
+++ b/Recursive Requirements Scan and Creation Tool/README.md
@@ -0,0 +1,296 @@
+# Recursive Requirements Scan and Creation Tool (RRSCT)
+
+This tool provides a **Qt6 / PySide6 GUI** to recursively scan Python projects for dependencies and automatically create a clean, deduplicated `requirements.txt` file. It also includes a **robust package installer** that supports multiple package managers.
+
+It scans:
+- **First 50 lines** of all `.py` files for `import` and `from` statements.
+- Any existing `requirements.txt` files.
+
+## Features
+
+### Requirements Generation
+- ✅ **GUI Interface** built with Qt6 / PySide6
+- ✅ **Recursive scanning** of directories
+- ✅ **Exclude standard library** option
+- ✅ **Automatic cleanup**: deduplication, removing local paths, handling version conflicts
+- ✅ **PyPI validation**: only valid packages remain
+- ✅ **In-memory storage**: Generate requirements without saving to file
+- ✅ **Individual error handling**: One file's failure won't stop the entire scan
+- ✅ **Log panel** inside GUI for warnings and debug messages
+
+### Package Installation
+- ✅ **Multiple package manager support**: pip, pipx, uv, poetry, conda, pip3
+- ✅ **Install from file or memory**: No need to save before installing
+- ✅ **Virtual environment detection**: Automatically detects venv vs global installation
+- ✅ **Individual package installation**: Each package is installed separately
+- ✅ **Robust error handling**: One failed package won't stop the rest
+- ✅ **Real-time progress tracking**: Live updates during installation
+- ✅ **Detailed installation summary**: Success, failed, and skipped packages
+
+---
+
+## Installation
+
+Make sure you have Python 3.8+ installed.
+Install dependencies:
+
+```bash
+pip install PySide6 requests packaging
+```
+
+---
+
+## Usage
+
+Run the script:
+
+```bash
+python rrsct.py
+```
+
+### Generating Requirements
+
+1. **Select Source Directory** → Folder containing your Python project(s)
+2. **(Optional) Select Destination** → Where to save the generated `requirements.txt`
+ - If no destination is selected, requirements are stored in memory only
+3. **(Optional) Select Package Manager** → Choose from detected package managers
+4. Optionally check **"Exclude standard libraries"** (recommended)
+5. Click **Generate Master Requirements**
+
+The tool will:
+- Scan `.py` and `requirements.txt` files recursively
+- Process each file individually (failures won't stop the scan)
+- Deduplicate dependencies
+- Validate packages on PyPI (each package validated separately)
+- Show detailed logs in the GUI
+- Store requirements in memory and optionally save to file
+
+### Installing Requirements
+
+1. Click **Install Requirements from File**
+2. If you have generated requirements in memory:
+ - **Yes** → Install directly from memory (no file needed)
+ - **No** → Select a requirements file to install from
+ - **Cancel** → Do nothing
+3. Confirm the installation (shows virtual environment status)
+4. Watch real-time progress as each package installs
+
+The installer will:
+- Detect if you're in a virtual environment or global Python
+- Install each package individually with the selected package manager
+- Continue even if individual packages fail
+- Provide a detailed summary of successes and failures
+
+---
+
+## Package Manager Support
+
+| Package Manager | Install Command | Notes |
+|-----------------|-----------------|-------|
+| **pip** | `pip install ` | Standard Python package installer |
+| **pip3** | `pip3 install ` | Python 3 specific pip |
+| **uv** | `uv pip install ` | Fast Python package installer |
+| **poetry** | `poetry add ` | Dependency management tool |
+| **conda** | `conda install -y ` | Cross-platform package manager |
+| **pipx** | N/A | Skipped (for applications only, not libraries) |
+
+The tool automatically detects which package managers are installed on your system.
+
+---
+
+## Output
+
+The generated `requirements.txt` will:
+- Contain **only valid third-party dependencies**
+- Deduplicate multiple versions, keeping the most restrictive or latest version
+- Include a header with metadata (package manager, total packages)
+- Be directly usable with any package manager
+
+Example output:
+```
+# Generated by Master Requirements Generator
+# Package Manager: pip
+# Total packages: 4
+
+numpy==1.26.4
+pandas==2.2.3
+requests>=2.31.0
+PySide6==6.7.0
+```
+
+---
+
+## Error Handling
+
+This tool is designed with **robust error handling** to prevent cascading failures:
+
+### During Generation:
+- Each file is processed independently
+- File read errors don't stop the scan
+- Invalid import lines are skipped with warnings
+- Each package validation happens separately
+- Network errors during PyPI checks won't halt the process
+
+### During Installation:
+- Each package is installed separately
+- Failed installations don't stop the rest
+- 2-minute timeout per package prevents hanging
+- Detailed error messages for each failure
+- Summary report at the end
+
+---
+
+## Examples
+
+### Sample Log Output
+```
+Detected package managers: pip, uv, conda
+✓ Source directory selected: /path/to/project
+Starting scan with package manager: pip
+Found 150 files to process
+Found 45 unique imports/requirements
+Excluded 20 standard library modules
+Cleaned and merged to 40 packages
+Validating packages on PyPI...
+✓ Validated: numpy
+✓ Validated: pandas
+✗ Package not found on PyPI: custom_local_package
+✓ SUCCESS: Requirements generated!
+Total packages: 38
+```
+
+### Installation Log
+```
+Installing to: virtual environment
+Python: /path/to/venv/bin/python
+Package manager: pip
+
+Installing from memory
+Found 38 packages to install
+
+[1/38] Installing numpy...
+ ✓ Successfully installed numpy
+[2/38] Installing pandas...
+ ✓ Successfully installed pandas
+[3/38] Installing invalid-package...
+ ✗ Failed to install invalid-package: No matching distribution found
+
+============================================================
+Installation Summary:
+ ✓ Success: 36
+ ✗ Failed: 2
+ ⚠ Skipped: 0
+============================================================
+```
+
+---
+
+## Options
+
+| Option | Description |
+|--------|-------------|
+| **Select Source Directory** | Required: The root folder to scan for Python files |
+| **Select Destination** | Optional: Where to save requirements.txt (omit to use memory only) |
+| **Package Manager** | Choose which tool to use for installation (auto-detected) |
+| **Exclude standard libraries** | Recommended: Skips built-in Python modules like `os`, `sys`, etc. |
+| **Log Panel** | Real-time display of all operations, warnings, and errors |
+
+---
+
+## Workflow Examples
+
+### Quick Generate & Install (No File)
+1. Select source directory
+2. Click "Generate Master Requirements" (no destination needed)
+3. Click "Install Requirements from File"
+4. Choose "Yes" to install from memory
+5. Done! No file created.
+
+### Generate, Save, & Install Later
+1. Select source directory
+2. Select destination file
+3. Click "Generate Master Requirements"
+4. Requirements saved to file AND stored in memory
+5. Install from memory or file anytime
+
+### Install Existing Requirements File
+1. Click "Install Requirements from File"
+2. If you have memory requirements, click "No" to use a file
+3. Select the requirements.txt file
+4. Confirm installation
+5. Watch the progress
+
+---
+
+## Dependencies
+
+- [PySide6](https://pypi.org/project/PySide6/) - Qt6 Python bindings for GUI
+- [requests](https://pypi.org/project/requests/) - HTTP library for PyPI validation
+- [packaging](https://pypi.org/project/packaging/) - Core utilities for version parsing
+
+Install all at once:
+
+```bash
+pip install PySide6 requests packaging
+```
+
+Or use the requirements.txt in this folder:
+
+```bash
+pip install -r requirements.txt
+```
+
+---
+
+## Troubleshooting
+
+### "No package managers detected"
+- Ensure at least pip is installed: `pip --version`
+- Restart the tool after installing a package manager
+
+### "Permission denied" errors during installation
+- Use a virtual environment: `python -m venv venv`
+- Or run with appropriate permissions
+
+### Installation hangs
+- Each package has a 2-minute timeout
+- Check your internet connection
+- Some packages may take longer to build
+
+### PyPI validation fails
+- Check your internet connection
+- Packages are still included if validation fails
+- Validation errors are logged but don't stop the process
+
+---
+
+## Advanced Usage
+
+### Custom Package Managers
+The tool automatically detects installed package managers. To add support for a new one, modify the `detect_package_managers()` function.
+
+### Encoding Issues
+The tool tries multiple encodings (utf-8-sig, utf-8, latin-1, cp1252) when reading files. Most encoding issues are handled automatically.
+
+### Large Projects
+- Processing hundreds of files works efficiently
+- Each file is processed independently
+- Progress is shown during generation and installation
+
+---
+
+## Future Enhancements
+
+- Export results in multiple formats (CSV, JSON)
+- Batch file processing options
+- Custom package repository support
+- Requirements.txt diff viewer
+- Dependency graph visualization
+
+---
+
+**Author:** Randy Northrup
+
+## License
+
+MIT License - Feel free to use and modify.
diff --git a/Recursive Requirements Scan and Creation Tool/requirements.txt b/Recursive Requirements Scan and Creation Tool/requirements.txt
new file mode 100644
index 00000000..a7353933
--- /dev/null
+++ b/Recursive Requirements Scan and Creation Tool/requirements.txt
@@ -0,0 +1,3 @@
+requests
+packaging
+PySide6
diff --git a/Recursive Requirements Scan and Creation Tool/rrsct.py b/Recursive Requirements Scan and Creation Tool/rrsct.py
new file mode 100644
index 00000000..431f7081
--- /dev/null
+++ b/Recursive Requirements Scan and Creation Tool/rrsct.py
@@ -0,0 +1,735 @@
+import sys
+import os
+import sysconfig
+import re
+import requests
+import subprocess
+from packaging.version import parse as parse_version, InvalidVersion
+from PySide6.QtWidgets import (
+ QApplication, QWidget, QVBoxLayout, QPushButton, QFileDialog,
+ QMessageBox, QProgressBar, QCheckBox, QTextEdit, QComboBox, QLabel
+)
+from PySide6.QtCore import Qt, QThread, Signal
+
+
+# -------------------- HELPERS --------------------
+
+def is_venv():
+ """Check if running in a virtual environment."""
+ return (hasattr(sys, 'real_prefix') or
+ (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix))
+
+
+def detect_package_managers():
+ """Detect available package managers on the system."""
+ managers = {}
+
+ # Check for pip
+ try:
+ result = subprocess.run(["pip", "--version"], capture_output=True, text=True, timeout=5)
+ if result.returncode == 0:
+ managers['pip'] = 'pip'
+ except Exception:
+ pass
+
+ # Check for pipx
+ try:
+ result = subprocess.run(["pipx", "--version"], capture_output=True, text=True, timeout=5)
+ if result.returncode == 0:
+ managers['pipx'] = 'pipx'
+ except Exception:
+ pass
+
+ # Check for uv
+ try:
+ result = subprocess.run(["uv", "--version"], capture_output=True, text=True, timeout=5)
+ if result.returncode == 0:
+ managers['uv'] = 'uv'
+ except Exception:
+ pass
+
+ # Check for poetry
+ try:
+ result = subprocess.run(["poetry", "--version"], capture_output=True, text=True, timeout=5)
+ if result.returncode == 0:
+ managers['poetry'] = 'poetry'
+ except Exception:
+ pass
+
+ # Check for conda
+ try:
+ result = subprocess.run(["conda", "--version"], capture_output=True, text=True, timeout=5)
+ if result.returncode == 0:
+ managers['conda'] = 'conda'
+ except Exception:
+ pass
+
+ # Check for pip3
+ try:
+ result = subprocess.run(["pip3", "--version"], capture_output=True, text=True, timeout=5)
+ if result.returncode == 0 and 'pip' not in managers:
+ managers['pip3'] = 'pip3'
+ except Exception:
+ pass
+
+ return managers
+
+
+def get_standard_libs():
+ std_lib = sysconfig.get_paths()["stdlib"]
+ std_libs = set()
+ for root, _, files in os.walk(std_lib):
+ for file in files:
+ if file.endswith(".py"):
+ rel_path = os.path.relpath(os.path.join(root, file), std_lib)
+ module = rel_path.replace(os.sep, ".").rsplit(".py", 1)[0]
+ std_libs.add(module.split(".")[0])
+ return std_libs
+
+
+def clean_and_merge_requirements(reqs, log_fn):
+ clean_reqs = {}
+ pattern = re.compile(r"^([A-Za-z0-9_.\-]+)\s*([=<>!~]*.*)?$")
+
+ for r in reqs:
+ r = r.strip()
+ if not r or r.startswith("#") or "@ file://" in r:
+ continue
+
+ match = pattern.match(r)
+ if not match:
+ log_fn(f"Skipping invalid line: {r}")
+ continue
+
+ pkg, spec = match.groups()
+ pkg = pkg.lower()
+ spec = spec.strip() if spec else ""
+
+ if pkg in clean_reqs:
+ old_spec = clean_reqs[pkg]
+ try:
+ if "==" in spec:
+ new_ver = spec.split("==")[-1]
+ old_ver = old_spec.split("==")[-1] if "==" in old_spec else ""
+ if not old_ver or parse_version(new_ver) > parse_version(old_ver):
+ clean_reqs[pkg] = spec
+ else:
+ clean_reqs[pkg] = old_spec or spec
+ except InvalidVersion:
+ log_fn(f"Invalid version format for {pkg}: {spec}")
+ clean_reqs[pkg] = spec or old_spec
+ else:
+ clean_reqs[pkg] = spec
+
+ return [f"{pkg}{spec}" if spec else pkg for pkg, spec in sorted(clean_reqs.items())]
+
+
+def validate_on_pypi(requirements, log_fn):
+ """Validate each package individually with error handling."""
+ valid_reqs = []
+ for line in requirements:
+ try:
+ pkg = re.split(r"[=<>!~]", line)[0].strip()
+ if not pkg:
+ log_fn(f"Skipping empty package name from line: {line}")
+ continue
+
+ url = f"https://pypi.org/pypi/{pkg}/json"
+ try:
+ resp = requests.get(url, timeout=5)
+ if resp.status_code == 200:
+ valid_reqs.append(line)
+ log_fn(f"✓ Validated: {pkg}")
+ elif resp.status_code == 404:
+ log_fn(f"✗ Package not found on PyPI: {pkg}")
+ else:
+ log_fn(f"⚠ Could not validate {pkg} (HTTP {resp.status_code})")
+ # Still include it in case it's a valid package with temporary issues
+ valid_reqs.append(line)
+ except requests.exceptions.Timeout:
+ log_fn(f"⚠ Timeout validating {pkg}, including anyway")
+ valid_reqs.append(line)
+ except requests.exceptions.RequestException as e:
+ log_fn(f"⚠ Network error validating {pkg}: {type(e).__name__}, including anyway")
+ valid_reqs.append(line)
+ except Exception as e:
+ log_fn(f"✗ Error processing line '{line}': {type(e).__name__}: {str(e)}")
+ # Continue with next package even if this one fails
+ continue
+
+ return valid_reqs
+
+
+def safe_read_file(file_path, log_fn):
+ """Safely read file with multiple encoding attempts and error handling."""
+ for enc in ["utf-8-sig", "utf-8", "latin-1", "cp1252"]:
+ try:
+ with open(file_path, "r", encoding=enc, errors="ignore") as f:
+ return f.readlines()
+ except FileNotFoundError:
+ log_fn(f"✗ File not found: {file_path}")
+ return []
+ except PermissionError:
+ log_fn(f"✗ Permission denied: {file_path}")
+ return []
+ except Exception as e:
+ continue
+ log_fn(f"✗ Could not read file with any encoding: {file_path}")
+ return []
+
+
+# -------------------- WORKER THREAD --------------------
+
+class Worker(QThread):
+ progress = Signal(int)
+ finished = Signal(list)
+ log_msg = Signal(str)
+
+ def __init__(self, source_dir, exclude_std, package_manager='pip'):
+ super().__init__()
+ self.source_dir = source_dir
+ self.exclude_std = exclude_std
+ self.package_manager = package_manager
+ self.std_libs = get_standard_libs() if exclude_std else set()
+
+ def log(self, message):
+ self.log_msg.emit(message)
+
+ def run(self):
+ requirements = set()
+ all_files = []
+
+ self.log(f"Starting scan with package manager: {self.package_manager}")
+ self.log(f"Scanning directory: {self.source_dir}")
+
+ try:
+ for root, _, files in os.walk(self.source_dir):
+ for file in files:
+ if file.endswith(".py") or file == "requirements.txt":
+ all_files.append(os.path.join(root, file))
+ except Exception as e:
+ self.log(f"✗ Error walking directory: {type(e).__name__}: {str(e)}")
+ self.finished.emit([])
+ return
+
+ total_files = len(all_files)
+ self.log(f"Found {total_files} files to process")
+
+ for idx, file_path in enumerate(all_files):
+ try:
+ if file_path.endswith(".py"):
+ self.process_python_file(file_path, requirements)
+ elif file_path.endswith("requirements.txt"):
+ self.process_requirements_file(file_path, requirements)
+ except Exception as e:
+ self.log(f"✗ Error processing {file_path}: {type(e).__name__}: {str(e)}")
+ # Continue with next file even if this one fails
+ continue
+ finally:
+ self.progress.emit(int((idx + 1) / total_files * 100))
+
+ self.log(f"Found {len(requirements)} unique imports/requirements")
+
+ if self.exclude_std:
+ before_count = len(requirements)
+ requirements = {pkg for pkg in requirements if pkg not in self.std_libs}
+ self.log(f"Excluded {before_count - len(requirements)} standard library modules")
+
+ try:
+ cleaned = clean_and_merge_requirements(requirements, self.log)
+ self.log(f"Cleaned and merged to {len(cleaned)} packages")
+ except Exception as e:
+ self.log(f"✗ Error cleaning requirements: {type(e).__name__}: {str(e)}")
+ cleaned = list(requirements)
+
+ try:
+ self.log("Validating packages on PyPI...")
+ validated = validate_on_pypi(cleaned, self.log)
+ self.log(f"Validation complete: {len(validated)} valid packages")
+ except Exception as e:
+ self.log(f"✗ Error validating requirements: {type(e).__name__}: {str(e)}")
+ validated = cleaned
+
+ self.finished.emit(validated)
+
+ def process_python_file(self, file_path, requirements):
+ """Process Python file with individual line error handling."""
+ try:
+ lines = safe_read_file(file_path, self.log)
+ for i, line in enumerate(lines):
+ if i >= 50:
+ break
+ try:
+ line = line.strip()
+ if line.startswith("import "):
+ parts = line.split()
+ if len(parts) >= 2:
+ pkg = parts[1].split(".")[0].split(",")[0]
+ if pkg and not pkg.startswith("_"):
+ requirements.add(pkg)
+ elif line.startswith("from "):
+ parts = line.split()
+ if len(parts) >= 2:
+ pkg = parts[1].split(".")[0]
+ if pkg and not pkg.startswith("_"):
+ requirements.add(pkg)
+ except Exception as e:
+ # Log but continue processing other lines
+ self.log(f"⚠ Error parsing line in {os.path.basename(file_path)}: {line[:50]}")
+ continue
+ except Exception as e:
+ self.log(f"✗ Error processing Python file {file_path}: {type(e).__name__}")
+
+ def process_requirements_file(self, file_path, requirements):
+ """Process requirements file with individual line error handling."""
+ try:
+ lines = safe_read_file(file_path, self.log)
+ for line in lines:
+ try:
+ line = line.strip()
+ if line and not line.startswith("#") and not line.startswith("-"):
+ # Handle different requirement formats
+ if "@" in line or "git+" in line or "http" in line:
+ # Skip git/URL requirements for now
+ self.log(f"⚠ Skipping git/URL requirement: {line[:50]}")
+ continue
+ requirements.add(line)
+ except Exception as e:
+ # Log but continue processing other lines
+ self.log(f"⚠ Error parsing requirement line: {line[:50]}")
+ continue
+ except Exception as e:
+ self.log(f"✗ Error processing requirements file {file_path}: {type(e).__name__}")
+
+
+# -------------------- INSTALL WORKER --------------------
+
+class InstallWorker(QThread):
+ progress = Signal(int, int) # current, total
+ finished = Signal(dict) # results dictionary
+ log_msg = Signal(str)
+
+ def __init__(self, requirements_source, package_manager='pip', from_memory=False):
+ super().__init__()
+ self.requirements_source = requirements_source # Either file path or list
+ self.package_manager = package_manager
+ self.from_memory = from_memory
+
+ def log(self, message):
+ self.log_msg.emit(message)
+
+ def run(self):
+ """Install packages one by one with individual error handling."""
+ results = {
+ 'success': [],
+ 'failed': [],
+ 'skipped': []
+ }
+
+ # Check if in venv
+ in_venv = is_venv()
+ venv_status = "virtual environment" if in_venv else "global Python"
+ self.log(f"Installing to: {venv_status}")
+ self.log(f"Python: {sys.executable}")
+ self.log(f"Package manager: {self.package_manager}\n")
+
+ # Get packages from either file or memory
+ if self.from_memory:
+ # Requirements already in list format
+ packages = self.requirements_source
+ self.log(f"Installing from memory")
+ else:
+ # Read requirements from file
+ try:
+ with open(self.requirements_source, 'r', encoding='utf-8') as f:
+ lines = f.readlines()
+ except Exception as e:
+ self.log(f"✗ Error reading requirements file: {type(e).__name__}: {str(e)}")
+ self.finished.emit(results)
+ return
+
+ # Parse requirements (skip comments and empty lines)
+ packages = []
+ for line in lines:
+ line = line.strip()
+ if line and not line.startswith('#'):
+ packages.append(line)
+
+ self.log(f"Installing from file: {self.requirements_source}")
+
+ total = len(packages)
+ self.log(f"Found {total} packages to install\n")
+
+ if total == 0:
+ self.log("⚠ No packages to install")
+ self.finished.emit(results)
+ return
+
+ # Install each package individually
+ for idx, package in enumerate(packages, 1):
+ self.progress.emit(idx, total)
+ pkg_name = package # Default to full package string
+
+ try:
+ # Extract package name for logging
+ pkg_name = re.split(r'[=<>!~]', package)[0].strip()
+ self.log(f"[{idx}/{total}] Installing {pkg_name}...")
+
+ # Build install command based on package manager
+ if self.package_manager == 'pip' or self.package_manager == 'pip3':
+ cmd = [self.package_manager, 'install', package]
+ elif self.package_manager == 'uv':
+ cmd = ['uv', 'pip', 'install', package]
+ elif self.package_manager == 'pipx':
+ # pipx installs apps, not libraries - skip
+ self.log(f" ⚠ Skipping {pkg_name} (pipx is for applications, not libraries)")
+ results['skipped'].append(package)
+ continue
+ elif self.package_manager == 'poetry':
+ cmd = ['poetry', 'add', package]
+ elif self.package_manager == 'conda':
+ cmd = ['conda', 'install', '-y', package]
+ else:
+ cmd = ['pip', 'install', package]
+
+ # Run installation command
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=120 # 2 minute timeout per package
+ )
+
+ if result.returncode == 0:
+ self.log(f" ✓ Successfully installed {pkg_name}")
+ results['success'].append(package)
+ else:
+ error_msg = result.stderr.strip().split('\n')[-1] if result.stderr else 'Unknown error'
+ self.log(f" ✗ Failed to install {pkg_name}: {error_msg}")
+ results['failed'].append(package)
+
+ except subprocess.TimeoutExpired:
+ self.log(f" ✗ Timeout installing {pkg_name} (>2 minutes)")
+ results['failed'].append(package)
+ except Exception as e:
+ self.log(f" ✗ Error installing {pkg_name}: {type(e).__name__}: {str(e)}")
+ results['failed'].append(package)
+
+ # Continue to next package regardless of success/failure
+
+ self.log(f"\n{'='*60}")
+ self.log(f"Installation Summary:")
+ self.log(f" ✓ Success: {len(results['success'])}")
+ self.log(f" ✗ Failed: {len(results['failed'])}")
+ self.log(f" ⚠ Skipped: {len(results['skipped'])}")
+ self.log(f"{'='*60}")
+
+ self.finished.emit(results)
+
+
+# -------------------- GUI --------------------
+
+class RequirementsCollector(QWidget):
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("Master Requirements Generator")
+ self.setGeometry(200, 200, 600, 500)
+
+ layout = QVBoxLayout()
+
+ self.select_src_btn = QPushButton("Select Source Directory")
+ self.select_src_btn.clicked.connect(self.select_source_dir)
+ layout.addWidget(self.select_src_btn)
+
+ self.select_dest_btn = QPushButton("Select Destination for Master Requirements")
+ self.select_dest_btn.clicked.connect(self.select_dest_file)
+ layout.addWidget(self.select_dest_btn)
+
+ # Package manager selection
+ pm_layout = QVBoxLayout()
+ pm_label = QLabel("Package Manager:")
+ pm_layout.addWidget(pm_label)
+
+ self.package_manager_combo = QComboBox()
+ self.available_managers = detect_package_managers()
+
+ if self.available_managers:
+ for name, cmd in self.available_managers.items():
+ self.package_manager_combo.addItem(f"{name} ({cmd})", cmd)
+ pm_layout.addWidget(self.package_manager_combo)
+ else:
+ # Fallback if no package managers detected
+ self.package_manager_combo.addItem("pip (default)", "pip")
+ pm_layout.addWidget(self.package_manager_combo)
+ self.log("⚠ No package managers detected, using pip as default")
+
+ layout.addLayout(pm_layout)
+
+ self.exclude_std_cb = QCheckBox("Exclude standard libraries")
+ self.exclude_std_cb.setChecked(True)
+ layout.addWidget(self.exclude_std_cb)
+
+ self.log_box = QTextEdit()
+ self.log_box.setReadOnly(True)
+ layout.addWidget(self.log_box)
+
+ self.generate_btn = QPushButton("Generate Master Requirements")
+ self.generate_btn.clicked.connect(self.generate_requirements)
+ layout.addWidget(self.generate_btn)
+
+ self.install_btn = QPushButton("Install Requirements from File")
+ self.install_btn.clicked.connect(self.install_requirements)
+ layout.addWidget(self.install_btn)
+
+ self.progress_label = QLabel("")
+ layout.addWidget(self.progress_label)
+
+ self.setLayout(layout)
+
+ self.source_dir = ""
+ self.dest_file = ""
+ self.generated_requirements = [] # Store generated requirements in memory
+
+ # Log detected package managers
+ if self.available_managers:
+ self.log(f"Detected package managers: {', '.join(self.available_managers.keys())}")
+
+ def select_source_dir(self):
+ dir_path = QFileDialog.getExistingDirectory(self, "Select Source Directory")
+ if dir_path:
+ self.source_dir = dir_path
+ self.log(f"✓ Source directory selected: {dir_path}")
+
+ def select_dest_file(self):
+ file_path, _ = QFileDialog.getSaveFileName(self, "Save Master Requirements", "requirements.txt", "Text Files (*.txt)")
+ if file_path:
+ self.dest_file = file_path
+ self.log(f"✓ Destination file selected: {file_path}")
+
+ def generate_requirements(self):
+ if not self.source_dir:
+ QMessageBox.warning(self, "Error", "Please select a source directory.")
+ return
+
+ selected_pm = self.package_manager_combo.currentData()
+ self.log(f"\n{'='*60}")
+ self.log(f"Starting requirements generation...")
+ self.log(f"Package Manager: {selected_pm}")
+ self.log(f"{'='*60}\n")
+
+ self.worker = Worker(self.source_dir, self.exclude_std_cb.isChecked(), selected_pm)
+ self.worker.log_msg.connect(self.log)
+ self.worker.finished.connect(self.write_requirements)
+ self.worker.start()
+
+ self.generate_btn.setEnabled(False)
+ self.generate_btn.setText("Generating...")
+
+ def log(self, message):
+ self.log_box.append(message)
+
+ def write_requirements(self, requirements):
+ self.generate_btn.setEnabled(True)
+ self.generate_btn.setText("Generate Master Requirements")
+
+ if not requirements:
+ self.log("\n⚠ Warning: No requirements found!")
+ QMessageBox.warning(self, "Warning", "No requirements were found.")
+ return
+
+ # Store requirements in memory
+ self.generated_requirements = requirements
+
+ self.log(f"\n{'='*60}")
+ self.log(f"✓ Requirements generated successfully!")
+ self.log(f"Total packages: {len(requirements)}")
+
+ # If destination file is selected, save to file
+ if self.dest_file:
+ try:
+ with open(self.dest_file, "w", encoding="utf-8") as f:
+ f.write(f"# Generated by Master Requirements Generator\n")
+ f.write(f"# Package Manager: {self.package_manager_combo.currentData()}\n")
+ f.write(f"# Total packages: {len(requirements)}\n\n")
+ for req in requirements:
+ f.write(req + "\n")
+
+ self.log(f"✓ Saved to file: {self.dest_file}")
+ self.log(f"{'='*60}")
+
+ QMessageBox.information(self, "Success",
+ f"Master requirements.txt created at:\n{self.dest_file}\n\nTotal packages: {len(requirements)}\n\nRequirements are also stored in memory for installation.")
+ except PermissionError:
+ error_msg = f"Permission denied writing to: {self.dest_file}"
+ self.log(f"✗ ERROR: {error_msg}")
+ self.log(f"Requirements are still in memory and can be installed.")
+ self.log(f"{'='*60}")
+ QMessageBox.warning(self, "Partial Success",
+ f"{error_msg}\n\nRequirements are stored in memory and can be installed without saving.")
+ except Exception as e:
+ error_msg = f"Could not write file: {type(e).__name__}: {str(e)}"
+ self.log(f"✗ ERROR: {error_msg}")
+ self.log(f"Requirements are still in memory and can be installed.")
+ self.log(f"{'='*60}")
+ QMessageBox.warning(self, "Partial Success",
+ f"{error_msg}\n\nRequirements are stored in memory and can be installed without saving.")
+ else:
+ # No destination file, just store in memory
+ self.log(f"✓ Requirements stored in memory (not saved to file)")
+ self.log(f"You can install them directly or select a destination to save.")
+ self.log(f"{'='*60}")
+
+ QMessageBox.information(self, "Success",
+ f"Requirements generated successfully!\n\nTotal packages: {len(requirements)}\n\nRequirements are stored in memory.\nYou can install them directly without saving to a file.")
+
+ def install_requirements(self):
+ """Install requirements from memory or a selected file."""
+ # Check if we have generated requirements in memory
+ if self.generated_requirements:
+ # Ask user if they want to install from memory or file
+ reply = QMessageBox.question(
+ self,
+ "Install Source",
+ f"You have {len(self.generated_requirements)} packages in memory.\n\nInstall from memory or select a file?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
+ QMessageBox.StandardButton.Yes
+ )
+
+ if reply == QMessageBox.StandardButton.Cancel:
+ return
+ elif reply == QMessageBox.StandardButton.Yes:
+ # Install from memory
+ self._install_from_memory()
+ return
+ # If No, continue to file selection
+
+ # Install from file
+ req_file, _ = QFileDialog.getOpenFileName(
+ self,
+ "Select Requirements File",
+ "",
+ "Text Files (*.txt);;All Files (*)"
+ )
+
+ if not req_file:
+ return
+
+ self._install_from_file(req_file)
+
+ def _install_from_memory(self):
+ """Install requirements from memory."""
+ self.log(f"\nInstalling from memory ({len(self.generated_requirements)} packages)")
+
+ # Check if in venv
+ in_venv = is_venv()
+ venv_msg = "You are running in a virtual environment." if in_venv else "You are running in global Python."
+
+ # Confirm installation
+ reply = QMessageBox.question(
+ self,
+ "Confirm Installation",
+ f"{venv_msg}\n\nInstall {len(self.generated_requirements)} packages from memory?\n\nUsing: {self.package_manager_combo.currentData()}\n\nThis will install packages one by one. Continue?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No
+ )
+
+ if reply == QMessageBox.StandardButton.No:
+ return
+
+ selected_pm = self.package_manager_combo.currentData()
+ self.log(f"\n{'='*60}")
+ self.log(f"Starting package installation from memory...")
+ self.log(f"{'='*60}\n")
+
+ self.install_worker = InstallWorker(self.generated_requirements, selected_pm, from_memory=True)
+ self.install_worker.log_msg.connect(self.log)
+ self.install_worker.progress.connect(self.update_install_progress)
+ self.install_worker.finished.connect(self.install_finished)
+ self.install_worker.start()
+
+ self.install_btn.setEnabled(False)
+ self.install_btn.setText("Installing...")
+ self.generate_btn.setEnabled(False)
+
+ def _install_from_file(self, req_file):
+ """Install requirements from a file."""
+ self.log(f"\nSelected requirements file: {req_file}")
+
+ # Check if in venv
+ in_venv = is_venv()
+ venv_msg = "You are running in a virtual environment." if in_venv else "You are running in global Python."
+
+ # Confirm installation
+ reply = QMessageBox.question(
+ self,
+ "Confirm Installation",
+ f"{venv_msg}\n\nInstall packages from:\n{req_file}\n\nUsing: {self.package_manager_combo.currentData()}\n\nThis will install packages one by one. Continue?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No
+ )
+
+ if reply == QMessageBox.StandardButton.No:
+ return
+
+ selected_pm = self.package_manager_combo.currentData()
+ self.log(f"\n{'='*60}")
+ self.log(f"Starting package installation from file...")
+ self.log(f"{'='*60}\n")
+
+ self.install_worker = InstallWorker(req_file, selected_pm, from_memory=False)
+ self.install_worker.log_msg.connect(self.log)
+ self.install_worker.progress.connect(self.update_install_progress)
+ self.install_worker.finished.connect(self.install_finished)
+ self.install_worker.start()
+
+ self.install_btn.setEnabled(False)
+ self.install_btn.setText("Installing...")
+ self.generate_btn.setEnabled(False)
+
+ def update_install_progress(self, current, total):
+ """Update progress label during installation."""
+ self.progress_label.setText(f"Installing package {current} of {total}...")
+
+ def install_finished(self, results):
+ """Handle installation completion."""
+ self.install_btn.setEnabled(True)
+ self.install_btn.setText("Install Requirements from File")
+ self.generate_btn.setEnabled(True)
+ self.progress_label.setText("")
+
+ success_count = len(results['success'])
+ failed_count = len(results['failed'])
+ skipped_count = len(results['skipped'])
+ total = success_count + failed_count + skipped_count
+
+ if failed_count == 0 and skipped_count == 0:
+ QMessageBox.information(
+ self,
+ "Installation Complete",
+ f"Successfully installed all {success_count} packages!"
+ )
+ elif success_count > 0:
+ msg = f"Installation completed with some issues:\n\n"
+ msg += f"✓ Success: {success_count}\n"
+ if failed_count > 0:
+ msg += f"✗ Failed: {failed_count}\n"
+ if skipped_count > 0:
+ msg += f"⚠ Skipped: {skipped_count}\n"
+ msg += f"\nCheck the log for details."
+ QMessageBox.warning(self, "Partial Success", msg)
+ else:
+ QMessageBox.critical(
+ self,
+ "Installation Failed",
+ f"Failed to install packages. Check the log for details.\n\n"
+ f"Failed: {failed_count}, Skipped: {skipped_count}"
+ )
+
+
+# -------------------- MAIN --------------------
+
+if __name__ == "__main__":
+ app = QApplication(sys.argv)
+ window = RequirementsCollector()
+ window.show()
+ sys.exit(app.exec())
diff --git a/Remove Background/README.md b/Remove Background/README.md
index 2793b60e..9c77df93 100644
--- a/Remove Background/README.md
+++ b/Remove Background/README.md
@@ -1,13 +1,211 @@
-## REMOVE BACKGROUND OF AN IMAGE
-This script removes bacckground of images.
-
-#### steps :
- Read the input
- Convert to gray
- Threshold and invert as a mask
- Optionally apply morphology to clean up any extraneous spots
- Anti-alias the edges
- Convert a copy of the input to BGRA and insert the mask as the alpha channel
- Save the results
-
-
\ No newline at end of file
+# Background/Foreground Remover & Replacer
+
+A modern Qt-based GUI application for advanced image processing that allows you to remove and replace backgrounds or foregrounds in images with various options including solid colors, images, and gradients.
+
+## Features
+
+### Core Functionality
+- **Background Removal**: Remove backgrounds from images using adaptive thresholding and morphological operations
+- **Background Replacement**: Replace backgrounds with solid colors, custom images, or gradients
+- **Foreground Removal**: Remove foreground elements from images
+- **Foreground Replacement**: Replace foregrounds with solid colors, custom images, or gradients
+
+### Replacement Options
+Both background and foreground operations support three replacement types:
+
+1. **Solid Color**: Choose any custom color using an interactive color picker
+2. **Image**: Load a custom image file (JPG, PNG, BMP) to use as replacement
+3. **Gradient**: Create custom gradients with two color stops using color pickers
+
+### User Interface
+- **Modern Dark Theme**: Clean, professional dark interface using Qt Fusion style
+- **Real-time Preview**: View original and processed images side-by-side
+- **Interactive Controls**: Tabbed interface for easy access to all replacement options
+- **Progress Feedback**: Visual progress indication during image processing
+- **Error Handling**: Comprehensive error messages and validation
+
+## Requirements
+
+```
+PySide6>=6.0.0
+opencv-python>=4.5.0
+numpy>=1.20.0
+```
+
+## Installation
+
+1. Install the required dependencies:
+```bash
+pip install PySide6 opencv-python numpy
+```
+
+2. Run the GUI application:
+```bash
+python bg_remover_gui.py
+```
+
+## Usage
+
+### Basic Workflow
+
+1. **Load Image**: Click "Load Image" to select an input image file
+2. **Choose Operation**: Select from four operation modes:
+ - Remove Background
+ - Replace Background
+ - Remove Foreground
+ - Replace Foreground
+3. **Configure Replacement** (for replace operations):
+ - Navigate to the appropriate tab (Background/Foreground)
+ - Choose replacement type (Color/Image/Gradient)
+ - Configure settings using color pickers or image selection
+4. **Process**: Click "Process Image" to apply the operation
+5. **Save**: Click "Save Result" to export the processed image
+
+### Operation Details
+
+#### Remove Background/Foreground
+Removes the specified portion of the image using:
+- Color space conversion (BGR to GRAY)
+- Adaptive binary thresholding
+- Morphological operations (erosion, dilation)
+- Gaussian blur for edge smoothing
+- Alpha channel creation for transparency
+
+#### Replace Background
+Replaces the background while preserving the foreground:
+- **Solid Color**: Select a single color to use as the new background
+- **Image**: Load an image file that will be scaled to fit the background area
+- **Gradient**: Create a vertical gradient with two colors (top and bottom)
+
+#### Replace Foreground
+Replaces the foreground while preserving the background:
+- **Solid Color**: Select a single color to use as the new foreground
+- **Image**: Load an image file that will be scaled to fit the foreground area
+- **Gradient**: Create a vertical gradient with two colors (top and bottom)
+
+### Tips for Best Results
+
+- **Image Quality**: Use high-resolution images for better processing results
+- **Subject Contrast**: Images with clear contrast between subject and background work best
+- **Color Selection**: Use the color picker to precisely match or complement your image colors
+- **Gradient Direction**: Gradients are currently vertical (top to bottom) for consistent results
+- **Image Replacement**: Replacement images are automatically scaled to fit - use high-quality images
+
+## Technical Details
+
+### Image Processing Pipeline
+
+1. **Input Validation**: Checks for valid image file and operation mode
+2. **Color Space Conversion**: Converts BGR to GRAY for threshold operations
+3. **Thresholding**: Applies binary threshold (127) with THRESH_BINARY_INV
+4. **Morphological Operations**:
+ - Erosion with 5x5 kernel (2 iterations)
+ - Dilation with 5x5 kernel (2 iterations)
+5. **Edge Smoothing**: Gaussian blur (5x5 kernel) for natural boundaries
+6. **Alpha Blending**: Combines processed mask with replacement content
+7. **Output**: Returns BGRA image with alpha channel for transparency
+
+### Architecture
+
+- **ImageProcessor (QThread)**: Background thread for non-blocking image processing
+ - Signals: `finished`, `error`, `progress`
+ - Operations: `remove_bg`, `replace_bg`, `remove_fg`, `replace_fg`
+
+- **BackgroundRemoverGUI (QMainWindow)**: Main application window
+ - Image display with aspect ratio preservation
+ - Tabbed interface for background and foreground controls
+ - Color pickers with live preview
+ - File selection dialogs
+ - Progress indication
+
+### Type Safety
+
+The application uses strict type checking with:
+- `cv2.typing.MatLike` for OpenCV matrices
+- `Optional` types for nullable values
+- `Dict[str, Any]` for configuration parameters
+- Full type annotations throughout
+
+## File Structure
+
+```
+Remove Background/
+├── bg_remover_gui.py # Main GUI application with Qt interface
+├── code.py # Simple command-line version (legacy)
+├── README.md # This documentation
+└── requirements.txt # Python dependencies (if present)
+```
+
+## Troubleshooting
+
+### Common Issues
+
+**Image not loading**
+- Ensure the image file exists and is a valid format (JPG, PNG, BMP)
+- Check file permissions
+
+**Processing fails**
+- Verify the image is not corrupted
+- Try a different image to isolate the issue
+- Check that all dependencies are installed correctly
+
+**Replacement image doesn't look right**
+- Use high-resolution replacement images
+- Ensure replacement images are the same aspect ratio as the original
+- Try adjusting the gradient colors or solid color selection
+
+**GUI doesn't start**
+- Verify PySide6 is installed: `pip install PySide6`
+- Check for Qt conflicts if multiple Qt versions are installed
+- Run from command line to see error messages
+
+### Performance Notes
+
+- Processing time varies based on image size and complexity
+- Large images (>4K) may take several seconds to process
+- Background operations run in a separate thread to keep UI responsive
+- Each operation has a 30-second timeout to prevent hanging
+
+## License
+
+This project is part of the Python-Scripts repository. Please refer to the repository's LICENSE file for licensing information.
+
+## Contributing
+
+Contributions are welcome! Please follow the repository's contribution guidelines:
+1. Fork the repository
+2. Create a feature branch
+3. Make your changes with proper type annotations
+4. Test thoroughly with various image types
+5. Submit a pull request
+
+## Legacy Version
+
+The `code.py` file contains a simpler command-line version of the background remover. This version:
+- Takes input from command-line arguments
+- Only supports basic background removal
+- Saves output automatically
+- Does not include replacement features
+
+Use `bg_remover_gui.py` for the full-featured GUI experience.
+
+## Future Enhancements
+
+Potential improvements for future versions:
+- Horizontal/diagonal gradient options
+- Custom gradient with multiple color stops
+- Batch processing for multiple images
+- Additional morphological operations
+- Machine learning-based segmentation
+- Real-time video processing
+- Custom threshold adjustment controls
+- Edge detection algorithms selection
+- Undo/redo functionality
+- Image filters and adjustments
+
+## Credits
+
+Built with:
+- **PySide6**: Qt6 bindings for Python
+- **OpenCV**: Computer vision and image processing
+- **NumPy**: Numerical computing for array operations
diff --git a/Remove Background/bg_remover_gui.py b/Remove Background/bg_remover_gui.py
new file mode 100644
index 00000000..eb9d7f66
--- /dev/null
+++ b/Remove Background/bg_remover_gui.py
@@ -0,0 +1,869 @@
+import sys
+import cv2
+import numpy as np
+from pathlib import Path
+from typing import Any, Dict, Optional, Tuple
+from PySide6.QtWidgets import (
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
+ QPushButton, QLabel, QFileDialog, QSlider, QTabWidget, QGroupBox,
+ QRadioButton, QButtonGroup, QSpinBox, QColorDialog, QComboBox,
+ QMessageBox, QSplitter, QScrollArea
+)
+from PySide6.QtCore import Qt, QThread, Signal, QSize
+from PySide6.QtGui import QImage, QPixmap, QPalette, QColor
+from cv2.typing import MatLike
+import numpy.typing as npt
+
+
+class ImageProcessor(QThread):
+ """Background thread for image processing operations."""
+ finished = Signal(object) # Returns processed image
+ error = Signal(str)
+
+ def __init__(self, image: MatLike, operation: str, params: Dict[str, Any]) -> None:
+ super().__init__()
+ self.image = image
+ self.operation = operation
+ self.params = params
+
+ def run(self) -> None:
+ try:
+ result: Optional[MatLike] = None
+ if self.operation == "remove_bg":
+ result = self.remove_background()
+ elif self.operation == "replace_bg":
+ result = self.replace_background()
+ elif self.operation == "remove_fg":
+ result = self.remove_foreground()
+ elif self.operation == "replace_fg":
+ result = self.replace_foreground()
+ else:
+ result = self.image
+
+ self.finished.emit(result)
+ except Exception as e:
+ self.error.emit(str(e))
+
+ def remove_background(self) -> MatLike:
+ """Remove background from image."""
+ threshold = int(self.params.get('threshold', 250))
+ blur_amount = int(self.params.get('blur', 2))
+ morph_size = int(self.params.get('morph_size', 3))
+
+ # Convert to gray
+ gray = cv2.cvtColor(self.image, cv2.COLOR_BGR2GRAY)
+
+ # Threshold to create mask
+ _, mask = cv2.threshold(gray, threshold, 255, cv2.THRESH_BINARY)
+
+ # Invert mask
+ mask = 255 - mask
+
+ # Apply morphology
+ kernel = np.ones((morph_size, morph_size), np.uint8)
+ mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
+ mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
+
+ # Blur and stretch mask
+ mask = cv2.GaussianBlur(mask, (0, 0), sigmaX=blur_amount, sigmaY=blur_amount,
+ borderType=cv2.BORDER_DEFAULT)
+ mask = (2 * (mask.astype(np.float32)) - 255.0).clip(0, 255).astype(np.uint8)
+
+ # Create RGBA result
+ result = cv2.cvtColor(self.image, cv2.COLOR_BGR2BGRA)
+ result[:, :, 3] = mask
+
+ return result
+
+ def replace_background(self) -> MatLike:
+ """Replace background with new image, color, or gradient."""
+ # First remove background
+ result = self.remove_background()
+
+ # Get replacement
+ replacement_type = self.params.get('replacement_type', 'color')
+
+ if replacement_type == 'image':
+ bg_image = self.params.get('bg_image')
+ if bg_image is not None:
+ # Resize background to match
+ bg_resized = cv2.resize(bg_image, (result.shape[1], result.shape[0]))
+ if bg_resized.shape[2] == 3:
+ bg_resized = cv2.cvtColor(bg_resized, cv2.COLOR_BGR2BGRA)
+
+ # Blend using alpha channel
+ alpha = result[:, :, 3:4] / 255.0
+ result_rgb = result[:, :, :3]
+ bg_rgb = bg_resized[:, :, :3]
+
+ blended = (result_rgb * alpha + bg_rgb * (1 - alpha)).astype(np.uint8)
+ final = cv2.cvtColor(blended, cv2.COLOR_BGR2BGRA)
+ final[:, :, 3] = 255
+ return final
+
+ elif replacement_type == 'color':
+ color_tuple = self.params.get('bg_color', (255, 255, 255))
+ bg = np.full_like(result, (*color_tuple, 255), dtype=np.uint8)
+
+ # Blend
+ alpha = result[:, :, 3:4] / 255.0
+ result_rgb = result[:, :, :3]
+ bg_rgb = bg[:, :, :3]
+
+ blended = (result_rgb * alpha + bg_rgb * (1 - alpha)).astype(np.uint8)
+ final = cv2.cvtColor(blended, cv2.COLOR_BGR2BGRA)
+ final[:, :, 3] = 255
+ return final
+
+ elif replacement_type == 'gradient':
+ color1 = self.params.get('gradient_color1', (255, 255, 255))
+ color2 = self.params.get('gradient_color2', (0, 0, 0))
+ direction = self.params.get('gradient_direction', 'vertical')
+
+ # Create gradient
+ h, w = result.shape[:2]
+ gradient = np.zeros((h, w, 4), dtype=np.uint8)
+
+ if direction == 'vertical':
+ for i in range(h):
+ ratio = i / h
+ color = tuple(int(c1 * (1 - ratio) + c2 * ratio)
+ for c1, c2 in zip(color1, color2))
+ gradient[i, :] = (*color, 255)
+ else: # horizontal
+ for j in range(w):
+ ratio = j / w
+ color = tuple(int(c1 * (1 - ratio) + c2 * ratio)
+ for c1, c2 in zip(color1, color2))
+ gradient[:, j] = (*color, 255)
+
+ # Blend
+ alpha = result[:, :, 3:4] / 255.0
+ result_rgb = result[:, :, :3]
+ gradient_rgb = gradient[:, :, :3]
+
+ blended = (result_rgb * alpha + gradient_rgb * (1 - alpha)).astype(np.uint8)
+ final = cv2.cvtColor(blended, cv2.COLOR_BGR2BGRA)
+ final[:, :, 3] = 255
+ return final
+
+ return result
+
+ def remove_foreground(self) -> MatLike:
+ """Remove foreground (inverse of remove background)."""
+ threshold = int(self.params.get('threshold', 250))
+ blur_amount = int(self.params.get('blur', 2))
+ morph_size = int(self.params.get('morph_size', 3))
+
+ gray = cv2.cvtColor(self.image, cv2.COLOR_BGR2GRAY)
+ _, mask = cv2.threshold(gray, threshold, 255, cv2.THRESH_BINARY)
+
+ # Don't invert for foreground removal
+ kernel = np.ones((morph_size, morph_size), np.uint8)
+ mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
+ mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
+
+ mask = cv2.GaussianBlur(mask, (0, 0), sigmaX=blur_amount, sigmaY=blur_amount,
+ borderType=cv2.BORDER_DEFAULT)
+ mask = (2 * (mask.astype(np.float32)) - 255.0).clip(0, 255).astype(np.uint8)
+
+ result = cv2.cvtColor(self.image, cv2.COLOR_BGR2BGRA)
+ result[:, :, 3] = mask
+
+ return result
+
+ def replace_foreground(self) -> MatLike:
+ """Replace foreground with new content."""
+ result = self.remove_foreground()
+
+ replacement_type = self.params.get('replacement_type', 'color')
+
+ if replacement_type == 'image':
+ fg_image = self.params.get('fg_image')
+ if fg_image is not None:
+ # Resize foreground to match
+ fg_resized = cv2.resize(fg_image, (result.shape[1], result.shape[0]))
+ if fg_resized.shape[2] == 3:
+ fg_resized = cv2.cvtColor(fg_resized, cv2.COLOR_BGR2BGRA)
+
+ # Blend using alpha channel
+ alpha = result[:, :, 3:4] / 255.0
+ result_rgb = result[:, :, :3]
+ fg_rgb = fg_resized[:, :, :3]
+
+ blended = (fg_rgb * alpha + result_rgb * (1 - alpha)).astype(np.uint8)
+ final = cv2.cvtColor(blended, cv2.COLOR_BGR2BGRA)
+ final[:, :, 3] = 255
+ return final
+
+ elif replacement_type == 'color':
+ color_tuple = self.params.get('fg_color', (0, 0, 0))
+
+ alpha = result[:, :, 3:4] / 255.0
+ result_rgb = result[:, :, :3]
+
+ # Apply color to foreground only
+ colored = np.full_like(result_rgb, color_tuple, dtype=np.uint8)
+ blended = (colored * alpha + result_rgb * (1 - alpha)).astype(np.uint8)
+
+ final = cv2.cvtColor(blended, cv2.COLOR_BGR2BGRA)
+ final[:, :, 3] = 255
+ return final
+
+ elif replacement_type == 'gradient':
+ color1 = self.params.get('gradient_color1', (255, 255, 255))
+ color2 = self.params.get('gradient_color2', (0, 0, 0))
+ direction = self.params.get('gradient_direction', 'vertical')
+
+ # Create gradient
+ h, w = result.shape[:2]
+ gradient = np.zeros((h, w, 4), dtype=np.uint8)
+
+ if direction == 'vertical':
+ for i in range(h):
+ ratio = i / h
+ color = tuple(int(c1 * (1 - ratio) + c2 * ratio)
+ for c1, c2 in zip(color1, color2))
+ gradient[i, :] = (*color, 255)
+ else: # horizontal
+ for j in range(w):
+ ratio = j / w
+ color = tuple(int(c1 * (1 - ratio) + c2 * ratio)
+ for c1, c2 in zip(color1, color2))
+ gradient[:, j] = (*color, 255)
+
+ # Blend
+ alpha = result[:, :, 3:4] / 255.0
+ result_rgb = result[:, :, :3]
+ gradient_rgb = gradient[:, :, :3]
+
+ blended = (gradient_rgb * alpha + result_rgb * (1 - alpha)).astype(np.uint8)
+ final = cv2.cvtColor(blended, cv2.COLOR_BGR2BGRA)
+ final[:, :, 3] = 255
+ return final
+
+ return result
+
+
+class BackgroundRemoverGUI(QMainWindow):
+ def __init__(self) -> None:
+ super().__init__()
+ self.setWindowTitle("Advanced Background Remover")
+ self.setGeometry(100, 100, 1400, 900)
+
+ # State variables
+ self.original_image: Optional[MatLike] = None
+ self.processed_image: Optional[MatLike] = None
+ self.bg_replacement_image: Optional[MatLike] = None
+ self.fg_replacement_image: Optional[MatLike] = None
+ self.processing: bool = False
+
+ # UI components (declare for type checking)
+ self.load_btn: QPushButton
+ self.save_btn: QPushButton
+ self.process_btn: QPushButton
+ self.mode_group: QButtonGroup
+ self.remove_bg_radio: QRadioButton
+ self.replace_bg_radio: QRadioButton
+ self.remove_fg_radio: QRadioButton
+ self.replace_fg_radio: QRadioButton
+ self.threshold_slider: QSlider
+ self.threshold_value_label: QLabel
+ self.blur_slider: QSlider
+ self.blur_value_label: QLabel
+ self.morph_spin: QSpinBox
+ self.replacement_group: QGroupBox
+ self.bg_replacement_tabs: QTabWidget
+ self.fg_replacement_tabs: QTabWidget
+ self.bg_color_btn: QPushButton
+ self.fg_color_btn: QPushButton
+ self.bg_color: QColor = QColor(255, 255, 255)
+ self.fg_color: QColor = QColor(0, 0, 0)
+ self.load_bg_image_btn: QPushButton
+ self.load_fg_image_btn: QPushButton
+ self.bg_image_label: QLabel
+ self.fg_image_label: QLabel
+ self.gradient_color1_btn: QPushButton
+ self.gradient_color2_btn: QPushButton
+ self.gradient_color1: QColor = QColor(255, 255, 255)
+ self.gradient_color2: QColor = QColor(0, 0, 0)
+ self.gradient_direction: QComboBox
+ self.fg_gradient_color1_btn: QPushButton
+ self.fg_gradient_color2_btn: QPushButton
+ self.fg_gradient_color1: QColor = QColor(255, 255, 255)
+ self.fg_gradient_color2: QColor = QColor(0, 0, 0)
+ self.fg_gradient_direction: QComboBox
+ self.image_splitter: QSplitter
+ self.original_label: QLabel
+ self.processed_label: QLabel
+ self.worker: ImageProcessor
+
+ # Setup UI
+ self.init_ui()
+
+ def init_ui(self) -> None:
+ """Initialize the user interface."""
+ central_widget = QWidget()
+ self.setCentralWidget(central_widget)
+
+ main_layout = QHBoxLayout(central_widget)
+
+ # Left side: Controls
+ controls_widget = QWidget()
+ controls_layout = QVBoxLayout(controls_widget)
+ controls_widget.setMaximumWidth(400)
+
+ # File controls
+ file_group = QGroupBox("File Operations")
+ file_layout = QVBoxLayout()
+
+ self.load_btn = QPushButton("Load Image")
+ self.load_btn.clicked.connect(self.load_image)
+ file_layout.addWidget(self.load_btn)
+
+ self.save_btn = QPushButton("Save Result")
+ self.save_btn.clicked.connect(self.save_image)
+ self.save_btn.setEnabled(False)
+ file_layout.addWidget(self.save_btn)
+
+ file_group.setLayout(file_layout)
+ controls_layout.addWidget(file_group)
+
+ # Mode selection
+ mode_group = QGroupBox("Operation Mode")
+ mode_layout = QVBoxLayout()
+
+ self.mode_group = QButtonGroup()
+ self.remove_bg_radio = QRadioButton("Remove Background")
+ self.replace_bg_radio = QRadioButton("Replace Background")
+ self.remove_fg_radio = QRadioButton("Remove Foreground")
+ self.replace_fg_radio = QRadioButton("Replace Foreground")
+
+ self.remove_bg_radio.setChecked(True)
+ self.mode_group.addButton(self.remove_bg_radio, 0)
+ self.mode_group.addButton(self.replace_bg_radio, 1)
+ self.mode_group.addButton(self.remove_fg_radio, 2)
+ self.mode_group.addButton(self.replace_fg_radio, 3)
+
+ mode_layout.addWidget(self.remove_bg_radio)
+ mode_layout.addWidget(self.replace_bg_radio)
+ mode_layout.addWidget(self.remove_fg_radio)
+ mode_layout.addWidget(self.replace_fg_radio)
+
+ mode_group.setLayout(mode_layout)
+ controls_layout.addWidget(mode_group)
+
+ # Processing parameters
+ params_group = QGroupBox("Processing Parameters")
+ params_layout = QVBoxLayout()
+
+ # Threshold
+ threshold_layout = QHBoxLayout()
+ threshold_layout.addWidget(QLabel("Threshold:"))
+ self.threshold_slider = QSlider(Qt.Orientation.Horizontal)
+ self.threshold_slider.setRange(0, 255)
+ self.threshold_slider.setValue(250)
+ self.threshold_value_label = QLabel("250")
+ self.threshold_slider.valueChanged.connect(
+ lambda v: self.threshold_value_label.setText(str(v))
+ )
+ threshold_layout.addWidget(self.threshold_slider)
+ threshold_layout.addWidget(self.threshold_value_label)
+ params_layout.addLayout(threshold_layout)
+
+ # Blur
+ blur_layout = QHBoxLayout()
+ blur_layout.addWidget(QLabel("Edge Blur:"))
+ self.blur_slider = QSlider(Qt.Orientation.Horizontal)
+ self.blur_slider.setRange(0, 10)
+ self.blur_slider.setValue(2)
+ self.blur_value_label = QLabel("2")
+ self.blur_slider.valueChanged.connect(
+ lambda v: self.blur_value_label.setText(str(v))
+ )
+ blur_layout.addWidget(self.blur_slider)
+ blur_layout.addWidget(self.blur_value_label)
+ params_layout.addLayout(blur_layout)
+
+ # Morphology
+ morph_layout = QHBoxLayout()
+ morph_layout.addWidget(QLabel("Cleanup Size:"))
+ self.morph_spin = QSpinBox()
+ self.morph_spin.setRange(1, 15)
+ self.morph_spin.setValue(3)
+ self.morph_spin.setSingleStep(2)
+ morph_layout.addWidget(self.morph_spin)
+ morph_layout.addStretch()
+ params_layout.addLayout(morph_layout)
+
+ params_group.setLayout(params_layout)
+ controls_layout.addWidget(params_group)
+
+ # Background Replacement options
+ self.replacement_group = QGroupBox("Replacement Options")
+ replacement_layout = QVBoxLayout()
+
+ # Background replacement tabs
+ self.bg_replacement_tabs = QTabWidget()
+
+ # BG Color tab
+ bg_color_tab = QWidget()
+ bg_color_layout = QVBoxLayout(bg_color_tab)
+
+ self.bg_color_btn = QPushButton("Choose Background Color")
+ self.bg_color_btn.clicked.connect(self.choose_bg_color)
+ self.update_color_button_style(self.bg_color_btn, self.bg_color)
+ bg_color_layout.addWidget(self.bg_color_btn)
+ bg_color_layout.addStretch()
+ self.bg_replacement_tabs.addTab(bg_color_tab, "Solid Color")
+
+ # BG Image tab
+ bg_image_tab = QWidget()
+ bg_image_layout = QVBoxLayout(bg_image_tab)
+
+ self.load_bg_image_btn = QPushButton("Load Background Image")
+ self.load_bg_image_btn.clicked.connect(self.load_bg_image)
+ bg_image_layout.addWidget(self.load_bg_image_btn)
+
+ self.bg_image_label = QLabel("No image loaded")
+ self.bg_image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.bg_image_label.setStyleSheet("border: 1px solid gray; padding: 10px;")
+ bg_image_layout.addWidget(self.bg_image_label)
+ bg_image_layout.addStretch()
+ self.bg_replacement_tabs.addTab(bg_image_tab, "Image")
+
+ # BG Gradient tab
+ bg_gradient_tab = QWidget()
+ bg_gradient_layout = QVBoxLayout(bg_gradient_tab)
+
+ self.gradient_color1_btn = QPushButton("Gradient Start Color")
+ self.gradient_color1_btn.clicked.connect(self.choose_gradient_color1)
+ self.update_color_button_style(self.gradient_color1_btn, self.gradient_color1)
+ bg_gradient_layout.addWidget(self.gradient_color1_btn)
+
+ self.gradient_color2_btn = QPushButton("Gradient End Color")
+ self.gradient_color2_btn.clicked.connect(self.choose_gradient_color2)
+ self.update_color_button_style(self.gradient_color2_btn, self.gradient_color2)
+ bg_gradient_layout.addWidget(self.gradient_color2_btn)
+
+ gradient_dir_layout = QHBoxLayout()
+ gradient_dir_layout.addWidget(QLabel("Direction:"))
+ self.gradient_direction = QComboBox()
+ self.gradient_direction.addItems(["Vertical", "Horizontal"])
+ gradient_dir_layout.addWidget(self.gradient_direction)
+ bg_gradient_layout.addLayout(gradient_dir_layout)
+ bg_gradient_layout.addStretch()
+ self.bg_replacement_tabs.addTab(bg_gradient_tab, "Gradient")
+
+ # Foreground replacement tabs
+ self.fg_replacement_tabs = QTabWidget()
+
+ # FG Color tab
+ fg_color_tab = QWidget()
+ fg_color_layout = QVBoxLayout(fg_color_tab)
+
+ self.fg_color_btn = QPushButton("Choose Foreground Color")
+ self.fg_color_btn.clicked.connect(self.choose_fg_color)
+ self.update_color_button_style(self.fg_color_btn, self.fg_color)
+ fg_color_layout.addWidget(self.fg_color_btn)
+ fg_color_layout.addStretch()
+ self.fg_replacement_tabs.addTab(fg_color_tab, "Solid Color")
+
+ # FG Image tab
+ fg_image_tab = QWidget()
+ fg_image_layout = QVBoxLayout(fg_image_tab)
+
+ self.load_fg_image_btn = QPushButton("Load Foreground Image")
+ self.load_fg_image_btn.clicked.connect(self.load_fg_image)
+ fg_image_layout.addWidget(self.load_fg_image_btn)
+
+ self.fg_image_label = QLabel("No image loaded")
+ self.fg_image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.fg_image_label.setStyleSheet("border: 1px solid gray; padding: 10px;")
+ fg_image_layout.addWidget(self.fg_image_label)
+ fg_image_layout.addStretch()
+ self.fg_replacement_tabs.addTab(fg_image_tab, "Image")
+
+ # FG Gradient tab
+ fg_gradient_tab = QWidget()
+ fg_gradient_layout = QVBoxLayout(fg_gradient_tab)
+
+ self.fg_gradient_color1_btn = QPushButton("Gradient Start Color")
+ self.fg_gradient_color1_btn.clicked.connect(self.choose_fg_gradient_color1)
+ self.update_color_button_style(self.fg_gradient_color1_btn, self.fg_gradient_color1)
+ fg_gradient_layout.addWidget(self.fg_gradient_color1_btn)
+
+ self.fg_gradient_color2_btn = QPushButton("Gradient End Color")
+ self.fg_gradient_color2_btn.clicked.connect(self.choose_fg_gradient_color2)
+ self.update_color_button_style(self.fg_gradient_color2_btn, self.fg_gradient_color2)
+ fg_gradient_layout.addWidget(self.fg_gradient_color2_btn)
+
+ fg_gradient_dir_layout = QHBoxLayout()
+ fg_gradient_dir_layout.addWidget(QLabel("Direction:"))
+ self.fg_gradient_direction = QComboBox()
+ self.fg_gradient_direction.addItems(["Vertical", "Horizontal"])
+ fg_gradient_dir_layout.addWidget(self.fg_gradient_direction)
+ fg_gradient_layout.addLayout(fg_gradient_dir_layout)
+ fg_gradient_layout.addStretch()
+ self.fg_replacement_tabs.addTab(fg_gradient_tab, "Gradient")
+
+ # Add tabs to main replacement layout
+ replacement_layout.addWidget(QLabel("Background Replacement:"))
+ replacement_layout.addWidget(self.bg_replacement_tabs)
+ replacement_layout.addWidget(QLabel("Foreground Replacement:"))
+ replacement_layout.addWidget(self.fg_replacement_tabs)
+
+ self.replacement_group.setLayout(replacement_layout)
+ self.replacement_group.setEnabled(False)
+ controls_layout.addWidget(self.replacement_group)
+
+ # Process button
+ self.process_btn = QPushButton("Process Image")
+ self.process_btn.clicked.connect(self.process_image)
+ self.process_btn.setEnabled(False)
+ self.process_btn.setStyleSheet("QPushButton { font-weight: bold; padding: 10px; }")
+ controls_layout.addWidget(self.process_btn)
+
+ controls_layout.addStretch()
+
+ # Right side: Image display
+ display_widget = QWidget()
+ display_layout = QVBoxLayout(display_widget)
+
+ # Image splitter
+ self.image_splitter = QSplitter(Qt.Orientation.Horizontal)
+
+ # Original image
+ original_scroll = QScrollArea()
+ original_container = QWidget()
+ original_layout = QVBoxLayout(original_container)
+ original_layout.addWidget(QLabel("Original Image"))
+ self.original_label = QLabel()
+ self.original_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.original_label.setStyleSheet("border: 2px solid gray;")
+ self.original_label.setMinimumSize(400, 400)
+ original_layout.addWidget(self.original_label)
+ original_scroll.setWidget(original_container)
+ original_scroll.setWidgetResizable(True)
+ self.image_splitter.addWidget(original_scroll)
+
+ # Processed image
+ processed_scroll = QScrollArea()
+ processed_container = QWidget()
+ processed_layout = QVBoxLayout(processed_container)
+ processed_layout.addWidget(QLabel("Processed Image"))
+ self.processed_label = QLabel()
+ self.processed_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.processed_label.setStyleSheet("border: 2px solid green;")
+ self.processed_label.setMinimumSize(400, 400)
+ processed_layout.addWidget(self.processed_label)
+ processed_scroll.setWidget(processed_container)
+ processed_scroll.setWidgetResizable(True)
+ self.image_splitter.addWidget(processed_scroll)
+
+ display_layout.addWidget(self.image_splitter)
+
+ # Add to main layout
+ main_layout.addWidget(controls_widget)
+ main_layout.addWidget(display_widget, stretch=1)
+
+ # Connect mode changes
+ self.mode_group.buttonClicked.connect(self.on_mode_changed)
+
+ def update_color_button_style(self, button: QPushButton, color: QColor) -> None:
+ """Update button style to show selected color."""
+ button.setStyleSheet(f"""
+ QPushButton {{
+ background-color: rgb({color.red()}, {color.green()}, {color.blue()});
+ color: {'white' if color.lightness() < 128 else 'black'};
+ border: 2px solid gray;
+ padding: 5px;
+ }}
+ """)
+
+ def on_mode_changed(self) -> None:
+ """Handle mode radio button changes."""
+ mode_id = self.mode_group.checkedId()
+ # Enable replacement options for replace modes
+ self.replacement_group.setEnabled(mode_id in [1, 3])
+
+ # Show/hide appropriate tabs
+ if mode_id == 1: # Replace background
+ self.bg_replacement_tabs.setVisible(True)
+ self.fg_replacement_tabs.setVisible(False)
+ elif mode_id == 3: # Replace foreground
+ self.bg_replacement_tabs.setVisible(False)
+ self.fg_replacement_tabs.setVisible(True)
+
+ def load_image(self) -> None:
+ """Load an image file."""
+ file_path, _ = QFileDialog.getOpenFileName(
+ self, "Open Image", "", "Image Files (*.png *.jpg *.jpeg *.bmp *.tiff)"
+ )
+
+ if file_path:
+ self.original_image = cv2.imread(file_path)
+ if self.original_image is None:
+ QMessageBox.critical(self, "Error", "Could not load image!")
+ return
+
+ # Display original
+ self.display_image(self.original_image, self.original_label)
+
+ # Enable processing
+ self.process_btn.setEnabled(True)
+ self.processed_label.clear()
+ self.processed_image = None
+
+ def load_bg_image(self) -> None:
+ """Load a background replacement image."""
+ file_path, _ = QFileDialog.getOpenFileName(
+ self, "Open Background Image", "", "Image Files (*.png *.jpg *.jpeg *.bmp *.tiff)"
+ )
+
+ if file_path:
+ self.bg_replacement_image = cv2.imread(file_path)
+ if self.bg_replacement_image is None:
+ QMessageBox.critical(self, "Error", "Could not load background image!")
+ return
+
+ # Show preview
+ self.bg_image_label.setText(Path(file_path).name)
+ pixmap = self.cv_to_pixmap(self.bg_replacement_image)
+ if not pixmap.isNull():
+ scaled_pixmap = pixmap.scaled(200, 150, Qt.AspectRatioMode.KeepAspectRatio,
+ Qt.TransformationMode.SmoothTransformation)
+ self.bg_image_label.setPixmap(scaled_pixmap)
+
+ def load_fg_image(self) -> None:
+ """Load a foreground replacement image."""
+ file_path, _ = QFileDialog.getOpenFileName(
+ self, "Open Foreground Image", "", "Image Files (*.png *.jpg *.jpeg *.bmp *.tiff)"
+ )
+
+ if file_path:
+ self.fg_replacement_image = cv2.imread(file_path)
+ if self.fg_replacement_image is None:
+ QMessageBox.critical(self, "Error", "Could not load foreground image!")
+ return
+
+ # Show preview
+ self.fg_image_label.setText(Path(file_path).name)
+ pixmap = self.cv_to_pixmap(self.fg_replacement_image)
+ if not pixmap.isNull():
+ scaled_pixmap = pixmap.scaled(200, 150, Qt.AspectRatioMode.KeepAspectRatio,
+ Qt.TransformationMode.SmoothTransformation)
+ self.fg_image_label.setPixmap(scaled_pixmap)
+
+ def choose_bg_color(self) -> None:
+ """Open color dialog for background color."""
+ color = QColorDialog.getColor(self.bg_color, self, "Choose Background Color")
+ if color.isValid():
+ self.bg_color = color
+ self.update_color_button_style(self.bg_color_btn, self.bg_color)
+
+ def choose_fg_color(self) -> None:
+ """Open color dialog for foreground color."""
+ color = QColorDialog.getColor(self.fg_color, self, "Choose Foreground Color")
+ if color.isValid():
+ self.fg_color = color
+ self.update_color_button_style(self.fg_color_btn, self.fg_color)
+
+ def choose_gradient_color1(self) -> None:
+ """Choose first gradient color for background."""
+ color = QColorDialog.getColor(self.gradient_color1, self, "Choose Gradient Start Color")
+ if color.isValid():
+ self.gradient_color1 = color
+ self.update_color_button_style(self.gradient_color1_btn, self.gradient_color1)
+
+ def choose_gradient_color2(self) -> None:
+ """Choose second gradient color for background."""
+ color = QColorDialog.getColor(self.gradient_color2, self, "Choose Gradient End Color")
+ if color.isValid():
+ self.gradient_color2 = color
+ self.update_color_button_style(self.gradient_color2_btn, self.gradient_color2)
+
+ def choose_fg_gradient_color1(self) -> None:
+ """Choose first gradient color for foreground."""
+ color = QColorDialog.getColor(self.fg_gradient_color1, self, "Choose FG Gradient Start Color")
+ if color.isValid():
+ self.fg_gradient_color1 = color
+ self.update_color_button_style(self.fg_gradient_color1_btn, self.fg_gradient_color1)
+
+ def choose_fg_gradient_color2(self) -> None:
+ """Choose second gradient color for foreground."""
+ color = QColorDialog.getColor(self.fg_gradient_color2, self, "Choose FG Gradient End Color")
+ if color.isValid():
+ self.fg_gradient_color2 = color
+ self.update_color_button_style(self.fg_gradient_color2_btn, self.fg_gradient_color2)
+
+ def process_image(self) -> None:
+ """Process the image based on selected mode."""
+ if self.original_image is None or self.processing:
+ return
+
+ self.processing = True
+ self.process_btn.setEnabled(False)
+ self.process_btn.setText("Processing...")
+
+ # Get mode
+ mode_id = self.mode_group.checkedId()
+ operations = ["remove_bg", "replace_bg", "remove_fg", "replace_fg"]
+ operation = operations[mode_id]
+
+ # Get parameters
+ params: Dict[str, Any] = {
+ 'threshold': self.threshold_slider.value(),
+ 'blur': self.blur_slider.value(),
+ 'morph_size': self.morph_spin.value()
+ }
+
+ # Add replacement parameters
+ if mode_id == 1: # Replace background
+ current_tab = self.bg_replacement_tabs.currentIndex()
+ if current_tab == 0: # Color
+ params['replacement_type'] = 'color'
+ params['bg_color'] = (self.bg_color.blue(), self.bg_color.green(), self.bg_color.red())
+ elif current_tab == 1: # Image
+ params['replacement_type'] = 'image'
+ params['bg_image'] = self.bg_replacement_image
+ elif current_tab == 2: # Gradient
+ params['replacement_type'] = 'gradient'
+ params['gradient_color1'] = (self.gradient_color1.blue(),
+ self.gradient_color1.green(),
+ self.gradient_color1.red())
+ params['gradient_color2'] = (self.gradient_color2.blue(),
+ self.gradient_color2.green(),
+ self.gradient_color2.red())
+ params['gradient_direction'] = self.gradient_direction.currentText().lower()
+
+ elif mode_id == 3: # Replace foreground
+ current_tab = self.fg_replacement_tabs.currentIndex()
+ if current_tab == 0: # Color
+ params['replacement_type'] = 'color'
+ params['fg_color'] = (self.fg_color.blue(), self.fg_color.green(), self.fg_color.red())
+ elif current_tab == 1: # Image
+ params['replacement_type'] = 'image'
+ params['fg_image'] = self.fg_replacement_image
+ elif current_tab == 2: # Gradient
+ params['replacement_type'] = 'gradient'
+ params['gradient_color1'] = (self.fg_gradient_color1.blue(),
+ self.fg_gradient_color1.green(),
+ self.fg_gradient_color1.red())
+ params['gradient_color2'] = (self.fg_gradient_color2.blue(),
+ self.fg_gradient_color2.green(),
+ self.fg_gradient_color2.red())
+ params['gradient_direction'] = self.fg_gradient_direction.currentText().lower()
+
+ # Start processing thread
+ self.worker = ImageProcessor(self.original_image, operation, params)
+ self.worker.finished.connect(self.on_processing_finished)
+ self.worker.error.connect(self.on_processing_error)
+ self.worker.start()
+
+ def on_processing_finished(self, result: MatLike) -> None:
+ """Handle processing completion."""
+ self.processed_image = result
+ self.display_image(result, self.processed_label)
+
+ self.processing = False
+ self.process_btn.setEnabled(True)
+ self.process_btn.setText("Process Image")
+ self.save_btn.setEnabled(True)
+
+ def on_processing_error(self, error_msg: str) -> None:
+ """Handle processing error."""
+ QMessageBox.critical(self, "Processing Error", f"Error: {error_msg}")
+
+ self.processing = False
+ self.process_btn.setEnabled(True)
+ self.process_btn.setText("Process Image")
+
+ def display_image(self, image: Optional[MatLike], label: QLabel) -> None:
+ """Display OpenCV image in QLabel."""
+ if image is None:
+ return
+
+ pixmap = self.cv_to_pixmap(image)
+ if not pixmap.isNull():
+ scaled_pixmap = pixmap.scaled(label.size(), Qt.AspectRatioMode.KeepAspectRatio,
+ Qt.TransformationMode.SmoothTransformation)
+ label.setPixmap(scaled_pixmap)
+
+ def cv_to_pixmap(self, cv_image: Optional[MatLike]) -> QPixmap:
+ """Convert OpenCV image to QPixmap."""
+ if cv_image is None:
+ return QPixmap()
+
+ # Handle different channel counts
+ if len(cv_image.shape) == 2: # Grayscale
+ height, width = cv_image.shape
+ bytes_per_line = width
+ q_image = QImage(cv_image.data, width, height, bytes_per_line,
+ QImage.Format.Format_Grayscale8)
+ elif cv_image.shape[2] == 3: # BGR
+ height, width, channel = cv_image.shape
+ bytes_per_line = 3 * width
+ rgb_image = cv2.cvtColor(cv_image, cv2.COLOR_BGR2RGB)
+ q_image = QImage(rgb_image.data, width, height, bytes_per_line,
+ QImage.Format.Format_RGB888)
+ elif cv_image.shape[2] == 4: # BGRA
+ height, width, channel = cv_image.shape
+ bytes_per_line = 4 * width
+ rgba_image = cv2.cvtColor(cv_image, cv2.COLOR_BGRA2RGBA)
+ q_image = QImage(rgba_image.data, width, height, bytes_per_line,
+ QImage.Format.Format_RGBA8888)
+ else:
+ return QPixmap()
+
+ return QPixmap.fromImage(q_image)
+
+ def save_image(self) -> None:
+ """Save the processed image."""
+ if self.processed_image is None:
+ return
+
+ file_path, _ = QFileDialog.getSaveFileName(
+ self, "Save Image", "processed_image.png",
+ "PNG Files (*.png);;JPEG Files (*.jpg);;All Files (*)"
+ )
+
+ if file_path:
+ success = cv2.imwrite(file_path, self.processed_image)
+ if success:
+ QMessageBox.information(self, "Success", f"Image saved to:\n{file_path}")
+ else:
+ QMessageBox.critical(self, "Error", "Could not save image!")
+
+
+def main() -> None:
+ app = QApplication(sys.argv)
+
+ # Set modern style
+ app.setStyle('Fusion')
+
+ # Dark palette
+ palette = QPalette()
+ palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53))
+ palette.setColor(QPalette.ColorRole.WindowText, QColor(255, 255, 255))
+ palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25))
+ palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53))
+ palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(255, 255, 255))
+ palette.setColor(QPalette.ColorRole.ToolTipText, QColor(255, 255, 255))
+ palette.setColor(QPalette.ColorRole.Text, QColor(255, 255, 255))
+ palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53))
+ palette.setColor(QPalette.ColorRole.ButtonText, QColor(255, 255, 255))
+ palette.setColor(QPalette.ColorRole.BrightText, QColor(255, 0, 0))
+ palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218))
+ palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218))
+ palette.setColor(QPalette.ColorRole.HighlightedText, QColor(0, 0, 0))
+ app.setPalette(palette)
+
+ window = BackgroundRemoverGUI()
+ window.show()
+
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Remove Background/requirements.txt b/Remove Background/requirements.txt
new file mode 100644
index 00000000..83fb3395
--- /dev/null
+++ b/Remove Background/requirements.txt
@@ -0,0 +1,3 @@
+PySide6>=6.0.0
+opencv-python>=4.5.0
+numpy>=1.20.0
diff --git a/SMB Scan and Transfer Tool/README.md b/SMB Scan and Transfer Tool/README.md
new file mode 100644
index 00000000..7cb429e2
--- /dev/null
+++ b/SMB Scan and Transfer Tool/README.md
@@ -0,0 +1,77 @@
+# SMB Scan and Transfer Tool
+
+A Qt6-based GUI application for discovering, browsing, and transferring files over SMB (Samba) network shares on macOS. Features Zeroconf/Bonjour discovery, two-pane file explorer, and resumable transfers with optional MD5 verification.
+
+## Features
+
+- **Zeroconf Discovery**: Automatically discovers SMB services on the local network via Bonjour (`_smb._tcp.local`)
+- **Host & Share Listing**: Lists available shares using macOS `smbutil view` (anonymous and authenticated)
+- **Mount/Unmount Shares**: Mount SMB shares via `mount_smbfs` to `/Volumes/`
+- **Two-Pane File Explorer**: Browse local and remote files side-by-side with `QFileSystemModel`
+- **File Operations**: Copy, delete, rename, and create new folders between local and remote panes
+- **Multi-Select**: Checkboxes on both panes for batch operations
+- **Resumable Transfers**: Size-aware and partial-file-aware recursive copy with resume support
+- **MD5 Verification**: Optional MD5 hash verification toggle for transferred files
+- **Threaded Transfers**: Per-file and overall progress bars with cancellation support
+- **Status & Logging**: Detailed log panel and persistent log at `~/Library/Logs/SimpleSMBExplorer.log`
+- **Credential Management**: Auth prompt with optional save to macOS Keychain via `keyring`
+
+## Requirements
+
+- Python 3.10+
+- macOS 12+ (uses macOS tools: `smbutil`, `mount_smbfs`, `diskutil`)
+- PySide6 6.6+
+- zeroconf
+- keyring
+- psutil (optional)
+
+## Installation
+
+Install the required Python packages:
+
+```bash
+pip install -r requirements.txt
+```
+
+Or install individually:
+
+```bash
+pip install PySide6>=6.6 zeroconf keyring
+```
+
+Optional:
+
+```bash
+pip install psutil
+```
+
+## Usage
+
+Run the application:
+
+```bash
+python smb_transfer_tool.py
+```
+
+### Workflow
+
+1. **Discover**: The tool automatically discovers SMB services on your local network
+2. **Connect**: Select a discovered host or enter an IP/hostname manually
+3. **Authenticate**: Enter credentials if required (optionally save to Keychain)
+4. **Browse**: Navigate local and remote files in the two-pane explorer
+5. **Transfer**: Select files and use the action buttons to copy between panes
+6. **Verify**: Enable MD5 verification for transfer integrity checks
+
+## Notes
+
+- This tool is designed for **macOS only** — it relies on macOS-specific command-line tools
+- MD5 verification on large files can be slow; disable if not needed
+- Resume will append from the destination file size if it is smaller than the source
+
+## Author
+
+Randy Northrup
+
+## License
+
+MIT License — free to use, modify, and share.
diff --git a/SMB Scan and Transfer Tool/requirements.txt b/SMB Scan and Transfer Tool/requirements.txt
new file mode 100644
index 00000000..81ae1a5c
--- /dev/null
+++ b/SMB Scan and Transfer Tool/requirements.txt
@@ -0,0 +1,3 @@
+PySide6>=6.6
+zeroconf
+keyring
diff --git a/SMB Scan and Transfer Tool/smb_transfer_tool.py b/SMB Scan and Transfer Tool/smb_transfer_tool.py
new file mode 100644
index 00000000..fe76699f
--- /dev/null
+++ b/SMB Scan and Transfer Tool/smb_transfer_tool.py
@@ -0,0 +1,721 @@
+#!/usr/bin/env python3
+"""
+Simple SMB Explorer for macOS (Qt6 + Python)
+
+Feature set (robust, end-to-end):
+- Zeroconf discovery of SMB services (Bonjour: _smb._tcp.local)
+- Host & share listing via macOS `smbutil view` (anonymous, then authenticated)
+- Mount/unmount SMB shares using `mount_smbfs` → /Volumes/
+- Two-pane file explorer (Local ⟷ Remote) using QFileSystemModel
+- **Action buttons in a centered vertical column between the two panes**:
+ Copy →, ← Copy, Delete Selected, Rename, New Folder
+- Checkboxes on both panes + multi-select
+- Recursive copy with **resume** (size- and partial-file aware)
+- Optional **MD5 verification** (toggle)
+- Threaded transfers with per-file and overall progress + cancellable
+- Detailed status and log panel; persistent log at ~/Library/Logs/SimpleSMBExplorer.log
+- Auth prompt with optional **save credentials to macOS Keychain** (`keyring`)
+- Connection status and error handling, including share mount checks
+- Failed/incomplete transfer detection + resume option
+
+Tested with: Python 3.10+, PySide6 6.6+, macOS 12+
+
+Install:
+ pip install PySide6 zeroconf keyring
+(Optional): pip install psutil
+
+NOTE:
+- Uses macOS tools: `smbutil`, `mount_smbfs`, `diskutil`.
+- MD5 on huge files can be slow; disable if needed.
+- Resume will append from destination size if smaller than source.
+"""
+from __future__ import annotations
+import os
+import sys
+import re
+import hashlib
+import shutil
+import subprocess
+import threading
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+from PySide6 import QtCore, QtGui, QtWidgets
+from PySide6.QtCore import Qt, Signal, Slot, QThread
+from PySide6.QtWidgets import (
+ QApplication, QMainWindow, QWidget, QTreeView, QSplitter, QVBoxLayout, QHBoxLayout,
+ QPushButton, QLabel, QLineEdit, QCheckBox, QProgressBar, QComboBox, QInputDialog,
+ QMessageBox, QFileDialog, QStatusBar, QTextEdit
+)
+
+try:
+ import keyring
+except Exception:
+ keyring = None
+
+try:
+ from zeroconf import ServiceBrowser, Zeroconf
+except Exception:
+ Zeroconf = None
+
+try:
+ import psutil
+except Exception:
+ psutil = None
+
+LOG_DIR = Path.home() / "Library/Logs"
+LOG_DIR.mkdir(parents=True, exist_ok=True)
+LOG_FILE = LOG_DIR / "SimpleSMBExplorer.log"
+SERVICE_NAME = "SimpleSMBExplorer"
+
+# --------------------------- Utilities ---------------------------
+
+def log(msg: str):
+ ts = time.strftime("%Y-%m-%d %H:%M:%S")
+ line = f"[{ts}] {msg}\n"
+ try:
+ with LOG_FILE.open("a", encoding="utf-8") as f:
+ f.write(line)
+ except Exception:
+ pass
+ print(line, end="")
+
+
+def run(cmd: List[str], timeout: int = 30) -> Tuple[int, str, str]:
+ log(f"RUN: {' '.join(cmd)}")
+ try:
+ p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
+ out = p.stdout.strip()
+ err = p.stderr.strip()
+ if p.returncode != 0:
+ log(f"ERR({p.returncode}): {err}")
+ else:
+ if out:
+ log(f"OUT: {out[:200]}{'…' if len(out)>200 else ''}")
+ return p.returncode, out, err
+ except Exception as e:
+ log(f"EXC: {e}")
+ return 1, "", str(e)
+
+
+def md5sum(path: Path, chunk: int = 2**20, start_offset: int = 0) -> str:
+ h = hashlib.md5()
+ with path.open("rb") as f:
+ if start_offset:
+ f.seek(start_offset)
+ while True:
+ b = f.read(chunk)
+ if not b:
+ break
+ h.update(b)
+ return h.hexdigest()
+
+
+def human(n: int) -> str:
+ units = ["B","KB","MB","GB","TB"]
+ s = float(n)
+ for u in units:
+ if s < 1024 or u == units[-1]:
+ return f"{s:.1f} {u}"
+ s /= 1024
+
+# --------------------------- Auth Dialog ---------------------------
+
+class AuthDialog(QtWidgets.QDialog):
+ def __init__(self, host: str, parent=None, preset_user: str = "") -> None:
+ super().__init__(parent)
+ self.setWindowTitle(f"Authenticate for {host}")
+ self.user = QLineEdit(preset_user)
+ self.domain = QLineEdit()
+ self.passw = QLineEdit(); self.passw.setEchoMode(QLineEdit.Password)
+ self.save = QCheckBox("Save credentials to Keychain")
+
+ form = QtWidgets.QFormLayout(self)
+ form.addRow("Username", self.user)
+ form.addRow("Domain (optional)", self.domain)
+ form.addRow("Password", self.passw)
+ form.addRow("", self.save)
+ btns = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel)
+ btns.accepted.connect(self.accept)
+ btns.rejected.connect(self.reject)
+ form.addRow(btns)
+
+ def values(self) -> Tuple[str, str, str, bool]:
+ return self.user.text(), self.domain.text(), self.passw.text(), self.save.isChecked()
+
+# --------------------------- SMB Discovery ---------------------------
+
+@dataclass
+class SMBService:
+ host: str
+ address: str
+ port: int
+
+
+class ZeroconfBrowser(QtCore.QObject):
+ serviceFound = Signal(object)
+ serviceRemoved = Signal(object)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._zc = None
+ self._browser = None
+
+ def start(self):
+ if Zeroconf is None:
+ log("zeroconf not installed; skipping browse")
+ return
+ self._zc = Zeroconf()
+ self._browser = ServiceBrowser(self._zc, "_smb._tcp.local.", handlers=[self._handler])
+
+ def stop(self):
+ try:
+ if self._zc:
+ self._zc.close()
+ except Exception:
+ pass
+
+ def _handler(self, zc, type_, name, state_change):
+ host = name.split(".")[0]
+ if "Added" in str(state_change):
+ self.serviceFound.emit(SMBService(host=host, address=host, port=445))
+ elif "Removed" in str(state_change):
+ self.serviceRemoved.emit(SMBService(host=host, address=host, port=445))
+
+# --------------------------- Checkable FS Model ---------------------------
+
+class CheckableFSModel(QtWidgets.QFileSystemModel):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._checked: Dict[str, Qt.CheckState] = {}
+ self.setOption(QtWidgets.QFileSystemModel.DontUseCustomDirectoryIcons, True)
+
+ def flags(self, index: QtCore.QModelIndex) -> Qt.ItemFlags:
+ f = super().flags(index)
+ if index.isValid():
+ f |= Qt.ItemIsUserCheckable
+ return f
+
+ def data(self, index, role=Qt.DisplayRole):
+ if role == Qt.CheckStateRole and index.column() == 0:
+ path = self.filePath(index)
+ return self._checked.get(path, Qt.Unchecked)
+ return super().data(index, role)
+
+ def setData(self, index, value, role=Qt.EditRole):
+ if role == Qt.CheckStateRole and index.column() == 0:
+ path = self.filePath(index)
+ self._checked[path] = Qt.CheckState(value)
+ self.dataChanged.emit(index, index, [Qt.CheckStateRole])
+ return True
+ return super().setData(index, value, role)
+
+ def checked_paths(self) -> List[Path]:
+ return [Path(p) for p, state in self._checked.items() if state == Qt.Checked]
+
+# --------------------------- Transfer Worker ---------------------------
+
+class TransferWorker(QtCore.QObject):
+ progress = Signal(int) # overall percent
+ itemProgress = Signal(str, int) # path, percent
+ status = Signal(str) # human-readable status
+ finished = Signal(bool) # ok flag
+ totals = Signal(int, int) # total files, total bytes
+
+ def __init__(self, sources: List[Path], dest_dir: Path, verify_md5: bool):
+ super().__init__()
+ self.sources = sources
+ self.dest_dir = dest_dir
+ self.verify_md5 = verify_md5
+ self._stop = False
+
+ @Slot()
+ def run(self):
+ try:
+ plan: List[Tuple[Path, Path]] = []
+ total_bytes = 0
+ for src in self.sources:
+ if src.is_dir():
+ base = src
+ for root, _, files in os.walk(src):
+ root_p = Path(root)
+ for fn in files:
+ s = root_p / fn
+ rel = s.relative_to(base)
+ d = self.dest_dir / base.name / rel
+ plan.append((s, d))
+ try:
+ total_bytes += s.stat().st_size
+ except Exception:
+ pass
+ else:
+ d = self.dest_dir / src.name
+ plan.append((src, d))
+ try:
+ total_bytes += src.stat().st_size
+ except Exception:
+ pass
+
+ self.totals.emit(len(plan), total_bytes)
+
+ copied_bytes = 0
+ for idx, (src, dst) in enumerate(plan, 1):
+ if self._stop:
+ raise RuntimeError("Transfer cancelled")
+ self.status.emit(f"Copying {src} → {dst}")
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ copied_bytes += self._copy_with_resume(src, dst)
+ self.itemProgress.emit(str(src), 100)
+ if self.verify_md5 and src.is_file():
+ sm = md5sum(src)
+ dm = md5sum(dst)
+ if sm != dm:
+ log(f"MD5 mismatch: {src} vs {dst}")
+ raise RuntimeError(f"MD5 mismatch for {src}")
+ # Update overall progress conservatively
+ self.progress.emit(int(min(99, (copied_bytes * 100) / max(1, total_bytes))))
+
+ self.status.emit("Transfer complete")
+ self.progress.emit(100)
+ self.finished.emit(True)
+ except Exception as e:
+ self.status.emit(f"Error: {e}")
+ self.finished.emit(False)
+
+ def stop(self):
+ self._stop = True
+
+ def _copy_with_resume(self, src: Path, dst: Path, chunk: int = 2**20) -> int:
+ """Resume if dst smaller than src; returns bytes written this invocation."""
+ if src.is_dir():
+ dst.mkdir(parents=True, exist_ok=True)
+ return 0
+ s = src.stat().st_size
+ existing = dst.stat().st_size if dst.exists() else 0
+ mode = 'r+b' if dst.exists() else 'wb'
+ written = 0
+ with src.open('rb') as fsrc, open(dst, mode) as fdst:
+ if existing and existing < s:
+ fsrc.seek(existing)
+ fdst.seek(existing)
+ copied = existing
+ while True:
+ buf = fsrc.read(chunk)
+ if not buf:
+ break
+ fdst.write(buf)
+ written += len(buf)
+ copied += len(buf)
+ pct = int((copied * 100) / max(1, s))
+ self.itemProgress.emit(str(src), pct)
+ return written
+
+# --------------------------- Main Window ---------------------------
+
+class MainWindow(QMainWindow):
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("Simple SMB Explorer")
+ self.resize(1280, 820)
+
+ self.statusbar = QStatusBar(); self.setStatusBar(self.statusbar)
+
+ central = QWidget(); self.setCentralWidget(central)
+ root_v = QVBoxLayout(central)
+
+ # Top controls: host/share + discovery/mount
+ top_h = QHBoxLayout()
+ self.host_combo = QComboBox(); self.host_combo.setEditable(True)
+ self.share_combo = QComboBox(); self.share_combo.setEditable(True)
+ self.scan_btn = QPushButton("Scan Network")
+ self.view_shares_btn = QPushButton("List Shares")
+ self.mount_btn = QPushButton("Mount")
+ self.unmount_btn = QPushButton("Unmount")
+ top_h.addWidget(QLabel("Host:")); top_h.addWidget(self.host_combo, 2)
+ top_h.addWidget(QLabel("Share:")); top_h.addWidget(self.share_combo, 2)
+ top_h.addWidget(self.scan_btn)
+ top_h.addWidget(self.view_shares_btn)
+ top_h.addWidget(self.mount_btn)
+ top_h.addWidget(self.unmount_btn)
+ root_v.addLayout(top_h)
+
+ # Progress + options
+ opts_h = QHBoxLayout()
+ self.progress = QProgressBar(); self.progress.setValue(0)
+ self.item_label = QLabel("")
+ self.verify_md5_cb = QCheckBox("MD5 verify")
+ self.verify_md5_cb.setChecked(True)
+ self.cancel_btn = QPushButton("Cancel Transfer")
+ self.cancel_btn.setEnabled(False)
+ opts_h.addWidget(QLabel("Progress:")); opts_h.addWidget(self.progress, 4)
+ opts_h.addWidget(self.item_label, 2)
+ opts_h.addStretch(1)
+ opts_h.addWidget(self.verify_md5_cb)
+ opts_h.addWidget(self.cancel_btn)
+ root_v.addLayout(opts_h)
+
+ # Splitter: Local | Actions | Remote
+ splitter = QSplitter(); splitter.setChildrenCollapsible(False)
+ root_v.addWidget(splitter, 1)
+
+ # Local panel
+ self.local_model = CheckableFSModel()
+ self.local_root = str(Path.home())
+ self.local_model.setRootPath(self.local_root)
+ self.local_view = QTreeView(); self.local_view.setModel(self.local_model)
+ self.local_view.setRootIndex(self.local_model.index(self.local_root))
+ self.local_view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
+ self.local_view.setAlternatingRowColors(True)
+ splitter.addWidget(self.local_view)
+
+ # Middle actions panel
+ mid = QWidget(); mid_v = QVBoxLayout(mid); mid_v.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
+ mid_v.addWidget(QLabel("Actions"))
+ self.copy_lr_btn = QPushButton("Copy →")
+ self.copy_rl_btn = QPushButton("← Copy")
+ self.delete_btn = QPushButton("Delete Selected")
+ self.rename_btn = QPushButton("Rename…")
+ self.new_folder_btn = QPushButton("New Folder…")
+ for b in [self.copy_lr_btn, self.copy_rl_btn, self.delete_btn, self.rename_btn, self.new_folder_btn]:
+ b.setMinimumWidth(160)
+ mid_v.addWidget(b)
+ mid_v.addStretch(1)
+ splitter.addWidget(mid)
+
+ # Remote panel (mounted shares live under /Volumes/)
+ self.remote_model = CheckableFSModel()
+ self.remote_root = "/Volumes"
+ self.remote_model.setRootPath(self.remote_root)
+ self.remote_view = QTreeView(); self.remote_view.setModel(self.remote_model)
+ self.remote_view.setRootIndex(self.remote_model.index(self.remote_root))
+ self.remote_view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
+ self.remote_view.setAlternatingRowColors(True)
+ splitter.addWidget(self.remote_view)
+ splitter.setSizes([600, 120, 600])
+
+ # Log area
+ self.log_text = QTextEdit(); self.log_text.setReadOnly(True)
+ root_v.addWidget(self.log_text, 0)
+ self._load_log()
+
+ # Signals
+ self.scan_btn.clicked.connect(self.scan_network)
+ self.view_shares_btn.clicked.connect(self.list_shares)
+ self.mount_btn.clicked.connect(self.mount_share)
+ self.unmount_btn.clicked.connect(self.unmount_share)
+ self.copy_lr_btn.clicked.connect(lambda: self.copy_selected(direction="lr"))
+ self.copy_rl_btn.clicked.connect(lambda: self.copy_selected(direction="rl"))
+ self.delete_btn.clicked.connect(self.delete_selected)
+ self.rename_btn.clicked.connect(self.rename_selected)
+ self.new_folder_btn.clicked.connect(self.create_folder)
+ self.cancel_btn.clicked.connect(self.cancel_transfer)
+
+ # Zeroconf
+ self.zc_browser = ZeroconfBrowser()
+ self.zc_browser.serviceFound.connect(self._on_service_found)
+ self.zc_browser.serviceRemoved.connect(self._on_service_removed)
+
+ # Transfer state
+ self.transfer_thread: Optional[QThread] = None
+ self.transfer_worker: Optional[TransferWorker] = None
+
+ # ----- Log helpers -----
+ def _load_log(self):
+ try:
+ if LOG_FILE.exists():
+ self.log_text.setPlainText(LOG_FILE.read_text())
+ except Exception:
+ pass
+
+ def _append_log(self, text: str):
+ log(text)
+ self.log_text.append(text)
+
+ # ----- Discovery & Shares -----
+ def scan_network(self):
+ self.statusbar.showMessage("Scanning for SMB services…")
+ if Zeroconf is None:
+ QMessageBox.information(self, "Zeroconf missing", "Install 'zeroconf' (pip install zeroconf) to enable discovery. You can still type a host manually.")
+ return
+ self.host_combo.clear()
+ self.zc_browser.start()
+ self._append_log("Started Zeroconf browsing for _smb._tcp.local")
+
+ @Slot(object)
+ def _on_service_found(self, svc: SMBService):
+ if self.host_combo.findText(svc.host) < 0:
+ self.host_combo.addItem(svc.host)
+ self._append_log(f"Found SMB host: {svc.host}")
+
+ @Slot(object)
+ def _on_service_removed(self, svc: SMBService):
+ idx = self.host_combo.findText(svc.host)
+ if idx >= 0:
+ self.host_combo.removeItem(idx)
+ self._append_log(f"Removed SMB host: {svc.host}")
+
+ def list_shares(self):
+ host = self.host_combo.currentText().strip()
+ if not host:
+ QMessageBox.warning(self, "Host required", "Enter or pick a host first.")
+ return
+ # Anonymous first
+ rc, out, err = run(["smbutil", "view", f"//{host}"])
+ if rc != 0:
+ # Prompt for creds
+ user = ""
+ if keyring:
+ try:
+ saved = keyring.get_password(SERVICE_NAME, f"{host}:username")
+ if saved:
+ user = saved
+ except Exception:
+ pass
+ dlg = AuthDialog(host, self, preset_user=user)
+ if dlg.exec() != QtWidgets.QDialog.Accepted:
+ return
+ username, domain, password, save = dlg.values()
+ auth = f"{domain+';' if domain else ''}{username}:{password}"
+ rc, out, err = run(["smbutil", "view", f"//{auth}@{host}"])
+ if rc == 0 and save and keyring:
+ try:
+ keyring.set_password(SERVICE_NAME, f"{host}:username", username)
+ keyring.set_password(SERVICE_NAME, f"{host}:password", password)
+ except Exception:
+ pass
+ if rc == 0:
+ shares = self._parse_smbutil_view(out)
+ self.share_combo.clear()
+ for s in shares:
+ self.share_combo.addItem(s)
+ self.statusbar.showMessage(f"Found {len(shares)} share(s) on {host}")
+ self._append_log(f"Shares on {host}: {shares}")
+ else:
+ QMessageBox.critical(self, "Error listing shares", err or out or "Unknown error")
+
+ def _parse_smbutil_view(self, out: str) -> List[str]:
+ shares: List[str] = []
+ for line in out.splitlines():
+ m = re.match(r"^\\\\[^\\]+\\([^\s]+)\s+", line.strip())
+ if m:
+ shares.append(m.group(1))
+ continue
+ parts = line.split()
+ if parts and parts[0] not in ("Share", "-----") and not line.startswith("\\"):
+ cand = parts[0]
+ if cand.upper() not in ("IPC$",):
+ shares.append(cand)
+ return sorted(list(dict.fromkeys(shares)))
+
+ # ----- Mount/Unmount -----
+ def _get_saved_creds(self, host: str) -> Tuple[Optional[str], Optional[str]]:
+ if not keyring:
+ return None, None
+ try:
+ u = keyring.get_password(SERVICE_NAME, f"{host}:username")
+ p = keyring.get_password(SERVICE_NAME, f"{host}:password")
+ return u, p
+ except Exception:
+ return None, None
+
+ def _is_mounted(self, mount_point: Path) -> bool:
+ if psutil:
+ try:
+ for p in psutil.disk_partitions(all=False):
+ if p.mountpoint == str(mount_point):
+ return True
+ except Exception:
+ pass
+ return mount_point.exists() and any(mount_point.iterdir())
+
+ def mount_share(self):
+ host = self.host_combo.currentText().strip()
+ share = self.share_combo.currentText().strip()
+ if not host or not share:
+ QMessageBox.warning(self, "Missing info", "Host and Share are required.")
+ return
+ username, password = self._get_saved_creds(host)
+ if username and password:
+ auth = f"{username}:{password}@"
+ else:
+ dlg = AuthDialog(host, self, preset_user=username or "")
+ if dlg.exec() != QtWidgets.QDialog.Accepted:
+ return
+ u, d, p, save = dlg.values()
+ userpart = f"{d+';' if d else ''}{u}"
+ auth = f"{userpart}:{p}@"
+ if save and keyring:
+ try:
+ keyring.set_password(SERVICE_NAME, f"{host}:username", u)
+ keyring.set_password(SERVICE_NAME, f"{host}:password", p)
+ except Exception:
+ pass
+ mount_point = Path("/Volumes") / share
+ mount_point.mkdir(parents=True, exist_ok=True)
+ url = f"//{auth}{host}/{share}"
+ rc, out, err = run(["mount_smbfs", url, str(mount_point)], timeout=60)
+ if rc == 0:
+ self.statusbar.showMessage(f"Mounted at {mount_point}")
+ self._append_log(f"Mounted {url} at {mount_point}")
+ self.remote_model.setRootPath(str(mount_point))
+ self.remote_view.setRootIndex(self.remote_model.index(str(mount_point)))
+ else:
+ QMessageBox.critical(self, "Mount failed", err or out or "Unknown error")
+
+ def unmount_share(self):
+ idx = self.remote_view.rootIndex()
+ path = Path(self.remote_model.filePath(idx)) if idx.isValid() else Path(self.remote_root)
+ if str(path).startswith("/Volumes/") and path != Path(self.remote_root):
+ rc, out, err = run(["diskutil", "unmount", str(path)])
+ if rc == 0:
+ self.statusbar.showMessage(f"Unmounted {path}")
+ self._append_log(f"Unmounted {path}")
+ self.remote_model.setRootPath(self.remote_root)
+ self.remote_view.setRootIndex(self.remote_model.index(self.remote_root))
+ else:
+ QMessageBox.critical(self, "Unmount failed", err or out or "Unknown error")
+ else:
+ QMessageBox.information(self, "Nothing to unmount", "No mounted share is active.")
+
+ # ----- File selection helpers -----
+ def _selected_checked(self, view: QTreeView, model: CheckableFSModel) -> List[Path]:
+ paths = set()
+ for p in model.checked_paths():
+ paths.add(p)
+ for idx in view.selectionModel().selectedRows():
+ paths.add(Path(model.filePath(idx)))
+ return [p for p in paths if str(p) and Path(str(p)).exists()]
+
+ def _active_root(self, view: QTreeView, model: CheckableFSModel) -> Path:
+ idx = view.rootIndex()
+ return Path(model.filePath(idx)) if idx.isValid() else Path("/")
+
+ # ----- File ops -----
+ def copy_selected(self, direction: str):
+ if self.transfer_thread:
+ QMessageBox.warning(self, "Busy", "A transfer is already in progress.")
+ return
+ if direction == "lr":
+ sources = self._selected_checked(self.local_view, self.local_model)
+ dest_root = self._active_root(self.remote_view, self.remote_model)
+ else:
+ sources = self._selected_checked(self.remote_view, self.remote_model)
+ dest_root = self._active_root(self.local_view, self.local_model)
+ if not sources:
+ QMessageBox.information(self, "Nothing selected", "Select or check files/folders to copy.")
+ return
+ if not dest_root.exists():
+ QMessageBox.critical(self, "Invalid destination", f"Destination root does not exist: {dest_root}")
+ return
+ self._start_transfer(sources, dest_root)
+
+ def _start_transfer(self, sources: List[Path], dest_dir: Path):
+ worker = TransferWorker(sources, dest_dir, self.verify_md5_cb.isChecked())
+ thread = QThread()
+ worker.moveToThread(thread)
+ worker.progress.connect(self.progress.setValue)
+ worker.itemProgress.connect(lambda p, pct: self.item_label.setText(f"{Path(p).name}: {pct}%"))
+ worker.status.connect(lambda s: (self._append_log(s), self.statusbar.showMessage(s)))
+ worker.finished.connect(lambda ok: self._on_transfer_finished(ok))
+ thread.started.connect(worker.run)
+ thread.finished.connect(thread.deleteLater)
+ self.transfer_thread = thread
+ self.transfer_worker = worker
+ self.cancel_btn.setEnabled(True)
+ thread.start()
+
+ def cancel_transfer(self):
+ if self.transfer_worker:
+ self.transfer_worker.stop()
+ self._append_log("Transfer cancellation requested")
+
+ def _on_transfer_finished(self, ok: bool):
+ self._append_log(f"Transfer {'OK' if ok else 'FAILED'}")
+ self.statusbar.showMessage(f"Transfer {'OK' if ok else 'FAILED'}")
+ if self.transfer_thread:
+ self.transfer_thread.quit()
+ self.transfer_thread.wait(2000)
+ self.transfer_thread = None
+ self.transfer_worker = None
+ self.progress.setValue(0)
+ self.item_label.setText("")
+ self.cancel_btn.setEnabled(False)
+
+ def delete_selected(self):
+ # Deletes from whichever pane has focus
+ if self.remote_view.hasFocus():
+ paths = self._selected_checked(self.remote_view, self.remote_model)
+ else:
+ paths = self._selected_checked(self.local_view, self.local_model)
+ if not paths:
+ QMessageBox.information(self, "Nothing selected", "Select or check files/folders to delete.")
+ return
+ if QMessageBox.question(self, "Confirm delete", f"Delete {len(paths)} item(s)?") != QMessageBox.Yes:
+ return
+ failed = []
+ for p in paths:
+ try:
+ if p.is_dir():
+ shutil.rmtree(p)
+ else:
+ p.unlink(missing_ok=True)
+ self._append_log(f"Deleted {p}")
+ except Exception as e:
+ failed.append((p, str(e)))
+ if failed:
+ self._append_log("Delete failures:" + "; ".join([f"{p}: {e}" for p, e in failed]))
+ QMessageBox.warning(self, "Delete issues", f"{len(failed)} item(s) could not be deleted. See log.")
+ self.statusbar.showMessage("Delete complete")
+
+ def rename_selected(self):
+ # Renames the first selected item in the focused pane
+ if self.remote_view.hasFocus():
+ view, model = self.remote_view, self.remote_model
+ else:
+ view, model = self.local_view, self.local_model
+ indexes = view.selectionModel().selectedRows()
+ if not indexes:
+ QMessageBox.information(self, "Nothing selected", "Select a file or folder to rename.")
+ return
+ idx = indexes[0]
+ path = Path(model.filePath(idx))
+ new_name, ok = QInputDialog.getText(self, "Rename", "New name:", text=path.name)
+ if not ok or not new_name:
+ return
+ try:
+ path.rename(path.with_name(new_name))
+ self._append_log(f"Renamed {path} → {path.with_name(new_name)}")
+ except Exception as e:
+ QMessageBox.critical(self, "Rename failed", str(e))
+
+ def create_folder(self):
+ # Creates a folder in the currently focused pane's root
+ if self.remote_view.hasFocus():
+ root = self._active_root(self.remote_view, self.remote_model)
+ else:
+ root = self._active_root(self.local_view, self.local_model)
+ name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
+ if not ok or not name:
+ return
+ try:
+ p = root / name
+ p.mkdir(parents=True, exist_ok=False)
+ self._append_log(f"Created folder {p}")
+ except Exception as e:
+ QMessageBox.critical(self, "Create failed", str(e))
+
+# --------------------------- Entrypoint ---------------------------
+
+def main():
+ if sys.platform != "darwin":
+ print("This app targets macOS. Mounting requires macOS utilities.")
+ app = QApplication(sys.argv)
+ # Nice default font size for readability
+ f = app.font(); f.setPointSize(f.pointSize()+1); app.setFont(f)
+ w = MainWindow(); w.show()
+ sys.exit(app.exec())
+
+if __name__ == "__main__":
+ main()
diff --git a/Snake Game/README.md b/Snake Game/README.md
index 51592849..27f0a469 100644
--- a/Snake Game/README.md
+++ b/Snake Game/README.md
@@ -1,3 +1,32 @@
-# Snake-Game
+# Snake Game
+
+A modern, feature-rich implementation of the classic Snake game using Python Turtle graphics.
+
+## Features
+- Start screen with title and start button
+- Countdown before gameplay begins
+- Pause, resume, quit, and new game controls:
+ - SPACE to pause/resume
+ - P for new game (with countdown)
+ - Q to quit
+- Lives system (3 lives per game)
+- Score and high score tracking (persistent)
+- Apple-shaped food, grid-aligned
+- Responsive controls (no movement during countdown)
+- Game area with clear instructions at the bottom
+- Seamless transitions between game states (start, pause, game over)
+
+## How to Play
+- Use arrow keys to control the snake
+- Eat apples to grow and increase your score
+- Avoid walls and your own tail
+- Lose a life on collision; game ends after 3 lives
+- Follow on-screen instructions for controls
+
+## Requirements
+- Python 3.x
+- See `requirements.txt` for dependencies
+
+---

diff --git a/Snake Game/data.txt b/Snake Game/data.txt
index 25bf17fc..c2270834 100644
--- a/Snake Game/data.txt
+++ b/Snake Game/data.txt
@@ -1 +1 @@
-18
\ No newline at end of file
+0
\ No newline at end of file
diff --git a/Snake Game/food.py b/Snake Game/food.py
index b014c00a..c8f66610 100644
--- a/Snake Game/food.py
+++ b/Snake Game/food.py
@@ -1,18 +1,27 @@
from turtle import Turtle
import random
+GRID_SIZE = 20
+GRID_MIN = -280
+GRID_MAX = 280
+
class Food(Turtle): #It refers to dot as food
def __init__(self):
super().__init__()
+ # Use 'circle' shape for apple
self.shape("circle")
self.penup()
- self.shapesize(stretch_len= 0.5, stretch_wid= 0.5)
- self.color("blue")
+ # Make food exactly 20x20 (same as snake segment)
+ self.shapesize(stretch_len=1, stretch_wid=1)
+ self.color("red") # Apple color
self.speed("fastest")
self.refresh()
def refresh(self):
- random_x = random.randint(-280, 280)
- random_y = random.randint(-280, 280)
+ # Only place food on grid positions
+ possible_x = [x for x in range(GRID_MIN, GRID_MAX + 1, GRID_SIZE)]
+ possible_y = [y for y in range(GRID_MIN, GRID_MAX + 1, GRID_SIZE)]
+ random_x = random.choice(possible_x)
+ random_y = random.choice(possible_y)
self.goto(random_x, random_y)
diff --git a/Snake Game/main.py b/Snake Game/main.py
index 5ad9c3cc..0506c428 100644
--- a/Snake Game/main.py
+++ b/Snake Game/main.py
@@ -1,46 +1,279 @@
-from turtle import Screen
-
+from turtle import Screen, Turtle
+from food import Food
from snake import Snake
+from scoreboard import Scoreboard
+import _tkinter
+import time
+
+screen = Screen()
+
+from turtle import Screen, Turtle
from food import Food
+from snake import Snake
from scoreboard import Scoreboard
+import _tkinter
import time
screen = Screen()
-screen.setup(width= 600, height= 600)
+screen.setup(width=800, height=800)
screen.bgcolor("black")
screen.title("Snake Game")
screen.tracer(0)
-snake = Snake()
-food = Food()
-scoreboard = Scoreboard()
+# Block movement until countdown ends
+allow_movement = False
-screen.listen()
-screen.onkey(snake.up, "Up")
-screen.onkey(snake.down,"Down")
-screen.onkey(snake.left,"Left")
-screen.onkey(snake.right,"Right")
+# --- Turtles for UI ---
+start_turtle = Turtle()
+start_turtle.hideturtle()
+start_turtle.penup()
+start_turtle.color("white")
+
+button_turtle = Turtle()
+button_turtle.hideturtle()
+button_turtle.penup()
+button_turtle.color("white")
+button_turtle.shape("square")
+button_turtle.shapesize(stretch_wid=2, stretch_len=8)
+
+pause_turtle = Turtle()
+pause_turtle.hideturtle()
+pause_turtle.penup()
+pause_turtle.color("yellow")
+
+countdown_turtle = Turtle()
+countdown_turtle.hideturtle()
+countdown_turtle.penup()
+countdown_turtle.color("yellow")
+countdown_turtle.goto(0, 0)
+
+bottom_text_turtle = Turtle()
+bottom_text_turtle.hideturtle()
+bottom_text_turtle.penup()
+bottom_text_turtle.color("white")
+# --- Game State ---
+game_started = False
+is_paused = False
is_game_on = True
-while is_game_on:
- screen.update()
- time.sleep(0.15)
- snake.move()
- #detect collision with the food
- if snake.head.distance(food) < 15:
- food.refresh()
- snake.extend()
- scoreboard.increase_score()
+is_game_over = False
+
+GAME_SPEED = 80
+ANIMATION_SPEED = 80
+GRID_SIZE = 20
+PLAY_AREA_LIMIT = 360
+next_direction = None
+
+# --- UI Functions ---
+def show_start_screen():
+ clear_all_text()
+ global game_started, is_paused, is_game_on, is_game_over
+ game_started = False
+ is_paused = False
+ is_game_on = True
+ is_game_over = False
+ start_turtle.goto(0, 100)
+ start_turtle.write("SNAKE GAME", align="center", font=("Courier", 48, "bold"))
+ button_turtle.goto(0, -50)
+ button_turtle.showturtle()
+ button_turtle.write("PRESS SPACE TO START", align="center", font=("Courier", 32, "bold"))
+ show_bottom_text("SPACE to pause | P for new game | Q to quit")
+
+def show_pause_explanation():
+ pause_turtle.clear()
+ pause_turtle.goto(0, 100)
+ pause_turtle.write("PAUSED", align="center", font=("Courier", 48, "bold"))
+ pause_turtle.goto(0, 0)
+ pause_turtle.write("SPACE: Resume\nQ: Quit\nP: New Game", align="center", font=("Courier", 24, "normal"))
- #detect collision with the wall
- if snake.head.xcor() > 280 or snake.head.xcor() < -280 or snake.head.ycor() > 280 or snake.head.ycor() < -280:
- scoreboard.reset()
+def show_gameover_explanation():
+ pause_turtle.clear()
+ pause_turtle.goto(0, 0)
+ pause_turtle.write("GAME OVER\nPress SPACE to return to title", align="center", font=("Courier", 32, "bold"))
+ show_bottom_text("SPACE to return to title")
+
+def show_bottom_text(text):
+ bottom_text_turtle.clear()
+ bottom_text_turtle.goto(0, -380)
+ bottom_text_turtle.write(text, align="center", font=("Courier", 18, "normal"))
+
+def clear_all_text():
+ start_turtle.clear()
+ button_turtle.clear()
+ pause_turtle.clear()
+ countdown_turtle.clear()
+ bottom_text_turtle.clear()
+# --- Pause/Resume/Quit/New Game ---
+def handle_space():
+ global game_started, is_paused, is_game_over
+ if is_game_over:
+ is_game_over = False
+ clear_all_text()
+ show_start_screen()
+ return
+ if not game_started:
+ clear_all_text()
+ start_game()
+ elif not is_paused:
+ is_paused = True
+ show_pause_explanation()
+ else:
+ is_paused = False
+ pause_turtle.clear()
+ show_bottom_text("SPACE to pause | P for new game | Q to quit")
+
+def handle_q():
+ global is_paused
+ if is_paused:
+ screen.bye()
+
+def handle_p():
+ global is_game_on, game_started, is_paused, next_direction, scoreboard, snake, food, GAME_SPEED, ANIMATION_SPEED, allow_movement
+ if is_paused:
+ is_game_on = True
+ game_started = True
+ is_paused = False
+ next_direction = None
+ pause_turtle.clear()
+ scoreboard.clear()
snake.reset()
+ food.refresh()
+ scoreboard.lives = 3
+ scoreboard.score = 0
+ scoreboard.update_score()
+ GAME_SPEED = 80
+ ANIMATION_SPEED = 80
+ allow_movement = False
+ show_bottom_text("SPACE to pause | P for new game | Q to quit")
+ show_countdown()
+
+screen.onkey(handle_space, "space")
+screen.onkey(handle_q, "q")
+screen.onkey(handle_p, "p")
+
+# --- Start logic ---
+def start_game():
+ global game_started
+ if game_started:
+ return
+ game_started = True
+ clear_all_text()
+ button_turtle.hideturtle() # Hide the button after starting
+ show_countdown()
+
+# --- Countdown ---
+def show_countdown():
+ global allow_movement
+ allow_movement = False
+ for i in range(3, 0, -1):
+ countdown_turtle.clear()
+ countdown_turtle.write(str(i), align="center", font=("Courier", 64, "bold"))
+ screen.update()
+ time.sleep(1)
+ countdown_turtle.clear()
+ screen.update()
+ allow_movement = True
+ start_snake_game()
+
+# --- Game Setup ---
+food = Food()
+snake = Snake()
+scoreboard = Scoreboard(lives=3)
+
+screen.listen()
+
+def set_up():
+ global next_direction
+ if allow_movement:
+ next_direction = 'up'
+def set_down():
+ global next_direction
+ if allow_movement:
+ next_direction = 'down'
+def set_left():
+ global next_direction
+ if allow_movement:
+ next_direction = 'left'
+def set_right():
+ global next_direction
+ if allow_movement:
+ next_direction = 'right'
+
+screen.onkey(set_up, "Up")
+screen.onkey(set_down, "Down")
+screen.onkey(set_left, "Left")
+screen.onkey(set_right, "Right")
+
+def move_and_update():
+ try:
+ food.showturtle()
+ for segment in snake.segments:
+ segment.showturtle()
+ screen.update()
+ except _tkinter.TclError:
+ print("Game closed.")
+ global is_game_on
+ is_game_on = False
+
+def apply_next_direction():
+ global next_direction
+ if next_direction == 'up':
+ snake.up()
+ elif next_direction == 'down':
+ snake.down()
+ elif next_direction == 'left':
+ snake.left()
+ elif next_direction == 'right':
+ snake.right()
+ next_direction = None
+
+def game_loop():
+ global is_game_on, is_game_over
+ try:
+ if is_game_on and game_started and not is_paused and not is_game_over:
+ move_and_update()
+ apply_next_direction()
+ snake.move()
+ # detect collision with the food (exact grid match)
+ if (round(snake.head.xcor()) == round(food.xcor()) and round(snake.head.ycor()) == round(food.ycor())):
+ food.refresh()
+ snake.extend()
+ scoreboard.increase_score()
+ # detect collision with the wall
+ if (
+ snake.head.xcor() > PLAY_AREA_LIMIT or snake.head.xcor() < -PLAY_AREA_LIMIT or
+ snake.head.ycor() > PLAY_AREA_LIMIT or snake.head.ycor() < -PLAY_AREA_LIMIT
+ ):
+ scoreboard.lose_life()
+ if scoreboard.lives == 0:
+ is_game_on = False
+ is_game_over = True
+ clear_all_text()
+ show_gameover_explanation()
+ else:
+ snake.reset()
+ # detect collision with the tail
+ for segment in snake.segments[1:]:
+ if snake.head.distance(segment) < 10:
+ scoreboard.lose_life()
+ if scoreboard.lives == 0:
+ is_game_on = False
+ is_game_over = True
+ clear_all_text()
+ show_gameover_explanation()
+ else:
+ snake.reset()
+ break
+ screen.ontimer(game_loop, GAME_SPEED)
+ except _tkinter.TclError:
+ print("Game closed.")
+ is_game_on = False
- #detect collision with the tail
- for segment in snake.segments[1:]:
- if snake.head.distance(segment) < 10:
- scoreboard.reset()
- snake.reset()
+def start_snake_game():
+ show_bottom_text("SPACE to pause | P for new game | Q to quit")
+ screen.ontimer(game_loop, GAME_SPEED)
+ screen.exitonclick()
-screen.exitonclick()
\ No newline at end of file
+if __name__ == "__main__":
+ show_start_screen()
+ screen.mainloop()
\ No newline at end of file
diff --git a/Snake Game/scoreboard.py b/Snake Game/scoreboard.py
index 074fe43f..855576aa 100644
--- a/Snake Game/scoreboard.py
+++ b/Snake Game/scoreboard.py
@@ -1,21 +1,28 @@
from turtle import Turtle
ALIGNMENT = "center"
FONT = ("Courier", 25, "normal")
+
class Scoreboard(Turtle):
- def __init__(self):
+ def __init__(self, lives=3):
super().__init__()
self.score = 0
- with open("data.txt") as data:
- self.highscore = int(data.read())
+ self.lives = lives
+ try:
+ with open("data.txt") as data:
+ self.highscore = int(data.read())
+ except FileNotFoundError:
+ with open("data.txt", "w") as data:
+ data.write("0")
+ self.highscore = 0
self.color("white")
self.penup()
- self.goto(0, 260)
+ self.goto(0, 370) # Move score/lives display to top for separation
self.hideturtle()
self.update_score()
def update_score(self):
self.clear()
- self.write(f"Score: {self.score} HighScore: {self.highscore}", align= ALIGNMENT, font= FONT)
+ self.write(f"Score: {self.score} HighScore: {self.highscore} Lives: {self.lives}", align=ALIGNMENT, font=FONT)
def reset(self):
if self.score > self.highscore:
@@ -29,3 +36,7 @@ def increase_score(self):
self.score += 1
self.update_score()
+ def lose_life(self):
+ self.lives -= 1
+ self.update_score()
+
diff --git a/Website Cloner/index.html b/Website Cloner/index.html
index 357d9e74..dfef7da0 100644
--- a/Website Cloner/index.html
+++ b/Website Cloner/index.html
@@ -1,32 +1,73 @@
- PROXY SERVER
+ Website Cloner & Dockerizer
+
+
diff --git a/Website Cloner/server.py b/Website Cloner/server.py
index 446f8bc6..9ef16169 100644
--- a/Website Cloner/server.py
+++ b/Website Cloner/server.py
@@ -2,8 +2,39 @@
import http.server
import socketserver
import os
+import subprocess
+import hashlib
+from urllib.parse import parse_qs
from pywebcopy import save_webpage
+LOG_FILE = "cloner.log"
+README_TEMPLATE = """
+# Docker Website Container
+
+This folder contains a Docker image exported as a .tar file, ready to be loaded into Docker Desktop or via CLI.
+
+## Files
+- {docker_tar}
+- Dockerfile
+- nginx.conf (optional, for advanced config)
+
+## How to Use
+1. Open Docker Desktop, go to Images, and click 'Load'.
+ - Or use CLI: `docker load -i {docker_tar}`
+2. The image will appear with the name: {docker_name}
+3. To run the container:
+ - `docker run -d -p 8080:80 {docker_name}`
+ - (Change port as needed)
+4. The website will be served by Nginx at http://localhost:8080
+
+## Advanced
+- You can edit `nginx.conf` and rebuild the image if needed.
+- The Dockerfile is included for reference or customization.
+
+---
+MD5 of image: {md5_hash}
+"""
+
class RequestHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
if self.path == "/":
@@ -16,41 +47,102 @@ def do_GET(self):
def do_POST(self):
content_length = int(self.headers.get('Content-Length', 0))
post_data = self.rfile.read(content_length).decode("utf-8")
+ form = parse_qs(post_data)
+ url = form.get("website_url", [""])[0]
+ docker_name = form.get("docker_name", [""])[0]
+ save_path = form.get("save_path", [""])[0]
+ project_folder = os.path.join('./cloned_sites', docker_name)
+ log_entries = []
- # Parse the URL from the form submission
- form = cgi.parse_qs(post_data)
- url = form.get("submitButton", [""])[0]
+ def log(msg):
+ log_entries.append(msg)
+ with open(LOG_FILE, "a") as f:
+ f.write(msg + "\n")
- if not url:
- self.send_error(400, "Bad Request: URL is missing")
+ if not url or not docker_name or not save_path:
+ self.send_error(400, "Bad Request: Missing fields")
return
- urlN = "https://" + url.strip("/")
- project_folder = os.path.join('./cloned_sites', url.replace("https://", "").replace("http://", ""))
-
+ urlN = url if url.startswith("http") else "https://" + url.strip("/")
if not os.path.isdir(project_folder):
os.makedirs(project_folder, exist_ok=True)
try:
+ log(f"Cloning {urlN} to {project_folder}")
save_webpage(
url=urlN,
project_folder=project_folder,
- project_name=url.replace("https://", "").replace("http://", "")
+ project_name=docker_name
)
+ log("Cloning complete.")
except Exception as e:
+ log(f"Error cloning website: {e}")
self.send_error(500, f"Error cloning website: {e}")
return
-
- path = os.path.join(project_folder, "index.html")
- if not os.path.isfile(path):
- self.send_error(404, "Cloned page not found")
+ # Write Dockerfile for Nginx
+ dockerfile_path = os.path.join(project_folder, "Dockerfile")
+ with open(dockerfile_path, "w") as f:
+ f.write(f"""
+FROM nginx:alpine
+COPY . /usr/share/nginx/html
+EXPOSE 80
+CMD [\"nginx\", \"-g\", \"daemon off;\"]
+""")
+ log("Dockerfile created.")
+ # Optionally write nginx.conf for advanced users
+ nginx_conf_path = os.path.join(project_folder, "nginx.conf")
+ with open(nginx_conf_path, "w") as f:
+ f.write("# Default Nginx config. Edit as needed.\n")
+ # Build Docker image
+ build_cmd = ["docker", "build", "-t", docker_name, project_folder]
+ try:
+ log(f"Building Docker image: {docker_name}")
+ result = subprocess.run(build_cmd, capture_output=True, text=True)
+ log(result.stdout)
+ log(result.stderr)
+ if result.returncode != 0:
+ log(f"Docker build failed: {result.stderr}")
+ self.send_error(500, f"Docker build failed: {result.stderr}")
+ return
+ log("Docker build complete.")
+ except Exception as e:
+ log(f"Error building Docker image: {e}")
+ self.send_error(500, f"Error building Docker image: {e}")
return
-
- self.send_response(301)
- self.send_header("Location", "/" + path)
+ # Get image ID and calculate MD5
+ try:
+ inspect_cmd = ["docker", "images", "--format", "{{.ID}}", docker_name]
+ image_id = subprocess.check_output(inspect_cmd, text=True).strip()
+ md5_hash = hashlib.md5(image_id.encode()).hexdigest()
+ log(f"Docker image ID: {image_id}")
+ log(f"MD5: {md5_hash}")
+ # Save image to tar file in chosen location
+ docker_tar = os.path.join(save_path, f"{docker_name}.tar")
+ save_cmd = ["docker", "save", "-o", docker_tar, docker_name]
+ try:
+ subprocess.run(save_cmd, check=True)
+ log(f"Image saved to: {docker_tar}")
+ except Exception as e:
+ log(f"Error saving image: {e}")
+ # Write README
+ readme_path = os.path.join(save_path, f"README_{docker_name}.md")
+ with open(readme_path, "w") as f:
+ f.write(README_TEMPLATE.format(
+ docker_tar=f"{docker_name}.tar",
+ docker_name=docker_name,
+ md5_hash=md5_hash
+ ))
+ log(f"README created: {readme_path}")
+ except Exception as e:
+ log(f"Error getting image ID: {e}")
+ # Respond with log and MD5
+ self.send_response(200)
+ self.send_header("Content-type", "text/plain")
self.end_headers()
+ self.wfile.write(("\n".join(log_entries)).encode())
PORT = 7000
+os.makedirs("cloned_sites", exist_ok=True)
os.chdir(os.path.dirname(os.path.abspath(__file__)))
with socketserver.TCPServer(("127.0.0.1", PORT), RequestHandler) as s:
diff --git a/WiFi QR Code Generator/README.md b/WiFi QR Code Generator/README.md
new file mode 100644
index 00000000..c97d8bc3
--- /dev/null
+++ b/WiFi QR Code Generator/README.md
@@ -0,0 +1,261 @@
+# WifiQR
+
+WifiQR is a cross-platform desktop app for generating Wi‑Fi QR codes and exporting Wi‑Fi profiles. Built with PySide6, it provides a modern Qt interface for fast, consistent onboarding across Windows, macOS, and mobile devices.
+
+
+
+
+## Table of contents
+- [Highlights](#highlights)
+- [Features](#features)
+- [Requirements](#requirements)
+- [Installation](#installation)
+- [Run the app](#run-the-app)
+- [Usage](#usage)
+- [Exports](#exports)
+- [Security modes](#security-modes)
+- [Saved Networks Table](#saved-networks-table)
+- [Save File Format](#save-file-format)
+- [Packaging](#packaging)
+- [Development](#development)
+- [Project layout](#project-layout)
+- [Troubleshooting](#troubleshooting)
+- [License](#license)
+
+## Highlights
+- Modern Qt UI with consistent QSS-based styling
+- Live QR preview with optional center image embedding
+- High error correction QR codes (30% error correction level H)
+- Saved networks table with smooth scrolling, search, sorting, and batch export
+- Smart column resizing with gap prevention
+- Double-click table rows to load networks into the preview
+- Windows .cmd export (netsh) and macOS .mobileconfig export
+- PNG and PDF export with optional location header
+- Portable save format with base64-encoded images
+- Full test suite with 100% coverage gate
+
+## Features
+- **Network configuration**: SSID, password, security mode, hidden network, and location fields
+- **Center image**: Optional image embedding in the QR code center (automatically resized to 100x100px)
+- **Live preview**: Real-time QR code generation with payload preview (matches exports and print output)
+- **Print support**: Print dialog with proper scaling and layout
+- **Export formats**:
+ - PNG/PDF with optional location header
+ - Windows .cmd scripts for automated deployment
+ - macOS .mobileconfig profiles for one-click setup
+- **Table management**:
+ - In-table editing with combo boxes (Security) and checkboxes (Hidden)
+ - Double-click rows to load into preview
+ - Password obfuscation with show/hide toggle
+ - Centered column headers
+ - Uniform sizing for Security and Hidden columns (100px each)
+- **Search**: Incremental search with next/previous match navigation
+- **Menu bar**: Save, Save As, Load, About, Print, and Export options
+- **Portable storage**: All data including images stored in a single JSON file
+
+## Requirements
+- Python 3.10+
+- PySide6 6.6+
+- qrcode 7.4+
+- Pillow 10.0+
+- CairoSVG 2.7+ (for SVG center images)
+
+## Installation
+1. Clone the repository and navigate to the project directory.
+2. Create and activate a virtual environment:
+
+```bash
+python -m venv .venv
+source .venv/bin/activate # On Windows: .venv\Scripts\activate
+```
+
+3. Install the app and dependencies:
+
+```bash
+pip install -e .
+```
+
+## Run the app
+
+```bash
+wifiqr
+```
+
+Or run directly via module:
+
+```bash
+python -m wifiqr.app
+```
+
+## Usage
+
+### Basic Workflow
+1. Enter **Location**, **SSID**, **Password**, and select **Security** type and **Hidden** status.
+2. (Optional) Click the **...** button next to Center Image to select an image file (PNG, JPG, JPEG, BMP, GIF, SVG).
+3. Use the preview panel to verify the QR code in real-time.
+4. Click **Add to Table** to save the network to the Saved Networks table.
+5. **Double-click** any row in the table to load that network back into the preview.
+6. Export individual networks or use batch export.
+
+### Center Image Feature
+- Click the ellipsis (**...**) button to browse for an image
+- Supported formats: PNG, JPG, JPEG, BMP, GIF, SVG
+- Images are automatically resized to 100x100 pixels
+- Images are base64-encoded and stored in the save file for portability
+- QR codes use high error correction (Level H - 30%) to ensure scannability with center images
+- Center images appear in both preview and all exports (PNG, PDF)
+- SVG files are converted to PNG on import for consistent rendering
+
+### Table Interactions
+- **Double-click** any row to load that network into the details form and preview
+- **Edit in-place**: Click cells to edit Location, SSID; use dropdowns for Security
+- **Toggle Hidden**: Click checkbox directly in the Hidden column
+- **Password visibility**: Click the eye icon to view/hide passwords
+- **Search**: Use Ctrl+F or Edit → Find to search networks
+- **Sort**: Click column headers to sort (Location and SSID only)
+- **Delete**: Select rows and press Delete key or right-click → Delete
+
+### Export Options
+- **PNG/PDF** for printed handouts, signage, or digital sharing
+- **Windows .cmd** for scripted profile deployment across Windows machines
+- **macOS .mobileconfig** for one-click installation on macOS/iOS devices
+- **Batch Export**: Select multiple rows or export all networks at once
+
+## Exports
+
+### QR (PNG/PDF)
+- Exports optionally include a location header above the QR code for easy identification.
+- Toggle "Show location header" checkbox to enable/disable header (preview matches exports and print).
+- PNG is ideal for digital sharing or signage; PDF is optimized for printing.
+- QR codes use error correction Level H (30%) for reliable scanning even with center images.
+- Center images (if selected) are embedded at 100x100 pixels in the QR code center.
+
+### Windows Script (.cmd)
+- Uses Windows netsh command to add Wi-Fi profiles and auto-connect.
+- For batch export, a single script is generated containing all saved networks.
+- Run the script as Administrator for proper execution.
+- Compatible with Windows 7 and later.
+
+### macOS Profile (.mobileconfig)
+- Creates a managed Wi‑Fi configuration profile for macOS and iOS.
+- For batch export, one profile file is generated containing all networks.
+- Install by double-clicking the .mobileconfig file and following system prompts.
+- Works on macOS 10.7+ and iOS 5+.
+
+## Security modes
+WifiQR accepts several security labels and normalizes them for each export format:
+- **WPA/WPA2/WPA3** (encoded as "WPA" for QR payloads, expanded for system profiles)
+- **WEP** (legacy encryption, not recommended)
+- **None / Open / No Password** (treated as open network)
+
+## Saved Networks Table
+- **Centered Headers**: All column headers are centered for visual consistency
+- **Smart Column Resizing**: Location, SSID, and Password columns resize independently from the left edge
+- **Gap Prevention**: Password column automatically expands to prevent blank space
+- **Pinned Columns**: Security and Hidden columns (100px each) stay fixed on the right side
+- **Smooth Scrolling**: Horizontal scrolling uses 10-pixel increments for smooth navigation
+- **Minimum Widths**: All columns enforce minimum width based on header label size
+- **Double-Click Loading**: Double-click any row to load that network into the form and preview
+
+## Save File Format
+- Networks are saved as JSON files (.json extension)
+- Each network includes: location, SSID, password, security, hidden status, and optional image data
+- Images are stored as base64-encoded strings for complete portability
+- Single file contains all networks and their images - no external dependencies
+- Compatible with Save (Ctrl+S), Save As, and Load (Ctrl+O) operations
+
+## Packaging
+WifiQR includes a GitHub Actions workflow that builds:
+- Linux executable and .deb package
+- Windows executable
+- macOS Intel executable
+
+### CI workflow
+The workflow is defined in [.github/workflows/build-packages.yml](.github/workflows/build-packages.yml). It runs on tags that start with v and on manual dispatch.
+
+### Local packaging (optional)
+You can build a local executable with PyInstaller:
+
+```bash
+pip install pyinstaller
+pyinstaller --name wifiqr --onefile --windowed -m wifiqr.app \
+ --add-data "src/wifiqr/resources:wifiqr/resources"
+```
+
+On Windows, use a semicolon separator:
+
+```bash
+pyinstaller --name wifiqr --onefile --windowed -m wifiqr.app \
+ --add-data "src/wifiqr/resources;wifiqr/resources"
+```
+
+## Development
+
+### Tests
+Install test dependencies and run the full test suite:
+
+```bash
+pip install -e .[test]
+python -m pytest
+```
+
+Coverage is enforced at 100%. Tests include:
+- UI flow and interaction tests
+- QR payload generation and validation
+- Export service tests for all formats
+- Table widget behavior and editing
+- Registry/configuration tests
+
+### Typing and linting
+Strict typing and linting are enforced with mypy and ruff:
+
+```bash
+pip install -e .[dev]
+python -m mypy src tests
+python -m ruff check .
+```
+
+Configuration uses Python 3.12 target with strict mypy checks enabled.
+
+## Project layout
+```
+src/wifiqr/
+ app.py # Application entry point
+ constants.py # Application constants
+ ui/
+ main_window.py # Main window UI and logic
+ services/
+ qr_service.py # QR code generation
+ export_service.py # Export orchestration
+ wifi_payload.py # QR payload formatting
+ windows_script.py # Windows .cmd generation
+ macos_profile.py # macOS .mobileconfig generation
+ wifi_profiles.py # Profile save/load logic
+ xml_utils.py # XML utilities for macOS profiles
+ resources/
+ style.qss # Application stylesheet
+tests/
+ test_app_ui_flow.py # UI interaction tests
+ test_payload.py # Payload generation tests
+ test_services.py # Export service tests
+ test_ui.py # UI component tests
+ test_registry.py # Configuration tests
+ test_table_save_batch.py # Table and batch tests
+ test_preview_updates.py # Preview update tests
+```
+
+## Troubleshooting
+- **Preview disabled**: Verify that SSID field is not empty.
+- **Export disabled**: Ensure at least one network is in the Saved Networks table.
+- **Windows script fails**: Run the .cmd file as Administrator; verify netsh is available.
+- **macOS profile not installing**: Open from System Settings → Profiles; check for valid XML format.
+- **QR code not scanning**: Ensure adequate contrast and size when printing; test with multiple QR readers. Center images may reduce scannability if too large or complex.
+- **QR code with image not scanning**: Try using a simpler image with high contrast, or remove the center image. Error correction Level H supports up to 30% damage/obscuration.
+- **Table columns not resizing**: Check that you're dragging from the left edge of column separators.
+- **Center image button not visible**: The ellipsis (...) button is embedded inside the Center Image text field on the right side.
+- **Image not showing in preview**: Verify the image file format is supported (PNG, JPG, JPEG, BMP, GIF, SVG) and the file is accessible.
+- **Saved file won't load**: Ensure the JSON file is valid and hasn't been manually edited with syntax errors.
+- **Double-click doesn't load network**: Make sure you're double-clicking on the row, not just selecting it.
+
+## License
+MIT
diff --git a/WiFi QR Code Generator/pyproject.toml b/WiFi QR Code Generator/pyproject.toml
new file mode 100644
index 00000000..b34333ad
--- /dev/null
+++ b/WiFi QR Code Generator/pyproject.toml
@@ -0,0 +1,75 @@
+[build-system]
+requires = ["setuptools>=69", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "wifiqr"
+version = "1.1"
+description = "Cross-platform WiFi QR code generator"
+readme = "README.md"
+requires-python = ">=3.10"
+license = { text = "MIT" }
+authors = [{ name = "WifiQR" }]
+dependencies = [
+ "PySide6>=6.6",
+ "qrcode>=7.4",
+ "Pillow>=10.0",
+ "CairoSVG>=2.7"
+]
+
+[project.optional-dependencies]
+test = [
+ "pytest>=8.0",
+ "pytest-qt>=4.2",
+ "pytest-cov>=5.0",
+ "opencv-python>=4.5"
+]
+dev = [
+ "mypy>=1.10",
+ "ruff>=0.6"
+]
+
+[tool.pytest.ini_options]
+addopts = "-q --cov=wifiqr --cov-report=term-missing --cov-fail-under=100"
+
+[tool.mypy]
+python_version = "3.12"
+strict = true
+warn_unused_ignores = true
+warn_unreachable = true
+no_implicit_reexport = true
+show_error_codes = true
+mypy_path = "src"
+files = ["src", "tests"]
+
+[[tool.mypy.overrides]]
+module = ["PySide6.*"]
+ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
+module = ["qrcode.*"]
+ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
+module = ["PIL.*"]
+ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
+module = ["pytest.*", "pytestqt.*"]
+ignore_missing_imports = true
+
+[tool.ruff]
+target-version = "py312"
+line-length = 100
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I", "UP", "B", "N"]
+
+[project.scripts]
+wifiqr = "wifiqr.app:main"
+
+[tool.setuptools]
+package-dir = { "" = "src" }
+
+[tool.setuptools.packages.find]
+where = ["src"]
diff --git a/WiFi QR Code Generator/requirements.txt b/WiFi QR Code Generator/requirements.txt
new file mode 100644
index 00000000..03f92c7b
--- /dev/null
+++ b/WiFi QR Code Generator/requirements.txt
@@ -0,0 +1,4 @@
+PySide6>=6.6
+qrcode>=7.4
+Pillow>=10.0
+CairoSVG>=2.7
diff --git a/WiFi QR Code Generator/src/wifiqr/__init__.py b/WiFi QR Code Generator/src/wifiqr/__init__.py
new file mode 100644
index 00000000..87b717d7
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/__init__.py
@@ -0,0 +1,3 @@
+from . import app
+
+__all__ = ["app"]
diff --git a/WiFi QR Code Generator/src/wifiqr/app.py b/WiFi QR Code Generator/src/wifiqr/app.py
new file mode 100644
index 00000000..2886ff58
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/app.py
@@ -0,0 +1,23 @@
+"""Application entry point."""
+
+import sys
+
+from PySide6.QtWidgets import QApplication
+
+from wifiqr.ui.main_window import MainWindow
+
+
+def main() -> int:
+ """Run the Qt application and return the exit code."""
+ app = QApplication(sys.argv)
+ app.setApplicationName("WifiQR")
+ app.setOrganizationName("WifiQR")
+
+ window = MainWindow()
+ window.show()
+
+ return app.exec()
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/WiFi QR Code Generator/src/wifiqr/constants.py b/WiFi QR Code Generator/src/wifiqr/constants.py
new file mode 100644
index 00000000..aaf3a18b
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/constants.py
@@ -0,0 +1,39 @@
+"""Application-wide constants."""
+
+from __future__ import annotations
+
+# QR Code Generation Defaults
+DEFAULT_QR_SIZE = 640
+DEFAULT_QR_BOX_SIZE = 10
+DEFAULT_QR_BORDER = 2
+DEFAULT_QR_FILL_COLOR = "#111827"
+DEFAULT_QR_BACKGROUND_COLOR = "white"
+
+# Security Options
+SECURITY_OPTIONS = ("WPA/WPA2/WPA3", "WEP", "None")
+
+# Security Label Normalization
+SECURITY_ALIASES = {
+ "WPA/WPA2/WPA3": "WPA",
+ "WPA2": "WPA",
+ "WPA3": "WPA",
+ "OPEN": "NOPASS",
+ "NONE": "NOPASS",
+ "NO PASSWORD": "NOPASS",
+}
+
+# Windows Profile Security Mapping (auth, encryption, key_type)
+WINDOWS_SECURITY_MAP = {
+ "NOPASS": ("open", "none", None),
+ "WEP": ("open", "WEP", "networkKey"),
+}
+WINDOWS_SECURITY_DEFAULT = ("WPA2PSK", "AES", "passPhrase")
+
+# macOS Profile Security Mapping
+MACOS_SECURITY_MAP = {
+ "NOPASS": "None",
+ "WEP": "WEP",
+}
+MACOS_SECURITY_DEFAULT = "WPA"
+# UI Performance
+PREVIEW_RESIZE_THRESHOLD = 10 # Minimum pixel difference to trigger rescale
diff --git a/WiFi QR Code Generator/src/wifiqr/resources/check.svg b/WiFi QR Code Generator/src/wifiqr/resources/check.svg
new file mode 100644
index 00000000..6837832b
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/resources/check.svg
@@ -0,0 +1,3 @@
+
diff --git a/WiFi QR Code Generator/src/wifiqr/resources/chevron_down.svg b/WiFi QR Code Generator/src/wifiqr/resources/chevron_down.svg
new file mode 100644
index 00000000..45ed5f81
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/resources/chevron_down.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/WiFi QR Code Generator/src/wifiqr/resources/chevron_up.svg b/WiFi QR Code Generator/src/wifiqr/resources/chevron_up.svg
new file mode 100644
index 00000000..efb6cb65
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/resources/chevron_up.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/WiFi QR Code Generator/src/wifiqr/resources/eye.svg b/WiFi QR Code Generator/src/wifiqr/resources/eye.svg
new file mode 100644
index 00000000..0ec54af2
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/resources/eye.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/WiFi QR Code Generator/src/wifiqr/resources/style.qss b/WiFi QR Code Generator/src/wifiqr/resources/style.qss
new file mode 100644
index 00000000..840a4f8b
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/resources/style.qss
@@ -0,0 +1,388 @@
+QWidget {
+ font-family: "Inter", "Segoe UI", "Helvetica", "Arial", sans-serif;
+ font-size: 13px;
+ color: #0b1220;
+ background-color: transparent;
+}
+
+QMainWindow {
+ background-color: #f5f7fb;
+}
+
+QDialog, QFrame, QStackedWidget {
+ background: #ffffff;
+ color: #0b1220;
+}
+
+QMenuBar {
+ background: #f5f7fb;
+ color: #0b1220;
+ border-bottom: 1px solid #cbd5e1;
+}
+
+QMenuBar::item {
+ background: transparent;
+ padding: 6px 10px;
+ color: #0b1220;
+}
+
+QMenuBar::item:selected {
+ background: #e8f0ff;
+ color: #0b1220;
+}
+
+QMenu {
+ background: #ffffff;
+ color: #0b1220;
+ border: 1px solid #cbd5e1;
+}
+
+QMenu::item {
+ color: #0b1220;
+ background: #ffffff;
+}
+
+QMenu::item:selected {
+ background: #dbeafe;
+ color: #0b1220;
+}
+
+QGroupBox {
+ border: 1px solid #cbd5e1;
+ border-radius: 10px;
+ margin-top: 12px;
+ padding: 12px;
+ background: #ffffff;
+ color: #0b1220;
+}
+
+QGroupBox::title {
+ subcontrol-origin: margin;
+ subcontrol-position: top left;
+ left: 10px;
+ padding: 0 6px;
+ color: #1f2937;
+ background: #ffffff;
+}
+
+QLineEdit, QComboBox {
+ padding: 8px 10px;
+ border: 1px solid #cbd5e1;
+ border-radius: 8px;
+ background: #ffffff;
+ color: #0b1220;
+}
+
+QLineEdit::placeholder {
+ color: #94a3b8;
+}
+
+QLineEdit:disabled, QComboBox:disabled {
+ background: #eef2f7;
+ color: #6b7280;
+}
+
+QCheckBox {
+ color: #0b1220;
+ background: transparent;
+}
+
+QCheckBox::indicator {
+ width: 18px;
+ height: 18px;
+ border: 2px solid #cbd5e1;
+ border-radius: 4px;
+ background: #ffffff;
+}
+
+QCheckBox::indicator:checked {
+ background: #3b82f6;
+ border-color: #3b82f6;
+ border-image: url(src/wifiqr/resources/check.svg);
+}
+
+QCheckBox::indicator:disabled {
+ background: #eef2f7;
+ border-color: #cbd5e1;
+}
+
+QLabel {
+ color: #0b1220;
+ background: transparent;
+}
+
+QLineEdit:focus, QComboBox:focus {
+ border: 1px solid #3b82f6;
+ background: #ffffff;
+}
+
+QComboBox {
+ padding-right: 32px;
+}
+
+QComboBox::drop-down {
+ subcontrol-origin: padding;
+ subcontrol-position: top right;
+ width: 26px;
+ border-left: 1px solid #cbd5e1;
+ border-top-right-radius: 8px;
+ border-bottom-right-radius: 8px;
+ background: #e8f0ff;
+}
+
+QComboBox::down-arrow {
+ image: none;
+ width: 0;
+ height: 0;
+}
+
+QComboBox QAbstractItemView {
+ background: #ffffff;
+ color: #0b1220;
+ border: 1px solid #cbd5e1;
+ outline: 0;
+}
+
+QComboBox QAbstractItemView::item {
+ background: #ffffff;
+ color: #0b1220;
+ padding: 8px 10px;
+}
+
+QComboBox QAbstractItemView::item:selected {
+ background: #dbeafe;
+ color: #0b1220;
+}
+
+QComboBox QAbstractItemView::item:hover {
+ background: #dbeafe;
+ color: #0b1220;
+}
+
+QTableWidget QAbstractItemView,
+QTableView QAbstractItemView {
+ background: #ffffff;
+ color: #0b1220;
+ selection-background-color: #dbeafe;
+ selection-color: #0b1220;
+ outline: 0;
+ border: 1px solid #cbd5e1;
+}
+
+QTableWidget QAbstractItemView::item,
+QTableView QAbstractItemView::item {
+ color: #0b1220;
+ background: #ffffff;
+}
+
+QTableWidget QAbstractItemView::item:alternate,
+QTableView QAbstractItemView::item:alternate {
+ background: #f0f4fb;
+}
+
+QTableWidget QAbstractItemView::item:selected,
+QTableView QAbstractItemView::item:selected {
+ background: #dbeafe;
+ color: #0b1220;
+}
+
+QTableWidget QAbstractItemView::item:hover,
+QTableView QAbstractItemView::item:hover {
+ background: #e8f0ff;
+ color: #0b1220;
+}
+
+QPushButton {
+ background: #e8f0ff;
+ color: #0b1220;
+ border: 1px solid #3b82f6;
+ padding: 8px 16px;
+ border-radius: 8px;
+}
+
+QPushButton:hover {
+ background: #dbeafe;
+ color: #0b1220;
+}
+
+QPushButton:pressed {
+ background: #bfdbfe;
+ color: #0b1220;
+}
+
+QPushButton:disabled {
+ background: #eef2f7;
+ color: #6b7280;
+ border: 1px solid #cbd5e1;
+}
+
+QToolTip {
+ background: #ffffff;
+ color: #0b1220;
+ border: 1px solid #cbd5e1;
+ border-radius: 4px;
+ padding: 4px 8px;
+ font-size: 11px;
+ font-family: "Inter", "Segoe UI", "Helvetica", "Arial", sans-serif;
+}
+
+QToolButton#PreviewToggle {
+ border: none;
+ background: transparent;
+ padding: 0;
+}
+
+QToolButton {
+ background: #ffffff;
+ border: 1px solid #cbd5e1;
+ border-radius: 8px;
+ color: #0b1220;
+ padding: 8px;
+}
+
+QToolButton:hover {
+ background: #e8f0ff;
+ border: 1px solid #3b82f6;
+}
+
+QToolButton:pressed {
+ background: #dbeafe;
+ border: 1px solid #3b82f6;
+}
+
+QToolButton:disabled {
+ background: #eef2f7;
+ color: #6b7280;
+ border: 1px solid #cbd5e1;
+}
+
+QLabel#PreviewLabel {
+ background: #ffffff;
+ border: 1px solid #cbd5e1;
+ border-radius: 12px;
+ color: #0b1220;
+}
+
+QTableWidget, QTableView {
+ background: #ffffff;
+ color: #0b1220;
+ selection-background-color: #dbeafe;
+ selection-color: #0b1220;
+ border: 1px solid #cbd5e1;
+ gridline-color: #e2e8f0;
+}
+
+QTableWidget::item, QTableView::item {
+ padding: 4px;
+ color: #0b1220;
+ background: #ffffff;
+}
+
+QTableWidget::item:selected, QTableView::item:selected {
+ background: #dbeafe;
+ color: #0b1220;
+}
+
+QTableWidget::item:alternate, QTableView::item:alternate {
+ background: #f0f4fb;
+}
+
+QTableCornerButton::section {
+ background: #e8f0ff;
+ border: 1px solid #cbd5e1;
+}
+
+QHeaderView::section {
+ background: #e8f0ff;
+ color: #0b1220;
+ border: 1px solid #cbd5e1;
+ padding: 6px;
+}
+
+QHeaderView::section:hover {
+ background: #dbeafe;
+}
+
+QMessageBox {
+ background: #ffffff;
+ color: #0b1220;
+}
+
+QMessageBox QLabel {
+ color: #0b1220;
+}
+
+QMessageBox QPushButton {
+ min-width: 80px;
+}
+
+QScrollBar:vertical {
+ background: #f5f7fb;
+ border: 1px solid #cbd5e1;
+ width: 14px;
+}
+
+QScrollBar:horizontal {
+ background: #f5f7fb;
+ border: 1px solid #cbd5e1;
+ height: 14px;
+}
+
+QScrollBar::handle:vertical, QScrollBar::handle:horizontal {
+ background: #cbd5e1;
+ border-radius: 6px;
+ min-height: 20px;
+}
+
+QScrollBar::handle:vertical:hover, QScrollBar::handle:horizontal:hover {
+ background: #94a3b8;
+}
+
+QScrollBar::add-line, QScrollBar::sub-line {
+ background: #e8f0ff;
+ border: 1px solid #cbd5e1;
+}
+
+QScrollBar::add-page, QScrollBar::sub-page {
+ background: #f5f7fb;
+}
+
+QInputDialog {
+ background: #ffffff;
+ color: #0b1220;
+}
+
+QInputDialog QLabel {
+ color: #0b1220;
+}
+
+QInputDialog QLineEdit {
+ background: #ffffff;
+ color: #0b1220;
+}
+
+QFileDialog {
+ background: #f5f7fb;
+ color: #0b1220;
+}
+
+QFileDialog QLabel {
+ color: #0b1220;
+}
+
+QFileDialog QPushButton {
+ background: #e8f0ff;
+ color: #0b1220;
+}
+
+QFileDialog QTreeView, QFileDialog QListView {
+ background: #ffffff;
+ color: #0b1220;
+ selection-background-color: #dbeafe;
+ selection-color: #0b1220;
+}
+
+QTableWidget:disabled, QTableView:disabled {
+ background: #eef2f7;
+ color: #6b7280;
+}
diff --git a/WiFi QR Code Generator/src/wifiqr/resources/toggle_off.svg b/WiFi QR Code Generator/src/wifiqr/resources/toggle_off.svg
new file mode 100644
index 00000000..ccebff08
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/resources/toggle_off.svg
@@ -0,0 +1,4 @@
+
diff --git a/WiFi QR Code Generator/src/wifiqr/resources/toggle_on.svg b/WiFi QR Code Generator/src/wifiqr/resources/toggle_on.svg
new file mode 100644
index 00000000..719979ef
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/resources/toggle_on.svg
@@ -0,0 +1,4 @@
+
diff --git a/WiFi QR Code Generator/src/wifiqr/services/export_service.py b/WiFi QR Code Generator/src/wifiqr/services/export_service.py
new file mode 100644
index 00000000..7e93ff18
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/services/export_service.py
@@ -0,0 +1,21 @@
+"""Qt conversion helpers for PIL images."""
+
+from __future__ import annotations
+
+from PIL import Image
+from PySide6.QtGui import QImage, QPixmap
+
+
+def pil_to_qimage(image: Image.Image) -> QImage:
+ """Convert a PIL image into a Qt QImage."""
+ if image.mode != "RGB":
+ image = image.convert("RGB")
+ width, height = image.size
+ data = image.tobytes("raw", "RGB")
+ qimage = QImage(data, width, height, QImage.Format.Format_RGB888)
+ return qimage.copy()
+
+
+def pil_to_qpixmap(image: Image.Image) -> QPixmap:
+ """Convert a PIL image into a Qt QPixmap."""
+ return QPixmap.fromImage(pil_to_qimage(image))
diff --git a/WiFi QR Code Generator/src/wifiqr/services/macos_profile.py b/WiFi QR Code Generator/src/wifiqr/services/macos_profile.py
new file mode 100644
index 00000000..289a92f4
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/services/macos_profile.py
@@ -0,0 +1,127 @@
+"""macOS configuration profile generation helpers."""
+
+from __future__ import annotations
+
+import uuid
+from dataclasses import dataclass
+
+from wifiqr.constants import MACOS_SECURITY_DEFAULT, MACOS_SECURITY_MAP
+from wifiqr.services.wifi_payload import WifiConfig, normalize_security
+from wifiqr.services.xml_utils import xml_escape
+
+
+@dataclass(frozen=True)
+class MacProfileExport:
+ identifier: str
+ content: str
+
+
+def _build_wifi_payload(
+ config: WifiConfig,
+ payload_identifier: str,
+ wifi_uuid: str,
+) -> str:
+ """Build a Wi-Fi payload dict for a macOS configuration profile."""
+ ssid = xml_escape(config.ssid)
+ password = xml_escape(config.password)
+ hidden = "true" if config.hidden else "false"
+
+ security = normalize_security(config.security)
+ encryption = MACOS_SECURITY_MAP.get(security, MACOS_SECURITY_DEFAULT)
+
+ password_block = ""
+ if encryption != "None":
+ password_block = f"Password{password}"
+
+ return (
+ ""
+ "PayloadTypecom.apple.wifi.managed"
+ "PayloadVersion1"
+ f"PayloadIdentifier{payload_identifier}"
+ f"PayloadUUID{wifi_uuid}"
+ f"PayloadDisplayNameWiFi {ssid}"
+ f"SSID_STR{ssid}"
+ f"HIDDEN_NETWORK<{hidden}/>"
+ f"EncryptionType{encryption}"
+ f"{password_block}"
+ ""
+ )
+
+
+def build_macos_mobileconfig(config: WifiConfig) -> MacProfileExport:
+ """Build a single-network macOS configuration profile."""
+ profile_uuid = str(uuid.uuid4())
+ wifi_uuid = str(uuid.uuid4())
+ identifier = f"com.wifiqr.profile.{profile_uuid}"
+
+ payload = _build_wifi_payload(
+ config,
+ payload_identifier=f"{identifier}.wifi",
+ wifi_uuid=wifi_uuid,
+ )
+
+ content = (
+ ""
+ ""
+ ""
+ ""
+ "PayloadContent"
+ ""
+ f"{payload}"
+ ""
+ "PayloadTypeConfiguration"
+ "PayloadVersion1"
+ f"PayloadIdentifier{identifier}"
+ f"PayloadUUID{profile_uuid}"
+ f"PayloadDisplayNameWifiQR Wi-Fi"
+ "PayloadOrganizationWifiQR"
+ "PayloadRemovalDisallowed"
+ ""
+ ""
+ )
+
+ return MacProfileExport(identifier=identifier, content=content)
+
+
+def build_macos_mobileconfig_multi(configs: list[WifiConfig]) -> MacProfileExport:
+ """Build a multi-network macOS configuration profile."""
+ if not configs:
+ raise ValueError("No networks provided")
+
+ profile_uuid = str(uuid.uuid4())
+ identifier = f"com.wifiqr.profile.{profile_uuid}"
+
+ payloads = []
+ for config in configs:
+ wifi_uuid = str(uuid.uuid4())
+ payloads.append(
+ _build_wifi_payload(
+ config,
+ payload_identifier=f"{identifier}.wifi.{wifi_uuid}",
+ wifi_uuid=wifi_uuid,
+ )
+ )
+
+ content = (
+ ""
+ ""
+ ""
+ ""
+ "PayloadContent"
+ ""
+ f"{''.join(payloads)}"
+ ""
+ "PayloadTypeConfiguration"
+ "PayloadVersion1"
+ f"PayloadIdentifier{identifier}"
+ f"PayloadUUID{profile_uuid}"
+ f"PayloadDisplayNameWifiQR Wi-Fi"
+ "PayloadOrganizationWifiQR"
+ "PayloadRemovalDisallowed"
+ ""
+ ""
+ )
+
+ return MacProfileExport(identifier=identifier, content=content)
diff --git a/WiFi QR Code Generator/src/wifiqr/services/qr_service.py b/WiFi QR Code Generator/src/wifiqr/services/qr_service.py
new file mode 100644
index 00000000..e8b2ec26
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/services/qr_service.py
@@ -0,0 +1,83 @@
+"""QR image generation helpers."""
+
+from __future__ import annotations
+
+import base64
+import binascii
+import io
+
+import qrcode
+from PIL import Image
+from qrcode.constants import ERROR_CORRECT_H
+from qrcode.image.pil import PilImage
+
+from wifiqr.constants import (
+ DEFAULT_QR_BACKGROUND_COLOR,
+ DEFAULT_QR_BORDER,
+ DEFAULT_QR_BOX_SIZE,
+ DEFAULT_QR_FILL_COLOR,
+ DEFAULT_QR_SIZE,
+)
+
+CENTER_IMAGE_SIZE = 100 # Optimal size for center image in QR codes
+
+
+def generate_qr_image(
+ payload: str, size: int = DEFAULT_QR_SIZE, center_image_data: str | None = None
+) -> Image.Image:
+ """Generate a QR image for the provided payload with optional center image."""
+ qr = qrcode.QRCode(
+ version=None,
+ error_correction=ERROR_CORRECT_H, # High error correction for center images
+ box_size=DEFAULT_QR_BOX_SIZE,
+ border=DEFAULT_QR_BORDER,
+ )
+ qr.add_data(payload)
+ qr.make(fit=True)
+ image_factory = qr.make_image(
+ image_factory=PilImage,
+ fill_color=DEFAULT_QR_FILL_COLOR,
+ back_color=DEFAULT_QR_BACKGROUND_COLOR,
+ )
+ image: Image.Image = image_factory.get_image().convert("RGB")
+
+ if size and image.size != (size, size):
+ image = image.resize((size, size), Image.Resampling.LANCZOS)
+
+ # Add center image if provided
+ if center_image_data:
+ try:
+ image_bytes = base64.b64decode(center_image_data, validate=True)
+ except binascii.Error as exc:
+ raise ValueError("Center image data is not valid base64.") from exc
+
+ try:
+ center_img = Image.open(io.BytesIO(image_bytes)).convert("RGBA")
+ except Exception as exc:
+ raise ValueError("Center image data is not a valid image.") from exc
+
+ # Resize center image to fixed size
+ center_img = center_img.resize(
+ (CENTER_IMAGE_SIZE, CENTER_IMAGE_SIZE), Image.Resampling.LANCZOS
+ )
+
+ # Calculate position to center the image
+ qr_width, qr_height = image.size
+ pos_x = (qr_width - CENTER_IMAGE_SIZE) // 2
+ pos_y = (qr_height - CENTER_IMAGE_SIZE) // 2
+
+ # Convert QR to RGBA for compositing
+ image = image.convert("RGBA")
+
+ # Paste center image
+ image.paste(center_img, (pos_x, pos_y), center_img)
+
+ # Convert back to RGB
+ image = image.convert("RGB")
+
+ return image
+
+
+def save_qr_image(image: Image.Image, file_path: str) -> None:
+ """Persist a QR image to disk."""
+ image.save(file_path)
diff --git a/WiFi QR Code Generator/src/wifiqr/services/wifi_payload.py b/WiFi QR Code Generator/src/wifiqr/services/wifi_payload.py
new file mode 100644
index 00000000..a16800a8
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/services/wifi_payload.py
@@ -0,0 +1,57 @@
+"""Wi-Fi payload helpers and configuration model."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from wifiqr.constants import SECURITY_ALIASES
+
+
+@dataclass(frozen=True)
+class WifiConfig:
+ location: str
+ ssid: str
+ password: str
+ security: str
+ hidden: bool = False
+ image_data: str | None = None # base64 encoded image
+
+
+def _escape(value: str) -> str:
+ """Escape payload delimiters for QR-encoded Wi-Fi strings."""
+ return (
+ value.replace("\\", "\\\\")
+ .replace(";", "\\;")
+ .replace(",", "\\,")
+ .replace(":", "\\:")
+ )
+
+
+def normalize_security(value: str) -> str:
+ """Normalize security labels into canonical forms."""
+ key = value.upper().strip()
+ return SECURITY_ALIASES.get(key, key)
+
+
+def security_for_qr(value: str) -> str:
+ """Map a security label to a QR-compatible value."""
+ normalized = normalize_security(value)
+ return "nopass" if normalized == "NOPASS" else normalized
+
+
+def is_open_security(value: str) -> bool:
+ """Return True when the security represents an open network."""
+ return normalize_security(value) == "NOPASS"
+
+
+def build_wifi_payload(config: WifiConfig) -> str:
+ """Build a Wi-Fi QR payload string from a configuration."""
+ ssid = _escape(config.ssid.strip())
+ password = _escape(config.password)
+ security = security_for_qr(config.security)
+ hidden = "true" if config.hidden else "false"
+
+ if security == "nopass":
+ return f"WIFI:T:nopass;S:{ssid};H:{hidden};;"
+
+ return f"WIFI:T:{security};S:{ssid};P:{password};H:{hidden};;"
diff --git a/WiFi QR Code Generator/src/wifiqr/services/wifi_profiles.py b/WiFi QR Code Generator/src/wifiqr/services/wifi_profiles.py
new file mode 100644
index 00000000..b0a65cb6
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/services/wifi_profiles.py
@@ -0,0 +1,53 @@
+"""WLAN profile XML generation for Windows exports."""
+
+from __future__ import annotations
+
+from wifiqr.constants import WINDOWS_SECURITY_DEFAULT, WINDOWS_SECURITY_MAP
+from wifiqr.services.wifi_payload import WifiConfig, normalize_security
+from wifiqr.services.xml_utils import xml_escape
+
+
+def _security_to_profile(config: WifiConfig) -> tuple[str, str, str | None]:
+ """Map a Wi-Fi config to profile authentication values."""
+ security = normalize_security(config.security)
+ return WINDOWS_SECURITY_MAP.get(security, WINDOWS_SECURITY_DEFAULT)
+
+
+def build_wlan_profile_xml(config: WifiConfig) -> str:
+ """Build WLAN profile XML content for a Wi-Fi config."""
+ auth, encryption, key_type = _security_to_profile(config)
+ ssid = xml_escape(config.ssid)
+ password = xml_escape(config.password)
+ hidden = "true" if config.hidden else "false"
+
+ key_material = (
+ f"{key_type}false"
+ f"{password}"
+ if key_type
+ else ""
+ )
+
+ return (
+ ""
+ ""
+ f"{ssid}"
+ ""
+ ""
+ f"{ssid}"
+ ""
+ f"{hidden}"
+ ""
+ "ESS"
+ "auto"
+ ""
+ ""
+ ""
+ f"{auth}"
+ f"{encryption}"
+ "false"
+ ""
+ f"{key_material}"
+ ""
+ ""
+ ""
+ )
diff --git a/WiFi QR Code Generator/src/wifiqr/services/windows_script.py b/WiFi QR Code Generator/src/wifiqr/services/windows_script.py
new file mode 100644
index 00000000..00da725e
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/services/windows_script.py
@@ -0,0 +1,67 @@
+"""Windows script generation for Wi-Fi profiles."""
+
+from __future__ import annotations
+
+import base64
+from dataclasses import dataclass
+
+from wifiqr.services.wifi_payload import WifiConfig
+from wifiqr.services.wifi_profiles import build_wlan_profile_xml
+
+
+@dataclass(frozen=True)
+class ScriptExport:
+ ssid: str
+ content: str
+
+
+def _profile_script_block(profile_path: str, profile_xml: str) -> list[str]:
+ """Build script lines for writing and importing a WLAN profile."""
+ # Base64 the XML to avoid cmd/PowerShell quoting pitfalls.
+ xml_b64 = base64.b64encode(profile_xml.encode("utf-8")).decode("ascii")
+ return [
+ f"set \"PROFILE_PATH={profile_path}\"",
+ "powershell -NoProfile -ExecutionPolicy Bypass -Command "
+ f"\"$xml=[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('{xml_b64}')); "
+ "Set-Content -LiteralPath \\\"%PROFILE_PATH%\\\" -Value $xml -Encoding UTF8\"",
+ "netsh wlan add profile filename=\"%PROFILE_PATH%\" user=all",
+ "del /f /q \"%PROFILE_PATH%\"",
+ ]
+
+
+def build_windows_connect_script(config: WifiConfig) -> ScriptExport:
+ """Build a single-network Windows connect script."""
+ ssid = config.ssid
+ profile_xml = build_wlan_profile_xml(config)
+ lines = ["@echo off", "setlocal EnableExtensions DisableDelayedExpansion"]
+ lines.extend(_profile_script_block("%TEMP%\\wifi-profile.xml", profile_xml))
+ lines.append(f"netsh wlan connect name=\"{ssid}\"")
+ lines.append("endlocal")
+
+ script = "\r\n".join(lines) + "\r\n"
+ return ScriptExport(ssid=ssid, content=script)
+
+
+def build_windows_connect_script_multi(configs: list[WifiConfig]) -> ScriptExport:
+ """Build a batch Windows connect script for multiple networks."""
+ if not configs:
+ raise ValueError("No networks provided")
+
+ lines = ["@echo off", "setlocal EnableExtensions DisableDelayedExpansion"]
+
+ for idx, config in enumerate(configs, start=1):
+ profile_xml = build_wlan_profile_xml(config)
+ temp_name = f"wifi-profile-{idx}.xml"
+ lines.extend(
+ _profile_script_block(
+ f"%TEMP%\\{temp_name}",
+ profile_xml,
+ )
+ )
+
+ last_ssid = configs[-1].ssid
+ lines.append(f"netsh wlan connect name=\"{last_ssid}\"")
+ lines.append("endlocal")
+
+ script = "\r\n".join(lines) + "\r\n"
+ return ScriptExport(ssid=last_ssid, content=script)
diff --git a/WiFi QR Code Generator/src/wifiqr/services/xml_utils.py b/WiFi QR Code Generator/src/wifiqr/services/xml_utils.py
new file mode 100644
index 00000000..9884c8e4
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/services/xml_utils.py
@@ -0,0 +1,14 @@
+"""Shared XML escaping utilities."""
+
+from __future__ import annotations
+
+
+def xml_escape(value: str) -> str:
+ """Escape XML special characters for use in XML/plist content."""
+ return (
+ value.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace('"', """)
+ .replace("'", "'")
+ )
diff --git a/WiFi QR Code Generator/src/wifiqr/ui/main_window.py b/WiFi QR Code Generator/src/wifiqr/ui/main_window.py
new file mode 100644
index 00000000..a6958899
--- /dev/null
+++ b/WiFi QR Code Generator/src/wifiqr/ui/main_window.py
@@ -0,0 +1,1681 @@
+from __future__ import annotations
+
+import json
+from importlib import metadata
+from dataclasses import replace
+from pathlib import Path
+from typing import override
+
+from PIL import Image, ImageDraw, ImageFont
+from PySide6.QtCore import QModelIndex, QPoint, QSize, Qt, QTimer, Slot
+from PySide6.QtGui import (
+ QAction,
+ QColor,
+ QFont,
+ QFontMetrics,
+ QIcon,
+ QKeySequence,
+ QPainter,
+ QPalette,
+ QPixmap,
+ QResizeEvent,
+ QShowEvent,
+)
+from PySide6.QtPrintSupport import QPrintDialog, QPrinter
+from PySide6.QtWidgets import (
+ QAbstractItemView,
+ QCheckBox,
+ QComboBox,
+ QFileDialog,
+ QFormLayout,
+ QGridLayout,
+ QGroupBox,
+ QHBoxLayout,
+ QHeaderView,
+ QInputDialog,
+ QLabel,
+ QLineEdit,
+ QMainWindow,
+ QMenu,
+ QMessageBox,
+ QPushButton,
+ QSizePolicy,
+ QTableWidget,
+ QTableWidgetItem,
+ QToolButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+from wifiqr.constants import PREVIEW_RESIZE_THRESHOLD, SECURITY_OPTIONS
+from wifiqr.services.export_service import pil_to_qpixmap
+from wifiqr.services.macos_profile import (
+ build_macos_mobileconfig,
+ build_macos_mobileconfig_multi,
+)
+from wifiqr.services.qr_service import generate_qr_image, save_qr_image
+from wifiqr.services.wifi_payload import WifiConfig, build_wifi_payload, is_open_security
+from wifiqr.services.windows_script import (
+ build_windows_connect_script,
+ build_windows_connect_script_multi,
+)
+
+RESOURCE_DIR = Path(__file__).resolve().parents[1] / "resources"
+
+
+class MainWindow(QMainWindow):
+ """Primary application window for building and exporting Wi-Fi QR codes."""
+
+ STANDARD_SSID_CHARS = 32
+ STANDARD_PASSWORD_CHARS = 63
+ MAX_WINDOW_SIZE = 16777215
+ TABLE_COLUMN_COUNT = 5
+ TABLE_HEADER_LABELS = (
+ "Location",
+ "SSID",
+ "Password",
+ "Security",
+ "Hidden",
+ )
+ INITIAL_TABLE_ROWS = 0
+ NO_SEARCH_INDEX = -1
+ WINDOW_MIN_WIDTH = 980
+ WINDOW_MIN_HEIGHT = 620
+ LAYOUT_MARGIN = 24
+ LAYOUT_SPACING = 24
+ LAYOUT_FORM_STRETCH = 1
+ LAYOUT_TABLE_STRETCH = 2
+ LAYOUT_PREVIEW_STRETCH = 1
+ FORM_VERTICAL_SPACING = 14
+ FORM_HORIZONTAL_SPACING = 12
+ ACTION_LAYOUT_SPACING = 12
+ PREVIEW_LAYOUT_SPACING = 16
+ PREVIEW_MIN_SIZE = 420
+ PREVIEW_TOGGLE_WIDTH = 44
+ PREVIEW_TOGGLE_HEIGHT = 24
+ PREVIEW_TOGGLE_SPACING = 8
+ SEARCH_ROW_SPACING = 6
+ TABLE_MIN_WIDTH = 520
+ ACTION_BUTTON_MIN_WIDTH = 160
+ ACTION_BUTTON_MIN_HEIGHT = 36
+ TIMER_DELAY_MS = 0
+ DEFAULT_EXPORT_INDEX = 0
+ HEADER_TEXT_POINT_SIZE = 14
+ HEADER_TEXT_PADDING = 16
+ HEADER_TEXT_BASELINE_PADDING = 8
+ TABLE_COLUMN_PADDING = 24
+ TABLE_WIDTH_PADDING = 24
+ PASSWORD_MASK_MIN_LENGTH = 6
+ PASSWORD_TOGGLE_SIZE = 24
+ DEFAULT_QR_HEADER_COLOR = "#0b1220"
+ PAYLOAD_TEXT_STYLE = "color: #64748b; font-size: 11px;"
+ PREVIEW_DEBOUNCE_MS = 180
+
+ def __init__(self) -> None:
+ """Initialize the main window and UI state."""
+ super().__init__()
+ self.setWindowTitle("WifiQR")
+ self.setMinimumSize(self.WINDOW_MIN_WIDTH, self.WINDOW_MIN_HEIGHT)
+
+ self.form_group: QGroupBox
+ self.table_group: QGroupBox
+ self.preview_group: QGroupBox
+ self.location_input: QLineEdit
+ self.ssid_input: QLineEdit
+ self.password_input: QLineEdit
+ self.security_input: QComboBox
+ self.image_browse_button: QPushButton
+ self.image_path_display: QLineEdit
+ self.hidden_input: QCheckBox
+ self.show_header_input: QCheckBox
+ self.add_table_button: QPushButton
+ self.print_button: QPushButton
+ self.export_png_button: QPushButton
+ self.export_pdf_button: QPushButton
+ self.export_script_button: QPushButton
+ self.export_macos_button: QPushButton
+ self.batch_export_button: QPushButton
+ self.preview_toggle: QToolButton
+ self.preview_label: QLabel
+ self.payload_label: QLabel
+ self.search_input: QLineEdit
+ self.search_up_button: QToolButton
+ self.search_down_button: QToolButton
+ self.network_table: QTableWidget
+ self.delete_table_button: QPushButton
+
+ self._config = WifiConfig(
+ location="",
+ ssid="",
+ password="",
+ security="WPA",
+ hidden=False,
+ image_data=None,
+ )
+ self._current_image_filename: str = ""
+ self._current_pixmap: QPixmap | None = None
+ self._current_payload = ""
+ self._current_save_path: str | None = None
+ self._sort_orders: dict[int, Qt.SortOrder] = {}
+ self._search_matches: list[int] = []
+ self._search_index: int = self.NO_SEARCH_INDEX
+ self._preview_timer = QTimer(self)
+ self._preview_timer.setSingleShot(True)
+ self._preview_timer.timeout.connect(self._refresh_preview_now)
+ self._last_preview_size = QSize()
+ self._scaled_pixmap: QPixmap | None = None
+ self._header_metrics: QFontMetrics | None = None
+ self._item_metrics: QFontMetrics | None = None
+ self._last_header_state = False
+ self._last_location = ""
+ self._last_image_data: str | None = None
+
+ self._setup_ui()
+ self._apply_style()
+ self._refresh_preview()
+
+ def _setup_ui(self) -> None:
+ """Create and arrange all UI widgets."""
+ self._build_menus()
+ root = self._build_root_container()
+ self._build_form_group()
+ self._build_preview_group()
+ self._build_table_group()
+ self._finalize_layout(root)
+ self._connect_form_signals()
+
+ def _build_root_container(self) -> QWidget:
+ """Create the root widget and horizontal layout."""
+ root = QWidget()
+ layout = QHBoxLayout(root)
+ layout.setContentsMargins(
+ self.LAYOUT_MARGIN,
+ self.LAYOUT_MARGIN,
+ self.LAYOUT_MARGIN,
+ self.LAYOUT_MARGIN,
+ )
+ layout.setSpacing(self.LAYOUT_SPACING)
+ layout.setStretch(0, self.LAYOUT_FORM_STRETCH)
+ layout.setStretch(1, self.LAYOUT_TABLE_STRETCH)
+ layout.setStretch(2, self.LAYOUT_PREVIEW_STRETCH)
+ return root
+
+ def _build_form_group(self) -> None:
+ """Create the form group for Wi-Fi inputs and actions."""
+ self.form_group = QGroupBox("Network details")
+ self.form_group.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
+ form_layout = QFormLayout(self.form_group)
+ form_layout.setVerticalSpacing(self.FORM_VERTICAL_SPACING)
+ form_layout.setHorizontalSpacing(self.FORM_HORIZONTAL_SPACING)
+
+ self._build_form_inputs()
+ self._build_action_buttons()
+ # Add spacing before bottom row
+ form_layout.addRow("", QWidget())
+ self._build_bottom_row(form_layout)
+
+ def _build_form_inputs(self) -> None:
+ """Create form inputs for location, SSID, and security."""
+ self.location_input = QLineEdit()
+ self.location_input.setPlaceholderText("e.g. HQ West")
+ self.location_input.setToolTip("Location identifier for this network.")
+ self.location_input.setAccessibleName("Network Location")
+
+ self.ssid_input = QLineEdit()
+ self.ssid_input.setPlaceholderText("e.g. Office-Guest")
+ self.ssid_input.setToolTip("Network name (SSID). Case sensitive.")
+ self.ssid_input.setAccessibleName("Network SSID")
+
+ self.password_input = QLineEdit()
+ self.password_input.setPlaceholderText("Password")
+ self.password_input.setEchoMode(QLineEdit.EchoMode.Password)
+ self.password_input.setToolTip(
+ "Network password. Disabled for open networks."
+ )
+ self.password_input.setAccessibleName("Network Password")
+
+ self.security_input = QComboBox()
+ self.security_input.addItems(list(SECURITY_OPTIONS))
+ self.security_input.setMaxVisibleItems(3)
+ view = self.security_input.view()
+ palette = view.palette()
+ palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff"))
+ palette.setColor(QPalette.ColorRole.Window, QColor("#ffffff"))
+ view.setPalette(palette)
+ self.security_input.setToolTip("Select the network security type.")
+ self.security_input.setAccessibleName("Security Type")
+
+ self.image_path_display = QLineEdit()
+ self.image_path_display.setPlaceholderText("No image selected (optional)")
+ self.image_path_display.setReadOnly(True)
+ self.image_path_display.setToolTip(
+ "Selected center image (PNG, JPG, JPEG, BMP, GIF, SVG). SVG is converted to PNG."
+ )
+
+ self.image_browse_button = QPushButton("...", self.image_path_display)
+ self.image_browse_button.setToolTip(
+ "Select a center image for the QR code (PNG, JPG, JPEG, BMP, GIF, SVG)."
+ )
+ self.image_browse_button.setFixedSize(30, 24)
+ self.image_browse_button.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.image_browse_button.clicked.connect(self._browse_image)
+
+ # Position button inside text field on the right
+ self.image_path_display.setStyleSheet("QLineEdit { padding-right: 35px; }")
+
+ self.hidden_input = QCheckBox("Hidden network")
+ self.hidden_input.setToolTip("Enable if the SSID is hidden (non-broadcast).")
+ self.hidden_input.setTristate(False)
+ self.hidden_input.setAccessibleName("Hidden Network Toggle")
+
+ self.show_header_input = QCheckBox("Show location header")
+ self.show_header_input.setToolTip(
+ "Include location text above the QR code in preview, prints, and exports."
+ )
+ self.show_header_input.setTristate(False)
+ self.show_header_input.setChecked(True)
+ self.show_header_input.setAccessibleName("Show Header Toggle")
+
+ # Put checkboxes side by side
+ checkbox_row = QHBoxLayout()
+ checkbox_row.setSpacing(20)
+ checkbox_row.addWidget(self.hidden_input)
+ checkbox_row.addWidget(self.show_header_input)
+ checkbox_row.addStretch(1)
+
+ form_layout = self.form_group.layout()
+ assert isinstance(form_layout, QFormLayout)
+ form_layout.addRow("Location", self.location_input)
+ form_layout.addRow("SSID", self.ssid_input)
+ form_layout.addRow("Password", self.password_input)
+ form_layout.addRow("Security", self.security_input)
+ form_layout.addRow("Center Image", self.image_path_display)
+ form_layout.addRow(checkbox_row)
+
+ def _build_action_buttons(self) -> None:
+ """Create and place action buttons for exports and table actions."""
+ self._create_action_buttons()
+ self._add_action_layout()
+
+ def _create_action_buttons(self) -> None:
+ """Instantiate action buttons and assign handlers."""
+ self.add_table_button = QPushButton("Add to Table")
+ self.add_table_button.clicked.connect(self._add_to_table)
+ self._configure_action_button(
+ self.add_table_button,
+ "Add to Table",
+ "Add current network to the table.",
+ )
+
+ self.print_button = QPushButton("Print")
+ self.print_button.clicked.connect(self._print)
+ self._configure_action_button(
+ self.print_button,
+ "Print",
+ "Print the QR code with the optional location header.",
+ )
+
+ self.export_png_button = QPushButton("Export PNG")
+ self.export_png_button.clicked.connect(self._export_png)
+ self._configure_action_button(
+ self.export_png_button,
+ "Export PNG",
+ "Export the QR code as a PNG image with the optional location header.",
+ )
+
+ self.export_pdf_button = QPushButton("Export PDF")
+ self.export_pdf_button.clicked.connect(self._export_pdf)
+ self._configure_action_button(
+ self.export_pdf_button,
+ "Export PDF",
+ "Export the QR code as a PDF document with the optional location header.",
+ )
+
+ self.export_script_button = QPushButton("Export Windows Script")
+ self.export_script_button.clicked.connect(self._export_windows_script)
+ self._configure_action_button(
+ self.export_script_button,
+ "Export Windows Script",
+ "Export a Windows .cmd file that adds the profile and connects.",
+ )
+
+ self.export_macos_button = QPushButton("Export macOS Profile")
+ self.export_macos_button.clicked.connect(self._export_macos_profile)
+ self._configure_action_button(
+ self.export_macos_button,
+ "Export macOS Profile",
+ "Export a macOS .mobileconfig profile for one-click install.",
+ )
+
+ self.batch_export_button = QPushButton("Batch Export")
+ self.batch_export_button.clicked.connect(self._batch_export)
+ self._configure_action_button(
+ self.batch_export_button,
+ "Batch Export",
+ "Export selected networks in bulk (or the current network if none selected).",
+ )
+
+ self.delete_table_button = QPushButton("Delete Selected")
+ self.delete_table_button.clicked.connect(self._remove_selected)
+ self._configure_action_button(
+ self.delete_table_button,
+ "Delete Selected",
+ "Delete selected network(s) from the table.",
+ )
+
+ def _add_action_layout(self) -> None:
+ """Add the action buttons to the form layout."""
+ action_layout = QGridLayout()
+ action_layout.setHorizontalSpacing(self.ACTION_LAYOUT_SPACING)
+ action_layout.setVerticalSpacing(self.ACTION_LAYOUT_SPACING)
+ action_layout.setColumnStretch(0, 1)
+ action_layout.setColumnStretch(1, 1)
+ action_layout.setColumnStretch(2, 1)
+ action_layout.addWidget(self.print_button, 0, 0)
+ action_layout.addWidget(self.export_png_button, 0, 1)
+ action_layout.addWidget(self.export_pdf_button, 0, 2)
+ action_layout.addWidget(self.export_script_button, 1, 0)
+ action_layout.addWidget(self.export_macos_button, 1, 1)
+ action_layout.addWidget(self.batch_export_button, 1, 2)
+ action_layout.addWidget(self.add_table_button, 2, 0)
+
+ form_layout = self.form_group.layout()
+ if isinstance(form_layout, QFormLayout):
+ form_layout.addRow(action_layout)
+
+ def _build_bottom_row(self, form_layout: QFormLayout) -> None:
+ """Create bottom row with delete button and preview toggle."""
+ self.preview_toggle = QToolButton()
+ self.preview_toggle.setCheckable(True)
+ self.preview_toggle.setChecked(True)
+ self.preview_toggle.setToolTip("Show or hide the preview panel.")
+ self.preview_toggle.setObjectName("PreviewToggle")
+ self.preview_toggle.setFixedSize(
+ self.PREVIEW_TOGGLE_WIDTH,
+ self.PREVIEW_TOGGLE_HEIGHT,
+ )
+ self.preview_toggle.setIconSize(self.preview_toggle.size())
+ self.preview_toggle.toggled.connect(self._toggle_preview_panel)
+ self._update_preview_toggle_icon()
+
+ bottom_row = QHBoxLayout()
+ bottom_row.addWidget(QLabel("Show preview"))
+ bottom_row.addSpacing(self.PREVIEW_TOGGLE_SPACING)
+ bottom_row.addWidget(self.preview_toggle)
+ bottom_row.addStretch(1)
+ bottom_row.addWidget(self.delete_table_button)
+ form_layout.addRow(bottom_row)
+
+ def _build_preview_group(self) -> None:
+ """Create the preview group for QR output."""
+ self.preview_group = QGroupBox("Preview")
+ self.preview_group.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
+ preview_layout = QVBoxLayout(self.preview_group)
+ preview_layout.setSpacing(self.PREVIEW_LAYOUT_SPACING)
+
+ self.preview_label = QLabel("Enter network details to generate a QR code")
+ self.preview_label.setObjectName("PreviewLabel")
+ self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.preview_label.setMinimumSize(
+ self.PREVIEW_MIN_SIZE,
+ self.PREVIEW_MIN_SIZE,
+ )
+
+ self.payload_label = QLabel("")
+ self.payload_label.setWordWrap(True)
+ self.payload_label.setTextInteractionFlags(
+ Qt.TextInteractionFlag.TextSelectableByMouse
+ )
+ self.payload_label.setStyleSheet(self.PAYLOAD_TEXT_STYLE)
+
+ preview_layout.addWidget(self.preview_label, stretch=1)
+ preview_layout.addWidget(self.payload_label)
+
+ def _build_table_group(self) -> None:
+ """Create the saved networks table group."""
+ self.table_group = QGroupBox("Saved networks")
+ self.table_group.setMinimumWidth(self.TABLE_MIN_WIDTH)
+ self.table_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+ table_layout = QVBoxLayout(self.table_group)
+ table_layout.setSpacing(self.ACTION_LAYOUT_SPACING)
+
+ search_row = self._build_table_search_row()
+ self._build_network_table()
+
+ table_layout.addLayout(search_row)
+ table_layout.addWidget(self.network_table)
+
+ self._update_button_labels()
+ self._apply_table_column_widths()
+
+ def _build_table_search_row(self) -> QHBoxLayout:
+ """Create the search row with navigation controls."""
+ self.search_input = QLineEdit()
+ self.search_input.setPlaceholderText("Search by location or SSID")
+ self.search_input.setToolTip("Filter saved networks by location or SSID.")
+ self.search_input.textChanged.connect(self._apply_search_filter)
+
+ search_row = QHBoxLayout()
+ search_row.setSpacing(self.SEARCH_ROW_SPACING)
+
+ self.search_up_button = QToolButton()
+ self.search_up_button.setToolTip("Find previous")
+ self.search_up_button.clicked.connect(self._find_previous)
+
+ self.search_down_button = QToolButton()
+ self.search_down_button.setToolTip("Find next")
+ self.search_down_button.clicked.connect(self._find_next)
+
+ up_icon = RESOURCE_DIR / "chevron_up.svg"
+ down_icon = RESOURCE_DIR / "chevron_down.svg"
+ self._set_icon_if_exists(self.search_up_button, up_icon)
+ self._set_icon_if_exists(self.search_down_button, down_icon)
+
+ search_row.addWidget(self.search_input)
+ search_row.addWidget(self.search_up_button)
+ search_row.addWidget(self.search_down_button)
+ return search_row
+
+ def _build_network_table(self) -> None:
+ """Create the network table widget and connect events."""
+ self.network_table = QTableWidget(self.INITIAL_TABLE_ROWS, self.TABLE_COLUMN_COUNT)
+ self.network_table.setHorizontalHeaderLabels(list(self.TABLE_HEADER_LABELS))
+
+ # Center all header labels
+ for i in range(self.TABLE_COLUMN_COUNT):
+ header_item = self.network_table.horizontalHeaderItem(i)
+ if header_item:
+ header_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
+
+ header = self.network_table.horizontalHeader()
+
+ # Calculate minimum widths based on header labels
+ font_metrics = QFontMetrics(header.font())
+ min_widths = {}
+ for i, label in enumerate(self.TABLE_HEADER_LABELS):
+ min_widths[i] = font_metrics.horizontalAdvance(label) + 30
+
+ # Set resize modes: Interactive for resizable columns, Fixed for pinned columns
+ header.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive) # Location
+ header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) # SSID
+ header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # Password
+ header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) # Security (pinned)
+ header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) # Hidden (pinned)
+ header.setStretchLastSection(False)
+ header.setDefaultAlignment(Qt.AlignmentFlag.AlignCenter) # Center all header labels
+
+ # Set minimum widths for all columns
+ for _col, min_width in min_widths.items():
+ header.setMinimumSectionSize(min_width)
+
+ # Set initial column widths
+ self.network_table.setColumnWidth(0, max(150, min_widths[0]))
+ self.network_table.setColumnWidth(1, max(200, min_widths[1]))
+ self.network_table.setColumnWidth(2, max(200, min_widths[2]))
+ self.network_table.setColumnWidth(3, 100) # Security - uniform width
+ self.network_table.setColumnWidth(4, 100) # Hidden - uniform width
+
+ # Connect to handle gap prevention
+ header.sectionResized.connect(self._handle_column_resize)
+
+ self.network_table.verticalHeader().setDefaultSectionSize(45)
+ self.network_table.setSelectionBehavior(
+ QAbstractItemView.SelectionBehavior.SelectRows
+ )
+ self.network_table.setEditTriggers(
+ QAbstractItemView.EditTrigger.DoubleClicked
+ )
+ self.network_table.setSortingEnabled(False)
+ self.network_table.setHorizontalScrollBarPolicy(
+ Qt.ScrollBarPolicy.ScrollBarAsNeeded
+ )
+ self.network_table.setHorizontalScrollMode(
+ QAbstractItemView.ScrollMode.ScrollPerPixel
+ )
+ self.network_table.setVerticalScrollMode(
+ QAbstractItemView.ScrollMode.ScrollPerPixel
+ )
+ self.network_table.horizontalScrollBar().setSingleStep(10)
+ self.network_table.horizontalHeader().sectionClicked.connect(
+ self._handle_sort
+ )
+ self.network_table.itemChanged.connect(self._table_item_changed)
+ self.network_table.setContextMenuPolicy(
+ Qt.ContextMenuPolicy.CustomContextMenu
+ )
+ self.network_table.customContextMenuRequested.connect(
+ self._table_context_menu
+ )
+ self.network_table.doubleClicked.connect(self._table_double_clicked)
+
+ def _finalize_layout(self, root: QWidget) -> None:
+ """Attach groups to the root layout and set the central widget."""
+ layout = root.layout()
+ assert isinstance(layout, QHBoxLayout)
+ layout.addWidget(self.form_group, self.LAYOUT_FORM_STRETCH)
+ layout.addWidget(self.table_group, self.LAYOUT_TABLE_STRETCH)
+ layout.addWidget(self.preview_group, self.LAYOUT_PREVIEW_STRETCH)
+ self.setCentralWidget(root)
+
+ def _connect_form_signals(self) -> None:
+ """Wire input changes to preview refresh handlers."""
+ self.ssid_input.textChanged.connect(self._refresh_preview)
+ self.location_input.textChanged.connect(self._refresh_preview)
+ self.password_input.textChanged.connect(self._refresh_preview)
+ self.security_input.currentTextChanged.connect(self._on_security_changed)
+ self.hidden_input.stateChanged.connect(self._refresh_preview)
+ self.show_header_input.stateChanged.connect(self._refresh_preview)
+ self._update_password_state()
+
+ def _apply_style(self) -> None:
+ """Apply the application stylesheet if available."""
+ style_path = RESOURCE_DIR / "style.qss"
+ if style_path.exists():
+ self.setStyleSheet(style_path.read_text(encoding="utf-8"))
+
+ def _configure_action_button(self, button: QPushButton, label: str, tooltip: str) -> None:
+ """Standardize action button styling and tooltip text."""
+ button.setToolTip(tooltip)
+ button.setProperty("fullText", label)
+ button.setMinimumWidth(self.ACTION_BUTTON_MIN_WIDTH)
+ button.setMinimumHeight(self.ACTION_BUTTON_MIN_HEIGHT)
+ button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+
+ def _set_icon_if_exists(self, widget: QPushButton | QToolButton, icon_path: Path) -> None:
+ """Assign an icon when the resource exists."""
+ if icon_path.exists():
+ widget.setIcon(QIcon(str(icon_path)))
+
+ def _build_menus(self) -> None:
+ """Create the menu bar actions."""
+ file_menu = self.menuBar().addMenu("File")
+
+ save_action = QAction("Save", self)
+ save_action.setShortcut(QKeySequence.StandardKey.Save)
+ save_action.triggered.connect(self._save)
+
+ save_as_action = QAction("Save As...", self)
+ save_as_action.setShortcut(QKeySequence.StandardKey.SaveAs)
+ save_as_action.triggered.connect(self._save_as)
+
+ load_action = QAction("Load...", self)
+ load_action.setShortcut(QKeySequence.StandardKey.Open)
+ load_action.triggered.connect(self._load)
+
+ exit_action = QAction("Exit", self)
+ exit_action.setShortcut(QKeySequence.StandardKey.Quit)
+ exit_action.triggered.connect(self.close)
+
+ file_menu.addAction(save_action)
+ file_menu.addAction(save_as_action)
+ file_menu.addAction(load_action)
+ file_menu.addSeparator()
+ file_menu.addAction(exit_action)
+
+ edit_menu = self.menuBar().addMenu("Edit")
+
+ add_action = QAction("Add to Table", self)
+ add_action.setShortcut(QKeySequence("Ctrl+Return"))
+ add_action.triggered.connect(self._add_to_table)
+
+ delete_action = QAction("Delete Selected", self)
+ delete_action.setShortcut(QKeySequence.StandardKey.Delete)
+ delete_action.triggered.connect(self._remove_selected)
+
+ find_action = QAction("Find...", self)
+ find_action.setShortcut(QKeySequence.StandardKey.Find)
+ find_action.triggered.connect(self._focus_search)
+
+ edit_menu.addAction(add_action)
+ edit_menu.addAction(delete_action)
+ edit_menu.addSeparator()
+ edit_menu.addAction(find_action)
+
+ help_menu = self.menuBar().addMenu("Help")
+ about_action = QAction("About", self)
+ about_action.triggered.connect(self._show_about)
+ help_menu.addAction(about_action)
+
+ def _browse_image(self) -> None:
+ """Open file dialog to select a center image for the QR code."""
+ import base64
+ import io
+ import os
+
+ path, _ = QFileDialog.getOpenFileName(
+ self,
+ "Select Center Image",
+ "",
+ "Image Files (*.png *.jpg *.jpeg *.bmp *.gif *.svg)",
+ )
+ if not path:
+ return
+
+ try:
+ # Read and validate image bytes
+ with open(path, "rb") as f:
+ image_bytes = f.read()
+ _, ext = os.path.splitext(path)
+ if ext.lower() == ".svg":
+ import cairosvg # type: ignore[import-untyped]
+
+ image_bytes = cairosvg.svg2png(bytestring=image_bytes)
+ if not isinstance(image_bytes, (bytes, bytearray, memoryview)):
+ raise ValueError("Image data is empty or invalid.")
+ image_bytes = bytes(image_bytes)
+ with Image.open(io.BytesIO(image_bytes)) as image:
+ image.verify()
+ image_base64 = base64.b64encode(image_bytes).decode("utf-8")
+ except Exception as exc:
+ QMessageBox.warning(self, "Image Load Failed", f"Could not load image: {exc}")
+ return
+
+ # Update UI and config
+ self.image_path_display.setText(path)
+ self._config = replace(self._config, image_data=image_base64)
+ self._refresh_preview()
+
+ def _update_config(self) -> None:
+ """Sync form input values into the current config."""
+ self._config = replace(
+ self._config,
+ location=self.location_input.text().strip(),
+ ssid=self.ssid_input.text().strip(),
+ password=self.password_input.text(),
+ security=self.security_input.currentText(),
+ hidden=self.hidden_input.isChecked(),
+ # Preserve image_data from current config
+ )
+
+ @Slot()
+ def _refresh_preview(self) -> None:
+ """Debounce QR preview updates for smoother typing."""
+ self._preview_timer.start(self.PREVIEW_DEBOUNCE_MS)
+
+ def _refresh_preview_now(self) -> None:
+ """Recompute QR preview and payload text."""
+ self._update_config()
+
+ if not self._config.ssid:
+ self._current_pixmap = None
+ self._current_payload = ""
+ self._scaled_pixmap = None
+ self._last_preview_size = QSize()
+ self._last_image_data = None
+ self.preview_label.clear()
+ self.preview_label.setText("Enter network details to generate a QR code")
+ self.payload_label.setText("")
+ self._toggle_actions(False)
+ return
+
+ payload = build_wifi_payload(self._config)
+ header_state = self.show_header_input.isChecked()
+ current_location = self._config.location
+
+ # Regenerate if payload, header state, or location changed
+ should_regenerate = (
+ payload != self._current_payload or
+ header_state != self._last_header_state or
+ current_location != self._last_location or
+ self._config.image_data != self._last_image_data or
+ self._current_pixmap is None
+ )
+
+ if should_regenerate:
+ self._current_payload = payload
+ self._last_header_state = header_state
+ self._last_location = current_location
+ self._last_image_data = self._config.image_data
+ try:
+ image = generate_qr_image(
+ self._current_payload, center_image_data=self._config.image_data
+ )
+ # Add header to preview if enabled and location exists
+ if header_state and current_location:
+ image = self._compose_qr_with_header(image, current_location)
+ self._current_pixmap = pil_to_qpixmap(image)
+ self._scaled_pixmap = None # Clear cache to force rescale
+ except Exception as exc:
+ QMessageBox.critical(self, "QR generation failed", str(exc))
+ self._toggle_actions(False)
+ return
+
+ self._update_scaled_preview()
+ self.preview_label.setText("")
+ self.preview_label.update()
+ self.payload_label.setText(self._current_payload)
+ self._toggle_actions(True)
+
+ def _toggle_actions(self, enabled: bool) -> None:
+ """Enable or disable action buttons based on QR state."""
+ self.print_button.setEnabled(enabled)
+ self.export_png_button.setEnabled(enabled)
+ self.export_pdf_button.setEnabled(enabled)
+ self.export_script_button.setEnabled(enabled)
+ self.export_macos_button.setEnabled(enabled)
+ self.batch_export_button.setEnabled(enabled)
+
+ @override
+ def resizeEvent(self, event: QResizeEvent) -> None:
+ """Handle resize events for responsive preview scaling."""
+ super().resizeEvent(event)
+ if self._current_pixmap:
+ self._update_scaled_preview()
+ self._update_button_labels()
+ self._position_image_button()
+
+ @override
+ def showEvent(self, event: QShowEvent) -> None:
+ """Finalize layout sizing when the window is shown."""
+ super().showEvent(event)
+ QTimer.singleShot(self.TIMER_DELAY_MS, self._update_button_labels)
+ QTimer.singleShot(self.TIMER_DELAY_MS, self._apply_panel_minimums)
+ QTimer.singleShot(self.TIMER_DELAY_MS, self._lock_window_size)
+ QTimer.singleShot(self.TIMER_DELAY_MS, self._position_image_button)
+
+ def _position_image_button(self) -> None:
+ """Position the browse button inside the image path text field."""
+ text_field_height = self.image_path_display.height()
+ button_height = self.image_browse_button.height()
+ y_pos = (text_field_height - button_height) // 2
+ x_pos = self.image_path_display.width() - self.image_browse_button.width() - 5
+ self.image_browse_button.move(x_pos, y_pos)
+
+ def _lock_window_size(self) -> None:
+ """Lock the window size to the layout's size hint."""
+ self.setMinimumSize(0, 0)
+ self.setMaximumSize(self.MAX_WINDOW_SIZE, self.MAX_WINDOW_SIZE)
+ central = self.centralWidget()
+ layout = central.layout() if central else None
+ if layout:
+ layout.activate()
+ layout.update()
+
+ # Force proper size calculation
+ central.adjustSize()
+ self.adjustSize()
+
+ target = self.sizeHint()
+ self.resize(target)
+ self.setMinimumSize(target)
+ self.setMaximumSize(target)
+
+ def _lock_window_width_only(self, height: int) -> None:
+ """Lock the window width while maintaining the given height."""
+ self.setMinimumSize(0, 0)
+ self.setMaximumSize(self.MAX_WINDOW_SIZE, self.MAX_WINDOW_SIZE)
+ central = self.centralWidget()
+ layout = central.layout() if central else None
+ if layout:
+ layout.activate()
+ target_width = central.sizeHint().width() if central else self.sizeHint().width()
+ self.resize(target_width, height)
+ self.setMinimumSize(target_width, height)
+ self.setMaximumSize(target_width, height)
+
+ def _update_preview_toggle_icon(self) -> None:
+ """Update the preview toggle icon to match its state."""
+ on_icon = RESOURCE_DIR / "toggle_on.svg"
+ off_icon = RESOURCE_DIR / "toggle_off.svg"
+ if self.preview_toggle.isChecked() and on_icon.exists():
+ self.preview_toggle.setIcon(QIcon(str(on_icon)))
+ elif not self.preview_toggle.isChecked() and off_icon.exists():
+ self.preview_toggle.setIcon(QIcon(str(off_icon)))
+
+ def _toggle_preview_panel(self, checked: bool) -> None:
+ """Show or hide the preview panel."""
+ # Save current window height - it should never change
+ current_height = self.height()
+
+ self.preview_group.setVisible(checked)
+ self._update_preview_toggle_icon()
+
+ # Allow layout to recalculate width, then lock it
+ QTimer.singleShot(0, lambda: self._lock_window_width_only(current_height))
+
+ def _update_scaled_preview(self) -> None:
+ """Scale and apply the QR preview pixmap only when needed."""
+ if not self._current_pixmap:
+ self.preview_label.clear()
+ return
+
+ size = self.preview_label.size()
+
+ # Skip rescaling if size difference is minimal (< PREVIEW_RESIZE_THRESHOLD px)
+ if self._last_preview_size.isValid() and self._scaled_pixmap:
+ w_diff = abs(size.width() - self._last_preview_size.width())
+ h_diff = abs(size.height() - self._last_preview_size.height())
+ if w_diff < PREVIEW_RESIZE_THRESHOLD and h_diff < PREVIEW_RESIZE_THRESHOLD:
+ self.preview_label.setPixmap(self._scaled_pixmap)
+ return
+
+ self._last_preview_size = size
+ self._scaled_pixmap = self._current_pixmap.scaled(
+ size,
+ Qt.AspectRatioMode.KeepAspectRatio,
+ Qt.TransformationMode.SmoothTransformation,
+ )
+ self.preview_label.setPixmap(self._scaled_pixmap)
+
+ def _on_security_changed(self, value: str) -> None:
+ """Update password state and refresh preview on security changes."""
+ self._update_password_state(value)
+ self._refresh_preview()
+
+ def _update_password_state(self, security_value: str | None = None) -> None:
+ """Enable or disable password input based on security type."""
+ security_value = security_value or self.security_input.currentText()
+ is_open = is_open_security(security_value)
+ self.password_input.setEnabled(not is_open)
+ if is_open:
+ self.password_input.setPlaceholderText("Not required for open networks")
+ else:
+ self.password_input.setPlaceholderText("Password")
+
+ def _apply_panel_minimums(self) -> None:
+ """Clamp group minimum sizes to their size hints."""
+ # First get max height across all visible panels
+ max_height = 0
+ for group in (self.form_group, self.table_group, self.preview_group):
+ if group.isVisible():
+ max_height = max(max_height, group.sizeHint().height())
+
+ # Now apply sizes
+ for group in (self.form_group, self.table_group, self.preview_group):
+ if not group.isVisible():
+ group.setMinimumSize(0, 0)
+ continue
+ size = group.sizeHint()
+ group.setMinimumSize(size.width(), max_height)
+ group.setMaximumHeight(max_height)
+ group.updateGeometry()
+ self._update_table_group_width()
+
+ def _update_button_labels(self) -> None:
+ """Normalize action button labels and widths."""
+ buttons = (
+ self.print_button,
+ self.export_png_button,
+ self.export_pdf_button,
+ self.export_script_button,
+ self.export_macos_button,
+ self.batch_export_button,
+ self.add_table_button,
+ self.delete_table_button,
+ )
+ for button in buttons:
+ full_text = button.property("fullText") or button.text()
+ base_size = button.property("basePointSize")
+ if not base_size:
+ base_size = button.font().pointSizeF()
+ button.setProperty("basePointSize", base_size)
+ font = QFont(button.font())
+ font.setPointSizeF(float(base_size))
+ button.setFont(font)
+ button.setText(str(full_text))
+
+ max_width = 0
+ for button in buttons:
+ max_width = max(max_width, button.sizeHint().width())
+
+ for button in buttons:
+ button.setMinimumWidth(max_width)
+
+ def _render_pixmap(self, printer: QPrinter) -> None:
+ """Render the current QR pixmap to a printer device."""
+ if not self._current_pixmap:
+ return
+ painter = QPainter(printer)
+ try:
+ rect = painter.viewport()
+ size = self._current_pixmap.size()
+ size.scale(rect.size(), Qt.AspectRatioMode.KeepAspectRatio)
+ painter.setViewport(rect.x(), rect.y(), size.width(), size.height())
+ painter.setWindow(self._current_pixmap.rect())
+ painter.drawPixmap(0, 0, self._current_pixmap)
+ finally:
+ painter.end()
+
+ @Slot()
+ def _print(self) -> None:
+ """Open the print dialog and print the QR preview."""
+ if not self._current_pixmap:
+ return
+ printer = QPrinter(QPrinter.PrinterMode.HighResolution)
+ dialog = QPrintDialog(printer, self)
+ if dialog.exec() == QPrintDialog.DialogCode.Accepted:
+ self._render_pixmap(printer)
+
+ @Slot()
+ def _export_png(self) -> None:
+ """Export the current QR as a PNG file."""
+ if not self._current_pixmap:
+ return
+ path, _ = QFileDialog.getSaveFileName(
+ self,
+ "Export PNG",
+ "wifi-qr.png",
+ "PNG Image (*.png)",
+ )
+ if not path:
+ return
+ try:
+ self._export_png_to_path(self._config, path)
+ except Exception as exc:
+ QMessageBox.critical(self, "Export failed", str(exc))
+
+ @Slot()
+ def _export_pdf(self) -> None:
+ """Export the current QR as a PDF file."""
+ if not self._current_pixmap:
+ return
+ path, _ = QFileDialog.getSaveFileName(
+ self,
+ "Export PDF",
+ "wifi-qr.pdf",
+ "PDF Document (*.pdf)",
+ )
+ if not path:
+ return
+ try:
+ printer = QPrinter(QPrinter.PrinterMode.HighResolution)
+ printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat)
+ printer.setOutputFileName(path)
+ self._export_pdf_to_path(self._config, printer)
+ except Exception as exc:
+ QMessageBox.critical(self, "Export failed", str(exc))
+
+ @Slot()
+ def _export_windows_script(self) -> None:
+ """Export a Windows connect script for the current config."""
+ if not self._current_payload:
+ return
+ path, _ = QFileDialog.getSaveFileName(
+ self,
+ "Export Windows Script",
+ "connect-wifi.cmd",
+ "Command Script (*.cmd)",
+ )
+ if not path:
+ return
+ try:
+ self._export_windows_script_to_path(self._config, path)
+ QMessageBox.information(
+ self,
+ "Export complete",
+ "Script exported. Run it as Administrator on Windows.",
+ )
+ except Exception as exc:
+ QMessageBox.critical(self, "Export failed", str(exc))
+
+ @Slot()
+ def _export_macos_profile(self) -> None:
+ """Export a macOS configuration profile for the current config."""
+ if not self._current_payload:
+ return
+ path, _ = QFileDialog.getSaveFileName(
+ self,
+ "Export macOS Profile",
+ "wifi-profile.mobileconfig",
+ "Configuration Profile (*.mobileconfig)",
+ )
+ if not path:
+ return
+ try:
+ self._export_macos_profile_to_path(self._config, path)
+ QMessageBox.information(
+ self,
+ "Export complete",
+ "Profile exported. Double-click on macOS to install.",
+ )
+ except Exception as exc:
+ QMessageBox.critical(self, "Export failed", str(exc))
+
+ def _export_png_to_path(self, config: WifiConfig, path: str) -> None:
+ """Write a QR PNG for a config to the target path."""
+ payload = build_wifi_payload(config)
+ image = generate_qr_image(payload, center_image_data=config.image_data)
+ if self.show_header_input.isChecked():
+ image = self._compose_qr_with_header(image, config.location)
+ save_qr_image(image, path)
+
+ def _export_pdf_to_path(self, config: WifiConfig, printer: QPrinter) -> None:
+ """Write a QR PDF for a config to the target printer."""
+ payload = build_wifi_payload(config)
+ image = generate_qr_image(payload, center_image_data=config.image_data)
+ pixmap = pil_to_qpixmap(image)
+ painter = QPainter(printer)
+ try:
+ rect = painter.viewport()
+ header_text = config.location.strip() if self.show_header_input.isChecked() else ""
+ header_height = 0
+ if header_text:
+ font = QFont(painter.font())
+ font.setPointSize(self.HEADER_TEXT_POINT_SIZE)
+ painter.setFont(font)
+ metrics = QFontMetrics(font)
+ header_height = metrics.height() + self.HEADER_TEXT_PADDING
+ painter.drawText(
+ rect.x(),
+ rect.y() + metrics.ascent() + self.HEADER_TEXT_BASELINE_PADDING,
+ rect.width(),
+ metrics.height(),
+ Qt.AlignmentFlag.AlignHCenter,
+ header_text,
+ )
+ size = pixmap.size()
+ available = rect.size()
+ available.setHeight(max(1, available.height() - header_height))
+ size.scale(available, Qt.AspectRatioMode.KeepAspectRatio)
+ painter.setViewport(rect.x(), rect.y() + header_height, size.width(), size.height())
+ painter.setWindow(pixmap.rect())
+ painter.drawPixmap(0, 0, pixmap)
+ finally:
+ painter.end()
+
+ def _compose_qr_with_header(self, image: Image.Image, header: str) -> Image.Image:
+ """Return a new image with a location header added."""
+ header = header.strip()
+ if not header:
+ return image
+
+ # Use a larger font for title-sized header
+ font = ImageFont.load_default(size=48)
+
+ draw = ImageDraw.Draw(image)
+ bbox = draw.textbbox((0, 0), header, font=font)
+ text_width = bbox[2] - bbox[0]
+ text_height = bbox[3] - bbox[1]
+ padding = self.HEADER_TEXT_PADDING * 2
+ new_width = int(image.width)
+ new_height = int(image.height + text_height + padding * 2)
+ new_image = Image.new("RGB", (new_width, new_height), "white")
+ y_offset = int(text_height + padding * 2)
+ new_image.paste(image, (0, y_offset))
+ draw = ImageDraw.Draw(new_image)
+ x = (new_width - text_width) // 2
+ y = padding
+ draw.text((x, y), header, fill=self.DEFAULT_QR_HEADER_COLOR, font=font)
+ return new_image
+
+ def _export_windows_script_to_path(self, config: WifiConfig, path: str) -> None:
+ """Write a Windows connect script to disk."""
+ script = build_windows_connect_script(config)
+ with open(path, "w", encoding="utf-8") as file:
+ file.write(script.content)
+
+ def _export_macos_profile_to_path(self, config: WifiConfig, path: str) -> None:
+ """Write a macOS profile to disk."""
+ profile = build_macos_mobileconfig(config)
+ with open(path, "w", encoding="utf-8") as file:
+ file.write(profile.content)
+
+ def _batch_export(self) -> None:
+ """Batch export selected or all table entries."""
+ configs = self._selected_or_all_configs()
+ if not configs:
+ if self._config.ssid:
+ configs = [self._config]
+ else:
+ return
+
+ export_type, ok = QInputDialog.getItem(
+ self,
+ "Batch Export",
+ "Choose export type:",
+ ["PNG", "PDF", "Windows Script", "macOS Profile"],
+ self.DEFAULT_EXPORT_INDEX,
+ False,
+ )
+ if not ok:
+ return
+
+ target_dir = QFileDialog.getExistingDirectory(self, "Select export folder")
+ if not target_dir:
+ return
+
+ target_dir_path = Path(target_dir)
+
+ if export_type == "Windows Script":
+ script_path = target_dir_path / "wifi-batch.cmd"
+ script = build_windows_connect_script_multi(configs)
+ with open(script_path, "w", encoding="utf-8") as file:
+ file.write(script.content)
+ return
+ if export_type == "macOS Profile":
+ profile_path = target_dir_path / "wifi-batch.mobileconfig"
+ profile = build_macos_mobileconfig_multi(configs)
+ with open(profile_path, "w", encoding="utf-8") as file:
+ file.write(profile.content)
+ return
+
+ for config in configs:
+ safe_name = self._sanitize_filename(config.ssid)
+ if export_type == "PNG":
+ self._export_png_to_path(config, str(target_dir_path / f"{safe_name}.png"))
+ elif export_type == "PDF":
+ printer = QPrinter(QPrinter.PrinterMode.HighResolution)
+ printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat)
+ printer.setOutputFileName(str(target_dir_path / f"{safe_name}.pdf"))
+ self._export_pdf_to_path(config, printer)
+
+ def _add_to_table(self) -> None:
+ """Add the current config to the table."""
+ self._update_config()
+ if not self._config.ssid:
+ return
+ self._add_or_update_row(self._config)
+
+ def _remove_selected(self) -> None:
+ """Remove selected rows from the table."""
+ rows = sorted(self._selected_rows(), reverse=True)
+ for row in rows:
+ self.network_table.removeRow(row)
+ self._apply_table_column_widths()
+
+ def _add_or_update_row(self, config: WifiConfig, row: int | None = None) -> None:
+ """Insert or update a table row for a config."""
+ if row is None:
+ row = self.network_table.rowCount()
+ self.network_table.insertRow(row)
+
+ self.network_table.blockSignals(True)
+ location_item = QTableWidgetItem(config.location)
+ location_item.setFlags(location_item.flags() | Qt.ItemFlag.ItemIsEditable)
+ # Store image_data in the location item's UserRole
+ location_item.setData(Qt.ItemDataRole.UserRole, config.image_data)
+
+ ssid_item = QTableWidgetItem(config.ssid)
+ ssid_item.setFlags(ssid_item.flags() | Qt.ItemFlag.ItemIsEditable)
+
+ password_item = QTableWidgetItem("")
+ password_item.setFlags(password_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
+ password_item.setData(Qt.ItemDataRole.UserRole, config.password)
+
+ self.network_table.setItem(row, 0, location_item)
+ self.network_table.setItem(row, 1, ssid_item)
+ self.network_table.setItem(row, 2, password_item)
+
+ password_widget = self._build_password_widget(row, config.password)
+ self.network_table.setCellWidget(row, 2, password_widget)
+
+ security_combo = QComboBox()
+ security_combo.addItems(list(SECURITY_OPTIONS))
+ security_combo.setCurrentText(config.security)
+ security_combo.setMaxVisibleItems(3)
+ view = security_combo.view()
+ palette = view.palette()
+ palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff"))
+ palette.setColor(QPalette.ColorRole.Window, QColor("#ffffff"))
+ view.setPalette(palette)
+ security_combo.currentTextChanged.connect(
+ lambda value, r=row: self._security_changed(r, value)
+ )
+ self.network_table.setCellWidget(row, 3, security_combo)
+
+ hidden_checkbox = QCheckBox()
+ hidden_checkbox.setChecked(config.hidden)
+ hidden_checkbox.stateChanged.connect(
+ lambda state, r=row: self._hidden_changed(r, state)
+ )
+ hidden_container = QWidget()
+ hidden_layout = QHBoxLayout(hidden_container)
+ hidden_layout.addWidget(hidden_checkbox)
+ hidden_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ hidden_layout.setContentsMargins(0, 0, 0, 0)
+ self.network_table.setCellWidget(row, 4, hidden_container)
+
+ self.network_table.blockSignals(False)
+ self._apply_table_column_widths()
+
+ def _table_item_changed(self, item: QTableWidgetItem) -> None:
+ """Normalize table item values and adjust widths."""
+ if item.column() == 0 and not item.text().strip():
+ item.setText("Unnamed")
+ if item.column() == 1 and not item.text().strip():
+ item.setText("Unnamed")
+ if item.column() == 3 and not item.text().strip():
+ item.setText(SECURITY_OPTIONS[0])
+ if item.column() in {1, 2}:
+ self._apply_table_column_widths()
+
+ def _view_password(self, row: int) -> None:
+ """Prompt for a new password for a table row."""
+ password_item = self.network_table.item(row, 2)
+ if not password_item:
+ return
+ current = password_item.data(Qt.ItemDataRole.UserRole) or ""
+ value, ok = QInputDialog.getText(
+ self,
+ "Password",
+ "Edit password:",
+ QLineEdit.EchoMode.Password,
+ str(current),
+ )
+ if not ok:
+ return
+ password_item.setData(Qt.ItemDataRole.UserRole, value)
+ self._update_password_widget(row, value, force_visible=False)
+
+ def _obfuscate_password(self, value: str) -> str:
+ """Return an obfuscated display string for a password."""
+ if not value:
+ return ""
+ return "•" * max(self.PASSWORD_MASK_MIN_LENGTH, len(value))
+
+ def _build_password_widget(self, row: int, password: str) -> QWidget:
+ """Build a password display widget with a visibility toggle."""
+ container = QWidget()
+ layout = QHBoxLayout(container)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(6)
+
+ label = QLabel(self._obfuscate_password(password))
+ label.setProperty("isVisible", False)
+
+ icon_path = RESOURCE_DIR / "eye.svg"
+ button = QPushButton()
+ self._set_icon_if_exists(button, icon_path)
+ button.setToolTip("Toggle password visibility")
+ button.setFixedSize(self.PASSWORD_TOGGLE_SIZE, self.PASSWORD_TOGGLE_SIZE)
+ button.clicked.connect(lambda *_: self._toggle_password_visibility(row))
+
+ layout.addWidget(label)
+ layout.addStretch(1)
+ layout.addWidget(button)
+ return container
+
+ def _update_password_widget(
+ self,
+ row: int,
+ password: str,
+ force_visible: bool | None = None,
+ ) -> None:
+ """Update the password widget contents for a row."""
+ widget = self.network_table.cellWidget(row, 2)
+ if not widget:
+ return
+ label = widget.findChild(QLabel)
+ if not label:
+ return
+ is_visible = bool(label.property("isVisible"))
+ if force_visible is not None:
+ is_visible = force_visible
+ label.setProperty("isVisible", is_visible)
+ label.setText(password if is_visible else self._obfuscate_password(password))
+ self._apply_table_column_widths()
+
+ def _apply_table_column_widths(self) -> None:
+ """Calculate and apply column widths for the table."""
+ # Cache font metrics to avoid repeated calculations
+ if not self._header_metrics:
+ self._header_metrics = QFontMetrics(
+ self.network_table.horizontalHeader().font()
+ )
+ if not self._item_metrics:
+ self._item_metrics = QFontMetrics(self.network_table.font())
+
+ standard_ssid_width = self._item_metrics.horizontalAdvance(
+ "M" * self.STANDARD_SSID_CHARS
+ )
+ standard_pwd_width = self._item_metrics.horizontalAdvance(
+ "M" * self.STANDARD_PASSWORD_CHARS
+ )
+
+ for column in range(self.network_table.columnCount()):
+ if column in (3, 4): # Skip Security, Hidden columns
+ continue
+
+ header_item = self.network_table.horizontalHeaderItem(column)
+ header_text = header_item.text() if header_item else ""
+ max_width = self._header_metrics.horizontalAdvance(header_text)
+ max_width = max(max_width, self.network_table.sizeHintForColumn(column))
+
+ if column == 1:
+ max_width = max(max_width, standard_ssid_width)
+ elif column == 2:
+ max_width = max(max_width, standard_pwd_width)
+
+ self.network_table.setColumnWidth(
+ column,
+ max_width + self.TABLE_COLUMN_PADDING,
+ )
+
+ self._update_table_group_width()
+
+ def _update_table_group_width(self) -> None:
+ """Update table group width based on column sizes."""
+ width = 0
+ for column in range(self.network_table.columnCount()):
+ width += self.network_table.columnWidth(column)
+ width += self.network_table.verticalHeader().width()
+ width += self.network_table.frameWidth() * 2
+ width += self.network_table.verticalScrollBar().sizeHint().width()
+ layout = self.table_group.layout()
+ if layout:
+ margins = layout.contentsMargins()
+ width += margins.left() + margins.right()
+ width += self.TABLE_WIDTH_PADDING
+ self.table_group.setMinimumWidth(width)
+ self.table_group.setMaximumWidth(width)
+ self.network_table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+
+ def _toggle_password_visibility(self, row: int) -> None:
+ """Toggle visibility of a row's password widget."""
+ password_item = self.network_table.item(row, 2)
+ if not password_item:
+ return
+ password = password_item.data(Qt.ItemDataRole.UserRole) or ""
+ widget = self.network_table.cellWidget(row, 2)
+ if not widget:
+ return
+ label = widget.findChild(QLabel)
+ if not label:
+ return
+ current = bool(label.property("isVisible"))
+ self._update_password_widget(row, password, force_visible=not current)
+
+ def _security_changed(self, row: int, value: str) -> None:
+ """Update the security text for a row."""
+ # Security value is stored in the combobox, no need to update item
+
+ def _hidden_changed(self, row: int, state: int) -> None:
+ """Update the hidden state for a row."""
+ # Hidden value is stored in the checkbox, no need to update item
+
+ def _table_context_menu(self, pos: QPoint) -> None:
+ """Show context actions for table cells."""
+ index = self.network_table.indexAt(pos)
+ if not index.isValid():
+ return
+ row = index.row()
+ column = index.column()
+ menu = QMenu(self)
+ if column == 1:
+ edit_ssid = menu.addAction("Edit SSID")
+ edit_ssid.triggered.connect(lambda *_: self._edit_ssid(row))
+ if column == 2:
+ edit_pwd = menu.addAction("Change Password")
+ edit_pwd.triggered.connect(lambda *_: self._view_password(row))
+ menu.exec(self.network_table.viewport().mapToGlobal(pos))
+
+ def _edit_ssid(self, row: int) -> None:
+ """Prompt for editing an SSID in the table."""
+ ssid_item = self.network_table.item(row, 1)
+ if not ssid_item:
+ return
+ current = ssid_item.text()
+ value, ok = QInputDialog.getText(self, "SSID", "Edit SSID:", text=current)
+ if ok and value.strip():
+ ssid_item.setText(value.strip())
+
+ def _apply_search_filter(self) -> None:
+ """Filter table rows by the search query."""
+ query = self.search_input.text().strip().lower()
+ self._search_matches = []
+ for row in range(self.network_table.rowCount()):
+ location_item = self.network_table.item(row, 0)
+ ssid_item = self.network_table.item(row, 1)
+ location = location_item.text().lower() if location_item else ""
+ ssid = ssid_item.text().lower() if ssid_item else ""
+ match = query in location or query in ssid
+ self.network_table.setRowHidden(row, not match if query else False)
+ if query and match:
+ self._search_matches.append(row)
+
+ # Update placeholder text with match count
+ if query and self._search_matches:
+ count = len(self._search_matches)
+ self.search_input.setPlaceholderText(
+ f"Search by location or SSID ({count} match{'es' if count != 1 else ''})"
+ )
+ else:
+ self.search_input.setPlaceholderText("Search by location or SSID")
+
+ self._search_index = 0 if self._search_matches else self.NO_SEARCH_INDEX
+ if self._search_matches:
+ self._select_search_row(self._search_matches[0])
+
+ def _select_search_row(self, row: int) -> None:
+ """Select and scroll to a table row."""
+ self.network_table.selectRow(row)
+ item = self.network_table.item(row, 1)
+ if item:
+ self.network_table.scrollToItem(item)
+
+ def _find_next(self) -> None:
+ """Select the next search match."""
+ if not self._search_matches:
+ return
+ self._search_index = (self._search_index + 1) % len(self._search_matches)
+ self._select_search_row(self._search_matches[self._search_index])
+
+ def _find_previous(self) -> None:
+ """Select the previous search match."""
+ if not self._search_matches:
+ return
+ self._search_index = (self._search_index - 1) % len(self._search_matches)
+ self._select_search_row(self._search_matches[self._search_index])
+
+ def _handle_column_resize(self, logical_index: int, old_size: int, new_size: int) -> None:
+ """Prevent gaps by expanding Password column when needed."""
+ if logical_index not in [0, 1, 2]: # Only care about resizable columns
+ return
+
+ # Calculate available width for resizable columns
+ table_width = self.network_table.viewport().width()
+ security_width = self.network_table.columnWidth(3)
+ hidden_width = self.network_table.columnWidth(4)
+ available_width = table_width - security_width - hidden_width
+
+ # Get current widths of resizable columns
+ location_width = self.network_table.columnWidth(0)
+ ssid_width = self.network_table.columnWidth(1)
+ password_width = self.network_table.columnWidth(2)
+
+ # Calculate total width of resizable columns
+ total_resizable_width = location_width + ssid_width + password_width
+
+ # If there would be a gap, expand Password column to fill it
+ if total_resizable_width < available_width:
+ gap = available_width - total_resizable_width
+ header = self.network_table.horizontalHeader()
+ header.sectionResized.disconnect(self._handle_column_resize)
+ self.network_table.setColumnWidth(2, password_width + gap)
+ header.sectionResized.connect(self._handle_column_resize)
+
+ def _handle_sort(self, column: int) -> None:
+ """Toggle sorting for the given column."""
+ if column not in {0, 1}:
+ return
+ order = self._sort_orders.get(column, Qt.SortOrder.AscendingOrder)
+ order = (
+ Qt.SortOrder.DescendingOrder
+ if order == Qt.SortOrder.AscendingOrder
+ else Qt.SortOrder.AscendingOrder
+ )
+ self._sort_orders[column] = order
+ self.network_table.sortItems(column, order)
+
+ def _selected_rows(self) -> list[int]:
+ """Return selected table row indices."""
+ rows = {item.row() for item in self.network_table.selectedItems()}
+ return sorted(rows)
+
+ def _selected_or_all_configs(self) -> list[WifiConfig]:
+ """Return configs for selected rows or all rows."""
+ rows = self._selected_rows()
+ if not rows:
+ rows = list(range(self.network_table.rowCount()))
+ configs = []
+ for row in rows:
+ config = self._row_to_config(row)
+ if config:
+ configs.append(config)
+ return configs
+
+ def _row_to_config(self, row: int) -> WifiConfig | None:
+ """Convert a table row into a config if possible."""
+ location_item = self.network_table.item(row, 0)
+ ssid_item = self.network_table.item(row, 1)
+ password_item = self.network_table.item(row, 2)
+ security_combo = self.network_table.cellWidget(row, 3)
+ if not location_item or not ssid_item or not password_item:
+ return None
+ security_value = None
+ if isinstance(security_combo, QComboBox):
+ security_value = security_combo.currentText()
+ else:
+ security_item = self.network_table.item(row, 3)
+ security_value = (
+ security_item.text().strip()
+ if security_item
+ else SECURITY_OPTIONS[0]
+ )
+ password = password_item.data(Qt.ItemDataRole.UserRole) or ""
+
+ hidden_widget = self.network_table.cellWidget(row, 4)
+ hidden_value = False
+ if hidden_widget:
+ hidden_checkbox = hidden_widget.findChild(QCheckBox)
+ if hidden_checkbox:
+ hidden_value = hidden_checkbox.isChecked()
+
+ # Image data is stored in location item's UserRole
+ image_data = location_item.data(Qt.ItemDataRole.UserRole)
+
+ return WifiConfig(
+ location=location_item.text().strip(),
+ ssid=ssid_item.text().strip(),
+ password=str(password),
+ security=security_value or SECURITY_OPTIONS[0],
+ hidden=hidden_value,
+ image_data=image_data if isinstance(image_data, str) else None,
+ )
+
+ def _save(self) -> None:
+ """Save to the last path or prompt for a new one."""
+ if self._current_save_path:
+ self._save_to_path(self._current_save_path)
+ else:
+ self._save_as()
+
+ def _save_as(self) -> None:
+ """Prompt for a save path and persist configs."""
+ path, _ = QFileDialog.getSaveFileName(
+ self,
+ "Save",
+ "wifi-networks.json",
+ "JSON Files (*.json)",
+ )
+ if not path:
+ return
+ self._current_save_path = path
+ self._save_to_path(path)
+
+ def _save_to_path(self, path: str) -> None:
+ """Write configs to a JSON path."""
+ configs = self._selected_or_all_configs()
+ if not configs and self._config.ssid:
+ configs = [self._config]
+
+ data = [
+ {
+ "location": c.location,
+ "ssid": c.ssid,
+ "password": c.password,
+ "security": c.security,
+ "hidden": c.hidden,
+ "image_data": c.image_data,
+ }
+ for c in configs
+ ]
+ with open(path, "w", encoding="utf-8") as file:
+ json.dump(data, file, indent=2)
+
+ def _load(self) -> None:
+ """Prompt for a JSON file and load configs."""
+ path, _ = QFileDialog.getOpenFileName(
+ self,
+ "Load",
+ "",
+ "JSON Files (*.json)",
+ )
+ if not path:
+ return
+ with open(path, encoding="utf-8") as file:
+ data = json.load(file)
+ self._load_from_data(data)
+ self._current_save_path = path
+
+ def _load_from_data(self, data: list[dict[str, object]]) -> None:
+ """Populate table and form from loaded data."""
+ self.network_table.setRowCount(0)
+ configs = []
+ for entry in data:
+ config = WifiConfig(
+ location=str(entry.get("location", "")),
+ ssid=str(entry.get("ssid", "")),
+ password=str(entry.get("password", "")),
+ security=str(entry.get("security", SECURITY_OPTIONS[0])),
+ hidden=bool(entry.get("hidden", False)),
+ image_data=(
+ str(img_data)
+ if (img_data := entry.get("image_data")) and isinstance(img_data, str)
+ else None
+ ),
+ )
+ configs.append(config)
+ self._add_or_update_row(config)
+ if len(configs) == 1:
+ self._set_form_from_config(configs[0])
+
+ def _set_form_from_config(self, config: WifiConfig) -> None:
+ """Set the form inputs based on a config."""
+ self.location_input.setText(config.location)
+ self.ssid_input.setText(config.ssid)
+ self.password_input.setText(config.password)
+ self.security_input.setCurrentText(config.security)
+ self.hidden_input.setChecked(config.hidden)
+ if config.image_data:
+ self.image_path_display.setText("(Image loaded from saved network)")
+ else:
+ self.image_path_display.clear()
+ self._config = config
+ self._refresh_preview()
+
+ def _table_double_clicked(self, index: QModelIndex) -> None:
+ """Load the double-clicked network into the form and preview."""
+ row = index.row()
+ config = self._row_to_config(row)
+ if config:
+ self._set_form_from_config(config)
+
+ def _show_about(self) -> None:
+ """Display the About dialog."""
+ try:
+ version = metadata.version("wifiqr")
+ except metadata.PackageNotFoundError:
+ version = "dev"
+
+ QMessageBox.information(
+ self,
+ "About WifiQR",
+ (
+ "WifiQR\n"
+ "Cross-platform Wi-Fi QR code generator\n"
+ f"Version: {version}\n"
+ "Built with PySide6, qrcode, Pillow, CairoSVG\n"
+ "Author: Randy Northrup"
+ ),
+ )
+
+ @Slot()
+ def _focus_search(self) -> None:
+ """Focus the search input field."""
+ self.search_input.setFocus()
+ self.search_input.selectAll()
+
+ def _sanitize_filename(self, value: str) -> str:
+ """Return a filesystem-safe filename from a label."""
+ return "".join(c if c.isalnum() or c in {"-", "_"} else "_" for c in value) or "wifi"