Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How do we temporarilly allow invalid input with a databinding? #670

Open
AmaiKinono opened this issue Sep 10, 2024 · 4 comments
Open

How do we temporarilly allow invalid input with a databinding? #670

AmaiKinono opened this issue Sep 10, 2024 · 4 comments
Labels
data binding discussion Meta talk and feedback

Comments

@AmaiKinono
Copy link
Contributor

I've created an example in the tutorial directory. Here are the important bits:

index.rml:

<rml>
<head>
    <link type="text/css" href="index.rcss"/>
    <title>Window</title>
</head>
<body data-model="data">
    <input type="text" data-value="date"></input>
</body>
</rml>

main.cpp:

//...
#include <string>

// Let's only put month in this for clarity. "In reality" I'm using
// std::chrono::year_month_day.
struct Date {
  int month;
} date;

bool SetupDataBinding(Rml::Context* context, Rml::DataModelHandle& model)
{
  Rml::DataModelConstructor ctor = context->CreateDataModel("data");
  if (!ctor) {
    return false;
  }
  ctor.RegisterScalar<Date>([](const Date& date, Rml::Variant& variant) {
    variant = std::to_string(date.month);
  },
    // This setter will not set if the input is invalid
    [](Date& date, const Rml::Variant& variant) {
      int month = 0;
      try {
        month = std::stoi(variant.Get<Rml::String>());
      } catch (...) {
        return;
      }
      if (0 < month && month <= 12) {
        date.month = month;
      }
    });
  model = ctor.GetModelHandle();
  ctor.Bind("date", &date);
  return true;
}

// ... in main function

    Rml::Debugger::Initialise(context);
    Shell::LoadFonts();

    Rml::DataModelHandle model_handle;
    SetupDataBinding(context, model_handle);

// ...

The behavior:

  • We couldn't type non-number chars or a number larger than 12. Usually the user would expect that they can type anything but a visual cue will tell if the input is invalid or not.
  • We couldn't delete all chars in the input, as an empty string is invalid too.
  • Seems there's no easy way to start the app with an empty input box, as the initial value "0" will be shown.

Do we have simple solutions for these now?

One I could think of is to use an underlying data type (a String, for example) that's "large enough" to contain all input states, and scatter the validation logic to other parts of the code. But that seems to defeat the purpose of the data-binding approach.

If in the setter we can somehow tell RmlUi that the input is invalid (another approach is to bind a piece of data with a validator function), and it should not update the view, that should solve the problem too.

@mikke89 mikke89 added discussion Meta talk and feedback data binding labels Sep 11, 2024
@mikke89
Copy link
Owner

mikke89 commented Sep 11, 2024

In my view, it makes a lot of sense to use a std::string here if you want the user to be able to type any value. That way, the data binding represents exactly what the value actually holds. At least, this is how I think about data bindings, I would be very confused if the element's value contained something other than what the element displays to the user. And then you add the validity check logic and visual cues on top of that.

By the way, you can also use the Rml::Variant constructor and getters to work directly with integers, instead of converting to and from strings manually.

@AmaiKinono
Copy link
Contributor Author

I don't have much GUI programming experience, so I may be wrong, but "data binding" to me means to bind domain-specific data to GUI elements, so the data is suitable to be directly used by business logic.

Imagine we bind a text input to Rml::String, but the business logic actually operates on some datetime data, then we have to pay the price of type conversion every time we use the data. Or we could add another piece of data in the datetime, but then we have to sync between the string and the datetime. It looks like we just defer the syncing problem from the MVC architecture to the client side.

I think my trouble comes from the two-way binding, so I'm trying to break it up, using a data view and a event callback. In the callback I could validate the input and update the data as appropriate. This works well, but the initial value of the data will be reflected in the text input, and I haven't figure out how to have an empty text input in the first place.

By the way, you can also use the Rml::Variant constructor and getters to work directly with integers, instead of converting to and from strings manually.

I've tried and this is really cool, Thanks! but I found myself lacking in understanding of data and its presentation in the UI. For example, how does RmlUi handle the converting between an integer and the value in text input? I'm guessing something like std::to_string is used here.

Also, I once bind a data attribute to a boolean value, and found I have to use selectors like [value=0] in rcss, so is the boolean value converted to integer or string "0" and "1"? I think I've also tried to bind a select element to an enum class but couldn't figure out how to do it.

This may be obvious to you, so I'm sorry to take up your time. I'd like to make a PR to the RmlUi documentation once I'm clear on this to help other users.

@mikke89
Copy link
Owner

mikke89 commented Oct 13, 2024

Thanks for updating the documentation by the way! Hopefully things are a bit more clear for the next user that comes along :)

I would say more generally, I think it's quite common to have some logic sitting between the actual business data, and the UI data. Even just for the sake of threading, one often keeps a UI copy of the data for the UI thread. And it is not uncommon to see cached results for presenting to the UI, for performance sake.

Maybe these can be considered deviations from the "ideal" though, which is that there is one true source of data. But I don't know, I certainly don't have any final say either way. I think generally, it's a bit problematic that the state of the view contains more information than the model (i.e. invalid characters).

At least, at some point in the pipeline, we need some way to express and display input values that are intermittently invalid. Maybe ideally that logic would be part of an e.g. <input type=datetime> "view". Though the business logic might need to know that there is no valid data currently. Regardless, assuming the lack of such an element type, I think doing this as part of updating the data model is a fairly pragmatic approach.

All of that aside, maybe you can make it behave the way you want with custom getters and setters. I'd be interested to see what ends up working for you.

@AmaiKinono
Copy link
Contributor Author

I'm still trying in the "one true source of data" direction. Maybe it's just because I haven't really got into multi-threading programming yet, but I think I've found a solution that works for me. I'll explain it below.

"One-way" binding

I am mainly using "one-way" binding, which is to set up a data view of the data, and modify the data (and mark it as dirty) by a callback. This is for 2 reasons:

  1. To workaround For input elements, change event callback happens before data model is updated? #668, because in the callback I can always use the latest input from the UI.
  2. To allow different events to modify the same piece of data and do different things, e.g., validation.

I found this approach give me much freedom to do whatever I want in the callback. And if I want to view the same piece of data in slightly differently ways, I could use DataModelConstructor::BindFunc.

Domain data and UI data

These may not be the best names but is what I'm using now. Let's take the date example again.

The domain data is a std::chrono::year_month_day. The UI data is a boolean flag of whether the input is valid, and is set in a data-event-change callback. I could apply some style based on the UI data to indicate the validity. As soon as the input is valid, we update the domain data and mark dirty variables.

I also have another boolean flag in the UI data, telling if the input is modified once. So when this is false, I would not show the "invalid" style (it's weird to mark a whole form as invalid before the user starts to fill in it).

The idea is to keep a set of domain data that's shared among UI components and elements, and control the behavior of views & controllers by UI data. Conceptually, domain data is the "real" data, and UI data is more like part of the UI element.

Let's see it in action:

simplescreenrecorder-2024-10-17_01.26.44.mp4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
data binding discussion Meta talk and feedback
Projects
None yet
Development

No branches or pull requests

2 participants