mirror of
https://github.com/mochabyte0x/CTFPacker
synced 2026-06-06 16:14:33 +00:00
391 lines
14 KiB
Python
391 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Shared GUI widget helpers."""
|
||
import os
|
||
|
||
from PyQt6.QtWidgets import (
|
||
QHBoxLayout, QVBoxLayout, QLineEdit, QPushButton, QFileDialog,
|
||
QWidget, QLabel, QSizePolicy,
|
||
QMessageBox, QInputDialog, QSpacerItem,
|
||
)
|
||
from PyQt6.QtCore import Qt, QMimeData, pyqtSignal
|
||
from PyQt6.QtGui import QPainter, QPen, QColor, QFont
|
||
|
||
from gui.theme import (
|
||
BG_BASE, BG_SURFACE, BG_ELEVATED, BORDER, BORDER_SUB,
|
||
ACCENT, ACCENT_DIM, ACCENT_BRIGHT,
|
||
TEXT, TEXT_DIM, TEXT_MUTED,
|
||
GREEN, RED,
|
||
# Nord compat aliases
|
||
NORD0, NORD1, NORD2, NORD3, NORD4,
|
||
FROST1, FROST2, FROST3,
|
||
)
|
||
|
||
|
||
class DropLineEdit(QLineEdit):
|
||
"""QLineEdit that accepts drag-and-drop files."""
|
||
|
||
def __init__(self, file_filter_exts=None, parent=None):
|
||
super().__init__(parent)
|
||
self.setAcceptDrops(True)
|
||
# e.g. ['.bin', '.pfx'] — if None, accept any file
|
||
self._filter_exts = file_filter_exts
|
||
|
||
def dragEnterEvent(self, event):
|
||
if event.mimeData().hasUrls():
|
||
urls = event.mimeData().urls()
|
||
if urls and urls[0].isLocalFile():
|
||
path = urls[0].toLocalFile()
|
||
if self._filter_exts is None or any(path.lower().endswith(e) for e in self._filter_exts):
|
||
event.acceptProposedAction()
|
||
return
|
||
event.ignore()
|
||
|
||
def dragMoveEvent(self, event):
|
||
event.acceptProposedAction()
|
||
|
||
def dropEvent(self, event):
|
||
urls = event.mimeData().urls()
|
||
if urls and urls[0].isLocalFile():
|
||
self.setText(urls[0].toLocalFile())
|
||
event.acceptProposedAction()
|
||
|
||
|
||
class DropZone(QWidget):
|
||
"""A visual drag-and-drop zone for payload files.
|
||
|
||
Shows a dashed-border area with a hint label. Highlights on drag-over.
|
||
Displays the filename once a file is selected. Includes a Browse button.
|
||
"""
|
||
|
||
path_changed = pyqtSignal(str)
|
||
|
||
def __init__(self, file_filter: str = "All Files (*)",
|
||
filter_exts=None,
|
||
hint_text: str = "Drag & drop shellcode (.bin) here",
|
||
browse_title: str = "Select file",
|
||
parent=None):
|
||
super().__init__(parent)
|
||
self.setAcceptDrops(True)
|
||
self._file_filter = file_filter
|
||
self._filter_exts = filter_exts # e.g. ['.bin']
|
||
self._hint_text = hint_text
|
||
self._browse_title = browse_title
|
||
self._path = ""
|
||
self._dragging = False
|
||
|
||
self.setMinimumHeight(110)
|
||
self.setSizePolicy(QSizePolicy.Policy.Expanding,
|
||
QSizePolicy.Policy.Fixed)
|
||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
|
||
# Internal layout
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(16, 12, 16, 12)
|
||
layout.setSpacing(4)
|
||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
# Upload arrow icon (hidden once a file is selected)
|
||
self._icon_label = QLabel("⬆")
|
||
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self._icon_label.setStyleSheet(
|
||
f"color: {ACCENT}; font-size: 18px; background: transparent; border: none;")
|
||
layout.addWidget(self._icon_label)
|
||
|
||
# Primary hint text
|
||
self._hint_label = QLabel(hint_text)
|
||
self._hint_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self._hint_label.setStyleSheet(
|
||
f"color: {TEXT_DIM}; font-size: 13px; background: transparent; border: none;")
|
||
layout.addWidget(self._hint_label)
|
||
|
||
# "or browse" sub-hint
|
||
self._sub_label = QLabel("or click to browse")
|
||
self._sub_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self._sub_label.setStyleSheet(
|
||
f"color: {ACCENT}; font-size: 11px; background: transparent; border: none;")
|
||
layout.addWidget(self._sub_label)
|
||
|
||
# Clear button — only visible when a file is loaded
|
||
self._clear_btn = QPushButton("× clear")
|
||
self._clear_btn.setObjectName("deleteButton")
|
||
self._clear_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||
self._clear_btn.setFixedWidth(70)
|
||
self._clear_btn.setStyleSheet(
|
||
self._clear_btn.styleSheet() +
|
||
"QPushButton#deleteButton { font-size: 10px; padding: 2px 8px; }")
|
||
self._clear_btn.setVisible(False)
|
||
self._clear_btn.clicked.connect(self._clear_file)
|
||
layout.addWidget(self._clear_btn, alignment=Qt.AlignmentFlag.AlignCenter)
|
||
|
||
# File path display (hidden until file selected)
|
||
self._file_label = QLabel("")
|
||
self._file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self._file_label.setWordWrap(True)
|
||
self._file_label.setStyleSheet(
|
||
f"color: {GREEN}; font-size: 12px; font-weight: bold; "
|
||
f"background: transparent; border: none;")
|
||
self._file_label.setVisible(False)
|
||
layout.addWidget(self._file_label)
|
||
|
||
def text(self) -> str:
|
||
"""Return the current file path (API compat with QLineEdit)."""
|
||
return self._path
|
||
|
||
def setText(self, path: str):
|
||
"""Set the file path programmatically."""
|
||
self._path = path
|
||
self._update_display()
|
||
self.path_changed.emit(path)
|
||
|
||
@property
|
||
def textChanged(self):
|
||
"""Signal compat — returns path_changed so callers can connect."""
|
||
return self.path_changed
|
||
|
||
def setProperty(self, name, value):
|
||
super().setProperty(name, value)
|
||
if name == "validationError":
|
||
self.update()
|
||
|
||
def _clear_file(self):
|
||
"""Remove the currently selected file."""
|
||
self._path = ""
|
||
self._update_display()
|
||
self.path_changed.emit("")
|
||
|
||
def _update_display(self):
|
||
if self._path:
|
||
filename = os.path.basename(self._path)
|
||
# Elide long paths so they don't break the layout
|
||
max_path_chars = 60
|
||
display_path = (self._path if len(self._path) <= max_path_chars
|
||
else f"…{self._path[-(max_path_chars - 1):]}")
|
||
self._icon_label.setVisible(False)
|
||
self._hint_label.setText(filename)
|
||
self._hint_label.setStyleSheet(
|
||
f"color: {ACCENT}; font-size: 13px; font-weight: bold; "
|
||
f"background: transparent; border: none;")
|
||
self._sub_label.setText(display_path)
|
||
self._sub_label.setStyleSheet(
|
||
f"color: {TEXT_DIM}; font-size: 10px; "
|
||
f"background: transparent; border: none;")
|
||
self._clear_btn.setVisible(True)
|
||
self._file_label.setVisible(False)
|
||
else:
|
||
self._icon_label.setVisible(True)
|
||
self._hint_label.setText(self._hint_text)
|
||
self._hint_label.setStyleSheet(
|
||
f"color: {TEXT_DIM}; font-size: 13px; "
|
||
f"background: transparent; border: none;")
|
||
self._sub_label.setText("or click to browse")
|
||
self._sub_label.setStyleSheet(
|
||
f"color: {ACCENT}; font-size: 11px; "
|
||
f"background: transparent; border: none;")
|
||
self._clear_btn.setVisible(False)
|
||
self._file_label.setVisible(False)
|
||
self.update()
|
||
|
||
# -- Paint the dashed border --
|
||
|
||
def paintEvent(self, event):
|
||
painter = QPainter(self)
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
|
||
validation_error = self.property("validationError")
|
||
|
||
if self._dragging:
|
||
border_color = QColor(ACCENT)
|
||
bg_color = QColor(ACCENT)
|
||
bg_color.setAlpha(18)
|
||
elif validation_error:
|
||
border_color = QColor(RED)
|
||
bg_color = QColor(RED)
|
||
bg_color.setAlpha(18)
|
||
elif self._path:
|
||
border_color = QColor(GREEN)
|
||
bg_color = QColor(GREEN)
|
||
bg_color.setAlpha(12)
|
||
else:
|
||
border_color = QColor(BORDER)
|
||
bg_color = QColor(BG_SURFACE)
|
||
|
||
# Background fill
|
||
painter.setBrush(bg_color)
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
painter.drawRoundedRect(self.rect().adjusted(1, 1, -1, -1), 2, 2)
|
||
|
||
# Dashed border — tighter dash pattern for a crisper look
|
||
pen = QPen(border_color, 1, Qt.PenStyle.CustomDashLine)
|
||
pen.setDashPattern([5, 3])
|
||
painter.setPen(pen)
|
||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||
painter.drawRoundedRect(self.rect().adjusted(1, 1, -1, -1), 2, 2)
|
||
|
||
# If dragging: draw a solid 2px outer accent ring on top
|
||
if self._dragging:
|
||
solid_pen = QPen(QColor(ACCENT), 2, Qt.PenStyle.SolidLine)
|
||
painter.setPen(solid_pen)
|
||
painter.drawRoundedRect(self.rect().adjusted(1, 1, -1, -1), 2, 2)
|
||
|
||
painter.end()
|
||
|
||
# -- Mouse click = browse --
|
||
|
||
def mousePressEvent(self, event):
|
||
if event.button() == Qt.MouseButton.LeftButton:
|
||
path, _ = QFileDialog.getOpenFileName(
|
||
self, self._browse_title, "",
|
||
self._file_filter)
|
||
if path:
|
||
self.setText(path)
|
||
|
||
# -- Drag & drop --
|
||
|
||
def _is_valid_file(self, path: str) -> bool:
|
||
if self._filter_exts is None:
|
||
return True
|
||
return any(path.lower().endswith(e) for e in self._filter_exts)
|
||
|
||
def dragEnterEvent(self, event):
|
||
if event.mimeData().hasUrls():
|
||
urls = event.mimeData().urls()
|
||
if urls and urls[0].isLocalFile():
|
||
if self._is_valid_file(urls[0].toLocalFile()):
|
||
self._dragging = True
|
||
self.update()
|
||
event.acceptProposedAction()
|
||
return
|
||
event.ignore()
|
||
|
||
def dragMoveEvent(self, event):
|
||
event.acceptProposedAction()
|
||
|
||
def dragLeaveEvent(self, event):
|
||
self._dragging = False
|
||
self.update()
|
||
|
||
def dropEvent(self, event):
|
||
self._dragging = False
|
||
urls = event.mimeData().urls()
|
||
if urls and urls[0].isLocalFile():
|
||
path = urls[0].toLocalFile()
|
||
if self._is_valid_file(path):
|
||
self.setText(path)
|
||
event.acceptProposedAction()
|
||
return
|
||
self.update()
|
||
event.ignore()
|
||
|
||
|
||
def file_picker_row(parent: QWidget, label: str = "Browse...",
|
||
file_filter: str = "All Files (*)",
|
||
save_mode: bool = False) -> tuple:
|
||
"""
|
||
Create a file picker row: DropLineEdit + Browse button.
|
||
Returns (layout, line_edit) so the caller can read the path.
|
||
"""
|
||
layout = QHBoxLayout()
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
# Extract extensions from file_filter for drag-drop validation
|
||
# e.g. "Binary Files (*.bin);;All Files (*)" -> ['.bin']
|
||
filter_exts = None
|
||
if file_filter and "*." in file_filter:
|
||
import re
|
||
exts = re.findall(r'\*\.(\w+)', file_filter)
|
||
if exts:
|
||
filter_exts = [f'.{e.lower()}' for e in exts]
|
||
|
||
line_edit = DropLineEdit(file_filter_exts=filter_exts, parent=parent)
|
||
line_edit.setPlaceholderText(label)
|
||
|
||
browse_btn = QPushButton("Browse")
|
||
browse_btn.setObjectName("browseButton")
|
||
browse_btn.setFixedWidth(80)
|
||
|
||
def on_browse():
|
||
if save_mode:
|
||
path, _ = QFileDialog.getSaveFileName(parent, label, "", file_filter)
|
||
else:
|
||
path, _ = QFileDialog.getOpenFileName(parent, label, "", file_filter)
|
||
if path:
|
||
line_edit.setText(path)
|
||
|
||
browse_btn.clicked.connect(on_browse)
|
||
|
||
layout.addWidget(line_edit, stretch=1)
|
||
layout.addWidget(browse_btn)
|
||
|
||
return layout, line_edit
|
||
|
||
|
||
# ── Dialog helpers ──────────────────────────────────────────────────────────────
|
||
# Qt does not reliably honour stylesheet min-width on QMessageBox.
|
||
# The only reliable fix is to inject a spacer into the grid layout.
|
||
_DLG_MIN_W = 500
|
||
|
||
|
||
def _widen(msg):
|
||
"""Inject a horizontal spacer to enforce a minimum dialog width."""
|
||
spacer = QSpacerItem(_DLG_MIN_W, 0,
|
||
QSizePolicy.Policy.Minimum,
|
||
QSizePolicy.Policy.Expanding)
|
||
layout = msg.layout()
|
||
layout.addItem(spacer, layout.rowCount(), 0, 1, layout.columnCount())
|
||
|
||
|
||
def dlg_warn(parent, title: str, text: str):
|
||
"""Warning dialog with guaranteed minimum width."""
|
||
msg = QMessageBox(parent)
|
||
msg.setWindowTitle(title)
|
||
msg.setText(text)
|
||
msg.setIcon(QMessageBox.Icon.Warning)
|
||
_widen(msg)
|
||
msg.exec()
|
||
|
||
|
||
def dlg_error(parent, title: str, text: str):
|
||
"""Error dialog with guaranteed minimum width."""
|
||
msg = QMessageBox(parent)
|
||
msg.setWindowTitle(title)
|
||
msg.setText(text)
|
||
msg.setIcon(QMessageBox.Icon.Critical)
|
||
_widen(msg)
|
||
msg.exec()
|
||
|
||
|
||
def dlg_info(parent, title: str, text: str):
|
||
"""Information dialog with guaranteed minimum width."""
|
||
msg = QMessageBox(parent)
|
||
msg.setWindowTitle(title)
|
||
msg.setText(text)
|
||
msg.setIcon(QMessageBox.Icon.Information)
|
||
_widen(msg)
|
||
msg.exec()
|
||
|
||
|
||
def dlg_ask(parent, title: str, text: str, default_yes: bool = False) -> bool:
|
||
"""Yes/No question dialog. Returns True if user clicked Yes."""
|
||
msg = QMessageBox(parent)
|
||
msg.setWindowTitle(title)
|
||
msg.setText(text)
|
||
msg.setIcon(QMessageBox.Icon.Question)
|
||
msg.setStandardButtons(
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||
msg.setDefaultButton(
|
||
QMessageBox.StandardButton.Yes if default_yes
|
||
else QMessageBox.StandardButton.No)
|
||
_widen(msg)
|
||
return msg.exec() == QMessageBox.StandardButton.Yes
|
||
|
||
|
||
def dlg_text(parent, title: str, label: str, default: str = "") -> tuple:
|
||
"""Text-input dialog. Returns (text, ok) like QInputDialog.getText."""
|
||
dlg = QInputDialog(parent)
|
||
dlg.setWindowTitle(title)
|
||
dlg.setLabelText(label)
|
||
dlg.setTextValue(default)
|
||
dlg.setMinimumWidth(420)
|
||
ok = dlg.exec() == QInputDialog.DialogCode.Accepted
|
||
return dlg.textValue(), ok
|