diff --git a/native/loader/CMakeLists.txt b/native/loader/CMakeLists.txt index 5cd1800..8ed2af8 100644 --- a/native/loader/CMakeLists.txt +++ b/native/loader/CMakeLists.txt @@ -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 / diff --git a/native/loader/src/InjectionOverlay.cpp b/native/loader/src/InjectionOverlay.cpp new file mode 100644 index 0000000..1f3cf6a --- /dev/null +++ b/native/loader/src/InjectionOverlay.cpp @@ -0,0 +1,287 @@ +#include "InjectionOverlay.h" + +#include "loader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +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(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(-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(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 diff --git a/native/loader/src/InjectionOverlay.h b/native/loader/src/InjectionOverlay.h new file mode 100644 index 0000000..5f82e78 --- /dev/null +++ b/native/loader/src/InjectionOverlay.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include + +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 diff --git a/native/loader/src/InstanceList.cpp b/native/loader/src/InstanceList.cpp new file mode 100644 index 0000000..cb30109 --- /dev/null +++ b/native/loader/src/InstanceList.cpp @@ -0,0 +1,125 @@ +#include "InstanceList.h" + +#include "InstanceRow.h" + +#include +#include +#include +#include +#include + +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& list) { + // Compute incoming pid set. + QSet incoming; + incoming.reserve(list.size()); + for (const auto& it : list) incoming.insert(it.pid); + + // Remove rows no longer present. + QList 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()) { + btn->setEnabled(on); + } + } +} + +void InstanceList::updateEmptyState() { + const bool empty = rows_.isEmpty(); + emptyLabel_->setVisible(empty); +} + +} // namespace loader diff --git a/native/loader/src/InstanceList.h b/native/loader/src/InstanceList.h new file mode 100644 index 0000000..474263d --- /dev/null +++ b/native/loader/src/InstanceList.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include + +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& 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 rows_; + bool interactive_ = true; +}; + +} // namespace loader diff --git a/native/loader/src/InstanceRow.cpp b/native/loader/src/InstanceRow.cpp new file mode 100644 index 0000000..44a166d --- /dev/null +++ b/native/loader/src/InstanceRow.cpp @@ -0,0 +1,194 @@ +#include "InstanceRow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 diff --git a/native/loader/src/InstanceRow.h b/native/loader/src/InstanceRow.h new file mode 100644 index 0000000..f8f9183 --- /dev/null +++ b/native/loader/src/InstanceRow.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +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 diff --git a/native/loader/src/MainWindow.cpp b/native/loader/src/MainWindow.cpp index c6bf6e9..e0f0bf1 100644 --- a/native/loader/src/MainWindow.cpp +++ b/native/loader/src/MainWindow.cpp @@ -1,22 +1,38 @@ #include "MainWindow.h" + +#include "InjectionOverlay.h" +#include "InstanceList.h" +#include "TitleBar.h" #include "loader.h" #include +#include +#include #include -#include #include -#include +#include +#include +#include +#include +#include #include -#include #include #include +#include #include +#ifdef Q_OS_WIN +# include +# include +#endif + #include namespace loader { namespace { +constexpr int kCornerRadius = 12; + QString fromW(const std::wstring& w) { return QString::fromWCharArray(w.c_str(), static_cast(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(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 filtered; + QVector 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 diff --git a/native/loader/src/MainWindow.h b/native/loader/src/MainWindow.h index 8273ba4..44b838b 100644 --- a/native/loader/src/MainWindow.h +++ b/native/loader/src/MainWindow.h @@ -1,37 +1,54 @@ #pragma once #include +#include #include -#include -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 rows_; + bool entrancePlayed_ = false; + bool injectionInFlight_ = false; + bool exiting_ = false; }; + } // namespace loader diff --git a/native/loader/src/SplashScreen.cpp b/native/loader/src/SplashScreen.cpp new file mode 100644 index 0000000..49d21a4 --- /dev/null +++ b/native/loader/src/SplashScreen.cpp @@ -0,0 +1,190 @@ +#include "SplashScreen.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(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 diff --git a/native/loader/src/SplashScreen.h b/native/loader/src/SplashScreen.h new file mode 100644 index 0000000..25c41bc --- /dev/null +++ b/native/loader/src/SplashScreen.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +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 diff --git a/native/loader/src/TitleBar.cpp b/native/loader/src/TitleBar.cpp new file mode 100644 index 0000000..817fb28 --- /dev/null +++ b/native/loader/src/TitleBar.cpp @@ -0,0 +1,162 @@ +#include "TitleBar.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(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 diff --git a/native/loader/src/TitleBar.h b/native/loader/src/TitleBar.h new file mode 100644 index 0000000..23d31c0 --- /dev/null +++ b/native/loader/src/TitleBar.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +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 diff --git a/native/loader/src/main.cpp b/native/loader/src/main.cpp index 1f24ca6..30f8de2 100644 --- a/native/loader/src/main.cpp +++ b/native/loader/src/main.cpp @@ -1,4 +1,5 @@ #include "MainWindow.h" +#include "SplashScreen.h" #include #include @@ -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; }