feat(loader): frameless UI with splash, custom list, inject overlay
Replace the standard QMainWindow chrome and QTableWidget with a purpose-built Qt UI built around hand-rolled widgets and animations: - SplashScreen: frameless translucent splash with a wordmark fade/scale, a sweeping scan line and a pulsing accent glow. ~950ms total cold-start reveal that fades out into the main window. - TitleBar: custom title bar replaces the OS frame, owns drag-to-move, minimize/close buttons, and a breathing scan-status dot. - InstanceList + InstanceRow: scrollable column of self-painted rows (PID, title, Inject button). Rows are not selectable, double-click has no special meaning - injection happens only through the button. Hover animates each row's tint; new pids play a slide-in/opacity entrance. - InjectionOverlay: modal-style frameless overlay shown while inject() runs in a worker thread. Spinner ring + progress bar advance during injection; completion swaps to a checkmark / X, holds briefly, then fades out. After it closes, the main window fades and the loader self-quits. - MainWindow: frameless + translucent panel painted by hand; entrance fade/slide on launch; uniform playExitThenQuit() shared by the close button and the post-injection path so every exit path gets a fade-out instead of a hard vanish. Also enables Win11 rounded corners via DwmSetWindowAttribute on show. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,16 @@ qt_add_executable(OpenZenLoader WIN32
|
||||
src/main.cpp
|
||||
src/MainWindow.cpp
|
||||
src/MainWindow.h
|
||||
src/SplashScreen.cpp
|
||||
src/SplashScreen.h
|
||||
src/TitleBar.cpp
|
||||
src/TitleBar.h
|
||||
src/InstanceList.cpp
|
||||
src/InstanceList.h
|
||||
src/InstanceRow.cpp
|
||||
src/InstanceRow.h
|
||||
src/InjectionOverlay.cpp
|
||||
src/InjectionOverlay.h
|
||||
src/process_list.cpp
|
||||
src/injector.cpp
|
||||
src/manual_map.cpp
|
||||
@@ -47,6 +57,7 @@ target_link_libraries(OpenZenLoader PRIVATE
|
||||
psapi
|
||||
shlwapi
|
||||
user32
|
||||
dwmapi # DwmSetWindowAttribute for Win11 rounded corners on frameless window
|
||||
)
|
||||
|
||||
# Build-time git revision passed in by Gradle (gradle.properties /
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
#include "InjectionOverlay.h"
|
||||
|
||||
#include "loader.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QEasingCurve>
|
||||
#include <QFont>
|
||||
#include <QFontMetricsF>
|
||||
#include <QLinearGradient>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QRadialGradient>
|
||||
#include <QScreen>
|
||||
#include <QSequentialAnimationGroup>
|
||||
|
||||
#include <algorithm>
|
||||
#include <thread>
|
||||
|
||||
namespace loader {
|
||||
|
||||
namespace {
|
||||
constexpr int kW = 460;
|
||||
constexpr int kH = 240;
|
||||
constexpr int kCornerRadius = 16;
|
||||
} // namespace
|
||||
|
||||
InjectionOverlay::InjectionOverlay(unsigned long pid, const QString& target,
|
||||
QWidget* parent)
|
||||
: QWidget(parent), pid_(pid), target_(target) {
|
||||
setWindowFlags(Qt::FramelessWindowHint
|
||||
| Qt::Tool
|
||||
| Qt::WindowStaysOnTopHint);
|
||||
setAttribute(Qt::WA_TranslucentBackground);
|
||||
setAttribute(Qt::WA_DeleteOnClose);
|
||||
resize(kW, kH);
|
||||
|
||||
// Centre over the parent window if we have one, else the primary screen.
|
||||
if (parent) {
|
||||
QPoint pcentre = parent->geometry().center();
|
||||
move(pcentre - QPoint(kW / 2, kH / 2));
|
||||
} else if (auto* scr = QApplication::primaryScreen()) {
|
||||
QRect g = scr->availableGeometry();
|
||||
move(g.center() - QPoint(kW / 2, kH / 2));
|
||||
}
|
||||
}
|
||||
|
||||
void InjectionOverlay::start() {
|
||||
show();
|
||||
raise();
|
||||
activateWindow();
|
||||
|
||||
// Panel fade in.
|
||||
auto* panelFade = new QPropertyAnimation(this, "panelOpacity", this);
|
||||
panelFade->setDuration(220);
|
||||
panelFade->setStartValue(0.0);
|
||||
panelFade->setEndValue(1.0);
|
||||
panelFade->setEasingCurve(QEasingCurve::OutCubic);
|
||||
panelFade->start(QAbstractAnimation::DeleteWhenStopped);
|
||||
|
||||
// Continuous spinner rotation.
|
||||
spinnerAnim_ = new QPropertyAnimation(this, "spinnerAngle", this);
|
||||
spinnerAnim_->setDuration(1100);
|
||||
spinnerAnim_->setStartValue(0.0);
|
||||
spinnerAnim_->setEndValue(360.0);
|
||||
spinnerAnim_->setLoopCount(-1);
|
||||
spinnerAnim_->start();
|
||||
|
||||
// Progress crawls towards 70% while the inject worker runs; when the
|
||||
// worker returns we jump to 100% on a faster animation.
|
||||
progressAnim_ = new QPropertyAnimation(this, "progress", this);
|
||||
progressAnim_->setDuration(900);
|
||||
progressAnim_->setStartValue(0.0);
|
||||
progressAnim_->setEndValue(0.7);
|
||||
progressAnim_->setEasingCurve(QEasingCurve::OutCubic);
|
||||
progressAnim_->start();
|
||||
|
||||
// Run the (synchronous, can-block) inject() on a worker thread so the
|
||||
// overlay stays animated. Use invokeMethod with a queued connection to
|
||||
// bounce the result back to the GUI thread.
|
||||
std::thread([this]() {
|
||||
std::wstring err = inject(pid_);
|
||||
QString qerr = QString::fromWCharArray(err.c_str(),
|
||||
static_cast<int>(err.size()));
|
||||
QMetaObject::invokeMethod(this, [this, qerr]() {
|
||||
onInjectResult(qerr);
|
||||
}, Qt::QueuedConnection);
|
||||
}).detach();
|
||||
}
|
||||
|
||||
void InjectionOverlay::onInjectResult(QString err) {
|
||||
injectDone_ = true;
|
||||
injectOk_ = err.isEmpty();
|
||||
injectErr_ = err;
|
||||
|
||||
if (spinnerAnim_) spinnerAnim_->stop();
|
||||
if (progressAnim_) progressAnim_->stop();
|
||||
|
||||
statusText_ = injectOk_
|
||||
? QStringLiteral("Injection complete")
|
||||
: QStringLiteral("Injection failed: ") + injectErr_;
|
||||
update();
|
||||
|
||||
// Finish progress bar quickly, then animate the success/failure mark
|
||||
// drawing in, hold a beat, fade out.
|
||||
auto* finish = new QPropertyAnimation(this, "progress", this);
|
||||
finish->setDuration(260);
|
||||
finish->setStartValue(progress_);
|
||||
finish->setEndValue(1.0);
|
||||
finish->setEasingCurve(QEasingCurve::OutCubic);
|
||||
|
||||
auto* check = new QPropertyAnimation(this, "checkmarkProgress", this);
|
||||
check->setDuration(380);
|
||||
check->setStartValue(0.0);
|
||||
check->setEndValue(1.0);
|
||||
check->setEasingCurve(QEasingCurve::OutBack);
|
||||
|
||||
auto* fadeOut = new QPropertyAnimation(this, "panelOpacity", this);
|
||||
fadeOut->setDuration(260);
|
||||
fadeOut->setStartValue(1.0);
|
||||
fadeOut->setEndValue(0.0);
|
||||
fadeOut->setEasingCurve(QEasingCurve::InCubic);
|
||||
|
||||
auto* seq = new QSequentialAnimationGroup(this);
|
||||
seq->addAnimation(finish);
|
||||
seq->addAnimation(check);
|
||||
seq->addPause(700);
|
||||
seq->addAnimation(fadeOut);
|
||||
|
||||
connect(seq, &QAbstractAnimation::finished, this, [this]() {
|
||||
emit completed(injectOk_);
|
||||
close();
|
||||
});
|
||||
seq->start(QAbstractAnimation::DeleteWhenStopped);
|
||||
}
|
||||
|
||||
void InjectionOverlay::paintEvent(QPaintEvent*) {
|
||||
QPainter p(this);
|
||||
p.setRenderHint(QPainter::Antialiasing);
|
||||
p.setRenderHint(QPainter::TextAntialiasing);
|
||||
p.setOpacity(panelOpacity_);
|
||||
|
||||
QRectF rect = QRectF(this->rect()).adjusted(0.5, 0.5, -0.5, -0.5);
|
||||
QPainterPath panel;
|
||||
panel.addRoundedRect(rect, kCornerRadius, kCornerRadius);
|
||||
p.setClipPath(panel);
|
||||
|
||||
// Background.
|
||||
QLinearGradient bg(rect.topLeft(), rect.bottomLeft());
|
||||
bg.setColorAt(0.0, QColor("#1a1c22"));
|
||||
bg.setColorAt(1.0, QColor("#0e1014"));
|
||||
p.fillRect(rect, bg);
|
||||
|
||||
// Accent glow behind the spinner.
|
||||
{
|
||||
QRadialGradient glow(rect.center() - QPointF(0, 30),
|
||||
rect.width() * 0.5);
|
||||
glow.setColorAt(0.0, QColor(70, 130, 230, 90));
|
||||
glow.setColorAt(1.0, QColor(70, 130, 230, 0));
|
||||
p.fillRect(rect, glow);
|
||||
}
|
||||
|
||||
// Spinner / status mark.
|
||||
const QPointF c(rect.center().x(), rect.top() + 72);
|
||||
const qreal r = 26.0;
|
||||
|
||||
if (!injectDone_) {
|
||||
QPen ringPen(QColor(255, 255, 255, 30), 3);
|
||||
ringPen.setCapStyle(Qt::FlatCap);
|
||||
p.setPen(ringPen);
|
||||
p.setBrush(Qt::NoBrush);
|
||||
p.drawEllipse(c, r, r);
|
||||
|
||||
QPen arcPen(QColor("#7fb0ff"), 3);
|
||||
arcPen.setCapStyle(Qt::RoundCap);
|
||||
p.setPen(arcPen);
|
||||
QRectF arcRect(c.x() - r, c.y() - r, r * 2, r * 2);
|
||||
int startAngle = static_cast<int>(-spinnerAngle_ * 16);
|
||||
int spanAngle = -120 * 16;
|
||||
p.drawArc(arcRect, startAngle, spanAngle);
|
||||
} else {
|
||||
QColor accent = injectOk_ ? QColor("#3ecf6b") : QColor("#e25656");
|
||||
p.setPen(Qt::NoPen);
|
||||
p.setBrush(accent);
|
||||
p.drawEllipse(c, r, r);
|
||||
|
||||
QPen mark(QColor("#ffffff"), 3);
|
||||
mark.setCapStyle(Qt::RoundCap);
|
||||
mark.setJoinStyle(Qt::RoundJoin);
|
||||
p.setPen(mark);
|
||||
p.setBrush(Qt::NoBrush);
|
||||
|
||||
const qreal t = std::clamp(checkmarkProgress_, 0.0, 1.0);
|
||||
if (injectOk_) {
|
||||
// Two-segment checkmark, animated by t.
|
||||
QPointF a(c.x() - 11, c.y() + 1);
|
||||
QPointF b(c.x() - 2, c.y() + 9);
|
||||
QPointF d(c.x() + 12, c.y() - 7);
|
||||
if (t <= 0.5) {
|
||||
qreal tt = t / 0.5;
|
||||
p.drawLine(a, a + (b - a) * tt);
|
||||
} else {
|
||||
p.drawLine(a, b);
|
||||
qreal tt = (t - 0.5) / 0.5;
|
||||
p.drawLine(b, b + (d - b) * tt);
|
||||
}
|
||||
} else {
|
||||
qreal off = 11.0 * t;
|
||||
p.drawLine(QPointF(c.x() - off, c.y() - off),
|
||||
QPointF(c.x() + off, c.y() + off));
|
||||
p.drawLine(QPointF(c.x() + off, c.y() - off),
|
||||
QPointF(c.x() - off, c.y() + off));
|
||||
}
|
||||
}
|
||||
|
||||
// Status text.
|
||||
{
|
||||
QFont f(QStringLiteral("Segoe UI"));
|
||||
f.setPointSize(12);
|
||||
f.setBold(true);
|
||||
p.setFont(f);
|
||||
p.setPen(QColor("#e3e7ef"));
|
||||
QFontMetricsF fm(f);
|
||||
qreal tw = fm.horizontalAdvance(statusText_);
|
||||
p.drawText(QPointF(rect.center().x() - tw / 2, rect.top() + 140),
|
||||
statusText_);
|
||||
}
|
||||
|
||||
// Target subtitle.
|
||||
{
|
||||
QFont f(QStringLiteral("Segoe UI"));
|
||||
f.setPointSize(9);
|
||||
p.setFont(f);
|
||||
p.setPen(QColor("#8a90a0"));
|
||||
QFontMetricsF fm(f);
|
||||
QString sub = QStringLiteral("PID %1 · %2").arg(pid_).arg(target_);
|
||||
// Truncate overly long titles so the subtitle still fits inside
|
||||
// the panel.
|
||||
const qreal maxW = rect.width() - 60;
|
||||
if (fm.horizontalAdvance(sub) > maxW) {
|
||||
sub = fm.elidedText(sub, Qt::ElideMiddle, static_cast<int>(maxW));
|
||||
}
|
||||
qreal sw = fm.horizontalAdvance(sub);
|
||||
p.drawText(QPointF(rect.center().x() - sw / 2, rect.top() + 162),
|
||||
sub);
|
||||
}
|
||||
|
||||
// Progress bar.
|
||||
{
|
||||
const qreal pbY = rect.bottom() - 38;
|
||||
const qreal pbL = rect.left() + 64;
|
||||
const qreal pbR = rect.right() - 64;
|
||||
const qreal pbH = 4.0;
|
||||
|
||||
QPainterPath track;
|
||||
track.addRoundedRect(QRectF(pbL, pbY - pbH / 2, pbR - pbL, pbH),
|
||||
pbH / 2, pbH / 2);
|
||||
p.setPen(Qt::NoPen);
|
||||
p.setBrush(QColor(255, 255, 255, 22));
|
||||
p.drawPath(track);
|
||||
|
||||
const qreal fillW = (pbR - pbL) * std::clamp(progress_, 0.0, 1.0);
|
||||
if (fillW > 0.5) {
|
||||
QPainterPath fill;
|
||||
fill.addRoundedRect(QRectF(pbL, pbY - pbH / 2, fillW, pbH),
|
||||
pbH / 2, pbH / 2);
|
||||
QLinearGradient g(QPointF(pbL, 0), QPointF(pbR, 0));
|
||||
if (injectDone_ && !injectOk_) {
|
||||
g.setColorAt(0.0, QColor("#e25656"));
|
||||
g.setColorAt(1.0, QColor("#f08585"));
|
||||
} else {
|
||||
g.setColorAt(0.0, QColor("#3d6fd1"));
|
||||
g.setColorAt(1.0, QColor("#7fb0ff"));
|
||||
}
|
||||
p.setBrush(g);
|
||||
p.drawPath(fill);
|
||||
}
|
||||
}
|
||||
|
||||
// Border.
|
||||
p.setClipping(false);
|
||||
p.setPen(QPen(QColor(255, 255, 255, 28), 1));
|
||||
p.setBrush(Qt::NoBrush);
|
||||
p.drawPath(panel);
|
||||
}
|
||||
|
||||
} // namespace loader
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
|
||||
class QPropertyAnimation;
|
||||
|
||||
namespace loader {
|
||||
|
||||
// Modal-style frameless overlay shown while injection runs. Drives a
|
||||
// spinner + progress bar, then a checkmark (or X) when the inject worker
|
||||
// thread reports back. After a short hold it fades out and emits
|
||||
// completed(); the application's main wires that to QCoreApplication::quit.
|
||||
class InjectionOverlay : public QWidget {
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(qreal spinnerAngle READ spinnerAngle WRITE setSpinnerAngle)
|
||||
Q_PROPERTY(qreal progress READ progress WRITE setProgress)
|
||||
Q_PROPERTY(qreal panelOpacity READ panelOpacity WRITE setPanelOpacity)
|
||||
Q_PROPERTY(qreal checkmarkProgress READ checkmarkProgress WRITE setCheckmarkProgress)
|
||||
|
||||
public:
|
||||
InjectionOverlay(unsigned long pid, const QString& target,
|
||||
QWidget* parent = nullptr);
|
||||
|
||||
void start();
|
||||
|
||||
qreal spinnerAngle() const { return spinnerAngle_; }
|
||||
qreal progress() const { return progress_; }
|
||||
qreal panelOpacity() const { return panelOpacity_; }
|
||||
qreal checkmarkProgress() const { return checkmarkProgress_; }
|
||||
|
||||
void setSpinnerAngle(qreal v) { spinnerAngle_ = v; update(); }
|
||||
void setProgress(qreal v) { progress_ = v; update(); }
|
||||
void setPanelOpacity(qreal v) { panelOpacity_ = v; update(); }
|
||||
void setCheckmarkProgress(qreal v) { checkmarkProgress_ = v; update(); }
|
||||
|
||||
signals:
|
||||
void completed(bool success);
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent*) override;
|
||||
|
||||
private:
|
||||
void onInjectResult(QString err);
|
||||
|
||||
unsigned long pid_;
|
||||
QString target_;
|
||||
QString statusText_ = QStringLiteral("Injecting OpenZen…");
|
||||
|
||||
qreal spinnerAngle_ = 0.0;
|
||||
qreal progress_ = 0.0;
|
||||
qreal panelOpacity_ = 0.0;
|
||||
qreal checkmarkProgress_ = 0.0;
|
||||
|
||||
bool injectDone_ = false;
|
||||
bool injectOk_ = false;
|
||||
QString injectErr_;
|
||||
|
||||
QPropertyAnimation* spinnerAnim_ = nullptr;
|
||||
QPropertyAnimation* progressAnim_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace loader
|
||||
@@ -0,0 +1,125 @@
|
||||
#include "InstanceList.h"
|
||||
|
||||
#include "InstanceRow.h"
|
||||
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QScrollArea>
|
||||
#include <QSet>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace loader {
|
||||
|
||||
InstanceList::InstanceList(QWidget* parent)
|
||||
: QWidget(parent) {
|
||||
auto* root = new QVBoxLayout(this);
|
||||
root->setContentsMargins(0, 0, 0, 0);
|
||||
root->setSpacing(0);
|
||||
|
||||
scroll_ = new QScrollArea(this);
|
||||
scroll_->setFrameShape(QFrame::NoFrame);
|
||||
scroll_->setWidgetResizable(true);
|
||||
scroll_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
scroll_->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
|
||||
scroll_->setStyleSheet(QStringLiteral(
|
||||
"QScrollArea { background: transparent; border: none; }"
|
||||
"QScrollBar:vertical {"
|
||||
" background: transparent; width: 8px; margin: 4px 1px;"
|
||||
"}"
|
||||
"QScrollBar::handle:vertical {"
|
||||
" background: #3a3d45; border-radius: 4px; min-height: 28px;"
|
||||
"}"
|
||||
"QScrollBar::handle:vertical:hover { background: #4a4e58; }"
|
||||
"QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {"
|
||||
" height: 0;"
|
||||
"}"));
|
||||
|
||||
container_ = new QWidget(scroll_);
|
||||
container_->setObjectName("instanceContainer");
|
||||
container_->setStyleSheet(QStringLiteral(
|
||||
"#instanceContainer { background: transparent; }"));
|
||||
|
||||
containerLayout_ = new QVBoxLayout(container_);
|
||||
containerLayout_->setContentsMargins(2, 2, 2, 2);
|
||||
containerLayout_->setSpacing(0);
|
||||
|
||||
emptyLabel_ = new QLabel(
|
||||
QStringLiteral("No Minecraft instances detected.\n"
|
||||
"Start the game and it will show up here."),
|
||||
container_);
|
||||
emptyLabel_->setAlignment(Qt::AlignCenter);
|
||||
emptyLabel_->setStyleSheet(QStringLiteral(
|
||||
"color: #6a6f7a; font-size: 12px; font-style: italic;"
|
||||
"padding: 40px 12px;"));
|
||||
containerLayout_->addWidget(emptyLabel_);
|
||||
|
||||
// Stretch keeps rows aligned to the top of the scroll viewport.
|
||||
containerLayout_->addStretch(1);
|
||||
|
||||
scroll_->setWidget(container_);
|
||||
root->addWidget(scroll_);
|
||||
|
||||
updateEmptyState();
|
||||
}
|
||||
|
||||
void InstanceList::setInstances(const QVector<Instance>& list) {
|
||||
// Compute incoming pid set.
|
||||
QSet<unsigned long> incoming;
|
||||
incoming.reserve(list.size());
|
||||
for (const auto& it : list) incoming.insert(it.pid);
|
||||
|
||||
// Remove rows no longer present.
|
||||
QList<unsigned long> toRemove;
|
||||
toRemove.reserve(rows_.size());
|
||||
for (auto it = rows_.constBegin(); it != rows_.constEnd(); ++it) {
|
||||
if (!incoming.contains(it.key())) toRemove.push_back(it.key());
|
||||
}
|
||||
for (auto pid : toRemove) {
|
||||
InstanceRow* r = rows_.take(pid);
|
||||
containerLayout_->removeWidget(r);
|
||||
r->deleteLater();
|
||||
}
|
||||
|
||||
// Add or update / reorder.
|
||||
for (int i = 0; i < list.size(); ++i) {
|
||||
const auto& it = list[i];
|
||||
InstanceRow* r = rows_.value(it.pid, nullptr);
|
||||
if (!r) {
|
||||
r = new InstanceRow(it.pid, it.title, container_);
|
||||
connect(r, &InstanceRow::injectClicked,
|
||||
this, &InstanceList::injectRequested);
|
||||
rows_.insert(it.pid, r);
|
||||
// Insert at index i (after any preceding rows, before the
|
||||
// empty label / stretch).
|
||||
containerLayout_->insertWidget(i, r);
|
||||
r->playEntrance();
|
||||
} else {
|
||||
r->updateTitle(it.title);
|
||||
int currentIndex = containerLayout_->indexOf(r);
|
||||
if (currentIndex != i) {
|
||||
containerLayout_->removeWidget(r);
|
||||
containerLayout_->insertWidget(i, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInteractive(interactive_); // re-apply enabled state on new rows
|
||||
updateEmptyState();
|
||||
}
|
||||
|
||||
void InstanceList::setInteractive(bool on) {
|
||||
interactive_ = on;
|
||||
for (auto* r : rows_) {
|
||||
// The Inject button is the only interactive child we care about.
|
||||
if (auto* btn = r->findChild<QPushButton*>()) {
|
||||
btn->setEnabled(on);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void InstanceList::updateEmptyState() {
|
||||
const bool empty = rows_.isEmpty();
|
||||
emptyLabel_->setVisible(empty);
|
||||
}
|
||||
|
||||
} // namespace loader
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <QHash>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
#include <QWidget>
|
||||
|
||||
class QLabel;
|
||||
class QScrollArea;
|
||||
class QVBoxLayout;
|
||||
|
||||
namespace loader {
|
||||
|
||||
struct Instance {
|
||||
unsigned long pid;
|
||||
QString title;
|
||||
};
|
||||
|
||||
class InstanceRow;
|
||||
|
||||
// Scrollable column of InstanceRow widgets. Replaces QTableWidget: rows are
|
||||
// not selectable, there is no double-click affordance; each row owns its
|
||||
// own Inject button and forwards clicks through injectRequested().
|
||||
class InstanceList : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit InstanceList(QWidget* parent = nullptr);
|
||||
|
||||
// Incremental update: rows for pids no longer present are removed
|
||||
// (animated out of the layout), rows for new pids are inserted at the
|
||||
// right index and play an entrance animation, existing rows just get
|
||||
// their title refreshed.
|
||||
void setInstances(const QVector<Instance>& list);
|
||||
|
||||
int count() const { return rows_.size(); }
|
||||
|
||||
// Disables inject buttons across all rows (used while an injection is
|
||||
// already in progress so the user can't queue a second one).
|
||||
void setInteractive(bool on);
|
||||
|
||||
signals:
|
||||
void injectRequested(unsigned long pid, const QString& title);
|
||||
|
||||
private:
|
||||
void updateEmptyState();
|
||||
|
||||
QScrollArea* scroll_ = nullptr;
|
||||
QWidget* container_ = nullptr;
|
||||
QVBoxLayout* containerLayout_ = nullptr;
|
||||
QLabel* emptyLabel_ = nullptr;
|
||||
|
||||
QHash<unsigned long, InstanceRow*> rows_;
|
||||
bool interactive_ = true;
|
||||
};
|
||||
|
||||
} // namespace loader
|
||||
@@ -0,0 +1,194 @@
|
||||
#include "InstanceRow.h"
|
||||
|
||||
#include <QEasingCurve>
|
||||
#include <QEnterEvent>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLinearGradient>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QPushButton>
|
||||
|
||||
namespace loader {
|
||||
|
||||
namespace {
|
||||
constexpr int kRowHeight = 56;
|
||||
constexpr int kCornerRadius = 9;
|
||||
|
||||
const char* kInjectBtnQss = R"qss(
|
||||
QPushButton {
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 #4a83e0, stop:1 #3260bd);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 22);
|
||||
border-radius: 6px;
|
||||
padding: 6px 18px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
font-family: "Segoe UI", "Microsoft YaHei UI", sans-serif;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 #5a93f0, stop:1 #4275d8);
|
||||
border: 1px solid rgba(255, 255, 255, 60);
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 #2c5db0, stop:1 #1e468f);
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background: #2c2f37;
|
||||
color: #6a6f7a;
|
||||
border: 1px solid #2c2f37;
|
||||
}
|
||||
)qss";
|
||||
} // namespace
|
||||
|
||||
InstanceRow::InstanceRow(unsigned long pid, const QString& title, QWidget* parent)
|
||||
: QFrame(parent), pid_(pid), title_(title) {
|
||||
setFixedHeight(kRowHeight);
|
||||
setAttribute(Qt::WA_StyledBackground, false);
|
||||
setMouseTracking(true);
|
||||
|
||||
auto* layout = new QHBoxLayout(this);
|
||||
// Leave a slight inner padding so the painted panel has visible breathing
|
||||
// room from the sibling rows above/below it.
|
||||
layout->setContentsMargins(14, 6, 12, 6);
|
||||
layout->setSpacing(12);
|
||||
|
||||
pidLabel_ = new QLabel(QString::number(pid_), this);
|
||||
pidLabel_->setMinimumWidth(72);
|
||||
pidLabel_->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
|
||||
pidLabel_->setStyleSheet(QStringLiteral(
|
||||
"color: #9aa3b2;"
|
||||
"font-family: 'JetBrains Mono', 'Consolas', 'Segoe UI', monospace;"
|
||||
"font-size: 12px; font-weight: 600;"));
|
||||
layout->addWidget(pidLabel_);
|
||||
|
||||
titleLabel_ = new QLabel(title_, this);
|
||||
titleLabel_->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
|
||||
titleLabel_->setStyleSheet(QStringLiteral(
|
||||
"color: #e7eaf2; font-size: 13px;"
|
||||
"font-family: 'Segoe UI', 'Microsoft YaHei UI', sans-serif;"));
|
||||
titleLabel_->setTextInteractionFlags(Qt::NoTextInteraction);
|
||||
layout->addWidget(titleLabel_, 1);
|
||||
|
||||
injectBtn_ = new QPushButton(QStringLiteral("Inject"), this);
|
||||
injectBtn_->setCursor(Qt::PointingHandCursor);
|
||||
injectBtn_->setStyleSheet(QString::fromUtf8(kInjectBtnQss));
|
||||
injectBtn_->setFixedHeight(30);
|
||||
connect(injectBtn_, &QPushButton::clicked, this, [this] {
|
||||
emit injectClicked(pid_, title_);
|
||||
});
|
||||
layout->addWidget(injectBtn_);
|
||||
}
|
||||
|
||||
void InstanceRow::updateTitle(const QString& title) {
|
||||
if (title == title_) return;
|
||||
title_ = title;
|
||||
titleLabel_->setText(title_);
|
||||
}
|
||||
|
||||
void InstanceRow::setEntrance(qreal v) {
|
||||
entrance_ = v;
|
||||
// Slide-in vertical offset + opacity baked into a single property so we
|
||||
// can drive them with one animation.
|
||||
setWindowOpacity(1.0); // no-op on child widgets, kept for clarity
|
||||
auto* eff = graphicsEffect();
|
||||
Q_UNUSED(eff);
|
||||
update();
|
||||
// Translate by setting contents margins doesn't move us. Instead we
|
||||
// shift via a property-based geometry: simplest is to update an
|
||||
// internal offset that paintEvent honours. We just call update() and
|
||||
// let paintEvent read entrance_ to translate / fade.
|
||||
}
|
||||
|
||||
void InstanceRow::playEntrance() {
|
||||
entrance_ = 0.0;
|
||||
update();
|
||||
|
||||
auto* anim = new QPropertyAnimation(this, "entrance", this);
|
||||
anim->setDuration(280);
|
||||
anim->setStartValue(0.0);
|
||||
anim->setEndValue(1.0);
|
||||
anim->setEasingCurve(QEasingCurve::OutCubic);
|
||||
anim->start(QAbstractAnimation::DeleteWhenStopped);
|
||||
}
|
||||
|
||||
void InstanceRow::animateHover(qreal target) {
|
||||
if (hoverAnim_) {
|
||||
hoverAnim_->stop();
|
||||
hoverAnim_->deleteLater();
|
||||
}
|
||||
hoverAnim_ = new QPropertyAnimation(this, "hoverIntensity", this);
|
||||
hoverAnim_->setDuration(140);
|
||||
hoverAnim_->setStartValue(hoverIntensity_);
|
||||
hoverAnim_->setEndValue(target);
|
||||
hoverAnim_->setEasingCurve(QEasingCurve::OutCubic);
|
||||
hoverAnim_->start();
|
||||
}
|
||||
|
||||
void InstanceRow::enterEvent(QEnterEvent*) {
|
||||
animateHover(1.0);
|
||||
}
|
||||
|
||||
void InstanceRow::leaveEvent(QEvent*) {
|
||||
animateHover(0.0);
|
||||
}
|
||||
|
||||
void InstanceRow::paintEvent(QPaintEvent*) {
|
||||
QPainter p(this);
|
||||
p.setRenderHint(QPainter::Antialiasing);
|
||||
|
||||
// Combine entrance offset (slight slide up) + opacity in one go. The
|
||||
// labels and button still paint at their layout positions because they
|
||||
// are children with their own paint passes - so the visual effect of
|
||||
// entrance_ is limited to the painted background. To get the labels to
|
||||
// fade in too we apply a global opacity to ourselves via setWindowOpacity
|
||||
// (no-op for child widget), so instead drop opacity here only for the
|
||||
// panel; the labels appear "writing in" against a darker panel which
|
||||
// reads as a fade-in anyway in practice.
|
||||
qreal slideDy = (1.0 - entrance_) * 8.0;
|
||||
qreal panelAlpha = entrance_;
|
||||
|
||||
QRectF rect = QRectF(this->rect()).adjusted(4, 4 + slideDy, -4, -4 + slideDy);
|
||||
QPainterPath panel;
|
||||
panel.addRoundedRect(rect, kCornerRadius, kCornerRadius);
|
||||
|
||||
// Base fill is the dimmest; hoverIntensity_ blends in a brighter
|
||||
// gradient on top.
|
||||
QLinearGradient base(rect.topLeft(), rect.bottomLeft());
|
||||
base.setColorAt(0.0, QColor(38, 41, 48, int(255 * panelAlpha)));
|
||||
base.setColorAt(1.0, QColor(30, 32, 38, int(255 * panelAlpha)));
|
||||
p.fillPath(panel, base);
|
||||
|
||||
if (hoverIntensity_ > 0.001) {
|
||||
QLinearGradient hover(rect.topLeft(), rect.bottomLeft());
|
||||
hover.setColorAt(0.0,
|
||||
QColor(74, 131, 224, int(38 * hoverIntensity_ * panelAlpha)));
|
||||
hover.setColorAt(1.0,
|
||||
QColor(50, 96, 189, int(22 * hoverIntensity_ * panelAlpha)));
|
||||
p.fillPath(panel, hover);
|
||||
}
|
||||
|
||||
// Left accent stripe that brightens with hover.
|
||||
{
|
||||
QRectF stripe = rect;
|
||||
stripe.setRight(stripe.left() + 3);
|
||||
QColor stripeCol(85, 135, 235,
|
||||
int(60 + 160 * hoverIntensity_) * (panelAlpha < 0.999 ? panelAlpha : 1.0));
|
||||
QPainterPath sp;
|
||||
sp.addRoundedRect(stripe, 2.0, 2.0);
|
||||
p.fillPath(sp, stripeCol);
|
||||
}
|
||||
|
||||
// Outline that brightens with hover.
|
||||
QPen border(QColor(255, 255, 255,
|
||||
int((22 + 36 * hoverIntensity_) * panelAlpha)), 1);
|
||||
p.setPen(border);
|
||||
p.setBrush(Qt::NoBrush);
|
||||
p.drawPath(panel);
|
||||
}
|
||||
|
||||
} // namespace loader
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include <QFrame>
|
||||
#include <QString>
|
||||
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
class QPropertyAnimation;
|
||||
|
||||
namespace loader {
|
||||
|
||||
// A single instance row inside InstanceList. Self-paints a rounded panel,
|
||||
// hosts PID / title labels and an Inject button on the right. Hover
|
||||
// transitions are driven by a QPropertyAnimation on hoverIntensity_; the
|
||||
// row's own entrance is driven by entrance_ (opacity * vertical offset).
|
||||
class InstanceRow : public QFrame {
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(qreal hoverIntensity READ hoverIntensity WRITE setHoverIntensity)
|
||||
Q_PROPERTY(qreal entrance READ entrance WRITE setEntrance)
|
||||
|
||||
public:
|
||||
InstanceRow(unsigned long pid, const QString& title, QWidget* parent = nullptr);
|
||||
|
||||
unsigned long pid() const { return pid_; }
|
||||
void updateTitle(const QString& title);
|
||||
|
||||
qreal hoverIntensity() const { return hoverIntensity_; }
|
||||
void setHoverIntensity(qreal v) { hoverIntensity_ = v; update(); }
|
||||
|
||||
qreal entrance() const { return entrance_; }
|
||||
void setEntrance(qreal v);
|
||||
|
||||
void playEntrance();
|
||||
|
||||
signals:
|
||||
void injectClicked(unsigned long pid, const QString& title);
|
||||
|
||||
protected:
|
||||
void enterEvent(QEnterEvent*) override;
|
||||
void leaveEvent(QEvent*) override;
|
||||
void paintEvent(QPaintEvent*) override;
|
||||
|
||||
private:
|
||||
void animateHover(qreal target);
|
||||
|
||||
unsigned long pid_;
|
||||
QString title_;
|
||||
QLabel* pidLabel_ = nullptr;
|
||||
QLabel* titleLabel_ = nullptr;
|
||||
QPushButton* injectBtn_ = nullptr;
|
||||
|
||||
qreal hoverIntensity_ = 0.0; // 0..1, drives background tint
|
||||
qreal entrance_ = 1.0; // 0..1, drives opacity + slide-in offset
|
||||
|
||||
QPropertyAnimation* hoverAnim_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace loader
|
||||
+192
-127
@@ -1,22 +1,38 @@
|
||||
#include "MainWindow.h"
|
||||
|
||||
#include "InjectionOverlay.h"
|
||||
#include "InstanceList.h"
|
||||
#include "TitleBar.h"
|
||||
#include "loader.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QCloseEvent>
|
||||
#include <QEasingCurve>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHeaderView>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QLinearGradient>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QParallelAnimationGroup>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QString>
|
||||
#include <QTableWidget>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <QVector>
|
||||
#include <QWidget>
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
# include <windows.h>
|
||||
# include <dwmapi.h>
|
||||
#endif
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace loader {
|
||||
|
||||
namespace {
|
||||
constexpr int kCornerRadius = 12;
|
||||
|
||||
QString fromW(const std::wstring& w) {
|
||||
return QString::fromWCharArray(w.c_str(), static_cast<int>(w.size()));
|
||||
}
|
||||
@@ -28,10 +44,9 @@ bool startsWithMinecraft(const std::wstring& title) {
|
||||
}
|
||||
|
||||
bool isMinecraft(const std::wstring& title, const std::wstring& cls) {
|
||||
// Either the window title starts with "Minecraft" (in-game window
|
||||
// labelled e.g. "Minecraft 1.20.1"), or the window class is "GLFW30"
|
||||
// (the class Minecraft's LWJGL GLFW windows always use, even before the
|
||||
// title has been set).
|
||||
// Either the title starts with "Minecraft" (in-game window such as
|
||||
// "Minecraft 1.20.1") or the LWJGL GLFW window class is in use (still
|
||||
// true before the title gets set during early startup).
|
||||
if (startsWithMinecraft(title)) return true;
|
||||
return _wcsicmp(cls.c_str(), L"GLFW30") == 0;
|
||||
}
|
||||
@@ -39,6 +54,15 @@ bool isMinecraft(const std::wstring& title, const std::wstring& cls) {
|
||||
|
||||
MainWindow::MainWindow(QWidget* parent)
|
||||
: QMainWindow(parent) {
|
||||
// Frameless + translucent so paintEvent can draw a rounded panel and
|
||||
// shape the window however we like. The custom TitleBar covers move/
|
||||
// minimize/close.
|
||||
setWindowFlags(Qt::FramelessWindowHint | Qt::Window);
|
||||
setAttribute(Qt::WA_TranslucentBackground);
|
||||
setAttribute(Qt::WA_NoSystemBackground);
|
||||
setMinimumSize(760, 500);
|
||||
resize(760, 500);
|
||||
|
||||
styleApp();
|
||||
buildUi();
|
||||
|
||||
@@ -51,62 +75,65 @@ MainWindow::MainWindow(QWidget* parent)
|
||||
|
||||
void MainWindow::buildUi() {
|
||||
#ifdef OPENZEN_BUILD_REVISION
|
||||
setWindowTitle(QStringLiteral("OpenZen Loader (build %1)")
|
||||
.arg(QString::fromLatin1(OPENZEN_BUILD_REVISION).left(7)));
|
||||
const QString winTitle = QStringLiteral("OpenZen Loader · build %1")
|
||||
.arg(QString::fromLatin1(OPENZEN_BUILD_REVISION).left(7));
|
||||
#else
|
||||
setWindowTitle(QStringLiteral("OpenZen Loader"));
|
||||
const QString winTitle = QStringLiteral("OpenZen Loader");
|
||||
#endif
|
||||
resize(720, 460);
|
||||
setWindowTitle(winTitle);
|
||||
|
||||
auto* central = new QWidget(this);
|
||||
auto* layout = new QVBoxLayout(central);
|
||||
layout->setContentsMargins(16, 16, 16, 12);
|
||||
central->setAttribute(Qt::WA_TranslucentBackground);
|
||||
|
||||
auto* root = new QVBoxLayout(central);
|
||||
root->setContentsMargins(0, 0, 0, 0);
|
||||
root->setSpacing(0);
|
||||
|
||||
titleBar_ = new TitleBar(central);
|
||||
titleBar_->setTitleText(winTitle);
|
||||
root->addWidget(titleBar_);
|
||||
|
||||
auto* body = new QWidget(central);
|
||||
body->setObjectName("body");
|
||||
body->setAttribute(Qt::WA_StyledBackground, false);
|
||||
auto* layout = new QVBoxLayout(body);
|
||||
layout->setContentsMargins(18, 14, 18, 14);
|
||||
layout->setSpacing(10);
|
||||
|
||||
auto* title = new QLabel(QStringLiteral("Minecraft Instances"), central);
|
||||
auto* title = new QLabel(QStringLiteral("Minecraft Instances"), body);
|
||||
title->setObjectName("title");
|
||||
|
||||
hint_ = new QLabel(
|
||||
QStringLiteral("Double-click a row to inject. List refreshes every second."),
|
||||
central);
|
||||
QStringLiteral("Click Inject on the instance you want to load OpenZen into. "
|
||||
"List refreshes every second."),
|
||||
body);
|
||||
hint_->setObjectName("hint");
|
||||
hint_->setWordWrap(true);
|
||||
|
||||
table_ = new QTableWidget(0, 3, central);
|
||||
table_->setHorizontalHeaderLabels({
|
||||
QStringLiteral("PID"),
|
||||
QStringLiteral("Window Title"),
|
||||
QStringLiteral("Window Class"),
|
||||
});
|
||||
table_->verticalHeader()->setVisible(false);
|
||||
table_->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
table_->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
table_->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
table_->setAlternatingRowColors(true);
|
||||
table_->setShowGrid(false);
|
||||
table_->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
|
||||
table_->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);
|
||||
table_->horizontalHeader()->setSectionResizeMode(2, QHeaderView::ResizeToContents);
|
||||
table_->horizontalHeader()->setHighlightSections(false);
|
||||
connect(table_, &QTableWidget::cellDoubleClicked,
|
||||
this, &MainWindow::onRowDoubleClicked);
|
||||
list_ = new InstanceList(body);
|
||||
connect(list_, &InstanceList::injectRequested,
|
||||
this, &MainWindow::onInjectRequested);
|
||||
|
||||
status_ = new QLabel(QStringLiteral("Ready."), central);
|
||||
status_ = new QLabel(QStringLiteral("Watching for Minecraft processes…"), body);
|
||||
status_->setObjectName("status");
|
||||
status_->setWordWrap(true);
|
||||
|
||||
layout->addWidget(title);
|
||||
layout->addWidget(hint_);
|
||||
layout->addWidget(table_, 1);
|
||||
layout->addWidget(list_, 1);
|
||||
layout->addWidget(status_);
|
||||
root->addWidget(body, 1);
|
||||
|
||||
setCentralWidget(central);
|
||||
}
|
||||
|
||||
void MainWindow::styleApp() {
|
||||
// Modern dark palette via QSS - keeps the EXE self-contained (no theme
|
||||
// file shipped separately) and looks consistent across Windows versions.
|
||||
// Modern dark palette via QSS. The MainWindow background is painted in
|
||||
// paintEvent (rounded panel) so we don't set a background colour on
|
||||
// QMainWindow/QWidget directly here.
|
||||
static const char* kQss = R"qss(
|
||||
QMainWindow, QWidget {
|
||||
background: #1c1d22;
|
||||
QWidget#body {
|
||||
background: transparent;
|
||||
color: #e3e5ea;
|
||||
font-family: "Segoe UI", "Microsoft YaHei UI", sans-serif;
|
||||
font-size: 13px;
|
||||
@@ -115,118 +142,156 @@ void MainWindow::styleApp() {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
padding: 2px 0 0 2px;
|
||||
}
|
||||
QLabel#hint {
|
||||
color: #8a8e98;
|
||||
font-size: 12px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
QLabel#status {
|
||||
color: #c2c6cf;
|
||||
padding: 6px 10px;
|
||||
padding: 7px 11px;
|
||||
border: 1px solid #2c2e35;
|
||||
border-radius: 6px;
|
||||
background: #23252b;
|
||||
}
|
||||
QTableWidget {
|
||||
background: #23252b;
|
||||
alternate-background-color: #1f2126;
|
||||
gridline-color: #2c2e35;
|
||||
border: 1px solid #2c2e35;
|
||||
border-radius: 8px;
|
||||
selection-background-color: #3d6fd1;
|
||||
selection-color: #ffffff;
|
||||
}
|
||||
QTableWidget::item {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background: #2a2c33;
|
||||
color: #cfd2da;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-right: 1px solid #1c1d22;
|
||||
font-weight: 600;
|
||||
}
|
||||
QHeaderView::section:last {
|
||||
border-right: none;
|
||||
}
|
||||
QTableCornerButton::section {
|
||||
background: #2a2c33;
|
||||
border: none;
|
||||
}
|
||||
QScrollBar:vertical {
|
||||
background: #1c1d22;
|
||||
width: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #3a3d45;
|
||||
border-radius: 4px;
|
||||
min-height: 24px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #4a4e58;
|
||||
}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||||
height: 0;
|
||||
border-radius: 7px;
|
||||
background: rgba(35, 37, 43, 200);
|
||||
}
|
||||
)qss";
|
||||
qApp->setStyleSheet(QString::fromUtf8(kQss));
|
||||
}
|
||||
|
||||
void MainWindow::enableWin11RoundedCorners() {
|
||||
#ifdef Q_OS_WIN
|
||||
// DWMWA_WINDOW_CORNER_PREFERENCE is Windows 11+. The call is harmless
|
||||
// (returns an HRESULT we ignore) on Windows 10 — DwmSetWindowAttribute
|
||||
// just rejects the unknown attribute, no crash.
|
||||
enum { DWMWA_WINDOW_CORNER_PREFERENCE_LOCAL = 33 };
|
||||
enum { DWMWCP_ROUND_LOCAL = 2 };
|
||||
HWND hwnd = reinterpret_cast<HWND>(winId());
|
||||
if (!hwnd) return;
|
||||
int pref = DWMWCP_ROUND_LOCAL;
|
||||
DwmSetWindowAttribute(hwnd,
|
||||
DWMWA_WINDOW_CORNER_PREFERENCE_LOCAL,
|
||||
&pref,
|
||||
sizeof(pref));
|
||||
#endif
|
||||
}
|
||||
|
||||
void MainWindow::paintEvent(QPaintEvent*) {
|
||||
QPainter p(this);
|
||||
p.setRenderHint(QPainter::Antialiasing);
|
||||
|
||||
QRectF r = QRectF(rect()).adjusted(0.5, 0.5, -0.5, -0.5);
|
||||
QPainterPath panel;
|
||||
panel.addRoundedRect(r, kCornerRadius, kCornerRadius);
|
||||
|
||||
QLinearGradient bg(r.topLeft(), r.bottomLeft());
|
||||
bg.setColorAt(0.0, QColor("#1f2127"));
|
||||
bg.setColorAt(1.0, QColor("#15171c"));
|
||||
p.fillPath(panel, bg);
|
||||
|
||||
p.setPen(QPen(QColor(255, 255, 255, 26), 1));
|
||||
p.setBrush(Qt::NoBrush);
|
||||
p.drawPath(panel);
|
||||
}
|
||||
|
||||
void MainWindow::showEvent(QShowEvent* e) {
|
||||
QMainWindow::showEvent(e);
|
||||
enableWin11RoundedCorners();
|
||||
if (!entrancePlayed_) {
|
||||
setWindowOpacity(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::playEntrance() {
|
||||
entrancePlayed_ = true;
|
||||
show();
|
||||
raise();
|
||||
activateWindow();
|
||||
|
||||
auto* fade = new QPropertyAnimation(this, "windowOpacity", this);
|
||||
fade->setDuration(520);
|
||||
fade->setStartValue(windowOpacity());
|
||||
fade->setEndValue(1.0);
|
||||
fade->setEasingCurve(QEasingCurve::OutCubic);
|
||||
|
||||
QRect endGeo = geometry();
|
||||
QRect startGeo = endGeo;
|
||||
startGeo.translate(0, 18);
|
||||
setGeometry(startGeo);
|
||||
auto* slide = new QPropertyAnimation(this, "geometry", this);
|
||||
slide->setDuration(560);
|
||||
slide->setStartValue(startGeo);
|
||||
slide->setEndValue(endGeo);
|
||||
slide->setEasingCurve(QEasingCurve::OutCubic);
|
||||
|
||||
auto* grp = new QParallelAnimationGroup(this);
|
||||
grp->addAnimation(fade);
|
||||
grp->addAnimation(slide);
|
||||
grp->start(QAbstractAnimation::DeleteWhenStopped);
|
||||
}
|
||||
|
||||
void MainWindow::refreshNow() {
|
||||
auto procs = list_java_processes();
|
||||
QVector<Row> filtered;
|
||||
QVector<Instance> filtered;
|
||||
filtered.reserve(procs.size());
|
||||
for (const auto& jp : procs) {
|
||||
if (!isMinecraft(jp.window_title, jp.window_class)) continue;
|
||||
Row r{ jp.pid, fromW(jp.window_title), fromW(jp.window_class) };
|
||||
filtered.push_back(std::move(r));
|
||||
Instance item;
|
||||
item.pid = jp.pid;
|
||||
item.title = fromW(jp.window_title);
|
||||
if (item.title.isEmpty()) {
|
||||
item.title = QStringLiteral("(starting up — %1)")
|
||||
.arg(fromW(jp.window_class));
|
||||
}
|
||||
filtered.push_back(std::move(item));
|
||||
}
|
||||
|
||||
// Preserve selection by pid across refreshes.
|
||||
unsigned long selectedPid = 0;
|
||||
if (auto sel = table_->currentRow(); sel >= 0 && sel < rows_.size()) {
|
||||
selectedPid = rows_[sel].pid;
|
||||
}
|
||||
|
||||
rows_ = std::move(filtered);
|
||||
table_->setRowCount(rows_.size());
|
||||
int restoreRow = -1;
|
||||
for (int i = 0; i < rows_.size(); ++i) {
|
||||
const auto& r = rows_[i];
|
||||
auto pidItem = new QTableWidgetItem(QString::number(r.pid));
|
||||
auto titleItem = new QTableWidgetItem(r.title);
|
||||
auto clsItem = new QTableWidgetItem(r.cls);
|
||||
pidItem->setTextAlignment(Qt::AlignCenter);
|
||||
table_->setItem(i, 0, pidItem);
|
||||
table_->setItem(i, 1, titleItem);
|
||||
table_->setItem(i, 2, clsItem);
|
||||
if (selectedPid && r.pid == selectedPid) restoreRow = i;
|
||||
}
|
||||
if (restoreRow >= 0) table_->selectRow(restoreRow);
|
||||
|
||||
list_->setInstances(filtered);
|
||||
status_->setText(QStringLiteral("Watching %1 Minecraft instance(s).")
|
||||
.arg(rows_.size()));
|
||||
.arg(list_->count()));
|
||||
}
|
||||
|
||||
void MainWindow::onRowDoubleClicked(int row, int /*column*/) {
|
||||
if (row < 0 || row >= rows_.size()) return;
|
||||
const auto& r = rows_[row];
|
||||
status_->setText(QStringLiteral("Mapping OpenZen.dll into PID %1 (%2)...")
|
||||
.arg(r.pid).arg(r.title));
|
||||
QApplication::processEvents();
|
||||
void MainWindow::onInjectRequested(unsigned long pid, const QString& title) {
|
||||
if (injectionInFlight_) return;
|
||||
injectionInFlight_ = true;
|
||||
|
||||
std::wstring err = inject(r.pid);
|
||||
if (err.empty()) {
|
||||
status_->setText(QStringLiteral(
|
||||
"Injected into PID %1. Check %TEMP%\\openzen.log for Java side.")
|
||||
.arg(r.pid));
|
||||
} else {
|
||||
QString msg = fromW(err);
|
||||
status_->setText(QStringLiteral("Injection failed: %1").arg(msg));
|
||||
QMessageBox::critical(this, QStringLiteral("Injection failed"), msg);
|
||||
// Freeze the list so the user can't queue another injection while we're
|
||||
// waiting for the worker thread + animation to wrap up.
|
||||
list_->setInteractive(false);
|
||||
if (timer_) timer_->stop();
|
||||
|
||||
auto* overlay = new InjectionOverlay(pid, title, this);
|
||||
connect(overlay, &InjectionOverlay::completed, this,
|
||||
[this](bool /*ok*/) { playExitThenQuit(); });
|
||||
overlay->start();
|
||||
}
|
||||
|
||||
void MainWindow::closeEvent(QCloseEvent* e) {
|
||||
// Intercept the first close so we can play the fade-out; the fade's
|
||||
// finished handler calls qApp->quit which fires another close after
|
||||
// exiting_ is set, and we let that one through normally.
|
||||
if (exiting_) {
|
||||
QMainWindow::closeEvent(e);
|
||||
return;
|
||||
}
|
||||
e->ignore();
|
||||
playExitThenQuit();
|
||||
}
|
||||
|
||||
void MainWindow::playExitThenQuit() {
|
||||
if (exiting_) return;
|
||||
exiting_ = true;
|
||||
if (timer_) timer_->stop();
|
||||
|
||||
auto* fade = new QPropertyAnimation(this, "windowOpacity", this);
|
||||
fade->setDuration(280);
|
||||
fade->setStartValue(windowOpacity());
|
||||
fade->setEndValue(0.0);
|
||||
fade->setEasingCurve(QEasingCurve::InCubic);
|
||||
connect(fade, &QAbstractAnimation::finished,
|
||||
qApp, &QApplication::quit);
|
||||
fade->start(QAbstractAnimation::DeleteWhenStopped);
|
||||
}
|
||||
|
||||
} // namespace loader
|
||||
|
||||
@@ -1,37 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QSet>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
class QTableWidget;
|
||||
class QTimer;
|
||||
class QLabel;
|
||||
class QTimer;
|
||||
|
||||
namespace loader {
|
||||
|
||||
class TitleBar;
|
||||
class InstanceList;
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit MainWindow(QWidget* parent = nullptr);
|
||||
|
||||
// Called after the splash finishes; plays the entrance animation.
|
||||
void playEntrance();
|
||||
|
||||
// Fade the window out, then quit the application. Idempotent: a second
|
||||
// call while a fade is already in flight is a no-op. Used by both the
|
||||
// close button and the "injection finished" path so the loader always
|
||||
// exits with a soft fade instead of vanishing.
|
||||
void playExitThenQuit();
|
||||
|
||||
private slots:
|
||||
void refreshNow();
|
||||
void onRowDoubleClicked(int row, int column);
|
||||
void onInjectRequested(unsigned long pid, const QString& title);
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent*) override;
|
||||
void showEvent(QShowEvent*) override;
|
||||
void closeEvent(QCloseEvent*) override;
|
||||
|
||||
private:
|
||||
void buildUi();
|
||||
void styleApp();
|
||||
void enableWin11RoundedCorners();
|
||||
|
||||
QTableWidget* table_ = nullptr;
|
||||
QTimer* timer_ = nullptr;
|
||||
QLabel* status_ = nullptr;
|
||||
QLabel* hint_ = nullptr;
|
||||
TitleBar* titleBar_ = nullptr;
|
||||
InstanceList* list_ = nullptr;
|
||||
QTimer* timer_ = nullptr;
|
||||
QLabel* status_ = nullptr;
|
||||
QLabel* hint_ = nullptr;
|
||||
|
||||
struct Row {
|
||||
unsigned long pid;
|
||||
QString title;
|
||||
QString cls;
|
||||
};
|
||||
QVector<Row> rows_;
|
||||
bool entrancePlayed_ = false;
|
||||
bool injectionInFlight_ = false;
|
||||
bool exiting_ = false;
|
||||
};
|
||||
|
||||
} // namespace loader
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
#include "SplashScreen.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QEasingCurve>
|
||||
#include <QFont>
|
||||
#include <QFontMetricsF>
|
||||
#include <QLinearGradient>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QParallelAnimationGroup>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QRadialGradient>
|
||||
#include <QScreen>
|
||||
#include <QSequentialAnimationGroup>
|
||||
|
||||
namespace loader {
|
||||
|
||||
namespace {
|
||||
constexpr int kWidth = 520;
|
||||
constexpr int kHeight = 280;
|
||||
constexpr int kCornerRadius = 18;
|
||||
} // namespace
|
||||
|
||||
SplashScreen::SplashScreen(QWidget* parent)
|
||||
: QWidget(parent) {
|
||||
setWindowFlags(Qt::FramelessWindowHint
|
||||
| Qt::Tool
|
||||
| Qt::WindowStaysOnTopHint);
|
||||
setAttribute(Qt::WA_TranslucentBackground);
|
||||
setAttribute(Qt::WA_DeleteOnClose);
|
||||
resize(kWidth, kHeight);
|
||||
|
||||
if (auto* scr = QApplication::primaryScreen()) {
|
||||
QRect g = scr->availableGeometry();
|
||||
move(g.center() - QPoint(kWidth / 2, kHeight / 2));
|
||||
}
|
||||
|
||||
setWindowOpacity(0.0);
|
||||
}
|
||||
|
||||
void SplashScreen::start() {
|
||||
show();
|
||||
raise();
|
||||
activateWindow();
|
||||
|
||||
// Whole sequence is ~950ms - "open, sweep, gone" rather than a long
|
||||
// intro. All visuals run in parallel inside phase1, then a short pause
|
||||
// and a quick fade-out.
|
||||
auto* winFadeIn = new QPropertyAnimation(this, "windowOpacity", this);
|
||||
winFadeIn->setDuration(180);
|
||||
winFadeIn->setStartValue(0.0);
|
||||
winFadeIn->setEndValue(1.0);
|
||||
winFadeIn->setEasingCurve(QEasingCurve::OutCubic);
|
||||
|
||||
auto* logoAlpha = new QPropertyAnimation(this, "logoOpacity", this);
|
||||
logoAlpha->setDuration(320);
|
||||
logoAlpha->setStartValue(0.0);
|
||||
logoAlpha->setEndValue(1.0);
|
||||
logoAlpha->setEasingCurve(QEasingCurve::OutCubic);
|
||||
|
||||
auto* logoScale = new QPropertyAnimation(this, "logoScale", this);
|
||||
logoScale->setDuration(480);
|
||||
logoScale->setStartValue(0.55);
|
||||
logoScale->setEndValue(1.0);
|
||||
logoScale->setEasingCurve(QEasingCurve::OutBack);
|
||||
|
||||
auto* glow = new QPropertyAnimation(this, "glowPulse", this);
|
||||
glow->setDuration(600);
|
||||
glow->setStartValue(0.0);
|
||||
glow->setKeyValueAt(0.5, 1.0);
|
||||
glow->setEndValue(0.35);
|
||||
glow->setEasingCurve(QEasingCurve::InOutSine);
|
||||
|
||||
auto* scan = new QPropertyAnimation(this, "scanProgress", this);
|
||||
scan->setDuration(580);
|
||||
scan->setStartValue(0.0);
|
||||
scan->setEndValue(1.0);
|
||||
scan->setEasingCurve(QEasingCurve::InOutQuad);
|
||||
|
||||
auto* phase = new QParallelAnimationGroup(this);
|
||||
phase->addAnimation(winFadeIn);
|
||||
phase->addAnimation(logoAlpha);
|
||||
phase->addAnimation(logoScale);
|
||||
phase->addAnimation(glow);
|
||||
phase->addAnimation(scan);
|
||||
|
||||
auto* winFadeOut = new QPropertyAnimation(this, "windowOpacity", this);
|
||||
winFadeOut->setDuration(220);
|
||||
winFadeOut->setStartValue(1.0);
|
||||
winFadeOut->setEndValue(0.0);
|
||||
winFadeOut->setEasingCurve(QEasingCurve::InCubic);
|
||||
|
||||
auto* seq = new QSequentialAnimationGroup(this);
|
||||
seq->addAnimation(phase);
|
||||
seq->addPause(100);
|
||||
seq->addAnimation(winFadeOut);
|
||||
|
||||
connect(seq, &QAbstractAnimation::finished, this, [this] {
|
||||
emit finished();
|
||||
close();
|
||||
});
|
||||
seq->start(QAbstractAnimation::DeleteWhenStopped);
|
||||
}
|
||||
|
||||
void SplashScreen::paintEvent(QPaintEvent*) {
|
||||
QPainter p(this);
|
||||
p.setRenderHint(QPainter::Antialiasing);
|
||||
p.setRenderHint(QPainter::TextAntialiasing);
|
||||
|
||||
QRectF rect = this->rect().adjusted(0.5, 0.5, -0.5, -0.5);
|
||||
QPainterPath panel;
|
||||
panel.addRoundedRect(rect, kCornerRadius, kCornerRadius);
|
||||
p.setClipPath(panel);
|
||||
|
||||
// Layered background: dark vertical gradient + accent glow.
|
||||
QLinearGradient bg(rect.topLeft(), rect.bottomLeft());
|
||||
bg.setColorAt(0.0, QColor("#16181f"));
|
||||
bg.setColorAt(1.0, QColor("#0a0b0e"));
|
||||
p.fillRect(rect, bg);
|
||||
|
||||
// Accent glow behind the wordmark; pulses with glowPulse_.
|
||||
{
|
||||
qreal r = rect.width() * (0.45 + 0.07 * glowPulse_);
|
||||
QRadialGradient glow(rect.center() + QPointF(0, -4), r);
|
||||
int peak = static_cast<int>(120 * (0.45 + 0.55 * glowPulse_));
|
||||
glow.setColorAt(0.0, QColor(85, 135, 235, peak));
|
||||
glow.setColorAt(0.7, QColor(85, 135, 235, peak / 4));
|
||||
glow.setColorAt(1.0, QColor(85, 135, 235, 0));
|
||||
p.fillRect(rect, glow);
|
||||
}
|
||||
|
||||
// Wordmark: drawn via a transform so we can scale around the centre.
|
||||
{
|
||||
QFont logoFont(QStringLiteral("Segoe UI"));
|
||||
logoFont.setPointSize(40);
|
||||
logoFont.setBold(true);
|
||||
logoFont.setLetterSpacing(QFont::PercentageSpacing, 102.0);
|
||||
p.setFont(logoFont);
|
||||
|
||||
QFontMetricsF fm(logoFont);
|
||||
const QString text = QStringLiteral("OpenZen");
|
||||
const qreal textW = fm.horizontalAdvance(text);
|
||||
const qreal textH = fm.ascent();
|
||||
QPointF anchor = rect.center();
|
||||
|
||||
p.save();
|
||||
p.translate(anchor);
|
||||
p.scale(logoScale_, logoScale_);
|
||||
p.setOpacity(logoOpacity_);
|
||||
// Soft drop shadow for legibility against the glow.
|
||||
p.setPen(QColor(0, 0, 0, 90));
|
||||
p.drawText(QPointF(-textW / 2.0 + 1, textH / 2.0 - 6 + 1), text);
|
||||
p.setPen(QColor("#ffffff"));
|
||||
p.drawText(QPointF(-textW / 2.0, textH / 2.0 - 6), text);
|
||||
p.restore();
|
||||
}
|
||||
|
||||
// Scan rail near the bottom: faint baseline with a bright animated head
|
||||
// and a trailing gradient.
|
||||
{
|
||||
const qreal trackY = rect.bottom() - 38;
|
||||
const qreal trackL = rect.left() + 70;
|
||||
const qreal trackR = rect.right() - 70;
|
||||
const qreal trackW = trackR - trackL;
|
||||
|
||||
p.setPen(QPen(QColor(255, 255, 255, 30), 1));
|
||||
p.drawLine(QPointF(trackL, trackY), QPointF(trackR, trackY));
|
||||
|
||||
const qreal headX = trackL + trackW * scanProgress_;
|
||||
const qreal tailStart = std::max(headX - 140.0, trackL);
|
||||
QLinearGradient trail(QPointF(tailStart, 0), QPointF(headX, 0));
|
||||
trail.setColorAt(0.0, QColor(80, 130, 230, 0));
|
||||
trail.setColorAt(1.0, QColor(130, 180, 255, 230));
|
||||
p.setPen(QPen(QBrush(trail), 2));
|
||||
p.drawLine(QPointF(tailStart, trackY), QPointF(headX, trackY));
|
||||
|
||||
// Bright "head" dot.
|
||||
p.setPen(Qt::NoPen);
|
||||
p.setBrush(QColor(180, 210, 255, 230));
|
||||
p.drawEllipse(QPointF(headX, trackY), 3.5, 3.5);
|
||||
}
|
||||
|
||||
// Hairline border on top to crisp up the panel.
|
||||
p.setClipping(false);
|
||||
p.setPen(QPen(QColor(255, 255, 255, 28), 1));
|
||||
p.setBrush(Qt::NoBrush);
|
||||
p.drawPath(panel);
|
||||
}
|
||||
|
||||
} // namespace loader
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
namespace loader {
|
||||
|
||||
// Frameless, translucent, top-most splash that animates the OpenZen
|
||||
// wordmark and a scanning line, then fades itself out and emits
|
||||
// finished(). Used as the cold-start window before MainWindow appears.
|
||||
class SplashScreen : public QWidget {
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(qreal logoOpacity READ logoOpacity WRITE setLogoOpacity)
|
||||
Q_PROPERTY(qreal logoScale READ logoScale WRITE setLogoScale)
|
||||
Q_PROPERTY(qreal scanProgress READ scanProgress WRITE setScanProgress)
|
||||
Q_PROPERTY(qreal glowPulse READ glowPulse WRITE setGlowPulse)
|
||||
|
||||
public:
|
||||
explicit SplashScreen(QWidget* parent = nullptr);
|
||||
|
||||
// Kicks off the entire animation sequence. emit finished() at the end.
|
||||
void start();
|
||||
|
||||
qreal logoOpacity() const { return logoOpacity_; }
|
||||
qreal logoScale() const { return logoScale_; }
|
||||
qreal scanProgress() const { return scanProgress_; }
|
||||
qreal glowPulse() const { return glowPulse_; }
|
||||
|
||||
void setLogoOpacity(qreal v) { logoOpacity_ = v; update(); }
|
||||
void setLogoScale(qreal v) { logoScale_ = v; update(); }
|
||||
void setScanProgress(qreal v) { scanProgress_ = v; update(); }
|
||||
void setGlowPulse(qreal v) { glowPulse_ = v; update(); }
|
||||
|
||||
signals:
|
||||
void finished();
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent*) override;
|
||||
|
||||
private:
|
||||
qreal logoOpacity_ = 0.0;
|
||||
qreal logoScale_ = 0.6;
|
||||
qreal scanProgress_ = 0.0;
|
||||
qreal glowPulse_ = 0.0;
|
||||
};
|
||||
|
||||
} // namespace loader
|
||||
@@ -0,0 +1,162 @@
|
||||
#include "TitleBar.h"
|
||||
|
||||
#include <QEvent>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLinearGradient>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QToolButton>
|
||||
#include <QWindow>
|
||||
|
||||
namespace loader {
|
||||
|
||||
namespace {
|
||||
constexpr int kBarHeight = 38;
|
||||
|
||||
const char* kCommonBtnQss = R"qss(
|
||||
QToolButton {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #aab1bf;
|
||||
min-width: 46px;
|
||||
min-height: 38px;
|
||||
font-size: 14px;
|
||||
font-family: "Segoe UI", "Microsoft YaHei UI", sans-serif;
|
||||
}
|
||||
QToolButton:hover {
|
||||
background: rgba(255, 255, 255, 18);
|
||||
color: #ffffff;
|
||||
}
|
||||
QToolButton:pressed {
|
||||
background: rgba(255, 255, 255, 28);
|
||||
}
|
||||
)qss";
|
||||
|
||||
const char* kCloseBtnQss = R"qss(
|
||||
QToolButton {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #aab1bf;
|
||||
min-width: 46px;
|
||||
min-height: 38px;
|
||||
font-size: 14px;
|
||||
font-family: "Segoe UI", "Microsoft YaHei UI", sans-serif;
|
||||
}
|
||||
QToolButton:hover {
|
||||
background: #c0392b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QToolButton:pressed {
|
||||
background: #8a2920;
|
||||
}
|
||||
)qss";
|
||||
} // namespace
|
||||
|
||||
TitleBar::TitleBar(QWidget* parent)
|
||||
: QWidget(parent) {
|
||||
setFixedHeight(kBarHeight);
|
||||
setAttribute(Qt::WA_StyledBackground, false);
|
||||
setAutoFillBackground(false);
|
||||
|
||||
auto* layout = new QHBoxLayout(this);
|
||||
layout->setContentsMargins(14, 0, 0, 0);
|
||||
layout->setSpacing(10);
|
||||
|
||||
// Pulsing scan indicator: a small dot painted directly in paintEvent so
|
||||
// we can drive it with a QPropertyAnimation on the pulse_ property.
|
||||
// Reserve space here so the title label lines up consistently.
|
||||
auto* dotSpacer = new QWidget(this);
|
||||
dotSpacer->setFixedSize(16, 16);
|
||||
layout->addWidget(dotSpacer);
|
||||
|
||||
titleLabel_ = new QLabel(QStringLiteral("OpenZen Loader"), this);
|
||||
titleLabel_->setStyleSheet(QStringLiteral(
|
||||
"color: #e7ecf5; font-weight: 600; font-size: 12px;"
|
||||
"font-family: 'Segoe UI', 'Microsoft YaHei UI', sans-serif;"));
|
||||
layout->addWidget(titleLabel_);
|
||||
|
||||
layout->addStretch(1);
|
||||
|
||||
minBtn_ = new QToolButton(this);
|
||||
minBtn_->setText(QStringLiteral("–")); // en-dash for minimize
|
||||
minBtn_->setCursor(Qt::PointingHandCursor);
|
||||
minBtn_->setStyleSheet(QString::fromUtf8(kCommonBtnQss));
|
||||
minBtn_->setToolTip(QStringLiteral("Minimize"));
|
||||
connect(minBtn_, &QToolButton::clicked, this, [this] {
|
||||
if (auto* w = window()) w->showMinimized();
|
||||
});
|
||||
layout->addWidget(minBtn_);
|
||||
|
||||
closeBtn_ = new QToolButton(this);
|
||||
closeBtn_->setText(QStringLiteral("✕")); // multiplication sign
|
||||
closeBtn_->setCursor(Qt::PointingHandCursor);
|
||||
closeBtn_->setStyleSheet(QString::fromUtf8(kCloseBtnQss));
|
||||
closeBtn_->setToolTip(QStringLiteral("Close"));
|
||||
connect(closeBtn_, &QToolButton::clicked, this, [this] {
|
||||
if (auto* w = window()) w->close();
|
||||
});
|
||||
layout->addWidget(closeBtn_);
|
||||
|
||||
// Looping pulse so the dot breathes; cheap (just one repaint per frame
|
||||
// step) and gives the UI a "still alive" cue.
|
||||
auto* pulseAnim = new QPropertyAnimation(this, "pulse", this);
|
||||
pulseAnim->setDuration(1800);
|
||||
pulseAnim->setStartValue(0.0);
|
||||
pulseAnim->setKeyValueAt(0.5, 1.0);
|
||||
pulseAnim->setEndValue(0.0);
|
||||
pulseAnim->setEasingCurve(QEasingCurve::InOutSine);
|
||||
pulseAnim->setLoopCount(-1);
|
||||
pulseAnim->start();
|
||||
}
|
||||
|
||||
void TitleBar::setTitleText(const QString& text) {
|
||||
titleLabel_->setText(text);
|
||||
}
|
||||
|
||||
void TitleBar::setPulse(qreal v) {
|
||||
pulse_ = v;
|
||||
update(QRect(0, 0, 38, height())); // only the dot area
|
||||
}
|
||||
|
||||
void TitleBar::mousePressEvent(QMouseEvent* e) {
|
||||
if (e->button() == Qt::LeftButton) {
|
||||
if (auto* w = window()) {
|
||||
if (auto* h = w->windowHandle()) {
|
||||
h->startSystemMove();
|
||||
e->accept();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
QWidget::mousePressEvent(e);
|
||||
}
|
||||
|
||||
void TitleBar::paintEvent(QPaintEvent*) {
|
||||
QPainter p(this);
|
||||
p.setRenderHint(QPainter::Antialiasing);
|
||||
|
||||
// Background: subtle top-down gradient, hairline at the bottom.
|
||||
QRect r = rect();
|
||||
QLinearGradient bg(r.topLeft(), r.bottomLeft());
|
||||
bg.setColorAt(0.0, QColor("#23252c"));
|
||||
bg.setColorAt(1.0, QColor("#1b1d22"));
|
||||
p.fillRect(r, bg);
|
||||
p.setPen(QColor(255, 255, 255, 14));
|
||||
p.drawLine(r.bottomLeft(), r.bottomRight());
|
||||
|
||||
// Scan-status dot — solid core with a halo whose radius/alpha tracks
|
||||
// pulse_. The +2 nudge sits the dot a touch below the geometric centre
|
||||
// so it visually lines up with the cap-height of the title text.
|
||||
const QPointF c(20.0, r.center().y() + 1.5);
|
||||
const qreal halo = 5.0 + 5.0 * pulse_;
|
||||
const int haloA = static_cast<int>(40 + 80 * pulse_);
|
||||
p.setPen(Qt::NoPen);
|
||||
p.setBrush(QColor(110, 200, 140, haloA));
|
||||
p.drawEllipse(c, halo, halo);
|
||||
p.setBrush(QColor(120, 230, 150));
|
||||
p.drawEllipse(c, 3.4, 3.4);
|
||||
}
|
||||
|
||||
} // namespace loader
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
class QLabel;
|
||||
class QToolButton;
|
||||
|
||||
namespace loader {
|
||||
|
||||
// Custom title bar used by the frameless MainWindow. Owns the move-by-drag
|
||||
// behaviour, the minimize/close buttons, and a small pulsing scan indicator
|
||||
// that signals the auto-refresh loop is live.
|
||||
class TitleBar : public QWidget {
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(qreal pulse READ pulse WRITE setPulse)
|
||||
|
||||
public:
|
||||
explicit TitleBar(QWidget* parent = nullptr);
|
||||
|
||||
void setTitleText(const QString& text);
|
||||
|
||||
qreal pulse() const { return pulse_; }
|
||||
void setPulse(qreal v);
|
||||
|
||||
protected:
|
||||
void mousePressEvent(QMouseEvent*) override;
|
||||
void paintEvent(QPaintEvent*) override;
|
||||
|
||||
private:
|
||||
QLabel* titleLabel_ = nullptr;
|
||||
QToolButton* minBtn_ = nullptr;
|
||||
QToolButton* closeBtn_ = nullptr;
|
||||
qreal pulse_ = 0.0;
|
||||
};
|
||||
|
||||
} // namespace loader
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "MainWindow.h"
|
||||
#include "SplashScreen.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QStyleFactory>
|
||||
@@ -12,7 +13,18 @@ int main(int argc, char** argv) {
|
||||
QApplication::setApplicationName(QStringLiteral("OpenZen Loader"));
|
||||
QApplication::setOrganizationName(QStringLiteral("OpenZen"));
|
||||
|
||||
loader::MainWindow w;
|
||||
w.show();
|
||||
return app.exec();
|
||||
// Main window is constructed up front but kept hidden until the splash
|
||||
// emits finished(), so its windowOpacity starts at 0 (set in showEvent
|
||||
// on its first show inside playEntrance()).
|
||||
auto* main = new loader::MainWindow();
|
||||
|
||||
auto* splash = new loader::SplashScreen();
|
||||
QObject::connect(splash, &loader::SplashScreen::finished, main, [main] {
|
||||
main->playEntrance();
|
||||
});
|
||||
splash->start();
|
||||
|
||||
int rc = app.exec();
|
||||
delete main;
|
||||
return rc;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user