slint-ui / slint

Slint is a declarative GUI toolkit to build native user interfaces for Rust, C++, or JavaScript apps.
https://slint.dev
Other
17.5k stars 599 forks source link

The documentation of the property system should explain when bindings are "broken" #5990

Open Enyium opened 2 months ago

Enyium commented 2 months ago

The reference documentation of properties should explain that binding can be broken when the property is set by some external means.

It should also explain more about two ways binding and what exactly happens when two bindings have an original value in the same component (the right-hand-side wins) (see https://github.com/slint-ui/slint/issues/6105#issuecomment-2346314732)

Was: LineEdit's text property doesn't follow its bound value anymore after editing

This should be fairly self-explanatory (corresponding SlintPad link):

import { LineEdit, VerticalBox, Button } from "std-widgets.slint";

export component AppWindow inherits Window {
    in-out property <string> value;

    init => {
        // No problem at the start. Text becomes visible in LineEdit.
        root.value = "ab";

        // Like manual editing, this also makes it not work anymore.
        // le.text = "abc";
    }

    VerticalBox {
        le := LineEdit {
            text: root.value;
            edited => {
                // (Actual use case would be conversion to `duration`.
                // Other direction in `text` binding.)
                root.value = self.text;
                debug("edited; value:", root.value, "; LineEdit text:", self.text);
            }
        }

        button := Button {
            text: "Set Value";
            clicked => {
                // The property is applied, but the text doesn't
                // become visible in the LineEdit. Same problem
                // when setting `value` from Rust.
                root.value = "xy";
                debug("clicked; value:", root.value, "; LineEdit text:", le.text);
            }
        }
    }
}

Reproduce the bug with these steps:

Enyium commented 2 months ago

I tried to patch LineEdit with the following code:

component PatchedLineEdit {
    in property <InputType> input-type <=> line-edit.input-type;
    in-out property <string> text;

    callback edited(string);

    init => {
        line-edit.text = root.text;
    }

    changed text => {
        line-edit.text = root.text;
    }

    line-edit := LineEdit {
        width: root.width;

        edited(text) => {
            root.text = text;
            root.edited(text);
        }
    }
}

But, unfortunately, this doesn't change anything, and I don't understand why!

The only way I can intermediately patch this is by having this changed-callback for the backing property in my component inheriting Window:

    in-out property <duration> dur;
    changed dur => {
        line-edit.text = root.dur-to-mins(root.dur);
    }

Among other things, this has the disadvantage that, when the user deletes the 2 in 0.2, the decimal separator is also deleted.

Enyium commented 2 months ago

Slider's and SpinBox's value have the same problem. When used in the manner initially described, their inner changing of their value property makes it so that setting a dependency of value doesn't update value anymore, leaving the widget "dead".

Only two-way bindings from own component property to widget's property don't give me these problems. But if I need to convert or transform the data, this isn't an option.

Enyium commented 2 months ago

@hunger (@ogoffart?): If I'm not overlooking something fundamental here, this bug should have high priority in my opinion! Changing a property that a Slider's value, e.g., is bound to and directly setting value (including by internal widget code through UI use) should just all be points for Slint where value changes. Directly setting value should never kill the binding! value should just be a sink for data from various sources. I can't say that I'm familiar with all potential use cases, so in other cases breaking the binding may be desired, but your own widgets seem to be written with the assumption that directly setting a property doesn't break the user's one-way binding to the same property. Should this require a fundamental change in behavior of one-way bindings and this is undesired, then maybe you could establish a distinction between breakable and non-breakable one-way binding declarations with new syntax.

hunger commented 2 months ago

This is definitely for @ogoffart to answer:-)

ogoffart commented 1 month ago

I think here there is a documentation issue. Binding are broken when we assign to them, and the TextInput's text property does that when it is edited by the user. With the exception of two way bindings (<=>) which stays the same.

Enyium commented 1 month ago

But how is one supposed to implement transformations of widget values? I have a Slider whose value must be mapped to a pow()-distorted scale, and LineEdits whose text is mapped to properties of type duration (in both directions, of course, because Rust code may fill the settings pages).

NigelBreslaw commented 1 month ago

I will take this as part of the doc updates. @Enyium This is working as intended and bindings should not be placed on the 'value' property of items such as the 'text' property of LineEdit or the 'value' of a slider. However what you are trying to achieve, such as being able to set a value with a slider AND a text input, is quite tricky right now. It will become easy to manage once the 'property changed' system is in place via https://slint.dev/blog/property-changed-callback

The logic is on a slider you move the slider and value is where the thumb is. Each move updates the value and destroys any binding someone else adds. Same with LineEdit. As you type the 'text' value is updated and any bindings placed there will be destroyed. Does it make sense now? So what is needed is that LineEdit and Slider don't bind to the root 'value'. Instead slider has changed text => { root.value = self.value } and LineEdit has changed text => { root.value = self.text } and then if you have a reset button you would set slider.value = 0, then LineEdit.text = "", root.value = "".

Enyium commented 1 month ago

@NigelBreslaw:

Coincidentally, I have one case where a Window component property is connected to a Slider and a LineEdit. But still, you misunderstood what I was referring to with this statement:

I have a Slider whose value must be mapped to a pow()-distorted scale, and LineEdits whose text is mapped to properties of type duration.

I meant these things individually. Different kinds of two-way data transformation is needed:


I'm not convinced non-breaking bindings wouldn't be worthwhile. As I see it, direct assignment could just clear the binding's dirty flag instead of destroying the binding, so that, when the property value is finally needed, the engine doesn't see a need to evaluate the binding, but directly takes the cached value which was directly assigned. If a binding source changes - leading to the dirty flag being set - and the property value is then needed, the binding would be evaluated.


As a workaround, I tried this DurationEdit implementation:

component DurationEdit {
    in-out property <duration> value;
    callback edited;

    changed value => {
        line-edit.text = Std.dur-to-mins(root.value);
    }

    line-edit := LineEdit {
        width: root.width;
        input-type: decimal;

        edited(text) => {
            root.value = Std.mins-to-dur(text.to-float());
            self.text = text; // Undesirable. Also doesn't work, because changed-callback is delayed.
            root.edited();
        }
    }
}

Then I used the widget with a two-way binding to its value property. Two-way bindings seem to be unproblematic regarding binding destruction.

It works in that the widget isn't left dead after manual editing, but value can be set from Rust after editing with the widget updating accordingly. However, when I, e.g., type 0.04, the text is immediately replaced with 0.039983332; and when I have the text 0.05 and delete the 5, the text is immediately replaced with 0. The problem is that assigning root.value in edited(text) => { ... } triggers the changed-callback, which is very much undesired. The workaround of following up with self.text = text; doesn't work (I know that Olivier's article you linked contains the reason).

Olivier's article also talks about loops in the context of changed-callbacks. The use case of the code above underlines the importance of solving this. My idea would be to have a covert keyword that you can use before assignments to tell Slint that this assignment shouldn't trigger changed-callbacks for the respective property. In the code above, you'd then have:

        edited(text) => {
            covert root.value = Std.mins-to-dur(text.to-float());
            root.edited();
        }

However, this assignment also being hidden from the changed-callbacks of the component users wouldn't be right. Maybe, covert would need to be defined as only applying in the context of the component it's used in? Perhaps, changed-callbacks could additionally get special syntax for either going with the covert filter or ignoring it.


Another relevant point: The DurationEdit code above converts on each edited event of the inner LineEdit - many unnecessary times, like when typing regularly or holding a repeated key. What if you wanted to only perform the conversion when the value property is actually requested (lazily)? You'd have to specify a binding on the value property (deriving it from line-edit.text) that's for reading it, and when value is set from the outside (never from the inside), the binding must persist and the changed-callback runs, which updates the LineEdit. This is another reason for non-breaking bindings.