frontend: Improve spin box UX

This commit is contained in:
Warchamp7
2026-08-24 15:46:37 -04:00
committed by Ryan Foster
parent 22e2b73716
commit dc1e75b9cb
14 changed files with 741 additions and 43 deletions
+5
View File
@@ -24,6 +24,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)
@@ -38,6 +42,7 @@ target_link_libraries(
OBS::frontend-api
OBS::json11
OBS::bpm
OBS::qt-obs-spinbox
)
include(cmake/ui-components.cmake)
+4
View File
@@ -165,6 +165,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);
@@ -1307,9 +1308,12 @@ 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);
min-width: var(--spinbox_width);
max-height: var(--input_height);
min-width: var(--spinbox_min_width);
}
QSpinBox:hover,
+1
View File
@@ -1,5 +1,6 @@
#include "OBSBasicTransform.hpp"
#include <DoubleSpinBox.hpp>
#include <widgets/OBSBasic.hpp>
#include "moc_OBSBasicTransform.cpp"
+38 -28
View File
@@ -489,7 +489,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="snapDistance">
<widget class="OBS::DoubleSpinBox" name="snapDistance">
<property name="decimals">
<number>1</number>
</property>
@@ -1947,7 +1947,7 @@
</widget>
</item>
<item>
<widget class="QSpinBox" name="multitrackVideoMaximumAggregateBitrate">
<widget class="OBS::SpinBox" name="multitrackVideoMaximumAggregateBitrate">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed">
<horstretch>0</horstretch>
@@ -1996,7 +1996,7 @@
</widget>
</item>
<item>
<widget class="QSpinBox" name="multitrackVideoMaximumVideoTracks">
<widget class="OBS::SpinBox" name="multitrackVideoMaximumVideoTracks">
<property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed">
<horstretch>0</horstretch>
@@ -2157,7 +2157,7 @@
<item row="1" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_35" stretch="0">
<item>
<widget class="QSpinBox" name="whipSimulcastTotalLayers">
<widget class="OBS::SpinBox" name="whipSimulcastTotalLayers">
<property name="minimum">
<number>1</number>
</property>
@@ -2507,7 +2507,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="simpleOutputVBitrate">
<widget class="OBS::SpinBox" name="simpleOutputVBitrate">
<property name="minimum">
<number>200</number>
</property>
@@ -3060,7 +3060,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="simpleRBSecMax">
<widget class="OBS::SpinBox" name="simpleRBSecMax">
<property name="suffix">
<string notr="true"> sec</string>
</property>
@@ -3083,7 +3083,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="simpleRBMegsMax">
<widget class="OBS::SpinBox" name="simpleRBMegsMax">
<property name="suffix">
<string notr="true"> MB</string>
</property>
@@ -4146,7 +4146,7 @@
</widget>
</item>
<item row="9" column="1">
<widget class="QSpinBox" name="advOutSplitFileTime">
<widget class="OBS::SpinBox" name="advOutSplitFileTime">
<property name="suffix">
<string> min</string>
</property>
@@ -4169,7 +4169,7 @@
</widget>
</item>
<item row="10" column="1">
<widget class="QSpinBox" name="advOutSplitFileSize">
<widget class="OBS::SpinBox" name="advOutSplitFileSize">
<property name="suffix">
<string> MB</string>
</property>
@@ -4520,7 +4520,7 @@
</widget>
</item>
<item row="7" column="1">
<widget class="QSpinBox" name="advOutFFVBitrate">
<widget class="OBS::SpinBox" name="advOutFFVBitrate">
<property name="minimum">
<number>0</number>
</property>
@@ -4540,7 +4540,7 @@
</widget>
</item>
<item row="8" column="1">
<widget class="QSpinBox" name="advOutFFVGOPSize">
<widget class="OBS::SpinBox" name="advOutFFVGOPSize">
<property name="maximum">
<number>1000000000</number>
</property>
@@ -4610,7 +4610,7 @@
</widget>
</item>
<item row="13" column="1">
<widget class="QSpinBox" name="advOutFFABitrate">
<widget class="OBS::SpinBox" name="advOutFFABitrate">
<property name="minimum">
<number>32</number>
</property>
@@ -5794,7 +5794,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="advRBSecMax">
<widget class="OBS::SpinBox" name="advRBSecMax">
<property name="suffix">
<string notr="true"> s</string>
</property>
@@ -5810,7 +5810,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="advRBMegsMax">
<widget class="OBS::SpinBox" name="advRBMegsMax">
<property name="suffix">
<string> MB</string>
</property>
@@ -6660,7 +6660,7 @@
<number>0</number>
</property>
<item alignment="Qt::AlignmentFlag::AlignTop">
<widget class="QSpinBox" name="fpsInteger">
<widget class="OBS::SpinBox" name="fpsInteger">
<property name="minimum">
<number>1</number>
</property>
@@ -6702,7 +6702,7 @@
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="fpsNumerator">
<widget class="OBS::SpinBox" name="fpsNumerator">
<property name="minimum">
<number>1</number>
</property>
@@ -6722,7 +6722,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="fpsDenominator">
<widget class="OBS::SpinBox" name="fpsDenominator">
<property name="minimum">
<number>1</number>
</property>
@@ -8118,7 +8118,7 @@
<number>0</number>
</property>
<item>
<widget class="QSpinBox" name="sdrWhiteLevel">
<widget class="OBS::SpinBox" name="sdrWhiteLevel">
<property name="suffix">
<string notr="true"> nits</string>
</property>
@@ -8147,7 +8147,7 @@
</widget>
</item>
<item>
<widget class="QSpinBox" name="hdrNominalPeakLevel">
<widget class="OBS::SpinBox" name="hdrNominalPeakLevel">
<property name="suffix">
<string notr="true"> nits</string>
</property>
@@ -8367,7 +8367,7 @@
<number>0</number>
</property>
<item>
<widget class="QSpinBox" name="streamDelaySec">
<widget class="OBS::SpinBox" name="streamDelaySec">
<property name="enabled">
<bool>true</bool>
</property>
@@ -8477,7 +8477,7 @@
<number>0</number>
</property>
<item>
<widget class="QSpinBox" name="reconnectRetryDelay">
<widget class="OBS::SpinBox" name="reconnectRetryDelay">
<property name="suffix">
<string> s</string>
</property>
@@ -8506,7 +8506,7 @@
</widget>
</item>
<item>
<widget class="QSpinBox" name="reconnectMaxRetries">
<widget class="OBS::SpinBox" name="reconnectMaxRetries">
<property name="minimum">
<number>1</number>
</property>
@@ -8797,9 +8797,19 @@
</widget>
<customwidgets>
<customwidget>
<class>UrlPushButton</class>
<extends>QPushButton</extends>
<header>components/UrlPushButton.hpp</header>
<class>OBS::SpinBox</class>
<extends>QSpinBox</extends>
<header>SpinBox.hpp</header>
</customwidget>
<customwidget>
<class>OBS::DoubleSpinBox</class>
<extends>QDoubleSpinBox</extends>
<header>DoubleSpinBox.hpp</header>
</customwidget>
<customwidget>
<class>AbsoluteSlider</class>
<extends>QSlider</extends>
<header>components/AbsoluteSlider.hpp</header>
</customwidget>
<customwidget>
<class>OBSHotkeyEdit</class>
@@ -8807,9 +8817,9 @@
<header>settings/OBSHotkeyEdit.hpp</header>
</customwidget>
<customwidget>
<class>AbsoluteSlider</class>
<extends>QSlider</extends>
<header>components/AbsoluteSlider.hpp</header>
<class>UrlPushButton</class>
<extends>QPushButton</extends>
<header>components/UrlPushButton.hpp</header>
</customwidget>
</customwidgets>
<tabstops>
+23 -11
View File
@@ -122,7 +122,7 @@
</widget>
</item>
<item row="1" column="3">
<widget class="QDoubleSpinBox" name="sizeX">
<widget class="OBS::DoubleSpinBox" name="sizeX">
<property name="minimumSize">
<size>
<width>120</width>
@@ -169,7 +169,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="QDoubleSpinBox" name="positionX">
<widget class="OBS::DoubleSpinBox" name="positionX">
<property name="minimumSize">
<size>
<width>120</width>
@@ -200,7 +200,7 @@
</widget>
</item>
<item row="1" column="4">
<widget class="QDoubleSpinBox" name="rotation">
<widget class="OBS::DoubleSpinBox" name="rotation">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
@@ -295,7 +295,7 @@
</widget>
</item>
<item row="2" column="1">
<widget class="QDoubleSpinBox" name="positionY">
<widget class="OBS::DoubleSpinBox" name="positionY">
<property name="minimumSize">
<size>
<width>120</width>
@@ -326,7 +326,7 @@
</widget>
</item>
<item row="2" column="3">
<widget class="QDoubleSpinBox" name="sizeY">
<widget class="OBS::DoubleSpinBox" name="sizeY">
<property name="minimumSize">
<size>
<width>120</width>
@@ -586,7 +586,7 @@
</widget>
</item>
<item row="2" column="1">
<widget class="QDoubleSpinBox" name="boundsWidth">
<widget class="OBS::DoubleSpinBox" name="boundsWidth">
<property name="enabled">
<bool>false</bool>
</property>
@@ -620,7 +620,7 @@
</widget>
</item>
<item row="3" column="1">
<widget class="QDoubleSpinBox" name="boundsHeight">
<widget class="OBS::DoubleSpinBox" name="boundsHeight">
<property name="enabled">
<bool>false</bool>
</property>
@@ -729,7 +729,7 @@
</widget>
</item>
<item row="2" column="3">
<widget class="QSpinBox" name="cropBottom">
<widget class="OBS::SpinBox" name="cropBottom">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
@@ -773,7 +773,7 @@
</spacer>
</item>
<item row="1" column="3">
<widget class="QSpinBox" name="cropRight">
<widget class="OBS::SpinBox" name="cropRight">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
@@ -804,7 +804,7 @@
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="cropLeft">
<widget class="OBS::SpinBox" name="cropLeft">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
@@ -845,7 +845,7 @@
</widget>
</item>
<item row="2" column="1">
<widget class="QSpinBox" name="cropTop">
<widget class="OBS::SpinBox" name="cropTop">
<property name="sizePolicy">
<sizepolicy hsizetype="Maximum" vsizetype="Fixed">
<horstretch>0</horstretch>
@@ -928,6 +928,18 @@
<tabstop>cropTop</tabstop>
<tabstop>cropBottom</tabstop>
</tabstops>
<customwidgets>
<customwidget>
<class>OBS::SpinBox</class>
<extends>QSpinBox</extends>
<header>SpinBox.hpp</header>
</customwidget>
<customwidget>
<class>OBS::DoubleSpinBox</class>
<extends>QDoubleSpinBox</extends>
<header>DoubleSpinBox.hpp</header>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
+5
View File
@@ -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
+7 -4
View File
@@ -25,6 +25,9 @@
#include <QObject>
#include <QDesktopServices>
#include <QUuid>
#include "DoubleSpinBox.hpp"
#include "SpinBox.hpp"
#include "double-slider.hpp"
#include "spinbox-ignorewheel.hpp"
#include "moc_properties-view.cpp"
@@ -434,7 +437,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));
@@ -482,7 +485,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);
@@ -517,8 +520,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);
@@ -0,0 +1,28 @@
/******************************************************************************
Copyright (C) 2026 by Taylor Giampaolo <warchamp7@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#include "AutoSelectLineEdit.hpp"
#include <QMouseEvent>
namespace OBS {
void AutoSelectLineEdit::mouseDoubleClickEvent(QMouseEvent *event)
{
selectAll();
event->accept();
}
} // namespace OBS
@@ -0,0 +1,38 @@
/******************************************************************************
Copyright (C) 2026 by Taylor Giampaolo <warchamp7@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#pragma once
#include <QLineEdit>
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
+14
View File
@@ -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)
+245
View File
@@ -0,0 +1,245 @@
/******************************************************************************
Copyright (C) 2026 by Taylor Giampaolo <warchamp7@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#include "DoubleSpinBox.hpp"
#include "AutoSelectLineEdit.hpp"
#include <QApplication>
#include <QKeyEvent>
#include <QLocale>
#include <QMouseEvent>
#include <QStyle>
#include <QStyleOptionSpinBox>
#include <QTimer>
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 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
+45
View File
@@ -0,0 +1,45 @@
/******************************************************************************
Copyright (C) 2026 by Taylor Giampaolo <warchamp7@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#pragma once
#include <QDoubleSpinBox>
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
+243
View File
@@ -0,0 +1,243 @@
/******************************************************************************
Copyright (C) 2026 by Taylor Giampaolo <warchamp7@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#include "SpinBox.hpp"
#include "AutoSelectLineEdit.hpp"
#include <QApplication>
#include <QKeyEvent>
#include <QLocale>
#include <QMouseEvent>
#include <QStyle>
#include <QStyleOptionSpinBox>
#include <QTimer>
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 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
+45
View File
@@ -0,0 +1,45 @@
/******************************************************************************
Copyright (C) 2026 by Taylor Giampaolo <warchamp7@obsproject.com>
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 <http://www.gnu.org/licenses/>.
******************************************************************************/
#pragma once
#include <QSpinBox>
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