feat: Add Snipping Tool-style area selection to Skill Scanner

NEW FEATURE - Snipping Tool Area Selection:

WORKFLOW:
1. Click 'Select Area' button in Skill Scanner
2. Screen dims with semi-transparent overlay
3. Drag to draw rectangle over your Skills window
4. Release to confirm (right-click or Escape to cancel)
5. Selected area is saved for all future scans
6. Click 'Start Smart Scan' to begin

SNIPPING WIDGET FEATURES:
- Fullscreen overlay with darkened background
- Drag to draw selection rectangle
- White border around selection
- Dimensions displayed (e.g., '800 x 600')
- Right-click to cancel
- Escape key to cancel
- Minimum 50x50 pixels required

UI UPDATES:
- Added 'Select Area' button with blue color (#4a9eff)
- Area label shows current selection: '800x600 at (100, 200)'
- All mode instructions updated to mention Select Area step

TECHNICAL:
- SnippingWidget inherits from QWidget
- Uses Qt.TranslucentBackground for transparency
- QPainter.CompositionMode_Clear for cutout effect
- Selected area stored as self.scan_area (x, y, w, h)
- SkillOCRThread accepts scan_area parameter
- Both single scan and multi-page scan use selected area

BENEFITS:
- No more window detection errors
- User has full control over scan region
- Works regardless of window title or process name
- Precise selection of Skills window area

If no area selected, falls back to full game window capture.
This commit is contained in:
LemonNexus 2026-02-15 01:20:54 +00:00
parent 4d9699c1a3
commit 67106ae64e
1 changed files with 206 additions and 17 deletions

View File

@ -172,23 +172,31 @@ class SkillOCRThread(QThread):
scan_error = pyqtSignal(str)
progress_update = pyqtSignal(str)
def __init__(self, ocr_service):
def __init__(self, ocr_service, scan_area=None):
super().__init__()
self.ocr_service = ocr_service
self.scan_area = scan_area # (x, y, width, height) or None
def run(self):
"""Perform OCR using core service - only on Entropia window."""
"""Perform OCR using core service - only on selected area or Entropia window."""
try:
self.progress_update.emit("Finding Entropia window...")
if self.scan_area:
# Use user-selected area
x, y, w, h = self.scan_area
self.progress_update.emit(f"Capturing selected area ({w}x{h})...")
# Capture only the Entropia game window
from PIL import ImageGrab
screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h))
else:
# Capture Entropia game window
self.progress_update.emit("Finding Entropia window...")
screenshot = capture_entropia_region()
if screenshot is None:
self.scan_error.emit("Could not capture game window")
self.scan_error.emit("Could not capture screen")
return
self.progress_update.emit("Running OCR on game window...")
self.progress_update.emit("Running OCR...")
# Use core OCR service with the captured image
result = self.ocr_service.recognize_image(screenshot)
@ -197,12 +205,22 @@ class SkillOCRThread(QThread):
self.scan_error.emit(result['error'])
return
self.progress_update.emit("Filtering and parsing skills...")
self.progress_update.emit("Parsing skills...")
# Filter out non-game text and parse skills
# Parse skills from text
raw_text = result.get('text', '')
skills_data = self._parse_skills_filtered(raw_text)
if not skills_data:
self.progress_update.emit("No skills found. Make sure Skills window is visible.")
else:
self.progress_update.emit(f"Found {len(skills_data)} skills")
self.scan_complete.emit(skills_data)
except Exception as e:
self.scan_error.emit(str(e))
if not skills_data:
self.progress_update.emit("No valid skills found. Make sure Skills window is open.")
else:
@ -348,6 +366,108 @@ class SkillOCRThread(QThread):
return skills
class SnippingWidget(QWidget):
"""Fullscreen overlay for snipping tool-style area selection."""
area_selected = pyqtSignal(int, int, int, int) # x, y, width, height
cancelled = pyqtSignal()
def __init__(self):
super().__init__()
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint |
Qt.WindowType.WindowStaysOnTopHint |
Qt.WindowType.Tool
)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
# Get screen geometry
from PyQt6.QtWidgets import QApplication
screen = QApplication.primaryScreen().geometry()
self.setGeometry(screen)
self.begin = None
self.end = None
self.drawing = False
# Semi-transparent dark overlay
self.overlay_color = Qt.GlobalColor.black
self.overlay_opacity = 160 # 0-255
def paintEvent(self, event):
from PyQt6.QtGui import QPainter, QPen, QColor, QBrush
painter = QPainter(self)
# Draw semi-transparent overlay
overlay = QColor(0, 0, 0, self.overlay_opacity)
painter.fillRect(self.rect(), overlay)
if self.begin and self.end:
# Clear overlay in selected area (make it transparent)
x = min(self.begin.x(), self.end.x())
y = min(self.begin.y(), self.end.y())
w = abs(self.end.x() - self.begin.x())
h = abs(self.end.y() - self.begin.y())
# Draw the clear rectangle
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Clear)
painter.fillRect(x, y, w, h, Qt.GlobalColor.transparent)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
# Draw border around selection
pen = QPen(Qt.GlobalColor.white, 2, Qt.PenStyle.SolidLine)
painter.setPen(pen)
painter.drawRect(x, y, w, h)
# Draw dimensions text
painter.setPen(Qt.GlobalColor.white)
from PyQt6.QtGui import QFont
font = QFont("Arial", 10)
painter.setFont(font)
painter.drawText(x, y - 5, f"{w} x {h}")
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.begin = event.pos()
self.end = event.pos()
self.drawing = True
self.update()
def mouseMoveEvent(self, event):
if self.drawing:
self.end = event.pos()
self.update()
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton and self.drawing:
self.drawing = False
self.end = event.pos()
# Calculate selection rectangle
x = min(self.begin.x(), self.end.x())
y = min(self.begin.y(), self.end.y())
w = abs(self.end.x() - self.begin.x())
h = abs(self.end.y() - self.begin.y())
# Minimum size check
if w > 50 and h > 50:
self.area_selected.emit(x, y, w, h)
else:
self.cancelled.emit()
self.close()
elif event.button() == Qt.MouseButton.RightButton:
# Right click to cancel
self.cancelled.emit()
self.close()
def keyPressEvent(self, event):
if event.key() == Qt.Key.Key_Escape:
self.cancelled.emit()
self.close()
class SignalHelper(QObject):
"""Helper QObject to hold signals since BasePlugin doesn't inherit from QObject."""
hotkey_triggered = pyqtSignal()
@ -383,6 +503,9 @@ class SkillScannerPlugin(BasePlugin):
self.current_scan_session = {} # Skills collected in current multi-page scan
self.pages_scanned = 0
# Scan area selection (x, y, width, height) - None means auto-detect game window
self.scan_area = None
# Connect signals (using signal helper QObject)
self._signals.hotkey_triggered.connect(self._scan_page_for_multi)
self._signals.update_status_signal.connect(self._update_multi_page_status_slot)
@ -447,6 +570,33 @@ class SkillScannerPlugin(BasePlugin):
scan_group = QGroupBox("OCR Scan (Core Service)")
scan_layout = QVBoxLayout(scan_group)
# Area selection row
area_layout = QHBoxLayout()
self.select_area_btn = QPushButton("📐 Select Area")
self.select_area_btn.setStyleSheet("""
QPushButton {
background-color: #4a9eff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background-color: #3a8eef;
}
""")
self.select_area_btn.clicked.connect(self._start_area_selection)
area_layout.addWidget(self.select_area_btn)
self.area_label = QLabel("Area: Not selected (will scan full game window)")
self.area_label.setStyleSheet("color: #888; font-size: 11px;")
area_layout.addWidget(self.area_label)
area_layout.addStretch()
scan_layout.addLayout(area_layout)
# Buttons row
buttons_layout = QHBoxLayout()
@ -510,7 +660,7 @@ class SkillScannerPlugin(BasePlugin):
# Instructions
self.instructions_label = QLabel(
"🤖 SMART MODE:\n"
"1. Position Skills window\n"
"1. Click 'Select Area' above and drag over your Skills window\n"
"2. Click 'Start Smart Scan'\n"
"3. Navigate pages in EU - auto-detect will scan for you\n"
"4. If auto fails, use hotkey F12 to scan manually\n"
@ -668,7 +818,8 @@ class SkillScannerPlugin(BasePlugin):
self.scan_progress.setText("Error: OCR service not available")
return
self.scanner = SkillOCRThread(ocr_service)
# Pass scan_area if user has selected one
self.scanner = SkillOCRThread(ocr_service, scan_area=self.scan_area)
self.scanner.scan_complete.connect(self._on_scan_complete)
self.scanner.scan_error.connect(self._on_scan_error)
self.scanner.progress_update.connect(self._on_scan_progress)
@ -732,6 +883,7 @@ class SkillScannerPlugin(BasePlugin):
def do_scan():
try:
from core.ocr_service import get_ocr_service
from PIL import ImageGrab
import re
from datetime import datetime
@ -740,8 +892,19 @@ class SkillScannerPlugin(BasePlugin):
self._signals.update_status_signal.emit("Error: OCR not available", False, True)
return
# Capture and OCR
result = ocr_service.recognize()
# Capture based on scan_area setting
if self.scan_area:
x, y, w, h = self.scan_area
screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h))
else:
screenshot = capture_entropia_region()
if screenshot is None:
self._signals.update_status_signal.emit("Error: Could not capture screen", False, True)
return
# OCR
result = ocr_service.recognize_image(screenshot)
text = result.get('text', '')
# Parse skills
@ -774,6 +937,32 @@ class SkillScannerPlugin(BasePlugin):
thread.daemon = True
thread.start()
def _start_area_selection(self):
"""Open snipping tool for user to select scan area."""
self.select_area_btn.setEnabled(False)
self.select_area_btn.setText("📐 Selecting...")
# Create and show snipping widget
self.snipping_widget = SnippingWidget()
self.snipping_widget.area_selected.connect(self._on_area_selected)
self.snipping_widget.cancelled.connect(self._on_area_cancelled)
self.snipping_widget.show()
def _on_area_selected(self, x, y, w, h):
"""Handle area selection from snipping tool."""
self.scan_area = (x, y, w, h)
self.area_label.setText(f"Area: {w}x{h} at ({x}, {y})")
self.area_label.setStyleSheet("color: #4ecdc4; font-size: 11px;")
self.select_area_btn.setEnabled(True)
self.select_area_btn.setText("📐 Select Area")
self._signals.update_status_signal.emit(f"✅ Scan area selected: {w}x{h}", True, False)
def _on_area_cancelled(self):
"""Handle cancelled area selection."""
self.select_area_btn.setEnabled(True)
self.select_area_btn.setText("📐 Select Area")
self._signals.update_status_signal.emit("Area selection cancelled", False, False)
def _parse_skills_from_text(self, text):
"""Parse skills from OCR text."""
skills = {}
@ -893,9 +1082,9 @@ class SkillScannerPlugin(BasePlugin):
def _on_scan_mode_changed(self, index):
"""Handle scan mode change."""
modes = [
"🤖 SMART MODE:\n1. Position Skills window\n2. Click 'Start Smart Scan'\n3. Navigate pages - auto-detect will scan\n4. If auto fails, press F12\n5. Click 'Save All' when done",
"⌨️ HOTKEY MODE:\n1. Position Skills window\n2. Navigate to page 1 in EU\n3. Press F12 to scan each page\n4. Click Next Page in EU\n5. Repeat F12 for each page",
"🖱️ MANUAL MODE:\n1. Position Skills window\n2. Click 'Scan Current Page'\n3. Wait for beep\n4. Click Next Page in EU\n5. Repeat"
"🤖 SMART MODE:\n1. Click 'Select Area' and drag over Skills window\n2. Click 'Start Smart Scan'\n3. Navigate pages - auto-detect will scan\n4. If auto fails, press F12\n5. Click 'Save All' when done",
"⌨️ HOTKEY MODE:\n1. Click 'Select Area' and drag over Skills window\n2. Navigate to page 1 in EU\n3. Press F12 to scan each page\n4. Click Next Page in EU\n5. Repeat F12 for each page",
"🖱️ MANUAL MODE:\n1. Click 'Select Area' and drag over Skills window\n2. Click 'Scan Current Page'\n3. Wait for beep\n4. Click Next Page in EU\n5. Repeat"
]
self.instructions_label.setText(modes[index])