Пишем калькулятор на Rust с GUI

Зачем еще один калькулятор? Да не зачем, просто как тестовый проект для рассмотрения GUI-библиотеки.

Изначально я хотел попробовать такие крейты, как GPUI, Floem и Xilem, но первая, кажется, пока работает только под MacOS и Linux, вторая не позволяет установить иконку окну и кушает оперативы побольше Webview в Tauri, а до третьей я так и не добрался, узнав об Slint.

Об Slint есть всего несколько новостных постов на Хабре, поэтому, возможно, вам будет интересно посмотреть, что это такое.

Об Slint

Slint — это GUI-фреймворк, который, по заявлениям разработчиков, выделяется своей компактностью и легкостью использования. Сравниваясь с конкурентами, разработчики Slint утверждают, что их рантайм занимает всего 300 кб в оперативной памяти, что делает его идеальным выбором для embedded-систем.

Фреймворк официально поддерживает Rust, C++ и Node.js, и предоставляет собственный DSL, который затем компилируется в нативный код перечисленных языков и платформы. DSL представляет из себя некую смесь CSS и SwiftUI, что делает точку входа значительно ниже.

Разработчики проекта предоставляют плагины для популярных и не очень IDE и редакторов кода, который даёт нам щепотку intellisense и немного кривой превью, который не может нормально отработать свойства default-font-*. Рекомендую к этому накатить ещё Codeium, у них есть бета-поддержка Slint DSL (хотя на чудо рассчитывать не нужно).

Slint распространяется под несколькими лицензиями.

Создаём проект

Для тех, кто мало знаком с Rust, буду всё подробно расписывать (ну, установить-то инфраструктуру языка, наверное, и без меня сможете, да? Да, ведь?…). Поэтому… создаём проект! Это делается следующими командами:

cargo new
# или
cargo init

По сути, обе команды делают одно и то же, но для cargo new нужно обязательно указать имя новой директории. Но если для cargo init не указать название, она создаст проект в текущей директории.

Если Вам не нужен локальный репозиторий, который создаётся при инициализации проекта, стоит использовать флаг --vcs none.

В нашем случае проект будет носить название calc-rs. Воспользовавшись командой, получаем проект со следующей структурой:

Структура проекта

Структура проекта

Зависимости проекта

Добавим зависимости проекта. Для этого будем использовать плагин cargo-edit. Плагин можно установить командой:

cargo install cargo-edit

Теперь может добавлять зависимости следующим образом:

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

Slint, или православный GUI

Slint предоставляет собственный DSL. Его можно использовать через макрос slint::slint!. И это довольно удобно, ведь можно в одном файле описывать и логику, и стили. Однако мы будем рассматривать случай с отдельными файлами.

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.

Или по нашенски:

Предметно-ориентированный язык (DSL) — это компьютерный язык, специализированный для конкретной прикладной области. В отличие от языка общего назначения (GPL), который широко применяется в разных областях.

Источник: wikipedia.org. Статья довольно исчерпывающая, поэтому не надо в меня тапками кидать за ссылку на вики.

Для этого создадим папку components, в которой создадим файл app.slint, в котором для начала определим простой счётчик.

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

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

    property  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;
                    }
                }
            }
        }
    }
}

Для вёрстки есть такие layout, как VerticalLayout, HorizontalLayout и GridLayout, которые не требуют никакого импорта. И вы могли заметить, что используются VerticalBox и HorizontalBox, которые импортируются из стандартной библиотеки компонентов. Вся разница в том, что у компонентов из стандартной библиотеки предопределены отступы между контентом по типу тех, которые веб-разработчики постоянно обнуляют.

В корне проекта создаём файл build.rs, являющийся скриптом сборки, и добавляем в него функцию main с вызовом функции compile. Теперь app.slint — точка входа, больше ничего не нужно указывать. Эта функция компилирует DSL в код Rust. То есть ранее описанный компонент AppWindow будет скомпилирован в struct AppWindow.

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

Далее вызываем макрос slint::include_modules!() в файле src/main.rs и теперь можем инициализировать экземпляр нашего AppWindow.

use slint::SharedString;

slint::include_modules!();

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

    Ok(())
}

Метод set_window_title был сгенерирован из in property window_title, описанный в файле components/app.slint. Спецификатор in делает доступным для записи свойство windows_title, и если его убрать, то он будет считаться приватным и метод set_window_title не будет сгенерирован. Выдержка из документации:

Добавьте к дополнительным свойствам спецификатор, который указывает, как это свойство может быть прочитано и записано в файл:

private (по умолчанию): Доступ к этому свойству возможен только из компонента.

in: Свойство является входным. Оно может быть установлено и изменено пользователем этого компонента, например, с помощью привязок или путем назначения в обратных вызовах. Компонент может предоставлять привязку по умолчанию, но не может перезаписывать ее с помощью назначения.

out: Выходное свойство, которое может быть установлено только компонентом. Оно доступно только для чтения пользователями компонентов.

in-out: Это свойство может быть прочитано и изменено кем угодно.

Источник: slint.dev.

Не совсем понял смысл спецификатора in-out, если есть in, делающий ровно то же самое ಠ_ರೃ. Прошу знающих написать в комментариях об этом подробнее.

cargo run — получаем на выходе наш счётчик-недокалькулятор:

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

Забавно то, что если вы случайно нажали Tab, то вряд ли уже получится снять фокус с кнопок без переопределения (ㆆ_ㆆ).

Мультиязычность

Slint из коробки поддерживает интернационализация Gettext через макросы, что, как по-мне, такое себе. Почему? — я часа три пытался заставить его работать, но я так и не понял, почему ни этот модуль, ни модуль логов Slint так и не заработали, а в доках чуть ли не пару слов о них написано (╯°□°)╯︵ ┻━┻. Если кому будет интересно, сохраню версию с gettext в соответствующей ветке репы.

Поэтому будем использовать Fluent от Mozilla (●'◡'●). Fluent — другая система локализации, устраняющая некоторые проблемы своего предшественника gettext. Некоторые из этих проблем:

  1. Идентификатор сообщения. В gettext используется исходная строка (обычно на английском) в качестве идентификатора. Это накладывает ограничения на разработчиков, вынуждая их никогда не изменять исходные сообщения, поскольку это потребует обновления всех переводов.

  1. Варианты сообщений. Gettext поддерживает небольшой набор функций для интернационализации, в частности — для множественных чисел. Fluent поддерживает базовую концепцию вариативности строк, которую можно использовать с селекторами.

  1. Внешние аргументы. Gettext не поддерживает внешние аргументы, то есть нельзя задать форматирование параметров — чисел, дат. Fluent поддерживает внешние аргументы и позволяет локализаторам создавать более точные тексты для конкретных случаев.

  1. Аннулирование перевода. Gettext объединяет все три уровня в одно состояние под названием нечёткое совпадение (fuzzy). Во Fluent использование уникальных идентификаторов позволяет держать два из этих уровней отдельно от третьего.

  1. Формат данных. Gettext использует три формата файлов — *.po, *.pot и *.mo. Fluent использует единый формат файла *.ftl, что упрощает внедрение и не требует дополнительных шагов, которые могут привести к расхождению в данных.

    Источник: репозиторий Fluent Project.

Мем оттуда же:

Gettext поддерживает UTF-8. В целом, на этом поддержка Unicode заканчивается.

Хотя Fluent и выглядит круче с точки зрения локализаторов (создал нужный файлик и работаешь, без надобности переводить в разные форматы и компилировать), но вот официальные крейты от Fluent Project — довольно сырая шляпа. Поэтому будем работать через функции-костыли без излишеств (асинхронность, кэширование, сравнение локалей, продвинутая обработка ошибок и т.п.).

Реализация вспомогательных функций

Новые зависимости:

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>(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) -> Result {
    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) -> 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+Debug) -> Result {
    read_msg(bundle, key, None)
}

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

fn read_msg(bundle: &Bundle, key: impl AsRef+Debug, args: Option<&FluentArgs>) -> Result {
    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;
pub fn load_locale(locale: impl AsRef+Debug) -> Result, 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+Debug) -> Result {
    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+Debug) -> Result {
    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)
    }
}

Файлы локализации будут лежать в папочке lang рядом с исполняемым файлом программы. Для копирования папки в папку с артефактами будем использовать простой крейт 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();
}

Файлы локализации

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

Пробрасывать эти функции в DSL будем через global singleton и pure callback. Да вообще многую логику в плюс-минус крупных проектах придётся пробрасывать через global singleton, если не хотите вести огромную цепочку из свойств от своего до корневого компонента, который реализует трейт Window.

Чистый коллбэк, или pure callback — спецификатор, дающий дать понять компилятору то, что коллбэк не реактивный, то есть никакие другие свойства при его вызове не изменяются. В случае функций, компилятор сам может определить, реактивные они или нет.

Источник: slint.dev.

Для большей наглядности отлепим всю вёрстку от AppWindow, создадим файл components/counter.slint и реализуем компонент Counter.

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

export component Counter {
    property  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;
                    }
                }
            }
        }
    }
}

Теперь создадим файл components/globals.slint, в котором опишем синглтон Fluent с коллбэком message, который будет принимать в качестве аргумента ключ для fluent-сообщения, и коллбэк param-message, который будет так же принимать ключ и массив (а-ля модель) параметров. Параметры будут представлены в виде структур (да-да, можно даже структуры описывать).

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

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

Если хотите, чтобы Fluent и структура были доступны в раст-коде, в components/app.slint (или в любой точке входа) импортировать Fluent и снова экспортировать (ఠ ͟ʖ ఠ). Структуру MessageParam компилятор сам подтягивает, видя её в аргументах коллбэка. Наверное ¯\_(ツ)_/¯.

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

Для того, чтобы добавить логики для коллбэка, нужно в src/main.rs получить экземпляр Fluent и добавить ему обработчик на коллбэк через метод on_{название коллбэка}.

let app = AppWindow::new()?;

let fluent = app.global::();
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()?;

Теперь можем убрать свойство window_title и код установки значения этого свойства в компоненте AppWindow и просто воспользоваться нашим модулем Fluent. Точно так же и с компонентом Counter.

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

export component Counter {
    property  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();
                    }
                }
            }
        }
    }
}

Люблю русский язык, хотя бы за то, что в нем есть гениальная фраза «да нет наверное»

Люблю русский язык, хотя бы за то, что в нем есть гениальная фраза «да нет наверное»

Иконка программы

Прекрасно, но не прекрасно далёко. А именно у нас нет нашей брендовой иконки! Предлагаю это исправить. Ищем нашу прекрасную иконку в png и ico форматах и закидываем в папку icons. В файле components/app.slint добавляем свойство icon и с помощью директивы @image-url указываем путь до нашего файла относительно компонента.

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

В файле Cargo.toml нужно добавить в зависимости winres. Крейт генерирует .rs файлы, поэтому иконка файла будет работать только под Windows.

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

Теперь в build.rs добавим следующий код:

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

Калькулятор

Предлагаю чуть усложнить задачу вёрстки и закончить всё-таки наш калькулятор. Хотелось бы сделать полноценный интерпретатор математических выражений на комбинаторах, но думаю, что этот тема для отдельной статьи.

Создадим файл components/calculator.rs с компонентом Calculator (тем временем наверное, читатель: (╯°□°)╯︵ ┻━┻). В котором определим свойство, в котором будет массив массивов со значениями кнопок.

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;
            }
        }
    }
}

Прекрасно. А теперь добавим поля, куда будут выводиться результаты ввода-вывода.

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;
        }
    }
}

Предлагаю задать состояние компоненту, которое будет хранить такое, как текущее значение, прошлое значение, оператор и флаг, устанавливаемый после вычисления значения.

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

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

Флаг computed сразу установлен в true, чтобы верхнее поле было прозрачным до начала ввода.

Можно было бы реализовать модуль MathLogic, или что-то типа того, чтобы придерживаться принципа разделения бизнес-логики и представлений, но это чрезмерно в нашем случае и будем всё реализовывать силами Slint DSL, который в конечном итоге всё равно будет скомпилирован в нативный код.

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

Теперь немного поменяем шаблон, добавив вывод значений и приправив всё это капелькой анимаций.

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

    //<...>
}

На последок можно сделать для стиля полупрозрачный фон окна с помощью функции Colors.rgba и свойства background . Вообще с этим свойством рекомендую быть осторожными, ибо оно переопределяет прозрачность у всего, что отрисовывается в окне (●'◡'●). Есть ещё свойство opacity, но оно не работает на окне, поэтому имеем то, что имеем.

import { Calculator } from "calculator.slint";

export component AppWindow inherits Window {
    in property  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;
    }
}

Кстати, если я ещё не говорил, то когда указываете свойства width и height у окна, то оно перестаёт быть resizable. Если хотите сохранить возможность спокойно растягивать окошко, нужно использовать либо min-, max-, либо preferred- свойства. Первый ограничивают размеры, на которые можно растянуть окно, а второе без каких-либо ограничений.

В случае обычных компонентов, а не окн, то все они работают похожим образом. Стандартные width-height задают жёсткие размеры, делая компоненты неотзывчивыми, статическими. min-, max- свойства задают границы, по которым может растягиваться компонент, а вот preferred- свойство задаёт предпочитаемые размеры, но не гарантирует, что они будут. То есть, условно говоря, компоненту задали preferred-width: 10000px, а ширина родителя всего 200px, поэтому компонент будет принудительно уменьшен в эти пределы.

Итого

Был пройден путь от счётчика до кривого калькулятора, в ходе которого были рассмотрены почти все возможности библиотеки Slint, хоть и в сжатом формате (привет, состояния и переходы). Под почти все я имею ввиду и i18n, и более сложные сценарии. Например, запрос к API при инициализации компонента или вызов асинхронных функций.

Запросы разработчиков целевой платформы библиотека покроет, но писать более сложные интерфейсы, скорее всего, будет довольно сложно и нецелесообразно. Например, в примерах я не видел ни одного, где был бы элементарный drag`n`drop. И в документации не нашёл ни одного упоминания прозрачности окна, до чего я дошёл сам. А GridLayout до сих пор сырой и вы не сможете его использовать вместе с циклами.

Но судя по репозиторию разработчиков, разработка идёт активным ходом. Хотя стоит учитывать, что они поддерживают свой DSL и три ЯП, поэтому возникают вопросы на счёт целесообразности использования DSL и поддерживать три ЯП, особенно Node.js, так как это, скорее всего, замедляет развитие проекта (обожаю тавтологии). Могли бы ограничиться либой на тех же плюсах, а уж бинды сообщество само бы смогло сделать.

На основе всего вышесказанного могу порекомендовать использовать другую библиотеку, если ваша цель не написать красивый интерфейс для принтера или кофемашины, например, Tauri, Avalonia или Flutter.

Ссылочки

Репозиторий с кодом: https://gitverse.ru/ertanic/calc-rs.

Репозиторий Slint: https://github.com/slint-ui/slint.

© Habrahabr.ru