diff --git a/src/duckstation-qt/CMakeLists.txt b/src/duckstation-qt/CMakeLists.txt
index 0b42c4558..7b246ca3c 100644
--- a/src/duckstation-qt/CMakeLists.txt
+++ b/src/duckstation-qt/CMakeLists.txt
@@ -149,6 +149,8 @@ set(SRCS
setupwizarddialog.h
setupwizarddialog.ui
texturereplacementsettingsdialog.ui
+ togglebutton.cpp
+ togglebutton.h
)
set(TS_FILES
diff --git a/src/duckstation-qt/duckstation-qt.vcxproj b/src/duckstation-qt/duckstation-qt.vcxproj
index 824567dd5..b1383c3ad 100644
--- a/src/duckstation-qt/duckstation-qt.vcxproj
+++ b/src/duckstation-qt/duckstation-qt.vcxproj
@@ -50,9 +50,11 @@
+
+
diff --git a/src/duckstation-qt/duckstation-qt.vcxproj.filters b/src/duckstation-qt/duckstation-qt.vcxproj.filters
index dd09949ab..ebfdf8f11 100644
--- a/src/duckstation-qt/duckstation-qt.vcxproj.filters
+++ b/src/duckstation-qt/duckstation-qt.vcxproj.filters
@@ -48,6 +48,7 @@
+
@@ -108,6 +109,7 @@
+
diff --git a/src/duckstation-qt/gamepatchdetailswidget.ui b/src/duckstation-qt/gamepatchdetailswidget.ui
index 06fe901b3..4ea19de85 100644
--- a/src/duckstation-qt/gamepatchdetailswidget.ui
+++ b/src/duckstation-qt/gamepatchdetailswidget.ui
@@ -24,7 +24,7 @@
-
-
+
Enabled
@@ -73,6 +73,13 @@
+
+
+ ToggleButton
+ QAbstractButton
+
+
+
diff --git a/src/duckstation-qt/gamepatchsettingswidget.cpp b/src/duckstation-qt/gamepatchsettingswidget.cpp
index a9e72ab94..8e204cb47 100644
--- a/src/duckstation-qt/gamepatchsettingswidget.cpp
+++ b/src/duckstation-qt/gamepatchsettingswidget.cpp
@@ -36,7 +36,7 @@ GamePatchDetailsWidget::GamePatchDetailsWidget(std::string name, const std::stri
DebugAssert(dialog->getSettingsInterface());
m_ui.enabled->setChecked(enabled);
- connect(m_ui.enabled, &QCheckBox::checkStateChanged, this, &GamePatchDetailsWidget::onEnabledStateChanged);
+ connect(m_ui.enabled, &ToggleButton::checkStateChanged, this, &GamePatchDetailsWidget::onEnabledStateChanged);
}
GamePatchDetailsWidget::~GamePatchDetailsWidget() = default;
diff --git a/src/duckstation-qt/togglebutton.cpp b/src/duckstation-qt/togglebutton.cpp
new file mode 100644
index 000000000..2bbe0f861
--- /dev/null
+++ b/src/duckstation-qt/togglebutton.cpp
@@ -0,0 +1,160 @@
+// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin
+// SPDX-License-Identifier: CC-BY-NC-ND-4.0
+
+#include "togglebutton.h"
+
+#include
+#include
+#include
+#include
+
+#include "moc_togglebutton.cpp"
+
+ToggleButton::ToggleButton(QWidget* parent) : QAbstractButton(parent), m_offset_animation(this, "offset")
+{
+ setCheckable(true);
+ setCursor(Qt::PointingHandCursor);
+ setFocusPolicy(Qt::StrongFocus);
+
+ m_offset_animation.setDuration(150);
+ m_offset_animation.setEasingCurve(QEasingCurve::OutCubic);
+}
+
+ToggleButton::~ToggleButton() = default;
+
+QSize ToggleButton::sizeHint() const
+{
+ return QSize(50, 25);
+}
+
+void ToggleButton::showEvent(QShowEvent* event)
+{
+ QAbstractButton::showEvent(event);
+
+ // Make sure the toggle position matches the current state when first shown
+ updateTogglePosition();
+}
+
+void ToggleButton::resizeEvent(QResizeEvent* event)
+{
+ QAbstractButton::resizeEvent(event);
+
+ // Update position when resized since it depends on widget dimensions
+ updateTogglePosition();
+}
+
+void ToggleButton::updateTogglePosition()
+{
+ // Immediately set the toggle to the correct position without animation
+ if (width() > 0)
+ {
+ m_offset_animation.stop();
+ m_offset = isChecked() ? width() - height() : 0;
+ update();
+ }
+}
+
+void ToggleButton::paintEvent(QPaintEvent* event)
+{
+ Q_UNUSED(event);
+
+ QPainter painter(this);
+ painter.setRenderHint(QPainter::Antialiasing);
+
+ QStyleOption opt;
+ opt.initFrom(this);
+
+ // Get colors from the current style
+ QColor background_color = isChecked() ? opt.palette.highlight().color() : opt.palette.dark().color();
+ QColor thumb_color = opt.palette.light().color();
+
+ if (m_hovered || hasFocus())
+ {
+ background_color = background_color.lighter(120);
+ }
+
+ if (!isEnabled())
+ {
+ background_color = opt.palette.mid().color();
+ thumb_color = opt.palette.midlight().color();
+ }
+
+ // Draw background
+ const int track_width = width() - 2;
+ const int track_height = height() - 2;
+ const int corner_radius = track_height / 2;
+
+ QPainterPath path;
+ path.addRoundedRect(1, 1, track_width, track_height, corner_radius, corner_radius);
+
+ painter.fillPath(path, background_color);
+
+ // Draw thumb
+ const int thumb_size = track_height - 4;
+ const int thumb_x = m_offset + 2;
+ const int thumb_y = 2;
+
+ QPainterPath thumbPath;
+ thumbPath.addEllipse(thumb_x, thumb_y, thumb_size, thumb_size);
+
+ painter.fillPath(thumbPath, thumb_color);
+}
+
+void ToggleButton::enterEvent(QEnterEvent* event)
+{
+ Q_UNUSED(event);
+ m_hovered = true;
+ update();
+}
+
+void ToggleButton::leaveEvent(QEvent* event)
+{
+ Q_UNUSED(event);
+ m_hovered = false;
+ update();
+}
+
+void ToggleButton::checkStateSet()
+{
+ QAbstractButton::checkStateSet();
+ animateToggle(isChecked());
+}
+
+void ToggleButton::animateToggle(bool checked)
+{
+ m_offset_animation.stop();
+ m_offset_animation.setStartValue(m_offset);
+ m_offset_animation.setEndValue(checked ? width() - height() : 0);
+ m_offset_animation.start();
+}
+
+void ToggleButton::nextCheckState()
+{
+ QAbstractButton::nextCheckState();
+ animateToggle(isChecked());
+ update();
+}
+
+void ToggleButton::keyPressEvent(QKeyEvent* event)
+{
+ if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Return)
+ {
+ setChecked(!isChecked());
+ event->accept();
+ }
+ else
+ {
+ QAbstractButton::keyPressEvent(event);
+ }
+}
+
+int ToggleButton::offset() const
+{
+ return m_offset;
+}
+
+void ToggleButton::setOffset(int value)
+{
+ m_offset = value;
+ update();
+}
\ No newline at end of file
diff --git a/src/duckstation-qt/togglebutton.h b/src/duckstation-qt/togglebutton.h
new file mode 100644
index 000000000..4de840d7f
--- /dev/null
+++ b/src/duckstation-qt/togglebutton.h
@@ -0,0 +1,46 @@
+// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin
+// SPDX-License-Identifier: CC-BY-NC-ND-4.0
+
+#pragma once
+
+#include
+#include
+
+class ToggleButton : public QAbstractButton
+{
+ Q_OBJECT
+ Q_PROPERTY(int offset READ offset WRITE setOffset)
+
+public:
+ explicit ToggleButton(QWidget* parent = nullptr);
+ ~ToggleButton() override;
+
+ QSize sizeHint() const override;
+
+Q_SIGNALS:
+ void checkStateChanged(Qt::CheckState state);
+
+protected:
+ void checkStateSet() override;
+ void nextCheckState() override;
+
+ void paintEvent(QPaintEvent* event) override;
+ void enterEvent(QEnterEvent* event) override;
+ void leaveEvent(QEvent* event) override;
+ void keyPressEvent(QKeyEvent* event) override;
+ void showEvent(QShowEvent* event) override;
+ void resizeEvent(QResizeEvent* event) override;
+
+private:
+ void animateToggle(bool checked);
+ void updateTogglePosition();
+
+ int offset() const;
+ void setOffset(int value);
+
+ int m_offset = 0;
+ bool m_hovered = false;
+
+ QPropertyAnimation m_offset_animation;
+ QPropertyAnimation m_background_animation;
+};