Files
mochabyte0x-CTFPacker/Linux/gui/widgets/common.py
T
2026-02-28 17:12:19 +01:00

391 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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