From 943e29cd6695c52de7587d8818dab2e48afb9ed6 Mon Sep 17 00:00:00 2001 From: Warchamp7 Date: Fri, 4 Jul 2025 14:02:26 -0400 Subject: [PATCH] frontend: Improve spin box UX --- frontend/CMakeLists.txt | 5 + frontend/data/themes/Yami.obt | 4 + frontend/dialogs/OBSBasicTransform.cpp | 1 + frontend/forms/OBSBasicSettings.ui | 66 ++--- frontend/forms/OBSBasicTransform.ui | 34 ++- shared/properties-view/CMakeLists.txt | 5 + shared/properties-view/properties-view.cpp | 11 +- shared/qt/obs-spinbox/AutoSelectLineEdit.cpp | 28 +++ shared/qt/obs-spinbox/AutoSelectLineEdit.hpp | 38 +++ shared/qt/obs-spinbox/CMakeLists.txt | 14 ++ shared/qt/obs-spinbox/DoubleSpinBox.cpp | 245 +++++++++++++++++++ shared/qt/obs-spinbox/DoubleSpinBox.hpp | 45 ++++ shared/qt/obs-spinbox/SpinBox.cpp | 243 ++++++++++++++++++ shared/qt/obs-spinbox/SpinBox.hpp | 45 ++++ 14 files changed, 741 insertions(+), 43 deletions(-) create mode 100644 shared/qt/obs-spinbox/AutoSelectLineEdit.cpp create mode 100644 shared/qt/obs-spinbox/AutoSelectLineEdit.hpp create mode 100644 shared/qt/obs-spinbox/CMakeLists.txt create mode 100644 shared/qt/obs-spinbox/DoubleSpinBox.cpp create mode 100644 shared/qt/obs-spinbox/DoubleSpinBox.hpp create mode 100644 shared/qt/obs-spinbox/SpinBox.cpp create mode 100644 shared/qt/obs-spinbox/SpinBox.hpp diff --git a/frontend/CMakeLists.txt b/frontend/CMakeLists.txt index 6f677d4fe53eb3..17419632c879be 100644 --- a/frontend/CMakeLists.txt +++ b/frontend/CMakeLists.txt @@ -26,6 +26,10 @@ if(NOT TARGET OBS::bpm) add_subdirectory("${CMAKE_SOURCE_DIR}/shared/bpm" bpm) endif() +if(NOT TARGET OBS::qt-obs-spinbox) + add_subdirectory("${CMAKE_SOURCE_DIR}/shared/qt/obs-spinbox" obs-spinbox) +endif() + add_executable(obs-studio) add_executable(OBS::studio ALIAS obs-studio) @@ -40,6 +44,7 @@ target_link_libraries( OBS::frontend-api OBS::json11 OBS::bpm + OBS::qt-obs-spinbox ) include(cmake/ui-components.cmake) diff --git a/frontend/data/themes/Yami.obt b/frontend/data/themes/Yami.obt index f3ece482dbc379..a3c4f5065eee65 100644 --- a/frontend/data/themes/Yami.obt +++ b/frontend/data/themes/Yami.obt @@ -142,6 +142,7 @@ --padding_base_border: calc(var(--padding_base) + 1px); + --spinbox_min_width: calc(var(--input_height) * 2.5); --spinbox_button_height: calc(var(--input_height_half) - 1px); --volume_slider: calc(calc(4px + var(--font_base_value)) / 4); @@ -1279,8 +1280,11 @@ QDoubleSpinBox { border: var(--input_border_width) solid var(--input_bg); border-radius: var(--border_radius); padding: var(--input_padding) var(--input_text_padding); + padding-right: var(--padding_base); + height: var(--input_height); max-height: var(--input_height); + min-width: var(--spinbox_min_width); } QSpinBox:hover, diff --git a/frontend/dialogs/OBSBasicTransform.cpp b/frontend/dialogs/OBSBasicTransform.cpp index 828aa9acc6b0db..98ad3cafc1c618 100644 --- a/frontend/dialogs/OBSBasicTransform.cpp +++ b/frontend/dialogs/OBSBasicTransform.cpp @@ -1,5 +1,6 @@ #include "OBSBasicTransform.hpp" +#include #include #include "moc_OBSBasicTransform.cpp" diff --git a/frontend/forms/OBSBasicSettings.ui b/frontend/forms/OBSBasicSettings.ui index a1f6e6320e1199..9ceaa73e1f5b2b 100644 --- a/frontend/forms/OBSBasicSettings.ui +++ b/frontend/forms/OBSBasicSettings.ui @@ -489,7 +489,7 @@ - + 1 @@ -1947,7 +1947,7 @@ - + 0 @@ -1996,7 +1996,7 @@ - + 0 @@ -2157,7 +2157,7 @@ - + 1 @@ -2507,7 +2507,7 @@ - + 200 @@ -3060,7 +3060,7 @@ - + sec @@ -3083,7 +3083,7 @@ - + MB @@ -4146,7 +4146,7 @@ - + min @@ -4169,7 +4169,7 @@ - + MB @@ -4520,7 +4520,7 @@ - + 0 @@ -4540,7 +4540,7 @@ - + 1000000000 @@ -4610,7 +4610,7 @@ - + 32 @@ -5794,7 +5794,7 @@ - + s @@ -5810,7 +5810,7 @@ - + MB @@ -6660,7 +6660,7 @@ 0 - + 1 @@ -6702,7 +6702,7 @@ - + 1 @@ -6722,7 +6722,7 @@ - + 1 @@ -8118,7 +8118,7 @@ 0 - + nits @@ -8147,7 +8147,7 @@ - + nits @@ -8367,7 +8367,7 @@ 0 - + true @@ -8477,7 +8477,7 @@ 0 - + s @@ -8506,7 +8506,7 @@ - + 1 @@ -8797,20 +8797,30 @@ - UrlPushButton - QPushButton -
components/UrlPushButton.hpp
+ OBS::SpinBox + QSpinBox +
SpinBox.hpp
- OBSHotkeyEdit - QLineEdit -
settings/OBSHotkeyEdit.hpp
+ OBS::DoubleSpinBox + QDoubleSpinBox +
DoubleSpinBox.hpp
AbsoluteSlider QSlider
components/AbsoluteSlider.hpp
+ + OBSHotkeyEdit + QLineEdit +
settings/OBSHotkeyEdit.hpp
+
+ + UrlPushButton + QPushButton +
components/UrlPushButton.hpp
+
listWidget diff --git a/frontend/forms/OBSBasicTransform.ui b/frontend/forms/OBSBasicTransform.ui index 9f27bc5d9c21c8..1a63fb35c18501 100644 --- a/frontend/forms/OBSBasicTransform.ui +++ b/frontend/forms/OBSBasicTransform.ui @@ -122,7 +122,7 @@
- + 120 @@ -169,7 +169,7 @@ - + 120 @@ -200,7 +200,7 @@ - + 0 @@ -295,7 +295,7 @@ - + 120 @@ -326,7 +326,7 @@ - + 120 @@ -586,7 +586,7 @@ - + false @@ -620,7 +620,7 @@ - + false @@ -729,7 +729,7 @@ - + 0 @@ -773,7 +773,7 @@ - + 0 @@ -804,7 +804,7 @@ - + 0 @@ -845,7 +845,7 @@ - + 0 @@ -928,6 +928,18 @@ cropTop cropBottom + + + OBS::SpinBox + QSpinBox +
SpinBox.hpp
+
+ + OBS::DoubleSpinBox + QDoubleSpinBox +
DoubleSpinBox.hpp
+
+
diff --git a/shared/properties-view/CMakeLists.txt b/shared/properties-view/CMakeLists.txt index 206455b95e9fb7..c5cd2302e42e1e 100644 --- a/shared/properties-view/CMakeLists.txt +++ b/shared/properties-view/CMakeLists.txt @@ -28,6 +28,10 @@ if(NOT TARGET OBS::qt-icon-label) add_subdirectory("${CMAKE_SOURCE_DIR}/shared/qt/icon-label" "${CMAKE_BINARY_DIR}/shared/qt/icon-label") endif() +if(NOT TARGET OBS::qt-obs-spinbox) + add_subdirectory("${CMAKE_SOURCE_DIR}/shared/qt/obs-spinbox" "${CMAKE_BINARY_DIR}/shared/qt/obs-spinbox") +endif() + add_library(properties-view INTERFACE) add_library(OBS::properties-view ALIAS properties-view) @@ -51,6 +55,7 @@ target_link_libraries( OBS::libobs OBS::qt-wrappers OBS::qt-plain-text-edit + OBS::qt-obs-spinbox OBS::qt-vertical-scroll-area OBS::qt-slider-ignorewheel OBS::qt-icon-label diff --git a/shared/properties-view/properties-view.cpp b/shared/properties-view/properties-view.cpp index c47758ac63275c..ea038b01ac90d4 100644 --- a/shared/properties-view/properties-view.cpp +++ b/shared/properties-view/properties-view.cpp @@ -25,6 +25,9 @@ #include #include #include + +#include "DoubleSpinBox.hpp" +#include "SpinBox.hpp" #include "double-slider.hpp" #include "spinbox-ignorewheel.hpp" #include "moc_properties-view.cpp" @@ -428,7 +431,7 @@ void OBSPropertiesView::AddInt(obs_property_t *prop, QFormLayout *layout, QLabel const char *name = obs_property_name(prop); int val = (int)obs_data_get_int(settings, name); - QSpinBox *spin = new SpinBoxIgnoreScroll(); + OBS::SpinBox *spin = new OBS::SpinBox(); spin->setEnabled(obs_property_enabled(prop)); @@ -476,7 +479,7 @@ void OBSPropertiesView::AddFloat(obs_property_t *prop, QFormLayout *layout, QLab const char *name = obs_property_name(prop); double val = obs_data_get_double(settings, name); - QDoubleSpinBox *spin = new QDoubleSpinBox(); + OBS::DoubleSpinBox *spin = new OBS::DoubleSpinBox(); if (!obs_property_enabled(prop)) spin->setEnabled(false); @@ -509,8 +512,8 @@ void OBSPropertiesView::AddFloat(obs_property_t *prop, QFormLayout *layout, QLab slider->setOrientation(Qt::Horizontal); subLayout->addWidget(slider); - connect(slider, &DoubleSlider::doubleValChanged, spin, &QDoubleSpinBox::setValue); - connect(spin, &QDoubleSpinBox::valueChanged, slider, &DoubleSlider::setDoubleVal); + connect(slider, &DoubleSlider::doubleValChanged, spin, &OBS::DoubleSpinBox::setValue); + connect(spin, &OBS::DoubleSpinBox::valueChanged, slider, &DoubleSlider::setDoubleVal); } connect(spin, &QDoubleSpinBox::valueChanged, info, &WidgetInfo::ControlChanged); diff --git a/shared/qt/obs-spinbox/AutoSelectLineEdit.cpp b/shared/qt/obs-spinbox/AutoSelectLineEdit.cpp new file mode 100644 index 00000000000000..7b96bdcfa882b4 --- /dev/null +++ b/shared/qt/obs-spinbox/AutoSelectLineEdit.cpp @@ -0,0 +1,28 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#include "AutoSelectLineEdit.hpp" + +#include + +namespace OBS { +void AutoSelectLineEdit::mouseDoubleClickEvent(QMouseEvent *event) +{ + selectAll(); + event->accept(); +} +} // namespace OBS diff --git a/shared/qt/obs-spinbox/AutoSelectLineEdit.hpp b/shared/qt/obs-spinbox/AutoSelectLineEdit.hpp new file mode 100644 index 00000000000000..a5309acf0a41b4 --- /dev/null +++ b/shared/qt/obs-spinbox/AutoSelectLineEdit.hpp @@ -0,0 +1,38 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#pragma once + +#include + +namespace OBS { +class DoubleSpinBox; +class SpinBox; + +class AutoSelectLineEdit : private QLineEdit { + Q_OBJECT + + friend class DoubleSpinBox; + friend class SpinBox; + +private: + explicit AutoSelectLineEdit(QWidget *parent) : QLineEdit(parent) {}; + +protected: + virtual void mouseDoubleClickEvent(QMouseEvent *event) override; +}; +} // namespace OBS diff --git a/shared/qt/obs-spinbox/CMakeLists.txt b/shared/qt/obs-spinbox/CMakeLists.txt new file mode 100644 index 00000000000000..98f98d1730341d --- /dev/null +++ b/shared/qt/obs-spinbox/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.28...3.30) + +find_package(Qt6 REQUIRED Core Widgets) + +add_library(qt-obs-spinbox INTERFACE) +add_library(OBS::qt-obs-spinbox ALIAS qt-obs-spinbox) + +target_sources( + qt-obs-spinbox + INTERFACE AutoSelectLineEdit.cpp AutoSelectLineEdit.hpp DoubleSpinBox.cpp DoubleSpinBox.hpp SpinBox.cpp SpinBox.hpp +) +target_include_directories(qt-obs-spinbox INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}") + +target_link_libraries(qt-obs-spinbox INTERFACE Qt::Core Qt::Widgets) diff --git a/shared/qt/obs-spinbox/DoubleSpinBox.cpp b/shared/qt/obs-spinbox/DoubleSpinBox.cpp new file mode 100644 index 00000000000000..4cce57fe483caf --- /dev/null +++ b/shared/qt/obs-spinbox/DoubleSpinBox.cpp @@ -0,0 +1,245 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#include "DoubleSpinBox.hpp" + +#include "AutoSelectLineEdit.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +QString trimAffixes(const QString &fullText, const QString &prefix, const QString &suffix) +{ + if (fullText.isEmpty()) { + return QString(); + } + + QString text = fullText; + + if (!prefix.isEmpty() && text.startsWith(prefix)) { + text.remove(0, prefix.length()); + } + + if (!suffix.isEmpty() && text.endsWith(suffix)) { + text.chop(suffix.length()); + } + + return text.trimmed(); +} +} // namespace + +namespace OBS { +DoubleSpinBox::DoubleSpinBox(QWidget *parent) : QDoubleSpinBox(parent) +{ + setKeyboardTracking(false); + setFocusPolicy(Qt::StrongFocus); + auto lineEdit = new AutoSelectLineEdit(this); + setLineEdit(lineEdit); + + connect(lineEdit, &QLineEdit::cursorPositionChanged, this, &DoubleSpinBox::clampCursorPosition); +} + +DoubleSpinBox::~DoubleSpinBox() {} + +QValidator::State DoubleSpinBox::validate(QString &text, int &) const +{ + QString stripped = trimAffixes(text, prefix(), suffix()); + + QString decPoint = locale().decimalPoint(); + if (stripped.isEmpty() || stripped == "-" || stripped == decPoint || stripped == "-" + decPoint) { + return QValidator::Intermediate; + } + + bool ok; + double val = locale().toDouble(stripped, &ok); + + if (!ok) { + return QValidator::Invalid; + } + + QString resultText = prefix() + textFromValue(val) + suffix(); + if (text != resultText) { + return QValidator::Intermediate; + } + + if (val < minimum() || val > maximum()) { + return QValidator::Intermediate; + } + + return QValidator::Acceptable; +} + +void DoubleSpinBox::fixup(QString &input) const +{ + QString stripped = trimAffixes(input, prefix(), suffix()); + + bool ok; + double val = locale().toDouble(stripped, &ok); + + if (ok) { + val = qBound(minimum(), val, maximum()); + input = prefix() + textFromValue(val) + suffix(); + } else { + // Fallback to base class behavior if parsing fails + QDoubleSpinBox::fixup(input); + } +} + +void DoubleSpinBox::mousePressEvent(QMouseEvent *event) +{ + QStyleOptionSpinBox opt; + initStyleOption(&opt); + + if (style()->subControlRect(QStyle::CC_SpinBox, &opt, QStyle::SC_SpinBoxUp, this).contains(event->pos())) { + QDoubleSpinBox::mousePressEvent(event); + return; + } + + if (style()->subControlRect(QStyle::CC_SpinBox, &opt, QStyle::SC_SpinBoxDown, this).contains(event->pos())) { + QDoubleSpinBox::mousePressEvent(event); + return; + } + + // Mouse button was pressed and is and not over the spinbox buttons, forward event to the lineEdit. + QRect lineEditRect = lineEdit()->geometry(); + mouseDragYOffset = ((double)height() / 2) - event->position().y(); + + if (!lineEditRect.contains(event->pos())) { + QPointF localPos = event->pos() - lineEditRect.topLeft(); + QMouseEvent forwardEvent(event->type(), localPos, event->globalPosition(), event->button(), + event->buttons(), event->modifiers()); + + QCoreApplication::sendEvent(lineEdit(), &forwardEvent); + event->accept(); + return; + } + + QDoubleSpinBox::mousePressEvent(event); +} + +void DoubleSpinBox::mouseMoveEvent(QMouseEvent *event) +{ + if (!(event->buttons() & Qt::LeftButton)) { + QDoubleSpinBox::mouseMoveEvent(event); + return; + } + + QStyleOptionSpinBox opt; + initStyleOption(&opt); + + if (style()->subControlRect(QStyle::CC_SpinBox, &opt, QStyle::SC_SpinBoxUp, this).contains(event->pos())) { + QDoubleSpinBox::mouseMoveEvent(event); + return; + } + + if (style()->subControlRect(QStyle::CC_SpinBox, &opt, QStyle::SC_SpinBoxDown, this).contains(event->pos())) { + QDoubleSpinBox::mouseMoveEvent(event); + return; + } + + // Mouse button is down and not over the spinbox buttons, forward move event to the lineEdit. + // This allows events within the spinbox but outside the actual lineEdit widget to work. + QPointF relativePos = lineEdit()->mapFromGlobal(event->globalPosition()); + relativePos.ry() -= mouseDragYOffset; + + QMouseEvent forwardEvent(event->type(), relativePos, event->globalPosition(), event->button(), event->buttons(), + event->modifiers()); + QCoreApplication::sendEvent(lineEdit(), &forwardEvent); +} + +void DoubleSpinBox::keyPressEvent(QKeyEvent *event) +{ + bool isShiftHeld = QGuiApplication::keyboardModifiers() & Qt::ShiftModifier; + + switch (event->key()) { + case Qt::Key_Up: + event->accept(); + if (stepEnabled() & QAbstractSpinBox::StepUpEnabled) { + stepBy(isShiftHeld ? 10 : 1); + } + return; + case Qt::Key_Down: + event->accept(); + if (stepEnabled() & QAbstractSpinBox::StepDownEnabled) { + stepBy(isShiftHeld ? -10 : -1); + } + return; + case Qt::Key_Return: + case Qt::Key_Enter: + event->accept(); + interpretText(); + return; + } + + QDoubleSpinBox::keyPressEvent(event); +} + +void DoubleSpinBox::wheelEvent(QWheelEvent *event) +{ + if (!hasFocus()) { + event->ignore(); + } else { + QDoubleSpinBox::wheelEvent(event); + } +} + +void DoubleSpinBox::clampCursorPosition() +{ + QSignalBlocker block(lineEdit()); + + if (suffix().isEmpty() && prefix().isEmpty()) { + return; + } + + int cursorPosition = lineEdit()->cursorPosition(); + bool hasSelectedText = !lineEdit()->selectedText().isEmpty(); + + if (!suffix().isEmpty()) { + int suffixIndex = lineEdit()->text().length() - suffix().length(); + if (cursorPosition > suffixIndex) { + int cursorOvershoot = lineEdit()->selectionEnd() - suffixIndex; + if (hasSelectedText && cursorOvershoot > 0) { + lineEdit()->setSelection(lineEdit()->selectionStart(), + lineEdit()->selectionLength() - cursorOvershoot); + } else { + lineEdit()->setCursorPosition(suffixIndex); + } + return; + } + } + + if (!prefix().isEmpty()) { + int prefixIndex = prefix().length(); + if (cursorPosition < prefixIndex) { + int cursorUndershoot = prefixIndex - lineEdit()->selectionStart(); + if (hasSelectedText && cursorUndershoot > 0) { + lineEdit()->setSelection(lineEdit()->selectionEnd(), + -lineEdit()->selectionLength() + cursorUndershoot); + } else { + lineEdit()->setCursorPosition(prefixIndex); + } + return; + } + } +} +} // namespace OBS diff --git a/shared/qt/obs-spinbox/DoubleSpinBox.hpp b/shared/qt/obs-spinbox/DoubleSpinBox.hpp new file mode 100644 index 00000000000000..fe4d8b93b5a1e7 --- /dev/null +++ b/shared/qt/obs-spinbox/DoubleSpinBox.hpp @@ -0,0 +1,45 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#pragma once + +#include + +namespace OBS { +class DoubleSpinBox : public QDoubleSpinBox { + Q_OBJECT + +public: + explicit DoubleSpinBox(QWidget *parent = nullptr); + ~DoubleSpinBox(); + + virtual QValidator::State validate(QString &text, int &pos) const override; + virtual void fixup(QString &input) const override; + +protected: + virtual void mousePressEvent(QMouseEvent *event) override; + virtual void mouseMoveEvent(QMouseEvent *event) override; + virtual void keyPressEvent(QKeyEvent *event) override; + virtual void wheelEvent(QWheelEvent *event) override; + +private: + double mouseDragYOffset = 0.0; + +private slots: + void clampCursorPosition(); +}; +} // namespace OBS diff --git a/shared/qt/obs-spinbox/SpinBox.cpp b/shared/qt/obs-spinbox/SpinBox.cpp new file mode 100644 index 00000000000000..d29cf7b6f18e2c --- /dev/null +++ b/shared/qt/obs-spinbox/SpinBox.cpp @@ -0,0 +1,243 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#include "SpinBox.hpp" + +#include "AutoSelectLineEdit.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +QString trimAffixes(const QString &fullText, const QString &prefix, const QString &suffix) +{ + if (fullText.isEmpty()) { + return QString(); + } + + QString text = fullText; + + if (!prefix.isEmpty() && text.startsWith(prefix)) { + text.remove(0, prefix.length()); + } + + if (!suffix.isEmpty() && text.endsWith(suffix)) { + text.chop(suffix.length()); + } + + return text.trimmed(); +} +} // namespace + +namespace OBS { +SpinBox::SpinBox(QWidget *parent) : QSpinBox(parent) +{ + setKeyboardTracking(false); + setFocusPolicy(Qt::StrongFocus); + auto lineEdit = new AutoSelectLineEdit(this); + setLineEdit(lineEdit); + + connect(lineEdit, &QLineEdit::cursorPositionChanged, this, &SpinBox::clampCursorPosition); +} + +SpinBox::~SpinBox() {} + +QValidator::State SpinBox::validate(QString &text, int &) const +{ + QString stripped = trimAffixes(text, prefix(), suffix()); + + if (stripped.isEmpty() || stripped == "-") { + return QValidator::Intermediate; + } + + bool ok; + int val = locale().toInt(stripped, &ok); + + if (!ok) { + return QValidator::Invalid; + } + + QString resultText = prefix() + textFromValue(val) + suffix(); + if (text != resultText) { + return QValidator::Intermediate; + } + + if (val < minimum() || val > maximum()) { + return QValidator::Intermediate; + } + + return QValidator::Acceptable; +} + +void SpinBox::fixup(QString &input) const +{ + QString stripped = trimAffixes(input, prefix(), suffix()); + + bool ok; + int val = locale().toInt(stripped, &ok); + + if (ok) { + val = qBound(minimum(), val, maximum()); + input = prefix() + textFromValue(val) + suffix(); + } else { + // Fallback to base class behavior if parsing fails + QSpinBox::fixup(input); + } +} + +void SpinBox::mousePressEvent(QMouseEvent *event) +{ + QStyleOptionSpinBox opt; + initStyleOption(&opt); + + if (style()->subControlRect(QStyle::CC_SpinBox, &opt, QStyle::SC_SpinBoxUp, this).contains(event->pos())) { + QSpinBox::mousePressEvent(event); + return; + } + + if (style()->subControlRect(QStyle::CC_SpinBox, &opt, QStyle::SC_SpinBoxDown, this).contains(event->pos())) { + QSpinBox::mousePressEvent(event); + return; + } + + // Mouse button was pressed and is and not over the spinbox buttons, forward event to the lineEdit. + QRect lineEditRect = lineEdit()->geometry(); + mouseDragYOffset = ((double)height() / 2) - event->position().y(); + + if (!lineEditRect.contains(event->pos())) { + QPointF localPos = event->pos() - lineEditRect.topLeft(); + QMouseEvent forwardEvent(event->type(), localPos, event->globalPosition(), event->button(), + event->buttons(), event->modifiers()); + + QCoreApplication::sendEvent(lineEdit(), &forwardEvent); + return; + } + + QSpinBox::mousePressEvent(event); +} + +void SpinBox::mouseMoveEvent(QMouseEvent *event) +{ + if (!(event->buttons() & Qt::LeftButton)) { + QSpinBox::mouseMoveEvent(event); + return; + } + + QStyleOptionSpinBox opt; + initStyleOption(&opt); + + if (style()->subControlRect(QStyle::CC_SpinBox, &opt, QStyle::SC_SpinBoxUp, this).contains(event->pos())) { + QSpinBox::mouseMoveEvent(event); + return; + } + + if (style()->subControlRect(QStyle::CC_SpinBox, &opt, QStyle::SC_SpinBoxDown, this).contains(event->pos())) { + QSpinBox::mouseMoveEvent(event); + return; + } + + // Mouse button is down and not over the spinbox buttons, forward move event to the lineEdit. + // This allows events within the spinbox but outside the actual lineEdit widget to work. + QPointF relativePos = lineEdit()->mapFromGlobal(event->globalPosition()); + relativePos.ry() -= mouseDragYOffset; + + QMouseEvent forwardEvent(event->type(), relativePos, event->globalPosition(), event->button(), event->buttons(), + event->modifiers()); + QCoreApplication::sendEvent(lineEdit(), &forwardEvent); +} + +void SpinBox::keyPressEvent(QKeyEvent *event) +{ + bool isShiftHeld = QGuiApplication::keyboardModifiers() & Qt::ShiftModifier; + + switch (event->key()) { + case Qt::Key_Up: + event->accept(); + if (stepEnabled() & QAbstractSpinBox::StepUpEnabled) { + stepBy(isShiftHeld ? 10 : 1); + } + return; + case Qt::Key_Down: + event->accept(); + if (stepEnabled() & QAbstractSpinBox::StepDownEnabled) { + stepBy(isShiftHeld ? -10 : -1); + } + return; + case Qt::Key_Return: + case Qt::Key_Enter: + event->accept(); + interpretText(); + return; + } + + QSpinBox::keyPressEvent(event); +} + +void SpinBox::wheelEvent(QWheelEvent *event) +{ + if (!hasFocus()) { + event->ignore(); + } else { + QSpinBox::wheelEvent(event); + } +} + +void SpinBox::clampCursorPosition() +{ + QSignalBlocker block(lineEdit()); + + if (suffix().isEmpty() && prefix().isEmpty()) { + return; + } + + int cursorPosition = lineEdit()->cursorPosition(); + bool hasSelectedText = !lineEdit()->selectedText().isEmpty(); + + if (!suffix().isEmpty()) { + int suffixIndex = lineEdit()->text().length() - suffix().length(); + if (cursorPosition > suffixIndex) { + int cursorOvershoot = lineEdit()->selectionEnd() - suffixIndex; + if (hasSelectedText && cursorOvershoot > 0) { + lineEdit()->setSelection(lineEdit()->selectionStart(), + lineEdit()->selectionLength() - cursorOvershoot); + } else { + lineEdit()->setCursorPosition(suffixIndex); + } + return; + } + } + + if (!prefix().isEmpty()) { + int prefixIndex = prefix().length(); + if (cursorPosition < prefixIndex) { + int cursorUndershoot = prefixIndex - lineEdit()->selectionStart(); + if (hasSelectedText && cursorUndershoot > 0) { + lineEdit()->setSelection(lineEdit()->selectionEnd(), + -lineEdit()->selectionLength() + cursorUndershoot); + } else { + lineEdit()->setCursorPosition(prefixIndex); + } + return; + } + } +} +} // namespace OBS diff --git a/shared/qt/obs-spinbox/SpinBox.hpp b/shared/qt/obs-spinbox/SpinBox.hpp new file mode 100644 index 00000000000000..e220f1ed6e9932 --- /dev/null +++ b/shared/qt/obs-spinbox/SpinBox.hpp @@ -0,0 +1,45 @@ +/****************************************************************************** + Copyright (C) 2026 by Taylor Giampaolo + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ + +#pragma once + +#include + +namespace OBS { +class SpinBox : public QSpinBox { + Q_OBJECT + +public: + explicit SpinBox(QWidget *parent = nullptr); + ~SpinBox(); + + virtual QValidator::State validate(QString &text, int &pos) const override; + virtual void fixup(QString &input) const override; + +protected: + virtual void mousePressEvent(QMouseEvent *event) override; + virtual void mouseMoveEvent(QMouseEvent *event) override; + virtual void keyPressEvent(QKeyEvent *event) override; + virtual void wheelEvent(QWheelEvent *event) override; + +private: + double mouseDragYOffset = 0.0; + +private slots: + void clampCursorPosition(); +}; +} // namespace OBS