From e86a35bc45ccd4b2e6c18d83a6d4a2f9228961f8 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 | 19 +- frontend/forms/OBSBasicTransform.ui | 21 +- shared/properties-view/CMakeLists.txt | 8 + shared/properties-view/properties-view.cpp | 7 +- .../qt/clamped-doublespinbox/CMakeLists.txt | 11 + .../ClampedDoubleSpinBox.cpp | 283 ++++++++++++++++++ .../ClampedDoubleSpinBox.hpp | 50 ++++ 10 files changed, 392 insertions(+), 17 deletions(-) create mode 100644 shared/qt/clamped-doublespinbox/CMakeLists.txt create mode 100644 shared/qt/clamped-doublespinbox/ClampedDoubleSpinBox.cpp create mode 100644 shared/qt/clamped-doublespinbox/ClampedDoubleSpinBox.hpp diff --git a/frontend/CMakeLists.txt b/frontend/CMakeLists.txt index 6f677d4fe53eb3..bfaaddcf38618e 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-clamped-doublespinbox) + add_subdirectory("${CMAKE_SOURCE_DIR}/shared/qt/clamped-doublespinbox" clamped-doublespinbox) +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-clamped-doublespinbox ) include(cmake/ui-components.cmake) diff --git a/frontend/data/themes/Yami.obt b/frontend/data/themes/Yami.obt index d00b26708fc4a1..f6f25aa81f13ff 100644 --- a/frontend/data/themes/Yami.obt +++ b/frontend/data/themes/Yami.obt @@ -135,6 +135,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); @@ -1215,8 +1216,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 04407a8e08a880..42878a82280859 100644 --- a/frontend/dialogs/OBSBasicTransform.cpp +++ b/frontend/dialogs/OBSBasicTransform.cpp @@ -1,6 +1,7 @@ #include "OBSBasicTransform.hpp" #include +#include "ClampedDoubleSpinBox.hpp" #include "moc_OBSBasicTransform.cpp" diff --git a/frontend/forms/OBSBasicSettings.ui b/frontend/forms/OBSBasicSettings.ui index 1d59d537b7dde5..77eb139effb9a5 100644 --- a/frontend/forms/OBSBasicSettings.ui +++ b/frontend/forms/OBSBasicSettings.ui @@ -489,7 +489,7 @@ - + 1 @@ -8703,9 +8703,14 @@ - UrlPushButton - QPushButton -
components/UrlPushButton.hpp
+ ClampedDoubleSpinBox + QDoubleSpinBox +
ClampedDoubleSpinBox.hpp
+
+ + AbsoluteSlider + QSlider +
components/AbsoluteSlider.hpp
OBSHotkeyEdit @@ -8713,9 +8718,9 @@
settings/OBSHotkeyEdit.hpp
- AbsoluteSlider - QSlider -
components/AbsoluteSlider.hpp
+ UrlPushButton + QPushButton +
components/UrlPushButton.hpp
diff --git a/frontend/forms/OBSBasicTransform.ui b/frontend/forms/OBSBasicTransform.ui index 6fb029bbbfa1d7..ddf80c2bea048f 100644 --- a/frontend/forms/OBSBasicTransform.ui +++ b/frontend/forms/OBSBasicTransform.ui @@ -88,7 +88,7 @@ 0 - + 120 @@ -119,7 +119,7 @@ - + 120 @@ -163,7 +163,7 @@ - + 0 @@ -231,7 +231,7 @@ 0 - + 120 @@ -265,7 +265,7 @@ - + 120 @@ -531,7 +531,7 @@ 0 - + false @@ -565,7 +565,7 @@ - + false @@ -852,6 +852,13 @@ + + + ClampedDoubleSpinBox + QDoubleSpinBox +
ClampedDoubleSpinBox.hpp
+
+
diff --git a/shared/properties-view/CMakeLists.txt b/shared/properties-view/CMakeLists.txt index 206455b95e9fb7..0029f1baa4d6ec 100644 --- a/shared/properties-view/CMakeLists.txt +++ b/shared/properties-view/CMakeLists.txt @@ -28,6 +28,13 @@ 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-clamped-doublespinbox) + add_subdirectory( + "${CMAKE_SOURCE_DIR}/shared/qt/clamped-doublespinbox" + "${CMAKE_BINARY_DIR}/shared/qt/clamped-doublespinbox" + ) +endif() + add_library(properties-view INTERFACE) add_library(OBS::properties-view ALIAS properties-view) @@ -51,6 +58,7 @@ target_link_libraries( OBS::libobs OBS::qt-wrappers OBS::qt-plain-text-edit + OBS::qt-clamped-doublespinbox 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 3e939d73024cec..70e48b245ce540 100644 --- a/shared/properties-view/properties-view.cpp +++ b/shared/properties-view/properties-view.cpp @@ -25,6 +25,7 @@ #include #include #include +#include "ClampedDoubleSpinBox.hpp" #include "double-slider.hpp" #include "spinbox-ignorewheel.hpp" #include "moc_properties-view.cpp" @@ -479,7 +480,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(); + ClampedDoubleSpinBox *spin = new ClampedDoubleSpinBox(); if (!obs_property_enabled(prop)) spin->setEnabled(false); @@ -512,8 +513,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, &ClampedDoubleSpinBox::setValue); + connect(spin, &ClampedDoubleSpinBox::valueChanged, slider, &DoubleSlider::setDoubleVal); } connect(spin, &QDoubleSpinBox::valueChanged, info, &WidgetInfo::ControlChanged); diff --git a/shared/qt/clamped-doublespinbox/CMakeLists.txt b/shared/qt/clamped-doublespinbox/CMakeLists.txt new file mode 100644 index 00000000000000..2b55fc034a9cf3 --- /dev/null +++ b/shared/qt/clamped-doublespinbox/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.28...3.30) + +find_package(Qt6 REQUIRED Core Widgets) + +add_library(qt-clamped-doublespinbox INTERFACE) +add_library(OBS::qt-clamped-doublespinbox ALIAS qt-clamped-doublespinbox) + +target_sources(qt-clamped-doublespinbox INTERFACE ClampedDoubleSpinBox.cpp ClampedDoubleSpinBox.hpp) +target_include_directories(qt-clamped-doublespinbox INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}") + +target_link_libraries(qt-clamped-doublespinbox INTERFACE Qt::Core Qt::Widgets) diff --git a/shared/qt/clamped-doublespinbox/ClampedDoubleSpinBox.cpp b/shared/qt/clamped-doublespinbox/ClampedDoubleSpinBox.cpp new file mode 100644 index 00000000000000..f865dd9d9f720d --- /dev/null +++ b/shared/qt/clamped-doublespinbox/ClampedDoubleSpinBox.cpp @@ -0,0 +1,283 @@ +/****************************************************************************** + Copyright (C) 2025 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 +#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 + +ClampedDoubleSpinBox::ClampedDoubleSpinBox(QWidget *parent) : QDoubleSpinBox(parent) +{ + setKeyboardTracking(false); + + connect(lineEdit(), &QLineEdit::textChanged, this, [=]() { + QSignalBlocker block(lineEdit()); + + QString text = cleanText(); + + if (text.contains(locale().decimalPoint() + locale().decimalPoint())) { + int pos = text.indexOf(locale().decimalPoint() + locale().decimalPoint()); + lineEdit()->setText(text.replace(locale().decimalPoint() + locale().decimalPoint(), + locale().decimalPoint())); + lineEdit()->setCursorPosition(pos + 1); + } else if (text.contains("..")) { + int pos = text.indexOf(".."); + lineEdit()->setText(text.replace("..", ".")); + lineEdit()->setCursorPosition(pos + 1); + } else if (text.contains("--")) { + int pos = text.indexOf("--"); + lineEdit()->setText(text.replace("--", "-")); + lineEdit()->setCursorPosition(pos + 1); + } + + if (countDecimals(text) > decimals()) { + if (lineEdit()->cursorPosition() > text.indexOf(locale().decimalPoint()) || + lineEdit()->cursorPosition() > text.indexOf(".")) { + int pos = lineEdit()->cursorPosition(); + lineEdit()->setText(text.sliced(0, text.length() - 1)); + lineEdit()->setCursorPosition(pos); + } + } + }); + + connect(lineEdit(), &QLineEdit::cursorPositionChanged, this, [=]() { clampCursorPosition(); }); +} + +ClampedDoubleSpinBox::~ClampedDoubleSpinBox() {} + +int ClampedDoubleSpinBox::countDecimals(const QString &text) +{ + int dotIndex = text.indexOf("."); + int decimalIndex = text.indexOf(locale().decimalPoint()); + if (dotIndex == -1 && decimalIndex == -1) { + return 0; + } + + int decimalCount = 0; + if (dotIndex >= 0) { + decimalCount = text.length() - dotIndex - 1; + } else if (decimalIndex >= 0) { + decimalCount = text.length() - decimalIndex - 1; + } + + return decimalCount; +} + +void ClampedDoubleSpinBox::setValue(double val) +{ + if (!isFocused) { + decimalsToDisplay_ = decimals(); + } + + QDoubleSpinBox::setValue(val); +} + +QString ClampedDoubleSpinBox::textFromValue(double value) const +{ + QLocale loc = locale(); + if (isGroupSeparatorShown()) { + loc.setNumberOptions(loc.numberOptions() & ~QLocale::OmitGroupSeparator); + } else { + loc.setNumberOptions(loc.numberOptions() | QLocale::OmitGroupSeparator); + } + + return loc.toString(value, 'f', decimalsToDisplay()); +} + +QValidator::State ClampedDoubleSpinBox::validate(QString &text_, int &pos) const +{ + QString trimmed = trimAffixes(text_, prefix(), suffix()); + trimmed = trimmed.replace(locale().decimalPoint(), "."); + + bool ok; + trimmed.toDouble(&ok); + + /* Permit empty textbox */ + if (trimmed.isEmpty()) { + return QValidator::Intermediate; + } + + /* Allow typing a dot or dash "over" an existing one */ + if (trimmed.contains("..") || trimmed.contains("--")) { + return QValidator::Intermediate; + } + + /* Only allow a single decimal or - */ + if (trimmed.count(".") > 1 || trimmed.count("-") > 1) { + return QValidator::Invalid; + } + + /* Only allow "-" at the start of the text */ + if (trimmed.count("-") == 1 && !trimmed.startsWith("-")) { + return QValidator::Invalid; + } + + /* Sane limit on total characters */ + int maxLength = + std::max(QString::number((int)abs(maximum())).length(), QString::number((int)abs(minimum())).length()); + maxLength += 1; + maxLength += decimals(); + maxLength += trimmed.startsWith("-") ? 1 : 0; + if (trimmed.indexOf(".") >= 0) { + maxLength += 1; + + if (decimals() > 0 && lineEdit()->cursorPosition() - 1 > trimmed.indexOf(".")) { + maxLength += 1; + } + } + + if (trimmed.length() > maxLength) { + return QValidator::Invalid; + } + + /* Run normal validator for any non-double */ + if (!ok) { + return QDoubleSpinBox::validate(text_, pos); + } + + return QValidator::Intermediate; +} + +void ClampedDoubleSpinBox::fixup(QString &inputText) +{ + inputText.remove(locale().groupSeparator()); + + inputText = trimAffixes(inputText, prefix(), suffix()); + inputText = inputText.replace(locale().decimalPoint(), "."); + + if (countDecimals(inputText) > decimals()) { + inputText.chop(1); + } + + bool ok; + double val = inputText.toDouble(&ok); + if (!ok) { + /* Restore existing value if text is not a valid double */ + lineEdit()->setText(textFromValue(value())); + return; + } + + /* Skip formatting when text ends with a decimal */ + if (inputText.indexOf(".") == inputText.length() - 1) { + return; + } + + decimalsToDisplay_ = std::min(countDecimals(inputText), decimals()); + + double clamped = std::clamp(val, minimum(), maximum()); + QString formatted = textFromValue(clamped); + + QSignalBlocker blocker(lineEdit()); + lineEdit()->setText(formatted); + clampCursorPosition(); +} + +void ClampedDoubleSpinBox::focusInEvent(QFocusEvent *event) +{ + isFocused = true; + + QDoubleSpinBox::focusInEvent(event); +} + +void ClampedDoubleSpinBox::focusOutEvent(QFocusEvent *event) +{ + QString text = lineEdit()->text(); + fixup(text); + setValue(valueFromText(text)); + + QDoubleSpinBox::focusOutEvent(event); + + isFocused = false; +} + +void ClampedDoubleSpinBox::keyPressEvent(QKeyEvent *event) +{ + int cursor = lineEdit()->cursorPosition(); + + switch (event->key()) { + case Qt::Key_Return: + case Qt::Key_Enter: + setValue(valueFromText(lineEdit()->text())); + lineEdit()->setCursorPosition(cursor); + event->accept(); + return; + } + + QDoubleSpinBox::keyPressEvent(event); +} + +void ClampedDoubleSpinBox::stepBy(int steps) +{ + QString stepAmount = QString::number(singleStep() * steps); + int stepDecimals = countDecimals(stepAmount); + + QString currentText = lineEdit()->text(); + fixup(currentText); + int currentDecimals = countDecimals(currentText); + decimalsToDisplay_ = std::max(stepDecimals, currentDecimals); + + /* Update spinbox value before performing step update */ + const bool wasBlocked = blockSignals(true); + setValue(valueFromText(currentText)); + blockSignals(wasBlocked); + + QDoubleSpinBox::stepBy(steps); +} + +void ClampedDoubleSpinBox::clampCursorPosition() +{ + if (suffix().isEmpty() && prefix().isEmpty()) { + return; + } + + if (!suffix().isEmpty()) { + int suffixIndex = lineEdit()->text().length() - suffix().length(); + bool isCursorAfterSuffix = lineEdit()->cursorPosition() > suffixIndex; + if (isCursorAfterSuffix) { + lineEdit()->setCursorPosition(suffixIndex); + return; + } + } + + if (!prefix().isEmpty()) { + int prefixIndex = lineEdit()->text().length() - prefix().length(); + bool isCursorAfterPrefix = lineEdit()->cursorPosition() < prefixIndex; + if (isCursorAfterPrefix) { + lineEdit()->setCursorPosition(prefixIndex); + return; + } + } +} diff --git a/shared/qt/clamped-doublespinbox/ClampedDoubleSpinBox.hpp b/shared/qt/clamped-doublespinbox/ClampedDoubleSpinBox.hpp new file mode 100644 index 00000000000000..0b3d73b961f32b --- /dev/null +++ b/shared/qt/clamped-doublespinbox/ClampedDoubleSpinBox.hpp @@ -0,0 +1,50 @@ +/****************************************************************************** + Copyright (C) 2025 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 + +class ClampedDoubleSpinBox : public QDoubleSpinBox { +public: + explicit ClampedDoubleSpinBox(QWidget *parent = nullptr); + ~ClampedDoubleSpinBox(); + + QString textFromValue(double value) const override; + +public slots: + void setValue(double val); + +private: + bool isFocused = false; + + int countDecimals(const QString &text); + + int decimalsToDisplay_ = decimals(); + int decimalsToDisplay() const { return std::min(decimals(), decimalsToDisplay_); } + +protected: + QValidator::State validate(QString &text, int &pos) const override; + void fixup(QString &inputText); + + void focusInEvent(QFocusEvent *event) override; + void focusOutEvent(QFocusEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + + void stepBy(int steps) override; + void clampCursorPosition(); +};