1241 lines
48 KiB
Python
1241 lines
48 KiB
Python
"""
|
|
EU-Utility - Skill Scanner Plugin
|
|
|
|
Uses core OCR and Log services via PluginAPI.
|
|
"""
|
|
|
|
import re
|
|
import json
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from PyQt6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
|
QPushButton, QTableWidget, QTableWidgetItem, QProgressBar,
|
|
QFrame, QGroupBox, QTextEdit, QSplitter, QComboBox
|
|
)
|
|
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QObject
|
|
|
|
from plugins.base_plugin import BasePlugin
|
|
|
|
|
|
def find_entropia_window():
|
|
"""
|
|
Find the Entropia Universe game window.
|
|
Returns (left, top, width, height) or None if not found.
|
|
"""
|
|
try:
|
|
import platform
|
|
|
|
if platform.system() == 'Windows':
|
|
import win32gui
|
|
import win32process
|
|
import psutil
|
|
|
|
def callback(hwnd, windows):
|
|
if not win32gui.IsWindowVisible(hwnd):
|
|
return True
|
|
|
|
# Get window title
|
|
title = win32gui.GetWindowText(hwnd)
|
|
|
|
# Check if it's an Entropia window (title contains "Entropia Universe")
|
|
if 'Entropia Universe' in title:
|
|
try:
|
|
# Get process ID
|
|
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
|
process = psutil.Process(pid)
|
|
|
|
# Verify process name contains "Entropia"
|
|
if 'entropia' in process.name().lower():
|
|
rect = win32gui.GetWindowRect(hwnd)
|
|
left, top, right, bottom = rect
|
|
windows.append((left, top, right - left, bottom - top, title))
|
|
except:
|
|
pass
|
|
|
|
return True
|
|
|
|
windows = []
|
|
win32gui.EnumWindows(callback, windows)
|
|
|
|
if windows:
|
|
# Return the largest window (most likely the main game)
|
|
windows.sort(key=lambda w: w[2] * w[3], reverse=True)
|
|
left, top, width, height, title = windows[0]
|
|
print(f"[SkillScanner] Found Entropia window: '{title}' at ({left}, {top}, {width}, {height})")
|
|
return (left, top, width, height)
|
|
|
|
elif platform.system() == 'Linux':
|
|
# Try using xdotool or wmctrl
|
|
try:
|
|
import subprocess
|
|
result = subprocess.run(
|
|
['xdotool', 'search', '--name', 'Entropia Universe'],
|
|
capture_output=True, text=True
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
window_id = result.stdout.strip().split('\n')[0]
|
|
# Get window geometry
|
|
geo_result = subprocess.run(
|
|
['xdotool', 'getwindowgeometry', window_id],
|
|
capture_output=True, text=True
|
|
)
|
|
if geo_result.returncode == 0:
|
|
# Parse: "Position: 100,200 (screen: 0)" and "Geometry: 1920x1080"
|
|
pos_match = re.search(r'Position: (\d+),(\d+)', geo_result.stdout)
|
|
geo_match = re.search(r'Geometry: (\d+)x(\d+)', geo_result.stdout)
|
|
if pos_match and geo_match:
|
|
left = int(pos_match.group(1))
|
|
top = int(pos_match.group(2))
|
|
width = int(geo_match.group(1))
|
|
height = int(geo_match.group(2))
|
|
print(f"[SkillScanner] Found Entropia window at ({left}, {top}, {width}, {height})")
|
|
return (left, top, width, height)
|
|
except Exception as e:
|
|
print(f"[SkillScanner] Linux window detection failed: {e}")
|
|
|
|
except Exception as e:
|
|
print(f"[SkillScanner] Window detection error: {e}")
|
|
|
|
print("[SkillScanner] Could not find Entropia window - will use full screen")
|
|
return None
|
|
|
|
|
|
def capture_entropia_region(region=None):
|
|
"""
|
|
Capture screen region of Entropia window.
|
|
If region is None, tries to find the window automatically.
|
|
Returns PIL Image or None.
|
|
"""
|
|
try:
|
|
from PIL import ImageGrab
|
|
|
|
if region is None:
|
|
region = find_entropia_window()
|
|
|
|
if region:
|
|
left, top, width, height = region
|
|
# Add some padding to ensure we capture everything
|
|
# Don't go below 0
|
|
left = max(0, left)
|
|
top = max(0, top)
|
|
screenshot = ImageGrab.grab(bbox=(left, top, left + width, top + height))
|
|
print(f"[SkillScanner] Captured Entropia window region: {width}x{height}")
|
|
return screenshot
|
|
else:
|
|
# Fallback to full screen
|
|
screenshot = ImageGrab.grab()
|
|
print("[SkillScanner] Captured full screen (window not found)")
|
|
return screenshot
|
|
|
|
except Exception as e:
|
|
print(f"[SkillScanner] Capture error: {e}")
|
|
return None
|
|
|
|
|
|
def is_valid_skill_text(text):
|
|
"""
|
|
Filter out non-game text from OCR results.
|
|
Returns True if text looks like it could be from the game.
|
|
"""
|
|
# List of patterns that indicate NON-game text (UI, Discord, etc.)
|
|
invalid_patterns = [
|
|
# App/UI elements
|
|
'Discord', 'Presence', 'Event Bus', 'Example', 'Game Reader',
|
|
'Test', 'Page Scanner', 'HOTKEY MODE', 'Skill Tracker',
|
|
'Navigate', 'window', 'UI', 'Plugin', 'Settings',
|
|
'Click', 'Button', 'Menu', 'Panel', 'Tab', 'Loading...',
|
|
'Calculator', 'Nexus Search', 'Dashboard', 'Analytics',
|
|
'Multi-Page', 'Scanner', 'Auto-detect', 'F12',
|
|
'Cleared', 'Parsed:', '[SkillScanner]', 'INFO', 'DEBUG',
|
|
'Loading', 'Initializing', 'Connecting', 'Error:', 'Warning:',
|
|
'Entropia.exe', 'Client (64 bit)', 'Arkadia', 'Calypso',
|
|
# Instructions from our own UI
|
|
'Position Skills', 'Position Skills window', 'Start Smart Scan',
|
|
'Scan Current Page', 'Save All', 'Clear Session',
|
|
'Select Area', 'Drag over', 'Navigate pages',
|
|
# Column headers that might be picked up
|
|
'Skill', 'Skills', 'Rank', 'Points', 'Name',
|
|
# Category names with extra text
|
|
'Combat Wounding', 'Combat Serendipity', 'Combat Reflexes',
|
|
'Scan Serendipity', 'Scan Wounding', 'Scan Reflexes',
|
|
'Position Wounding', 'Position Serendipity', 'Position Reflexes',
|
|
'Current Page', 'Smart Scan', 'All Scanned',
|
|
]
|
|
|
|
# Check for invalid patterns
|
|
text_upper = text.upper()
|
|
for pattern in invalid_patterns:
|
|
if pattern.upper() in text_upper:
|
|
return False
|
|
|
|
# Check for reasonable skill name length (not too long, not too short)
|
|
words = text.split()
|
|
if len(words) > 7: # Skills rarely have 7+ words (reduced from 10)
|
|
return False
|
|
|
|
# Check if text contains button/action words combined with skill-like text
|
|
action_words = ['Click', 'Scan', 'Position', 'Select', 'Navigate', 'Start', 'Save', 'Clear']
|
|
text_lower = text.lower()
|
|
for word in action_words:
|
|
if word.lower() in text_lower:
|
|
# If it has action words, it's probably UI text
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
class SkillOCRThread(QThread):
|
|
"""OCR scan using core service."""
|
|
scan_complete = pyqtSignal(dict)
|
|
scan_error = pyqtSignal(str)
|
|
progress_update = pyqtSignal(str)
|
|
|
|
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 selected area or Entropia window."""
|
|
try:
|
|
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})...")
|
|
|
|
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 screen")
|
|
return
|
|
|
|
self.progress_update.emit("Running OCR...")
|
|
|
|
# Use core OCR service with the captured image
|
|
result = self.ocr_service.recognize_image(screenshot)
|
|
|
|
if 'error' in result and result['error']:
|
|
self.scan_error.emit(result['error'])
|
|
return
|
|
|
|
self.progress_update.emit("Parsing 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:
|
|
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))
|
|
|
|
def _parse_skills(self, text):
|
|
"""Parse skill data from OCR text with improved handling for 3-column layout."""
|
|
skills = {}
|
|
|
|
# Ranks in Entropia Universe (including multi-word ranks)
|
|
# Single word ranks
|
|
SINGLE_RANKS = [
|
|
'Newbie', 'Inept', 'Beginner', 'Amateur', 'Average',
|
|
'Skilled', 'Expert', 'Professional', 'Master',
|
|
'Champion', 'Legendary', 'Guru', 'Astonishing', 'Remarkable',
|
|
'Outstanding', 'Marvelous', 'Prodigious', 'Amazing', 'Incredible', 'Awesome'
|
|
]
|
|
# Multi-word ranks (must be checked first - longer matches first)
|
|
MULTI_RANKS = [
|
|
'Arch Master', 'Grand Master'
|
|
]
|
|
|
|
# Combine: multi-word first (so they match before single word), then single
|
|
ALL_RANKS = MULTI_RANKS + SINGLE_RANKS
|
|
rank_pattern = '|'.join(ALL_RANKS)
|
|
|
|
# Clean up the text - remove common headers and junk
|
|
text = text.replace('SKILLS', '').replace('ALL CATEGORIES', '')
|
|
text = text.replace('SKILL NAME', '').replace('RANK', '').replace('POINTS', '')
|
|
|
|
# Remove category names that appear as standalone words
|
|
for category in ['Attributes', 'COMBAT', 'Combat', 'Design', 'Construction',
|
|
'Defense', 'General', 'Handgun', 'Heavy Melee Weapons',
|
|
'Heavy Weapons', 'Information', 'Inflict Melee Damage',
|
|
'Inflict Ranged Damage', 'Light Melee Weapons', 'Longblades',
|
|
'Medical', 'Mining', 'Science', 'Social', 'Beauty', 'Mindforce']:
|
|
text = text.replace(category, ' ')
|
|
|
|
# Remove extra whitespace
|
|
text = ' '.join(text.split())
|
|
|
|
# Find all skills in the text using finditer
|
|
for match in re.finditer(
|
|
rf'([A-Za-z][A-Za-z\s]{{2,50}}?)\s+({rank_pattern})\s+(\d{{1,6}})(?:\s|$)',
|
|
text, re.IGNORECASE
|
|
):
|
|
skill_name = match.group(1).strip()
|
|
rank = match.group(2)
|
|
points = int(match.group(3))
|
|
|
|
# Clean up skill name - remove common words that might be prepended
|
|
skill_name = re.sub(r'^(Skill|SKILL)\s*', '', skill_name, flags=re.IGNORECASE)
|
|
skill_name = skill_name.strip()
|
|
|
|
# Validate skill name - filter out UI text
|
|
if not is_valid_skill_text(skill_name):
|
|
print(f"[SkillScanner] Filtered invalid skill name: '{skill_name}'")
|
|
continue
|
|
|
|
# Validate - points should be reasonable (not too small)
|
|
if points > 0 and skill_name and len(skill_name) > 2:
|
|
skills[skill_name] = {
|
|
'rank': rank,
|
|
'points': points,
|
|
'scanned_at': datetime.now().isoformat()
|
|
}
|
|
print(f"[SkillScanner] Parsed: {skill_name} = {rank} ({points})")
|
|
|
|
# If no skills found with primary method, try alternative
|
|
if not skills:
|
|
skills = self._parse_skills_alternative(text, ALL_RANKS)
|
|
|
|
return skills
|
|
|
|
def _parse_skills_filtered(self, text):
|
|
"""
|
|
Parse skills with filtering to remove non-game text.
|
|
Only returns skills that pass validity checks.
|
|
"""
|
|
# First, split text into lines and filter each line
|
|
lines = text.split('\n')
|
|
valid_lines = []
|
|
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
# Check if line contains a rank (required for skill lines)
|
|
has_rank = any(rank in line for rank in [
|
|
'Newbie', 'Inept', 'Beginner', 'Amateur', 'Average',
|
|
'Skilled', 'Expert', 'Professional', 'Master',
|
|
'Arch Master', 'Grand Master', # Multi-word first
|
|
'Champion', 'Legendary', 'Guru', 'Astonishing', 'Remarkable',
|
|
'Outstanding', 'Marvelous', 'Prodigious', 'Amazing', 'Incredible', 'Awesome'
|
|
])
|
|
|
|
if not has_rank:
|
|
continue # Skip lines without ranks
|
|
|
|
# Check for invalid patterns
|
|
if not is_valid_skill_text(line):
|
|
print(f"[SkillScanner] Filtered out: '{line[:50]}...'")
|
|
continue
|
|
|
|
valid_lines.append(line)
|
|
|
|
# Join valid lines and parse
|
|
filtered_text = '\n'.join(valid_lines)
|
|
|
|
if not filtered_text.strip():
|
|
print("[SkillScanner] No valid game text found after filtering")
|
|
return {}
|
|
|
|
print(f"[SkillScanner] Filtered {len(lines)} lines to {len(valid_lines)} valid lines")
|
|
|
|
return self._parse_skills(filtered_text)
|
|
|
|
def _parse_skills_alternative(self, text, ranks):
|
|
"""Alternative parser for when text is heavily merged."""
|
|
skills = {}
|
|
|
|
# Find all rank positions in the text
|
|
for rank in ranks:
|
|
# Look for pattern: [text] [Rank] [number]
|
|
pattern = rf'([A-Z][a-z]{{2,}}(?:\s+[A-Z][a-z]{{2,}}){{0,3}})\s+{re.escape(rank)}\s+(\d{{1,6}})'
|
|
matches = re.finditer(pattern, text, re.IGNORECASE)
|
|
|
|
for match in matches:
|
|
skill_name = match.group(1).strip()
|
|
points = int(match.group(2))
|
|
|
|
# Clean skill name
|
|
skill_name = re.sub(r'^(Skill|SKILL)\s*', '', skill_name, flags=re.IGNORECASE)
|
|
|
|
if points > 0 and len(skill_name) > 2:
|
|
skills[skill_name] = {
|
|
'rank': rank,
|
|
'points': points,
|
|
'scanned_at': datetime.now().isoformat()
|
|
}
|
|
|
|
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()
|
|
update_status_signal = pyqtSignal(str, bool, bool) # message, success, error
|
|
update_session_table_signal = pyqtSignal(object) # skills dict
|
|
update_counters_signal = pyqtSignal()
|
|
enable_scan_button_signal = pyqtSignal(bool)
|
|
|
|
|
|
class SkillScannerPlugin(BasePlugin):
|
|
"""Scan skills using core OCR and track gains via core Log service."""
|
|
|
|
name = "Skill Scanner"
|
|
version = "2.1.0"
|
|
author = "ImpulsiveFPS"
|
|
description = "Uses core OCR and Log services"
|
|
hotkey = "ctrl+shift+s"
|
|
|
|
def initialize(self):
|
|
"""Setup skill scanner."""
|
|
# Create signal helper (QObject) for thread-safe UI updates
|
|
self._signals = SignalHelper()
|
|
|
|
self.data_file = Path("data/skill_tracker.json")
|
|
self.data_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Load saved data
|
|
self.skills_data = {}
|
|
self.skill_gains = []
|
|
self._load_data()
|
|
|
|
# Multi-page scanning state
|
|
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)
|
|
self._signals.update_session_table_signal.connect(self._update_session_table)
|
|
self._signals.update_counters_signal.connect(self._update_counters_slot)
|
|
# Note: enable_scan_button_signal connected in get_ui() after button created
|
|
|
|
# Subscribe to skill gain events from core Log service
|
|
try:
|
|
from core.plugin_api import get_api
|
|
api = get_api()
|
|
|
|
# Check if log service available
|
|
log_service = api.services.get('log')
|
|
if log_service:
|
|
print(f"[SkillScanner] Connected to core Log service")
|
|
|
|
except Exception as e:
|
|
print(f"[SkillScanner] Could not connect to Log service: {e}")
|
|
|
|
def _load_data(self):
|
|
"""Load saved skill data."""
|
|
if self.data_file.exists():
|
|
try:
|
|
with open(self.data_file, 'r') as f:
|
|
data = json.load(f)
|
|
self.skills_data = data.get('skills', {})
|
|
self.skill_gains = data.get('gains', [])
|
|
except:
|
|
pass
|
|
|
|
def _save_data(self):
|
|
"""Save skill data."""
|
|
with open(self.data_file, 'w') as f:
|
|
json.dump({
|
|
'skills': self.skills_data,
|
|
'gains': self.skill_gains
|
|
}, f, indent=2)
|
|
|
|
def get_ui(self):
|
|
"""Create skill scanner UI."""
|
|
widget = QWidget()
|
|
layout = QVBoxLayout(widget)
|
|
layout.setSpacing(15)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
# Header
|
|
header = QLabel("Skill Tracker")
|
|
header.setStyleSheet("font-size: 18px; font-weight: bold; color: white;")
|
|
layout.addWidget(header)
|
|
|
|
# Info about core services
|
|
info = self._get_service_status()
|
|
info_label = QLabel(info)
|
|
info_label.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;")
|
|
layout.addWidget(info_label)
|
|
|
|
# Splitter
|
|
splitter = QSplitter(Qt.Orientation.Vertical)
|
|
|
|
# Scan section
|
|
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()
|
|
|
|
scan_btn = QPushButton("Scan Skills Window")
|
|
scan_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #ff8c42;
|
|
color: white;
|
|
padding: 12px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-weight: bold;
|
|
}
|
|
""")
|
|
scan_btn.clicked.connect(self._scan_skills)
|
|
buttons_layout.addWidget(scan_btn)
|
|
|
|
reset_btn = QPushButton("Reset Data")
|
|
reset_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #ff4757;
|
|
color: white;
|
|
padding: 12px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-weight: bold;
|
|
}
|
|
""")
|
|
reset_btn.clicked.connect(self._reset_data)
|
|
buttons_layout.addWidget(reset_btn)
|
|
|
|
scan_layout.addLayout(buttons_layout)
|
|
|
|
self.scan_progress = QLabel("Ready to scan")
|
|
self.scan_progress.setStyleSheet("color: rgba(255,255,255,150);")
|
|
scan_layout.addWidget(self.scan_progress)
|
|
|
|
self.skills_table = QTableWidget()
|
|
self.skills_table.setColumnCount(3)
|
|
self.skills_table.setHorizontalHeaderLabels(["Skill", "Rank", "Points"])
|
|
self.skills_table.horizontalHeader().setStretchLastSection(True)
|
|
scan_layout.addWidget(self.skills_table)
|
|
|
|
splitter.addWidget(scan_group)
|
|
|
|
# Multi-Page Scanning section
|
|
multi_page_group = QGroupBox("Multi-Page Scanner")
|
|
multi_page_layout = QVBoxLayout(multi_page_group)
|
|
|
|
# Mode selection
|
|
mode_layout = QHBoxLayout()
|
|
mode_layout.addWidget(QLabel("Mode:"))
|
|
|
|
self.scan_mode_combo = QComboBox()
|
|
self.scan_mode_combo.addItems(["Smart Auto + Hotkey Fallback", "Manual Hotkey Only", "Manual Click Only"])
|
|
self.scan_mode_combo.currentIndexChanged.connect(self._on_scan_mode_changed)
|
|
mode_layout.addWidget(self.scan_mode_combo)
|
|
mode_layout.addStretch()
|
|
multi_page_layout.addLayout(mode_layout)
|
|
|
|
# Instructions
|
|
self.instructions_label = QLabel(
|
|
"🤖 SMART MODE:\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"
|
|
"5. Click 'Save All' when done"
|
|
)
|
|
self.instructions_label.setStyleSheet("color: #888; font-size: 11px;")
|
|
multi_page_layout.addWidget(self.instructions_label)
|
|
|
|
# Hotkey info
|
|
self.hotkey_info = QLabel("Hotkey: F12 = Scan Current Page")
|
|
self.hotkey_info.setStyleSheet("color: #4ecdc4; font-weight: bold;")
|
|
multi_page_layout.addWidget(self.hotkey_info)
|
|
|
|
# Status row
|
|
status_layout = QHBoxLayout()
|
|
|
|
self.multi_page_status = QLabel("⏳ Ready to scan page 1")
|
|
self.multi_page_status.setStyleSheet("color: #ff8c42; font-size: 14px;")
|
|
status_layout.addWidget(self.multi_page_status)
|
|
|
|
self.pages_scanned_label = QLabel("Pages: 0")
|
|
self.pages_scanned_label.setStyleSheet("color: #4ecdc4;")
|
|
status_layout.addWidget(self.pages_scanned_label)
|
|
|
|
self.total_skills_label = QLabel("Skills: 0")
|
|
self.total_skills_label.setStyleSheet("color: #4ecdc4;")
|
|
status_layout.addWidget(self.total_skills_label)
|
|
|
|
status_layout.addStretch()
|
|
multi_page_layout.addLayout(status_layout)
|
|
|
|
# Buttons
|
|
mp_buttons_layout = QHBoxLayout()
|
|
|
|
self.scan_page_btn = QPushButton("▶️ Start Smart Scan")
|
|
self.scan_page_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #4ecdc4;
|
|
color: #141f23;
|
|
padding: 12px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-weight: bold;
|
|
font-size: 13px;
|
|
}
|
|
QPushButton:hover {
|
|
background-color: #3dbdb4;
|
|
}
|
|
""")
|
|
self.scan_page_btn.clicked.connect(self._start_smart_scan)
|
|
mp_buttons_layout.addWidget(self.scan_page_btn)
|
|
|
|
# Connect signal helper for button enabling (now that button exists)
|
|
self._signals.enable_scan_button_signal.connect(self.scan_page_btn.setEnabled)
|
|
|
|
save_all_btn = QPushButton("💾 Save All Scanned")
|
|
save_all_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #ff8c42;
|
|
color: white;
|
|
padding: 12px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-weight: bold;
|
|
font-size: 13px;
|
|
}
|
|
""")
|
|
save_all_btn.clicked.connect(self._save_multi_page_scan)
|
|
mp_buttons_layout.addWidget(save_all_btn)
|
|
|
|
clear_session_btn = QPushButton("🗑 Clear Session")
|
|
clear_session_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #666;
|
|
color: white;
|
|
padding: 12px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
}
|
|
""")
|
|
clear_session_btn.clicked.connect(self._clear_multi_page_session)
|
|
mp_buttons_layout.addWidget(clear_session_btn)
|
|
|
|
multi_page_layout.addLayout(mp_buttons_layout)
|
|
|
|
# Current session table
|
|
self.session_table = QTableWidget()
|
|
self.session_table.setColumnCount(3)
|
|
self.session_table.setHorizontalHeaderLabels(["Skill", "Rank", "Points"])
|
|
self.session_table.horizontalHeader().setStretchLastSection(True)
|
|
self.session_table.setMaximumHeight(200)
|
|
self.session_table.setStyleSheet("""
|
|
QTableWidget {
|
|
background-color: #0d1117;
|
|
border: 1px solid #333;
|
|
}
|
|
QTableWidget::item {
|
|
padding: 4px;
|
|
color: #c9d1d9;
|
|
}
|
|
""")
|
|
multi_page_layout.addWidget(self.session_table)
|
|
|
|
splitter.addWidget(multi_page_group)
|
|
|
|
# Log section
|
|
log_group = QGroupBox("Log Tracking (Core Service)")
|
|
log_layout = QVBoxLayout(log_group)
|
|
|
|
log_status = QLabel("Skill gains tracked from chat log")
|
|
log_status.setStyleSheet("color: #4ecdc4;")
|
|
log_layout.addWidget(log_status)
|
|
|
|
self.gains_text = QTextEdit()
|
|
self.gains_text.setReadOnly(True)
|
|
self.gains_text.setMaximumHeight(150)
|
|
self.gains_text.setPlaceholderText("Recent skill gains from core Log service...")
|
|
log_layout.addWidget(self.gains_text)
|
|
|
|
self.total_gains_label = QLabel(f"Total gains: {len(self.skill_gains)}")
|
|
self.total_gains_label.setStyleSheet("color: rgba(255,255,255,150);")
|
|
log_layout.addWidget(self.total_gains_label)
|
|
|
|
splitter.addWidget(log_group)
|
|
|
|
layout.addWidget(splitter)
|
|
|
|
self._refresh_skills_table()
|
|
|
|
return widget
|
|
|
|
def _get_service_status(self) -> str:
|
|
"""Get status of core services."""
|
|
try:
|
|
from core.ocr_service import get_ocr_service
|
|
from core.log_reader import get_log_reader
|
|
|
|
ocr = get_ocr_service()
|
|
log = get_log_reader()
|
|
|
|
ocr_status = "✓" if ocr.is_available() else "✗"
|
|
log_status = "✓" if log.is_available() else "✗"
|
|
|
|
return f"Core Services - OCR: {ocr_status} Log: {log_status}"
|
|
except:
|
|
return "Core Services - status unknown"
|
|
|
|
def _scan_skills(self):
|
|
"""Start OCR scan using core service."""
|
|
try:
|
|
from core.ocr_service import get_ocr_service
|
|
ocr_service = get_ocr_service()
|
|
|
|
if not ocr_service.is_available():
|
|
self.scan_progress.setText("Error: OCR service not available")
|
|
return
|
|
|
|
# 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)
|
|
self.scanner.start()
|
|
|
|
except Exception as e:
|
|
self.scan_progress.setText(f"Error: {e}")
|
|
|
|
def _on_scan_progress(self, message):
|
|
self.scan_progress.setText(message)
|
|
|
|
def _on_scan_complete(self, skills_data):
|
|
self.skills_data.update(skills_data)
|
|
self._save_data()
|
|
self._refresh_skills_table()
|
|
self.scan_progress.setText(f"Found {len(skills_data)} skills")
|
|
|
|
def _reset_data(self):
|
|
"""Reset all skill data."""
|
|
from PyQt6.QtWidgets import QMessageBox
|
|
|
|
reply = QMessageBox.question(
|
|
None,
|
|
"Reset Skill Data",
|
|
"Are you sure you want to clear all scanned skill data?\n\nThis cannot be undone.",
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
QMessageBox.StandardButton.No
|
|
)
|
|
|
|
if reply == QMessageBox.StandardButton.Yes:
|
|
self.skills_data = {}
|
|
self.skill_gains = []
|
|
self._save_data()
|
|
self._refresh_skills_table()
|
|
self.gains_text.clear()
|
|
self.total_gains_label.setText("Total gains: 0")
|
|
self.scan_progress.setText("Data cleared")
|
|
|
|
def _on_scan_error(self, error):
|
|
self.scan_progress.setText(f"Error: {error}")
|
|
self.scan_progress.setText(f"Error: {error}")
|
|
|
|
def _refresh_skills_table(self):
|
|
self.skills_table.setRowCount(len(self.skills_data))
|
|
for i, (name, data) in enumerate(sorted(self.skills_data.items())):
|
|
self.skills_table.setItem(i, 0, QTableWidgetItem(name))
|
|
self.skills_table.setItem(i, 1, QTableWidgetItem(data.get('rank', '-')))
|
|
self.skills_table.setItem(i, 2, QTableWidgetItem(str(data.get('points', 0))))
|
|
|
|
def _scan_page_for_multi(self):
|
|
"""Scan current page and add to multi-page session."""
|
|
from PyQt6.QtCore import QTimer
|
|
|
|
self.multi_page_status.setText("📷 Scanning...")
|
|
self.multi_page_status.setStyleSheet("color: #ffd93d;")
|
|
self.scan_page_btn.setEnabled(False)
|
|
|
|
# Run scan in thread
|
|
from threading import Thread
|
|
|
|
def do_scan():
|
|
try:
|
|
from core.ocr_service import get_ocr_service
|
|
from PIL import ImageGrab
|
|
import re
|
|
from datetime import datetime
|
|
|
|
ocr_service = get_ocr_service()
|
|
if not ocr_service.is_available():
|
|
self._signals.update_status_signal.emit("Error: OCR not available", False, True)
|
|
return
|
|
|
|
# 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
|
|
skills = self._parse_skills_from_text(text)
|
|
|
|
# Add to session
|
|
for skill_name, data in skills.items():
|
|
self.current_scan_session[skill_name] = data
|
|
|
|
self.pages_scanned += 1
|
|
|
|
# Update UI via signals (thread-safe)
|
|
self._signals.update_session_table_signal.emit(self.current_scan_session)
|
|
|
|
# Show success with checkmark and beep
|
|
self._signals.update_status_signal.emit(
|
|
f"✅ Page {self.pages_scanned} scanned! {len(skills)} skills found. Click Next Page in game →",
|
|
True, False
|
|
)
|
|
|
|
# Play beep sound
|
|
self._play_beep()
|
|
|
|
except Exception as e:
|
|
self._signals.update_status_signal.emit(f"Error: {str(e)}", False, True)
|
|
finally:
|
|
self._signals.enable_scan_button_signal.emit(True)
|
|
|
|
thread = Thread(target=do_scan)
|
|
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 = {}
|
|
|
|
# Ranks in Entropia Universe - multi-word first for proper matching
|
|
SINGLE_RANKS = [
|
|
'Newbie', 'Inept', 'Beginner', 'Amateur', 'Average',
|
|
'Skilled', 'Expert', 'Professional', 'Master',
|
|
'Champion', 'Legendary', 'Guru', 'Astonishing', 'Remarkable',
|
|
'Outstanding', 'Marvelous', 'Prodigious', 'Amazing', 'Incredible', 'Awesome'
|
|
]
|
|
MULTI_RANKS = ['Arch Master', 'Grand Master']
|
|
ALL_RANKS = MULTI_RANKS + SINGLE_RANKS
|
|
rank_pattern = '|'.join(ALL_RANKS)
|
|
|
|
# Clean text
|
|
text = text.replace('SKILLS', '').replace('ALL CATEGORIES', '')
|
|
text = text.replace('SKILL NAME', '').replace('RANK', '').replace('POINTS', '')
|
|
|
|
# Remove category names
|
|
for category in ['Attributes', 'COMBAT', 'Combat', 'Design', 'Construction',
|
|
'Defense', 'General', 'Handgun', 'Heavy Melee Weapons',
|
|
'Heavy Weapons', 'Information', 'Inflict Melee Damage',
|
|
'Inflict Ranged Damage', 'Light Melee Weapons', 'Longblades',
|
|
'Medical', 'Mining', 'Science', 'Social', 'Beauty', 'Mindforce']:
|
|
text = text.replace(category, ' ')
|
|
|
|
text = ' '.join(text.split())
|
|
|
|
# Find all skills
|
|
import re
|
|
for match in re.finditer(
|
|
rf'([A-Za-z][A-Za-z\s]{{2,50}}?)\s+({rank_pattern})\s+(\d{{1,6}})(?:\s|$)',
|
|
text, re.IGNORECASE
|
|
):
|
|
skill_name = match.group(1).strip()
|
|
rank = match.group(2)
|
|
points = int(match.group(3))
|
|
|
|
skill_name = re.sub(r'^(Skill|SKILL)\s*', '', skill_name, flags=re.IGNORECASE)
|
|
skill_name = skill_name.strip()
|
|
|
|
# Validate skill name - filter out UI text
|
|
if not is_valid_skill_text(skill_name):
|
|
print(f"[SkillScanner] Filtered invalid skill name: '{skill_name}'")
|
|
continue
|
|
|
|
if points > 0 and skill_name and len(skill_name) > 2:
|
|
skills[skill_name] = {'rank': rank, 'points': points}
|
|
|
|
return skills
|
|
|
|
def _update_multi_page_status_slot(self, message, success=False, error=False):
|
|
"""Slot for updating multi-page status (called via signal)."""
|
|
color = "#4ecdc4" if success else "#ff4757" if error else "#ff8c42"
|
|
|
|
self.multi_page_status.setText(message)
|
|
self.multi_page_status.setStyleSheet(f"color: {color}; font-size: 14px;")
|
|
self._update_counters_slot()
|
|
|
|
def _update_counters_slot(self):
|
|
"""Slot for updating counters (called via signal)."""
|
|
self.pages_scanned_label.setText(f"Pages: {self.pages_scanned}")
|
|
self.total_skills_label.setText(f"Skills: {len(self.current_scan_session)}")
|
|
|
|
def _play_beep(self):
|
|
"""Play a beep sound to notify user."""
|
|
try:
|
|
import winsound
|
|
winsound.MessageBeep(winsound.MB_OK)
|
|
except:
|
|
# Fallback - try to use system beep
|
|
try:
|
|
print('\a') # ASCII bell character
|
|
except:
|
|
pass
|
|
|
|
def _update_session_table(self, skills):
|
|
"""Update the session table with current scan data."""
|
|
self.session_table.setRowCount(len(skills))
|
|
for i, (name, data) in enumerate(sorted(skills.items())):
|
|
self.session_table.setItem(i, 0, QTableWidgetItem(name))
|
|
self.session_table.setItem(i, 1, QTableWidgetItem(data.get('rank', '-')))
|
|
self.session_table.setItem(i, 2, QTableWidgetItem(str(data.get('points', 0))))
|
|
|
|
def _save_multi_page_scan(self):
|
|
"""Save all scanned skills from multi-page session."""
|
|
if not self.current_scan_session:
|
|
from PyQt6.QtWidgets import QMessageBox
|
|
QMessageBox.information(None, "No Data", "No skills scanned yet. Scan some pages first!")
|
|
return
|
|
|
|
# Merge with existing data
|
|
self.skills_data.update(self.current_scan_session)
|
|
self._save_data()
|
|
self._refresh_skills_table()
|
|
|
|
from PyQt6.QtWidgets import QMessageBox
|
|
QMessageBox.information(
|
|
None,
|
|
"Scan Complete",
|
|
f"Saved {len(self.current_scan_session)} skills from {self.pages_scanned} pages!"
|
|
)
|
|
|
|
# Clear session after saving
|
|
self._clear_multi_page_session()
|
|
|
|
def _clear_multi_page_session(self):
|
|
"""Clear the current multi-page scanning session."""
|
|
self.current_scan_session = {}
|
|
self.pages_scanned = 0
|
|
self.auto_scan_active = False
|
|
self.session_table.setRowCount(0)
|
|
self.multi_page_status.setText("⏳ Ready to scan page 1")
|
|
self.multi_page_status.setStyleSheet("color: #ff8c42; font-size: 14px;")
|
|
self.pages_scanned_label.setText("Pages: 0")
|
|
self.total_skills_label.setText("Skills: 0")
|
|
|
|
# Unregister hotkey if active
|
|
self._unregister_hotkey()
|
|
|
|
def _on_scan_mode_changed(self, index):
|
|
"""Handle scan mode change."""
|
|
modes = [
|
|
"🤖 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])
|
|
|
|
def _start_smart_scan(self):
|
|
"""Start smart auto-scan with hotkey fallback."""
|
|
mode = self.scan_mode_combo.currentIndex()
|
|
|
|
if mode == 0: # Smart Auto + Hotkey
|
|
self._start_auto_scan_with_hotkey()
|
|
elif mode == 1: # Hotkey only
|
|
self._register_hotkey()
|
|
self._signals.update_status_signal.emit("Hotkey F12 ready! Navigate to first page and press F12", True, False)
|
|
else: # Manual click
|
|
self._scan_page_for_multi()
|
|
|
|
def _start_auto_scan_with_hotkey(self):
|
|
"""Start auto-detection with fallback to hotkey."""
|
|
self.auto_scan_active = True
|
|
self.auto_scan_failures = 0
|
|
self.last_page_number = None
|
|
|
|
# Register F12 hotkey as fallback
|
|
self._register_hotkey()
|
|
|
|
# Start monitoring
|
|
self._signals.update_status_signal.emit("🤖 Auto-detect started! Navigate to page 1...", True, False)
|
|
|
|
# Start auto-detection timer
|
|
self.auto_scan_timer = QTimer()
|
|
self.auto_scan_timer.timeout.connect(self._check_for_page_change)
|
|
self.auto_scan_timer.start(500) # Check every 500ms
|
|
|
|
def _register_hotkey(self):
|
|
"""Register F12 hotkey for manual scan."""
|
|
try:
|
|
import keyboard
|
|
keyboard.on_press_key('f12', lambda e: self._hotkey_scan())
|
|
self.hotkey_registered = True
|
|
except Exception as e:
|
|
print(f"[SkillScanner] Could not register hotkey: {e}")
|
|
self.hotkey_registered = False
|
|
|
|
def _unregister_hotkey(self):
|
|
"""Unregister hotkey."""
|
|
try:
|
|
if hasattr(self, 'hotkey_registered') and self.hotkey_registered:
|
|
import keyboard
|
|
keyboard.unhook_all()
|
|
self.hotkey_registered = False
|
|
except:
|
|
pass
|
|
|
|
# Stop auto-scan timer
|
|
if hasattr(self, 'auto_scan_timer') and self.auto_scan_timer:
|
|
self.auto_scan_timer.stop()
|
|
self.auto_scan_active = False
|
|
|
|
def _hotkey_scan(self):
|
|
"""Scan triggered by F12 hotkey - thread safe via signal."""
|
|
# Emit signal to safely call from hotkey thread
|
|
self._signals.hotkey_triggered.emit()
|
|
|
|
def _check_for_page_change(self):
|
|
"""Auto-detect page changes by monitoring page number area."""
|
|
if not self.auto_scan_active:
|
|
return
|
|
|
|
try:
|
|
from PIL import ImageGrab
|
|
import pytesseract
|
|
|
|
# Capture page number area (bottom center of skills window)
|
|
# This is approximate - may need adjustment
|
|
screen = ImageGrab.grab()
|
|
width, height = screen.size
|
|
|
|
# Try to capture the page number area (bottom center, small region)
|
|
# EU skills window shows page like "1/12" at bottom
|
|
page_area = (width // 2 - 50, height - 100, width // 2 + 50, height - 50)
|
|
page_img = ImageGrab.grab(bbox=page_area)
|
|
|
|
# OCR just the page number
|
|
page_text = pytesseract.image_to_string(page_img, config='--psm 7 -c tessedit_char_whitelist=0123456789/')
|
|
|
|
# Extract current page number
|
|
import re
|
|
match = re.search(r'(\d+)/(\d+)', page_text)
|
|
if match:
|
|
current_page = int(match.group(1))
|
|
total_pages = int(match.group(2))
|
|
|
|
# If page changed, trigger scan
|
|
if self.last_page_number is not None and current_page != self.last_page_number:
|
|
self._signals.update_status_signal.emit(f"📄 Page change detected: {current_page}/{total_pages}", True, False)
|
|
self._scan_page_for_multi()
|
|
|
|
self.last_page_number = current_page
|
|
else:
|
|
# Failed to detect page number
|
|
self.auto_scan_failures += 1
|
|
if self.auto_scan_failures >= 10: # After 5 seconds of failures
|
|
self._fallback_to_hotkey()
|
|
|
|
except Exception as e:
|
|
self.auto_scan_failures += 1
|
|
if self.auto_scan_failures >= 10:
|
|
self._fallback_to_hotkey()
|
|
|
|
def _fallback_to_hotkey(self):
|
|
"""Fallback to hotkey mode when auto-detection fails."""
|
|
if hasattr(self, 'auto_scan_timer') and self.auto_scan_timer:
|
|
self.auto_scan_timer.stop()
|
|
|
|
self.auto_scan_active = False
|
|
|
|
# Keep hotkey registered
|
|
self._signals.update_status_signal.emit(
|
|
"⚠️ Auto-detect unreliable. Use F12 hotkey to scan each page manually!",
|
|
False, True
|
|
)
|
|
|
|
# Play alert sound
|
|
self._play_beep()
|