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:
Shirona1337
2026-05-24 17:54:03 +08:00
parent 778b9980b8
commit 0c2441f3a6
14 changed files with 1466 additions and 144 deletions
+11
View File
@@ -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 /
+287
View File
@@ -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
+63
View File
@@ -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
+125
View File
@@ -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
+56
View File
@@ -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
+194
View File
@@ -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
+58
View File
@@ -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
View File
@@ -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
+31 -14
View File
@@ -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
+190
View File
@@ -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
+46
View File
@@ -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
+162
View File
@@ -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
+36
View File
@@ -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
+15 -3
View File
@@ -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;
}