SpinCtrl

Started by Kvaz1r, 12 September 2019, 12:13:25

Kvaz1r

SpinCtrl is a popular control that combine SpinButton and one line TextBox. It's not so hard do it in user code but it would be much better to have it out of the box from library.

texus

I've though about it in the past, but I never got around to actually do it.
It would add even more duplication than there already is in the gui as it must have most functions from both EditBox and SpinButton.

Writing it would also be so much easier if a widget could consist of subwidgets. Right now you either have to manually deal with all events yourself of make the widget inherit from Group which adds several functions to the API that you wouldn't want in your widget. I think I'm going to look into this issue in the near feature. I'm not going to be adding the SpinCtrl widget soon, but at least I'm going to try to make it easier to add in the future.

Kvaz1r

I've finally finished SpinCtrl, here is code and example:


#include <TGUI/TGUI.hpp>
#include <iostream>

class SpinCtrl : public tgui::SubwidgetContainer
{
public:
    typedef std::shared_ptr<SpinCtrl> Ptr; //!< Shared widget pointer
    typedef std::shared_ptr<const SpinCtrl> ConstPtr; //!< Shared constant widget pointer

    SpinCtrl(float min = 0.0F, float max = 10.0F, float step = 1.0F, float value = 0.0F)
    {
        m_type = "SpinCtrl";

        m_spinButton = tgui::SpinButton::create();
        m_spinText = tgui::EditBox::create();
        m_spinText->setText(tgui::String(m_spinButton->getValue()));

        m_spinButton->setPosition(tgui::bindRight(m_spinText), tgui::bindTop(m_spinText));
        m_spinButton->onValueChange([this](const float value)
            {
                m_spinText->setText(tgui::String(value));
                onValueChange.emit(this, value);
            });

        m_spinButton->setMinimum(min);
        m_spinButton->setMaximum(max);
        m_spinButton->setValue(value);
        m_spinButton->setStep(step);

        m_spinText->setSize(m_spinText->getSize().x, m_spinButton->getSize().y);
        m_spinText->onTextChange([this](const tgui::String& text)
            {
                auto curValue = m_spinButton->getValue();
                try
                {
                    std::size_t pos = 0;
                    float value = std::stof(text.toAnsiString(), &pos);
                    if (pos != text.size() || !inRange(value))
                    {
                        m_spinText->setText(tgui::String(curValue));
                    }
                    else if (curValue != value)
                    {
                        m_spinButton->setValue(value);
                    }
                }
                catch (...)
                {
                    m_spinText->setText(tgui::String(curValue));
                }
            });
        m_container->add(m_spinText);
        m_container->add(m_spinButton);
        auto butSize = m_spinButton->getSize();
        auto txtSize = m_spinText->getSize();
        setSize({ butSize.x + txtSize.x, butSize.y });
    }
    static SpinCtrl::Ptr create(float min = 0.0F, float max = 10.0F, float step = 1.0F, float value = 0.0F)
    {
        return std::make_shared<SpinCtrl>(min, max, step, value);
    }
    static SpinCtrl::Ptr copy(SpinCtrl::ConstPtr spinctrl)
    {
        if (spinctrl)
            return std::static_pointer_cast<SpinCtrl>(spinctrl->clone());
        else
            return nullptr;
    }
    Widget::Ptr clone() const override
    {
        return std::make_shared<SpinCtrl>(*this);
    }
    bool SetValue(const float value)
    {
        if (inRange(value) && m_spinButton->getValue() != value)
        {
            m_spinButton->setValue(value);
            m_spinText->setText(tgui::String(value));
            return true;
        }
        return false;
    }
    void setMinimum(const float min)
    {
        m_spinButton->setMinimum(min);
    }
    void setMaximum(const float max)
    {
        m_spinButton->setMaximum(max);
    }
    void setStep(const float inc)
    {
        m_spinButton->setStep(inc);
    }
    float getValue() const
    {
        return m_spinButton->getValue();
    }
    float getMinimum() const
    {
        return m_spinButton->getMinimum();
    }
    float getMaximum() const
    {
        return m_spinButton->getMaximum();
    }
    float getStep() const
    {
        return m_spinButton->getStep();
    }
    tgui::SpinButtonRenderer* getSpinButtonRenderer()
    {
        return m_spinButton->getRenderer();
    }
    tgui::SpinButtonRenderer* getSpinButtonSharedRenderer()
    {
        return m_spinButton->getSharedRenderer();
    }
    tgui::EditBoxRenderer* getSpinTextRenderer()
    {
        return m_spinText->getRenderer();
    }
    tgui::EditBoxRenderer* getSpinTextSharedRenderer()
    {
        return m_spinText->getSharedRenderer();
    }

    tgui::SignalTyped<float> onValueChange = { "ValueChanged" };

private:
    bool inRange(const float value) const
    {
        return m_spinButton->getMinimum() <= value && value <= m_spinButton->getMaximum();
    }

    tgui::SpinButton::Ptr m_spinButton;
    tgui::EditBox::Ptr m_spinText;
};

class MyFrame
{
public:
    MyFrame()
    {
        window.create(sf::VideoMode(800, 600), "SpinCtrl");
        gui.setTarget(window);
        auto spin = SpinCtrl::create(0, 10, 0.2F, 5);
        spin->getSpinTextRenderer()->setBackgroundColor(tgui::Color(205, 14, 98));
        spin->getSpinTextRenderer()->setBackgroundColorHover(tgui::Color(205, 14, 98));
        spin->getSpinButtonRenderer()->setBackgroundColor(tgui::Color(138, 250, 134));
        spin->getSpinButtonRenderer()->setBackgroundColorHover(tgui::Color(138, 250, 134));
        gui.add(spin);

        spin->onValueChange([](float value) { std::cout << "New value is " << value << '\n'; });
    }

    void main()
    {
        while (window.isOpen())
        {
            sf::Event event;
            while (window.pollEvent(event))
            {
                if (event.type == sf::Event::Closed)
                    window.close();
                gui.handleEvent(event);
            }
            gui.draw();
            window.display();
        }
    }
    sf::RenderWindow window;
    tgui::Gui gui;
};

int main()
{
    MyFrame().main();
}


If such implementation is ok, I am going to open PR (will split onto .h/.cpp and add tests).

As an option maybe it make sense to add method createIntegerSpinCtrl and set in that method Integer Validator.

texus

Thanks a lot. I'm sure several people will find this class useful.

The only think that I miss in the class is the setSize function. If setSize is called on the SpinCtrl, the spin button should probably keep its ratio (so if height is doubled then width is doubled too) and the edit box would take up all the remaining width.

I'm not a big fan of having a createIntegerSpinCtrl function, I would prefer that all widgets would be created via the 'create' function, but I don't see many alternatives either. Maybe instead of choosing Int vs Float, there should be an amount of digits behind the comma? If set to 0, only ints get accepted, if set to 2 then "x", "x.a" or "x.ab" would be accepted but "x.abc" would be rounded. The default could be -1 which allows any float input.
Maybe the TextChanged event could also be replaced by ReturnOrUnfocused, so that you can still e.g. copy "x.abc" into the edit field and it would only be rounded and accepted when pressing enter or unfocusing the edit box?
Ultimately I'll leave this decision up to you, it's just some idea to think about. I would merge the class no matter how the int vs float is solved.

Instead of using std::stof and exceptions, I think String::toFloat should be used. Whether we should depend on the locale to parse numbers is arguable, but for now it would probably be best to rely on the code always behaving the same on every pc. I recently discovered that std::stof does depend on the locale and may turn "1.5" into "1" for some people, which is why I stopped using std::stof in the String class.

Finally some nitpicks. Maybe you would have already made some of these changes when cleaning up the code, but here are a couple of minor things that don't correspond to the rest of TGUI code:
- You can use SignalFloat instead of SignalTyped<float>
- SetValue shouldn't have capital S
- Lowercase 'f' instead of 'F' is used in TGUI behind floating point numbers
- If a value isn't going to be changed, it can be a 'const' variable (e.g. curValue)

Kvaz1r

Quote from: texus on 10 July 2020, 23:25:12
Maybe instead of choosing Int vs Float, there should be an amount of digits behind the comma? If set to 0, only ints get accepted, if set to 2 then "x", "x.a" or "x.ab" would be accepted but "x.abc" would be rounded. The default could be -1 which allows any float input.
That's indeed better.

Quote from: texus on 10 July 2020, 23:25:12
Instead of using std::stof and exceptions, I think String::toFloat should be used. Whether we should depend on the locale to parse numbers is arguable, but for now it would probably be best to rely on the code always behaving the same on every pc.
Yeah, I though about that, but String::toFloat doesn't have any way to know was full string converted or only some first part. E.g. "123text" give 123  so one need or make extra conversion back to string or something else. For now I copied simplified version of toFloat as a private function, but guess String::toFloat should contains overloading for such cases. Maybe like std::stof with parameter for storing the number of characters processed or something else.

texus

#5
Does it really matter that if the user typed "123text", we keep the "123" instead of resetting the value? I don't think anyone is even going to notice the behavior (because you typically do type actual digits in it). And I would kind of consider it a feature that it keeps the value I typed if I accidentally hit another button together with the enter key :).
Maybe the edit box should have Validator::Float set, that way you can't even type text or have multiple commas.

The compile error is coming from line 45 because you have a generic lambda. GCC 5 doesn't seem to like it that you call a member function from inside a generic lambda. You should change "auto" to "float" there. I personally never use auto for simple integers and floats, usually it doesn't matter but in this particular case you can't use auto (at least not when keeping the older compilers supported).
There are also a few warnings about you reusing the "value" variable name multiple times in the same function (line 45 and 69 reuse the name declared at line 33).

Kvaz1r

Quote from: texus on 14 July 2020, 19:31:51
Does it really matter that if the user typed "123text", we keep the "123" instead of resetting the value? I don't think anyone is even going to notice the behavior (because you typically do type actual digits in it). And I would kind of consider it a feature that it keeps the value I typed if I accidentally hit another button together with the enter key :).
Maybe the edit box should have Validator::Float set, that way you can't even type text or have multiple commas.
Yeah, I didn't think about that. Updated with validator. 

texus

The code has been merged. Thanks again for contributing this.

I noticed that there is still a warning left, but I will fix this myself. Once I'm done with what I'm working on (ability to change mouse cursors plus some code reorganization), I'm going to have a detailed look at the code you added and make some nitpick changes anyway.

Kvaz1r

Wow, mouse cursors will be really cool addition.

Yeah, I'm sure that I missed something (except code style of course). I only now found that widgets based on SubwidgetContainer don't have load/save support out-of-the-box. And I don't see how it should be implemented (I haven't big experience with any DSL-based GUI therefore not use it at all so it's not a problem for me).

texus

I've implemented saving and loading. Some fixes unrelated to SpinControl were needed to make it work.
The mouse cursor change has also been added by now.