Back to Slint

Custom Controls

docs/astro/src/content/docs/guide/development/custom-controls.mdx

1.16.127.3 KB
Original Source

import { Tabs, TabItem } from '@astrojs/starlight/components'; import LangRefLink from '@slint/common-files/src/components/LangRefLink.astro';

Custom Control Introduction

A Clickable Button

slint
import { VerticalBox, Button } from "std-widgets.slint";
export component Recipe inherits Window {
    in-out property <int> counter: 0;
    VerticalBox {
        button := Button {
            text: "Button, pressed " + root.counter + " times";
            clicked => {
                root.counter += 1;
            }
        }
    }
}

In this first example, you see the basics of the Slint language:

  • We import the VerticalBox layout and the Button widget from the standard library using the import statement. This statement can import widgets or your own components declared in different files. You don't need to import built-in element such as Window or Rectangle.
  • We declare the Recipe component using the component keyword. Recipe inherits from Window and has elements: A layout (VerticalBox) with one button.
  • You instantiate elements using their name followed by a pair of braces (with optional contents. You can assign a name to a specific element using :=
  • Elements have properties. Use : to set property values. Here we assign a binding that computes a string by concatenating some string literals, and the counter property to the Button's text property.
  • You can declare custom properties for any element with property <...>. A property needs to have a type, and can have a default value and an access specifier. Access specifiers like private, in, out or in-out defines how outside elements can interact with the property. Private is the default value and stops any outside element from accessing the property. The counter property is custom in this example.
  • Elements can also have callback. In this case we assign a callback handler to the clicked callback of the button with => { ... }.
  • Property bindings are automatically re-evaluated if any of the properties the binding depends on changes. The text binding of the button is automatically re-computed whenever the counter changes.

React to a Button Click in Native Code

This example increments the counter using native code:

slint
import { VerticalBox, Button } from "std-widgets.slint";
export component Recipe inherits Window {
    in-out property <int> counter: 0;
    callback button-pressed <=> button.clicked;
    VerticalBox {
        button := Button {
            text: "Button, pressed " + root.counter + " times";
        }
    }
}

The <=> syntax binds two callbacks together. Here the new button-pressed callback binds to button.clicked.

The root element of the main component exposes all non-private properties and callbacks to native code.

In Slint, - and _ are equivalent and interchangeable in all identifiers. This is different in native code: Most programming languages forbid - in identifiers, so - is replaced with _.

<Tabs syncKey="dev-language"> <TabItem label="Rust">

For technical reasons, this example uses export {Recipe} in the slint! macro. In real code, you can put the whole Slint code in the slint! macro, or use an external .slint file together with a build script.

rust
slint::slint!(export { Recipe } from "docs/reference/src/recipes/button_native.slint";);

fn main() {
    let recipe = Recipe::new().unwrap();
    let recipe_weak = recipe.as_weak();
    recipe.on_button_pressed(move || {
        let recipe = recipe_weak.upgrade().unwrap();
        let mut value = recipe.get_counter();
        value = value + 1;
        recipe.set_counter(value);
    });
    recipe.run().unwrap();
}

The Slint compiler generates a struct Recipe with a getter (get_counter) and a setter (set_counter) for each accessible property of the root element of the Recipe component. It also generates a function for each accessible callback, like in this case on_button_pressed.

The Recipe struct implements the <LangRefLink lang="rust-slint" relpath="trait.ComponentHandle.html">slint::ComponentHandle</LangRefLink> trait. A component manages a strong and a weak reference count, similar to an Rc. We call the as_weak function to get a weak handle to the component, which we can move into the callback.

We can't use a strong handle here, because that would form a cycle: The component handle has ownership of the callback, which itself has ownership of the closure's captured variables.

</TabItem> <TabItem label="C++">

In C++ you can write

cpp
#include "button_native.h"

int main(int argc, char **argv)
{
    auto recipe = Recipe::create();
    recipe->on_button_pressed([&]() {
        auto value = recipe->get_counter();
        value += 1;
        recipe->set_counter(value);
    });
    recipe->run();
}

The CMake integration handles the Slint compiler invocations as needed, which will parse the .slint file and generate the button_native.h header.

This header file contains a Recipe class with a getter and setter for each accessible property, as well as a function to set up a callback for each accessible callback in Recipe. In this case we will have get_counter, set_counter to access the counter property and on_button_pressed to set up the callback.

</TabItem> <TabItem label="Python">

In Python, you can write:

python
import slint

class App(slint.loader.recipe.Recipe):
    @slint.callback
    def button_pressed(self):
        value = self.counter
        value = value + 1
        self.counter = value

app = App()
app.run()

The Slint auto-loader provides a Recipe class originating from recipe.slint, which is subclassed. The Recipe class provides the counter property, and the @slint.callback decorator connects the button_pressed method with the button-pressed callback.

</TabItem> </Tabs>

Use Property Bindings to Synchronize Controls

slint
import { VerticalBox, Slider } from "std-widgets.slint";
export component Recipe inherits Window {
    VerticalBox {
        slider := Slider {
            maximum: 100;
        }
        Text {
            text: "Value: \{round(slider.value)}";
        }
    }
}

This example introduces the Slider widget.

It also introduces interpolation in string literals: Use \{...} to render the result of code between the curly braces as a string.

Animation Examples

Animate the Position of an Element

slint
import { CheckBox } from "std-widgets.slint";
export component Recipe inherits Window {
    width: 200px;
    height: 100px;

    rect := Rectangle {
        x:0;
        y: 5px;
        width: 40px;
        height: 40px;
        background: blue;
        animate x {
            duration: 500ms;
            easing: ease-in-out;
        }
    }


    CheckBox {
        y: 25px;
        text: "Align rect to the right";
        toggled => {
            if (self.checked) {
                 rect.x = parent.width - rect.width;
            } else {
                 rect.x = 0px;
            }
        }
    }
}

Layouts position elements automatically. In this example we manually position elements instead, using the x, y, width, height properties.

Notice the animate x block that specifies an animation. It's run whenever the property changes: Either because a callback sets the property, or because its binding value changes.

Animation Sequence

slint
import { CheckBox } from "std-widgets.slint";
export component Recipe inherits Window {
    width: 200px;
    height: 100px;

    rect := Rectangle {
        x:0;
        y: 5px;
        width: 40px;
        height: 40px;
        background: blue;
        animate x {
            duration: 500ms;
            easing: ease-in-out;
        }
        animate y {
            duration: 250ms;
            delay: 500ms;
            easing: ease-in;
        }
    }


    CheckBox {
        y: 25px;
        text: "Align rect bottom right";
        toggled => {
            if (self.checked) {
                 rect.x = parent.width - rect.width;
                 rect.y = parent.height - rect.height;
            } else {
                 rect.x = 0px;
                 rect.y = 0px;
            }
        }
    }
}

This example uses the delay property to make one animation run after another.

States Examples

Associate Property Values With States

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

component Circle inherits Rectangle {
    width: 30px;
    height: 30px;
    border-radius: root.width / 2;
    animate x { duration: 250ms; easing: ease-in; }
    animate y { duration: 250ms; easing: ease-in-out; }
    animate background { duration: 250ms; }
}

export component Recipe inherits Window {
    states [
        left-aligned when b1.pressed: {
            circle1.x: 0px; circle1.y: 40px; circle1.background: green;
            circle2.x: 0px; circle2.y: 0px; circle2.background: blue;
        }
        right-aligned when b2.pressed: {
            circle1.x: 170px; circle1.y: 70px; circle1.background: green;
            circle2.x: 170px; circle2.y: 00px; circle2.background: blue;
        }
    ]

    VerticalBox {
        HorizontalBox {
            max-height: self.min-height;
            b1 := Button {
                text: "State 1";
            }
            b2 := Button {
                text: "State 2";
            }
        }
        Rectangle {
            background: root.background.darker(20%);
            width: 200px;
            height: 100px;

            circle1 := Circle { y:0; background: green; x: 85px; }
            circle2 := Circle { background: green; x: 85px; y: 40px; }
        }
    }
}

Transitions

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

component Circle inherits Rectangle {
    width: 30px;
    height: 30px;
    border-radius: root.width / 2;
}

export component Recipe inherits Window {
    states [
        left-aligned when b1.pressed: {
            circle1.x: 0px; circle1.y: 40px;
            circle2.x: 0px; circle2.y: 0px;
            in {
                animate circle1.x, circle2.x { duration: 250ms; }
            }
            out {
                animate circle1.x, circle2.x { duration: 500ms; }
            }
        }
        right-aligned when !b1.pressed: {
            circle1.x: 170px; circle1.y: 70px;
            circle2.x: 170px; circle2.y: 00px;
        }
    ]

    VerticalBox {
        HorizontalBox {
            max-height: self.min-height;
            b1 := Button {
                text: "Press and hold to change state";
            }
        }
        Rectangle {
            background: root.background.darker(20%);
            width: 250px;
            height: 100px;

            circle1 := Circle { y:0; background: green; x: 85px; }
            circle2 := Circle { background: blue; x: 85px; y: 40px; }
        }
    }
}

Layout Examples

Vertical

slint
import { VerticalBox, Button } from "std-widgets.slint";
export component Recipe inherits Window {
    VerticalBox {
        Button { text: "First"; }
        Button { text: "Second"; }
        Button { text: "Third"; }
    }
}

Horizontal

slint
import { HorizontalBox, Button } from "std-widgets.slint";
export component Recipe inherits Window {
    HorizontalBox {
        Button { text: "First"; }
        Button { text: "Second"; }
        Button { text: "Third"; }
    }
}

Grid

slint
import { GridBox, Button, Slider } from "std-widgets.slint";
export component Recipe inherits Window {
    GridBox {
        Row {
            Button { text: "First"; }
            Button { text: "Second"; }
        }
        Row {
            Button { text: "Third"; }
            Button { text: "Fourth"; }
        }
        Row {
            Slider {
                colspan: 2;
            }
        }
    }
}

Global Callbacks

Invoke a Globally Registered Native Callback from Slint

This example uses a global singleton to implement common logic in native code. This singleton may also store properties that are accessible to native code.

Note: The preview visualize the Slint code only. It's not connected to the native code.

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

export global Logic  {
    pure callback to-upper-case(string) -> string;
    // You can collect other global properties here
}

export component Recipe inherits Window {
    VerticalBox {
        input := LineEdit {
            text: "Text to be transformed";
        }
        HorizontalBox {
            Text { text: "Transformed:"; }
            // Callback invoked in binding expression
            Text {
                text: {
                    Logic.to-upper-case(input.text);
                }
            }
        }
    }
}
<Tabs syncKey="dev-language"> <TabItem label="Rust">

In Rust you can set the callback like this:

rust
fn main() {
    let recipe = Recipe::new().unwrap();
    recipe.global::<Logic>().on_to_upper_case(|string| {
        string.as_str().to_uppercase().into()
    });
    // ...
}
</TabItem> <TabItem label="C++"> <summary>C++ code</summary> In C++ you can set the callback like this:
cpp
int main(int argc, char **argv)
{
    auto recipe = Recipe::create();
    recipe->global<Logic>().on_to_upper_case([](slint::SharedString str) -> slint::SharedString {
        std::string arg(str);
        std::transform(arg.begin(), arg.end(), arg.begin(), toupper);
        return slint::SharedString(arg);
    });
    // ...
}
</TabItem> <TabItem label="NodeJS">

In JavaScript you can set the callback like this:

js
let slint = require("slint-ui");
let file = slint.loadFile("recipe.slint");
let recipe = new file.Recipe();
recipe.Logic.to_upper_case = (str) => {
    return str.toUpperCase();
};
// ...
</TabItem> <TabItem label="Python">

In Python, the callback is associated with the global_name parameter of the @slint.callback decorator:

python
import slint

class App(slint.loader.recipe.Recipe):
    @slint.callback(global_name="Logic")
    def to_upper_case(&self, value: str) -> str:
        return value.upper()

# ...
</TabItem> </Tabs>

Custom Widgets

Custom Button

slint
component Button inherits Rectangle {
    in-out property text <=> txt.text;
    callback clicked <=> touch.clicked;
    border-radius: root.height / 2;
    border-width: 1px;
    border-color: root.background.darker(25%);
    background: touch.pressed ? #6b8282 : touch.has-hover ? #6c616c :  #456;
    height: txt.preferred-height * 1.33;
    min-width: txt.preferred-width + 20px;
    txt := Text {
        x: (parent.width - self.width)/2 + (touch.pressed ? 2px : 0);
        y: (parent.height - self.height)/2 + (touch.pressed ? 1px : 0);
        color: touch.pressed ? #fff : #eee;
    }
    touch := TouchArea { }
}

export component Recipe inherits Window {
    VerticalLayout {
        alignment: start;
        Button { text: "Button"; }
    }
}

ToggleSwitch

slint
export component ToggleSwitch inherits Rectangle {
    callback toggled;
    in-out property <string> text;
    in-out property <bool> checked;
    in-out property<bool> enabled <=> touch-area.enabled;
    height: 20px;
    horizontal-stretch: 0;
    vertical-stretch: 0;

    HorizontalLayout {
        spacing: 8px;
        indicator := Rectangle {
            width: 40px;
            border-width: 1px;
            border-radius: root.height / 2;
            border-color: self.background.darker(25%);
            background: root.enabled ? (root.checked ? blue: white)  : white;
            animate background { duration: 100ms; }

            bubble := Rectangle {
                width: root.height - 8px;
                height: bubble.width;
                border-radius: bubble.height / 2;
                y: 4px;
                x: 4px + self.a * (indicator.width - bubble.width - 8px);
                property <float> a: root.checked ? 1 : 0;
                background: root.checked ? white : (root.enabled ? blue : gray);
                animate a, background { duration: 200ms; easing: ease;}
            }
        }

        Text {
            min-width: max(100px, self.preferred-width);
            text: root.text;
            vertical-alignment: center;
            color: root.enabled ? black : gray;
        }

    }

    touch-area := TouchArea {
        width: root.width;
        height: root.height;
        clicked => {
            if (root.enabled) {
                root.checked = !root.checked;
                root.toggled();
            }
        }
    }
}

export component Recipe inherits Window {
    VerticalLayout {
        alignment: start;
        ToggleSwitch { text: "Toggle me"; }
        ToggleSwitch { text: "Disabled"; enabled: false; }
    }
}

CustomSlider

The TouchArea is covering the entire widget, so you can drag this slider from any point within itself.

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

export component MySlider inherits Rectangle {
    in-out property<float> maximum: 100;
    in-out property<float> minimum: 0;
    in-out property<float> value;

    min-height: 24px;
    min-width: 100px;
    horizontal-stretch: 1;
    vertical-stretch: 0;

    border-radius: root.height/2;
    background: touch.pressed ? #eee: #ddd;
    border-width: 1px;
    border-color: root.background.darker(25%);

    handle := Rectangle {
        width: self.height;
        height: parent.height;
        border-width: 3px;
        border-radius: self.height / 2;
        background: touch.pressed ? #f8f: touch.has-hover ? #66f : #0000ff;
        border-color: self.background.darker(15%);
        x: (root.width - handle.width) * (root.value - root.minimum)/(root.maximum - root.minimum);
    }
    touch := TouchArea {
        property <float> pressed-value;
        pointer-event(event) => {
            if (event.button == PointerEventButton.left && event.kind == PointerEventKind.down) {
                self.pressed-value = root.value;
            }
        }
        moved => {
            if (self.enabled && self.pressed) {
                root.value = max(root.minimum, min(root.maximum,
                    self.pressed-value + (touch.mouse-x - touch.pressed-x) * (root.maximum - root.minimum) / (root.width - handle.width)));

            }
        }
    }
}

export component Recipe inherits Window {
    VerticalBox {
        alignment: start;
        slider := MySlider {
            maximum: 100;
        }
        Text {
            text: "Value: \{round(slider.value)}";
        }
    }
}

This example show another implementation that has a drag-able handle: The handle only moves when we click on that handle. The TouchArea is within the handle and moves with the handle.

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

export component MySlider inherits Rectangle {
    in-out property<float> maximum: 100;
    in-out property<float> minimum: 0;
    in-out property<float> value;

    min-height: 24px;
    min-width: 100px;
    horizontal-stretch: 1;
    vertical-stretch: 0;

    border-radius: root.height/2;
    background: touch.pressed ? #eee: #ddd;
    border-width: 1px;
    border-color: root.background.darker(25%);

    handle := Rectangle {
        width: self.height;
        height: parent.height;
        border-width: 3px;
        border-radius: self.height / 2;
        background: touch.pressed ? #f8f: touch.has-hover ? #66f : #0000ff;
        border-color: self.background.darker(15%);
        x: (root.width - handle.width) * (root.value - root.minimum)/(root.maximum - root.minimum);

        touch := TouchArea {
            moved => {
                if (self.enabled && self.pressed) {
                    root.value = max(root.minimum, min(root.maximum,
                        root.value + (self.mouse-x - self.pressed-x) * (root.maximum - root.minimum) / root.width));
                }
            }
        }
    }
}

export component Recipe inherits Window {
    VerticalBox {
        alignment: start;
        slider := MySlider {
            maximum: 100;
        }
        Text {
            text: "Value: \{round(slider.value)}";
        }
    }
}

Custom Tabs

Use this recipe as a basis to when you want to create your own custom tab widget.

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

export component Recipe inherits Window {
    preferred-height: 200px;
    in-out property <int> active-tab;
    VerticalLayout {
        tab_bar := HorizontalLayout {
            spacing: 3px;
            Button {
                text: "Red";
                clicked => { root.active-tab = 0; }
            }
            Button {
                text: "Blue";
                clicked => { root.active-tab = 1; }
            }
            Button {
                text: "Green";
                clicked => { root.active-tab = 2; }
            }
        }
        Rectangle {
            clip: true;
            Rectangle {
                background: red;
                x: root.active-tab == 0 ? 0 : root.active-tab < 0 ? - self.width - 1px : parent.width + 1px;
                animate x { duration: 125ms; easing: ease; }
            }
            Rectangle {
                background: blue;
                x: root.active-tab == 1 ? 0 : root.active-tab < 1 ? - self.width - 1px : parent.width + 1px;
                animate x { duration: 125ms; easing: ease; }
            }
            Rectangle {
                background: green;
                x: root.active-tab == 2 ? 0 : root.active-tab < 2 ? - self.width - 1px : parent.width + 1px;
                animate x { duration: 125ms; easing: ease; }
            }
        }
    }
}

Custom Table View

Slint provides a table widget, but you can also do something custom based on a ListView.

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

component TableView inherits Rectangle {
    in property <[string]> columns;
    in property <[[string]]> values;

    private property <length> e: self.width / root.columns.length;
    private property <[length]> column_sizes: [
        root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e,
        root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e,
        root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e, root.e,
    ];

    VerticalBox {
        padding: 5px;
        HorizontalLayout {
            padding: 5px; spacing: 5px;
            vertical-stretch: 0;
            for title[idx] in root.columns : HorizontalLayout {
                width: root.column_sizes[idx];
                Text { overflow: elide; text: title; }
                Rectangle {
                    width: 1px;
                    background: gray;
                    TouchArea {
                        width: 10px;
                        x: (parent.width - self.width) / 2;
                        property <length> cached;
                        pointer-event(event) => {
                            if (event.button == PointerEventButton.left && event.kind == PointerEventKind.down) {
                                self.cached = root.column_sizes[idx];
                            }
                        }
                        moved => {
                            if (self.pressed) {
                                root.column_sizes[idx] += (self.mouse-x - self.pressed-x);
                                if (root.column_sizes[idx] < 0) {
                                    root.column_sizes[idx] = 0;
                                }
                            }
                        }
                        mouse-cursor: ew-resize;
                    }
                }
            }
        }
        ListView {
            for r in root.values : HorizontalLayout {
                padding: 5px;
                spacing: 5px;
                for t[idx] in r : HorizontalLayout {
                    width: root.column_sizes[idx];
                    Text { overflow: elide; text: t; }
                }
            }
        }
    }
}

export component Example inherits Window {
   TableView {
       columns: ["Device", "Mount Point", "Total", "Free"];
       values: [
            ["/dev/sda1", "/", "255GB", "82.2GB"] ,
            ["/dev/sda2", "/tmp", "60.5GB", "44.5GB"] ,
            ["/dev/sdb1", "/home", "255GB", "32.2GB"] ,
       ];
   }
}

Breakpoints for Responsive User Interfaces

Use recipe implements a responsive SideBar that collapses when the parent width is smaller than the given break-point. When clicking the Button, the SideBar expands again. Use the blue Splitter to resize the container and test the responsive behavior.

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

export component SideBar inherits Rectangle {
    private property <bool> collapsed: root.reference-width < root.break-point;

    /// Defines the reference width to check `break-point`.
    in-out property <length> reference-width;

    /// If `reference-width` is less `break-point` the `SideBar` collapses.
    in-out property <length> break-point: 600px;

    /// Set the text of the expand button.
    in-out property <string> expand-button-text;

    width: 160px;

    container := Rectangle {
        private property <bool> expanded;

        width: parent.width;
        background: Palette.background.darker(0.2);

        VerticalLayout {
            padding: 2px;
            alignment: start;

            HorizontalLayout {
                alignment: start;

                if (root.collapsed) : Button {
                    checked: container.expanded;
                    text: root.expand-button-text;

                    clicked => {
                        container.expanded = !container.expanded;
                    }
                }
            }

            @children
        }

        states [
            expanded when container.expanded && root.collapsed : {
                width: 160px;

                in {
                    animate width { duration: 200ms; }
                }
                out {
                    animate width { duration: 200ms; }
                }
                in {
                        animate width { duration: 200ms; }
                }
                out {
                        animate width { duration: 200ms; }
                }
            }
        ]
    }

    states [
        collapsed when root.collapsed : {
            width: 62px;
        }
    ]
}

component Splitter inherits TouchArea {
    width: 4px;
    mouse-cursor: ew-resize;

    Rectangle {
        width: 100%;
        height: 100%;
        background: blue;
    }
}

export component SideBarTest inherits Window {
    preferred-width: 700px;
    min-height: 400px;
    background: gray;

    GridLayout {
        x: 0;
        width: splitter.x;

        Rectangle {
            height: 100%;
            col: 1;
            background: white;

            HorizontalLayout {
                padding: 8px;

                Text {
                    color: black;
                    text: "Content";
                }
            }
        }
        SideBar {
            col: 0;
            reference-width: parent.width;
            expand-button-text: "E";
        }
    }

    splitter := Splitter {
        x: root.width - self.width;
        height: 100%;

        moved => {
            self.x = min(root.width - self.width, max(400px, self.x + self.mouse-x - self.pressed-x));
        }
    }
}