Writing a calculator in Rust with GUI

The framework officially supports Rust, C++ and Node.js, and provides its own DSL, which is then compiled into native code for the listed languages ​​and platforms. The DSL is a mixture of CSS and SwiftUI, which makes the entry point much lower.

The project developers provide plugins for popular and not so popular IDEs and code editors, which gives us a pinch of intellisense and a little crooked preview that cannot work out the properties properly default-font-*. I recommend downloading more of this Codeiumthey have beta support for Slint DSL (although you shouldn't count on miracles).

Slint is distributed under several licenses.

Creating a project

For those who are little familiar with Rust, I will describe everything in detail (well, you can probably install the language infrastructure without me, right? Yes, right?…). Therefore… let's create a project! This is done with the following commands:

cargo new
# или
cargo init

Essentially, both commands are doing the same thing, but for cargo new You must specify the name of the new directory. But if for cargo init do not specify a name, it will create a project in the current directory.

If you do not need a local repository, which is created when the project is initialized, you should use the flag --vcs none.

In our case, the project will be called calc-rs. Using the command, we get a project with the following structure:

Project structure

Project structure

Project Dependencies

Let's add project dependencies. For this we will use a plugin cargo-edit. The plugin can be installed with the command:

cargo install cargo-edit

Can now add dependencies like this:

cargo add slint                                            # GUI
cargo add --build slint-build                              # Поддержка файлов DSL

Slint, or Orthodox GUI

Slint provides its own DSL. It can be used via macro slint::slint!. And this is quite convenient, because you can describe both logic and styles in one file. However, we will consider the case with individual files.

A domain-specific language (DSL) is a computer language specialized to a particular application domain. This is in contrast to a general-purpose language (GPL), which is broadly applicable across domains.

Or in our language:

A domain-specific language (DSL) is a computer language specialized for a specific application domain. This is in contrast to the General Purpose Language (GPL), which is widely used in various fields.

Source: wikipedia.org. The article is quite comprehensive, so don’t throw slippers at me for the link to the wiki.

To do this, let's create a folder componentsin which we will create a file app.slintin which we first define a simple counter.

import { VerticalBox, HorizontalBox, Button } from "std-widgets.slint";
export component AppWindow inherits Window {
    in property <string> window_title;

    width: 400px;
    height: 500px;
    title: window_title;
    default-font-size: 20px;

    property <int> count: 0;

    VerticalBox {
        alignment: center;
    
        Text {
            font-size: 30px;
            horizontal-alignment: center;
            text: "Count: " + count;
        }
        HorizontalBox {
            Button {
                text: "add";
                clicked => {
                    root.count += 1;
                }
            }
            Button {
                text: "subtract";
                clicked => {
                    if root.count != 0 {
                        root.count -= 1;
                    }
                }
            }
        }
    }
}

For layout there are such layouts as VerticalLayout, HorizontalLayout And GridLayout, which do not require any import. And you may have noticed that they are used VerticalBox And HorizontalBox, which are imported from the standard content library. The only difference is that the components from the standard library have predefined indents between content, similar to those that web developers constantly reset to zero.

Create a file in the root of the project build.rswhich is a build script, and add a function to it main with a function call compile. Now app.slint – entry point, nothing else needs to be specified. This function compiles the DSL into Rust code. That is, the previously described AppWindow component will be compiled into struct AppWindow.

fn main() {
    slint_build::compile("components/app.slint").unwrap();
}

Next, call the macro slint::include_modules!() in file src/main.rs and now we can initialize an instance of our AppWindow.

use slint::SharedString;

slint::include_modules!();

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let app = AppWindow::new()?;
    app.set_window_title(SharedString::from("Counter"));
    app.run()?;

    Ok(())
}

Method set_window_title was generated from in property window_titledescribed in the file components/app.slint. Specifier in makes a property writable windows_titleand if you remove it, it will be considered private and the method set_window_titlelike the method get_window_title, will not be generated. Excerpt from documentation on access specifiers:

Add a specifier to the additional properties that specifies how the property can be read from and written to the file:

private (default): This property can only be accessed from within the component.

in: The property is an input. It can be set and changed by the user of that component, for example through bindings or by assignment in callbacks. A component can provide a default binding, but cannot override it with an assignment.

out: An output property that can only be set by the component. It is read-only by component users.

in-out: This property can be read and modified by anyone.

Source: slint.dev.

I didn’t quite understand the meaning of the specifier in-outif there in, doing exactly the same thing ಠ_ರೃ. I ask those who know to write more about this in the comments.

cargo run – we get our counter-under-calculator at the output:

"Wake the F*** up, Samurai.  We have a city to burn."

“Wake the F*** up, Samurai. We have a city to burn.”

The funny thing is that if you accidentally press Tab, it is unlikely that you will be able to remove focus from the buttons without overriding (ㆆ_ㆆ).

Multilingual

Slint supports internationalization out of the box Gettext through macros, which, in my opinion, is so-so. Why? – I tried for three hours to get it to work, but I still didn’t understand why neither this module nor the Slint log module worked, and in the docks almost a couple of words are written about them (╯°□°)╯︵ ┻ ━┻. If anyone is interested, I’ll save the version with gettext in the appropriate branch turnips

Therefore we will use Fluent by Mozilla (●'◡'●). Fluent is another localization system that addresses some of the problems of its predecessor gettext. Some of these problems:

  1. Message ID. gettext uses a source string (usually in English) as an identifier. This places restrictions on developers, forcing them to never change the original messages, as this would require updating all translations.

  1. Message options. Gettext supports a small set of functions for internationalization, in particular for plurals. Fluent supports the basic concept of string variability, which can be used with selectors.

  1. External arguments. Gettext does not support external arguments, that is, you cannot set the formatting of parameters – numbers, dates. Fluent supports external arguments and allows localizers to create more precise text for specific cases.

  1. Cancellation of transfer. Gettext combines all three levels into one state called fuzzy match. In Fluent, the use of unique identifiers allows you to keep two of these levels separate from the third.

  1. Data format. Gettext uses three file formats – *.po, *.pot and *.mo. Fluent uses a single *.ftl file format, which simplifies implementation and does not require additional steps that could lead to data discrepancies.

    Source: Fluent Project repository.

Meme from there:

Gettext supports UTF-8. In general, this is where Unicode support ends.

Although Fluent looks cooler from the point of view of localizers (you create the necessary file and you work, without the need to translate into different formats and compile), but the official crates from the Fluent Project are a rather crude hat. Therefore, we will work through crutch functions without frills (asynchrony, caching, locale comparison, advanced error handling, etc.).

Implementation of auxiliary functions

New dependencies:

cargo add thiserror fluent sys-locale unic-langid
cargo add --build copy_to_output

src/file.rs:

use std::{env, path::{Path, PathBuf}};

use thiserror::Error;

#[derive(Error, Debug)]
pub enum FileReadError {
    #[error("Unable to read file: {0}")]
    ReadError(String),
    #[error("Unable to read file as UTF-8")]
    Utf8Error
}

/// Возвращает относительный путь к корневой папке проекта
pub fn get_dir_path<T: AsRef<Path>>(subfolder: T) -> PathBuf {
    let folder = subfolder.as_ref();
    get_root_dir().join(folder)
}

/// Возвращает абсолютный путь к корневой папке проекта, в котором расположен исполняемый файл программы
pub fn get_root_dir() -> PathBuf {
    env::current_exe()
        .expect("Unable to get a exe path.")
        .parent()
        .expect("Unable to get a root folder.")
        .into()
}

/// Считывает содержимое файлов
pub fn get_file_text(path: impl AsRef<Path>) -> Result<String, FileReadError> {
    let buf = match std::fs::read(path) {
        Ok(buf) => buf,
        Err(e) => return Err(FileReadError::ReadError(e.to_string()))
    };

    let text = match String::from_utf8(buf) {
        Ok(text) => text,
        Err(_) => return Err(FileReadError::Utf8Error)
    };

    Ok(text)
}

pub fn file_exists(path: impl AsRef<Path>) -> bool {
    std::fs::metadata(path).is_ok()
}

src/fluent.rs:

use std::{fmt::Debug, rc::Rc};
use fluent::{FluentArgs, FluentBundle, FluentResource};

use crate::file::{file_exists, get_file_text};

#[derive(thiserror::Error, Debug)]
pub enum FluentError {
    #[error("Unable to read FTL file: {0}")]
    UnableReadFtlError(#[from] crate::file::FileReadError),

    #[error("Fluent syntax error")]
    FluentSyntaxError,

    #[error("Message by key {0} not found")]
    MessageNotFoundError(String)
}

#[inline]
pub fn get_locale() -> String {
    if let Some(locale) = sys_locale::get_locale() {
        locale
    } else {
        default_locale()
    }
}

#[inline]
fn default_locale() -> String {
    "en-US".to_string()
}

#[inline]
pub fn get_msg(bundle: &Bundle, key: impl AsRef<str>+Debug) -> Result<String, FluentError> {
    read_msg(bundle, key, None)
}

#[inline]
pub fn get_param_msg(bundle: &Bundle, key: impl AsRef<str>+Debug, args: FluentArgs) -> Result<String, FluentError> {
    read_msg(bundle, key, Some(&args))
}

fn read_msg(bundle: &Bundle, key: impl AsRef<str>+Debug, args: Option<&FluentArgs>) -> Result<String, FluentError> {
    let key = key.as_ref();

    if let Some(fluent_msg) = bundle.get_message(key) {
        if let Some(pattern) = fluent_msg.value() {
            let mut errors = Vec::new();
            let msg = bundle.format_pattern(pattern, args, &mut errors);
            
            for e in errors {
                eprintln!("{}", e);
            }
            
            Ok(msg.into_owned())
        } else {
            Err(FluentError::MessageNotFoundError(key.to_string()))
        }
    } else {
        Err(FluentError::MessageNotFoundError(key.to_string()))
    }
}

type Bundle = FluentBundle<FluentResource>;
pub fn load_locale(locale: impl AsRef<str>+Debug) -> Result<Rc<Bundle>, FluentError> {
    let locale = locale.as_ref();
    let resource = create_resource(&locale)?;
    let bundle = create_bundle(resource, locale)?;

    Ok(
        Rc::new(bundle)
    )
}

fn create_resource(locale: impl AsRef<str>+Debug) -> Result<FluentResource, FluentError> {
    let locale = locale.as_ref();
    
    let lang_folder = crate::file::get_dir_path(std::path::Path::new("lang"));
    let file = |l: &str| lang_folder.join(format!("{}.ftl", l));

    let map_err = |e| FluentError::from(e);
    let locale_file = file(locale);
    let resource = if file_exists(&locale_file) {
        get_file_text(locale_file).map_err(map_err)?
    } else {
        get_file_text(file(default_locale().as_str())).map_err(map_err)?
    };

    match FluentResource::try_new(resource) {
        Ok(resource) => Ok(resource),
        Err(_) => Err(FluentError::FluentSyntaxError)
    }
}

fn create_bundle(resource: FluentResource, locale: impl AsRef<str>+Debug) -> Result<Bundle, FluentError> {
    let locale = locale.as_ref();
    
    let langid: unic_langid::LanguageIdentifier = locale
        .parse()
        .map_err(|_| FluentError::FluentSyntaxError)?;
    let mut bundle = FluentBundle::new(vec![langid]);
    
    match bundle.add_resource(resource) {
        Ok(_) => Ok(bundle),
        Err(_) => Err(FluentError::FluentSyntaxError)
    }
}

Localization files will be in the folder lang next to the program's executable file. To copy a folder to a folder with artifacts we will use a simple crate copy_to_output.

cargo add --build copy_to_output
// build.rs
use std::env;
use copy_to_output::copy_to_output;

fn main() {
    copy_to_output("lang", &env::var("PROFILE").unwrap()).unwrap();
    
    slint_build::compile("components/app.slint").unwrap();
}
Localization files

lang/ru-RU.ftl

app-name = Счётчик

counter-count = 
    { $count ->
        [0] Ни одного очка
        [one] {$count} очко
        [few] {$count} очка
        *[other] {$count} очков
    }

counter-add = Прибавить
counter-subtract = Убавить

lang/en-US.ftl

app-name = Counter

counter-count = 
    {$count -> 
        [0] You have no points
        [one] You have one point
        *[other] You have {$count} points
    }

counter-add = Add
counter-subtract = Subtract

We will forward these functions to the DSL via global singleton and pure callback. In general, a lot of logic in plus or minus large projects will have to be passed through a global singleton, if you don’t want to run a huge chain of properties from your own to the root component that implements the trait Window.

Clean callback, or pure callback is a specifier that makes it clear to the compiler that the callback is not reactive, that is, no other properties are changed when it is called. In the case of functions, the compiler itself can determine whether they are reactive or not.

Source: slint.dev.

For greater clarity, let’s peel off the entire layout from AppWindowlet's create a file components/counter.slint and implement the component Counter.

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

export component Counter {
    property <int> count: 0;

    VerticalBox {
        alignment: center;
    
        Text {
            font-size: 30px;
            horizontal-alignment: center;
            text: "Count: " + count;
        }
        HorizontalBox {
            Button {
                text: "add";
                clicked => {
                    root.count += 1;
                }
            }
            Button {
                text: "subtract";
                clicked => {
                    if root.count != 0 {
                        root.count -= 1;
                    }
                }
            }
        }
    }
}

Now let's create a file components/globals.slintin which we describe the Fluent singleton with a callback message, which will take the key for the fluent message as an argument, and the param-message callback, which will also take the key and an array (a la model) of parameters. The parameters will be presented in the form of structures (yes, you can even describe structures).

export struct MessageParam {
    name: string,
    value: string
}

export global Fluent {
    pure callback message(string) -> string;
    pure callback param-message(string, [MessageParam]) -> string;
}

If you want to Fluent and structure were available in the growth code, in components/app.slint (or at any entry point) import Fluent and export again (ఠ ͟ʖ ఠ). Structure MessageParam the compiler itself pulls it up, seeing it in the callback arguments. Probably ¯\_(ツ)_/¯.

// components/app.slint
import { Fluent } from "globals.slint";
export { Fluent }

In order to add logic for the callback, you need to src/main.rs get a copy Fluent and add a callback handler to it via the method on_{название коллбэка}.

let app = AppWindow::new()?;

let fluent = app.global::<Fluent>();
let b = bundle.clone();
fluent.on_message(move |key| {
    match get_msg(&b, &key, None) {
        Ok(msg) => SharedString::from(msg),
        Err(_) => key
    }
});
fluent.on_param_message(move |key, args| {
    let args = FluentArgs::from_iter(
        args
        .iter()
        .map(|a| (a.name.to_string(), FluentValue::try_number(a.value.to_string())))
    );

    match get_msg(&bundle, &key, Some(&args)) {
        Ok(msg) => SharedString::from(msg),
        Err(_) => key
    }
});

app.run()?;

Now we can remove the property window_title and the code for setting the value of this property in the component AppWindow and just use our module Fluent. Same thing with the component Counter.

// components/counter.slint
import { VerticalBox, HorizontalBox, Button } from "std-widgets.slint";
import { Fluent } from "globals.slint";

export component Counter {
    property <int> count: 0;
    pure function get-counter-fluent() -> string {
        Fluent.param-message("counter-count", [{name: "count", value: count}] )
    }
    function update-counter() {
        counter.text = get-counter-fluent();
    }

    VerticalBox {
        alignment: center;
    
        counter := Text {
            font-size: 30px;
            horizontal-alignment: center;
            text: get-counter-fluent();
        }
        HorizontalBox {
            Button {
                text: Fluent.message("counter-add");
                clicked => {
                    root.count += 1;
                    update-counter();
                }
            }
            Button {
                text: Fluent.message("counter-subtract");
                clicked => {
                    if root.count != 0 {
                        root.count -= 1;
                        update-counter();
                    }
                }
            }
        }
    }
}
I love the Russian language, at least because it has the brilliant phrase “no, probably not”

I love the Russian language, at least because it has the brilliant phrase “no, probably not”

Program icon

Great, but not great by a long shot. Namely, we don’t have our brand icon! I propose to fix this. We look for our beautiful icon in png and ico formats and put it in a folder icons. In file components/app.slint add a property icon and using the directive @image-url We indicate the path to our file relative to the component.

export component AppWindow inherits Window {
    icon: @image-url("../icons/icon.png");
    // <...>
}

In file Cargo.toml need to be added depending winres. Crate generates .rs files, so the file icon will only work on Windows.

[target.'cfg(windows)'.build-dependencies]
winres = "0.1.12"

Now in build.rs let's add the following code:

if cfg!(target_os = "windows") {
    winres::WindowsResource::new()
        .set_icon("icons/icon.ico")
        .compile()
        .unwrap();
}

Calculator

I propose to complicate the layout task a little and finally finish our calculator. I would like to make a full-fledged interpreter of mathematical expressions using combinators, but I think that this is a topic for a separate article.

Let's create a file components/calculator.rs with component Calculator (in the meantime, probably, reader: (╯°□°)╯︵ ┻━┻). In which we will define a property in which there will be an array of arrays with the values ​​of the buttons.

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

export component Calculator {
    property <[[string]]> buttons: [
        ["1", "2", "3", "+"],
        ["4", "5", "6", "-"],
        ["7", "8", "9", "*"],
        ["C", "0", "=", "/"]
    ];
    
    VerticalBox {
        for row in root.buttons: HorizontalBox {
            for button in row: Button {
                text: button;
            }
        }
    }
}

Wonderful. Now let’s add fields where I/O results will be displayed.

VerticalBox {
    VerticalBox {
        Text {
            text: "0";
            font-size: 30px;
            opacity: 0;
            horizontal-alignment: right;
            vertical-alignment: center;
        }
        Text {
            text: "0";
            font-size: 60px;
            horizontal-alignment: right;
            vertical-alignment: center;
        }
    }

    for row in root.buttons: HorizontalBox {
        for button in row: Button {
            text: button;
        }
    }
}

I propose to set a state to the component, which will store such as the current value, the past value, an operator and a flag that is set after calculating the value.

struct CalcState {
    current-value: int,
    last-value: int,
    operator: string,
    computed: bool
}

export component Calculator {
    property <CalcState> state: { current-value: 0, last-value: 0, operator: "", computed: true };
    //<...>
}

Flag computed immediately installed in trueso that the top field is transparent before you start typing.

It would be possible to implement a module MathLogicor something like adhering to the principle of separating business logic and views, but this is excessive in our case and we will implement everything using the Slint DSL, which will ultimately be compiled into native code anyway.

export component Calculator {
    function get-state() -> CalcState{ { current-value: 0, last-value: 0, operator: "", computed: true } }
    property <CalcState> state: get-state();
    property <[[string]]> buttons: [/*<..>*/];
    
    callback value-computed;
    callback state-cleared;
    callback operator-pressed(string);
    callback number-pressed(int);
    
    // Не кидайте в меня тапки, но да, обработка действий в коллбэках
    operator-pressed(operator) => {
        if (state.computed) {
            state.last-value = state.current-value;
            state.current-value = 0;
            state.computed = false;
        } else if (state.operator == "") {
            state.last-value = state.current-value;
            state.current-value = 0;
        }
        state.operator = operator;
    }
    state-cleared => {
        state = get-state();
    }
    number-pressed(number) => {
        if (state.computed) {
            state.last-value = 0;
            state.operator = "";
        }

        state.current-value = state.current-value * 10 + number;
    }
    value-computed() => {
        state.computed = true;

        if (state.operator == "+") {
            state.current-value = state.last-value + state.current-value;
        } else if (state.operator == "-") {
            state.current-value = state.last-value - state.current-value;
        } else if (state.operator == "×") {
            state.current-value = state.last-value * state.current-value;
        } else if (state.current-value != 0) {
            state.current-value = state.last-value / state.current-value;
        }
        
        state.last-value = 0;
        state.operator = "";
    }
    // Роутинг кнопок
    function route-actions(action: string) {
        if action == "=" {
            value-computed();
        } else {
            state-cleared();
        }
    }
    function button-pressed(button: string) {
        if (is-operator(button)) {
            operator-pressed(button);
        } else if (is-action(button)) {
            route-actions(button);
        } else {
            number-pressed(button.to-float());
        }
    }
    
    //<...>
    
    pure function is-operator(button: string) -> bool {
        button == "+" 
        || button == "-" 
        || button == "*" 
        || button == "/"
    }
    pure function is-action(button: string) -> bool {
        button == "=" || button == "C"
    }
}

Now let's change the template a little, adding output values ​​and spicing it all up with a little animation.

export component Calculator {
    function get-state() -> CalcState{ { current-value: 0, last-value: 0, operator: "", computed: true } }
    property <CalcState> state: get-state();

    //<...>

    VerticalBox {
        VerticalBox {
            Text {
                text: "\{state.last-value} \{state.operator}";
                font-size: 30px;
                opacity: 0;
                horizontal-alignment: right;
                vertical-alignment: center;

                states [
                    visible when !state.computed : {
                        opacity: 1;
                        in {
                            animate opacity { duration: 120ms; }
                        }
                        out {
                            animate opacity { duration: 60ms; }
                        }
                    }
                ]
            }
            Text {
                text: state.current-value;
                font-size: 60px;
                horizontal-alignment: right;
                vertical-alignment: center;
            }
        }

        for row in root.buttons: HorizontalBox {
            for button in row: btn := Button {
                text: button;
                clicked => { button-pressed(btn.text) }
            }
        }
    }

    //<...>
}

Finally, you can create a translucent window background for the style using the function Colors.rgba and properties background . In general, I recommend being careful with this property, because it overrides the transparency of everything that is drawn in the window (●'◡'●). There is another property opacitybut it doesn’t work on a window, so we have what we have.

import { Calculator } from "calculator.slint";

export component AppWindow inherits Window {
    in property <string> window_title;

    width: 300px;
    height: 500px;
    title: window_title;
    icon: @image-url("../icons/icon.png");
    background: Colors.rgba(28,28,28,0.9); // <-- Делаем окно чуть-чуть прозрачным
    default-font-size: 20px;
    
    Calculator {
        width: parent.width;
        height: parent.height;
    }
}

By the way, if I haven’t mentioned it yet, when you specify properties width And height at the window, then it ceases to be resizable. If you want to retain the ability to calmly stretch the window, you need to use either min-,max-or preferred- properties. The first is limited by the size to which the window can be stretched, and the second is without any restrictions.

In the case of ordinary components, and not windows, they all work in a similar way. Standard widthheight set rigid dimensions, making components unresponsive and static. min-, max- properties define the boundaries along which the component can stretch, but preferred- the property specifies the preferred sizes, but does not guarantee that they will exist. That is, relatively speaking, the component was given preferred-width: 10000pxand the width of the parent is only 200pxso the component will be forced to be reduced within these limits.

Total

We walked the path from a counter to a curved calculator, during which almost all the capabilities of the Slint library were considered, albeit in a compressed format (hello, states and transitions). By almost everything I mean both i18n and more complex scenarios. For example, a request to the API when initializing a component or calling asynchronous functions.

The library will cover the requests of target platform developers, but writing more complex interfaces will most likely be quite difficult and impractical. For example, in the examples I did not see a single one where there was an elementary drag`n`drop. And I didn’t find a single mention of window transparency in the documentation, which I figured out myself. But GridLayout is still raw and you cannot use it together with loops.

But judging by the developers’ repository, development is progressing actively. Although it is worth considering that they support their own DSL and three languages, so questions arise about the advisability of using a DSL and supporting three languages, especially Node.js, since this most likely slows down the development of the project (I love tautologies). We could have limited ourselves either to the same advantages, and the community itself could have done the binds.

Based on all of the above, I can recommend using another library if your goal is not to write a beautiful interface for a printer or coffee machine, for example, Tauri, Avalonia or Flutter.

Links

Repository with code: https://gitverse.ru/ertanic/calc-rs.

Slint repository: https://github.com/slint-ui/slint.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *