slint-ui / slint

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

Create multiple components in generated Rust and C++ code #784

Closed tronical closed 3 months ago

tronical commented 2 years ago

The export keyword in .60 files can be used to make a custom component usable from other .60 files, and it is also used to mark a component for becoming "visible" to generated Rust and C++ code.

Currently, only the last component in the main .60 file that is compiled is "exported". In Rust a struct and in C++ a class is generated for it, with the known show(), etc. functions and accessors for declared properties. It also provides API to access singletons.

We may want to extend support in the compiler to allow exporting and thus creating multiple structs/classes, using the export keyword.

There are open questions in this context, such as what should happen to data structures marked as global? How can they be shared? Where could state that is local to the created component/window go?

Be-ing commented 2 years ago

This would be a nice improvement. The current solution of using a bunch of property bindings on the top level Window element is quite clunky.

ogoffart commented 2 years ago

The goal here is to support multiple windows.

One would still need to make properties accessible in the top level, or in a global singleton component.

Be-ing commented 2 years ago

Is there a reason applications couldn't get a handle to subcomponents from the top level ComponentHandle of the window?

Be-ing commented 2 years ago

I would like an API like:

MainWindow := Window {
    MyTextBox := Text {
        color: blue;
    }
}

in Rust:

let main_window = MainWindow::new();
let my_text_box = main_window.get_mytextbox();
my_text_box.set_text("Hello world!");
ogoffart commented 2 years ago

@Be-ing Is what you want related to issue #251 ?

I believe we still want to declare explicitly what are the public properties.

Be-ing commented 2 years ago

@Be-ing Is what you want related to issue #251 ?

Yes.

tronical commented 1 year ago

We discussed this topic a bit this morning and decided to each think about separately how to solve this. This is what I think could be considered as a solution:

The proposal covers to questions:

  1. How can data structures declared as global singletons be shared across multiple instances of components created in Rust?
  2. How can we support "per-window" specific state?

Example Code

palette.slint:

global struct Palette {

}

main.slint:

import { Palette } from "palette.slint";

export component App {
    Window {
        background: Palette.my-window-background;
    }
}

dialog.slint:

import { Palette } from "palette.slint";

interface DialogSpecificState {
    property <string> dialog-button-text;
}

export component Dialog implements DialogSpecificState {
    Window {
        background: Palette.my-window-background;

        DialogSpecificButton {

        }
    }
}

component DialogSpecificButton requires DialogSpecificState {
    Button {
        text: DialogSpecificState.dialog-button-text;
    }
}

build.rs

fn main() {
    // creates a module for each input file, so `mod dialog {}` and `mod main {}`
    slint_build::compile(["dialog.slint", "main.slint"]).unwrap();
}

mod ui {
slint::include_modules!()
}

fn main() {
    let shared_globals = ui::Globals::new();
    let app = ui::main::App::new_with_shared_globals(&shared_globals);
    let dialog = ui::dialog::Dialog::new_with_shared_globals(&shared_globals);

    let black = slint::Color::from_rgb_u8(0, 0, 0);
    shared_globals::<ui::Palette>.set_my_window_background(black);
    assert_eq!(app.globals<ui::Palette>().get_my_window_background, black);

    // ...

    dialog.
}

Shared Globals

The proposed solution affects only the generated code:

  1. For each input file, the compiler generates a Rust module.
  2. In the top-level (mod ui in the example), the compiler generates a Globals struct that provides access to all global singletons, with the same API as the existing app.global<Foo>() accessor.
  3. The Rust API for creating components provides a variant of new that can take an explicitly shared reference to the Globals. This way it's evident to the developer how the global data sharing declared in Slint is reflected in Rust.

Internal implications to Rust generated code:

At the moment, each generated component that is not inlined results in a struct that has a reference back to the "app"/"root" component, in order to access globals and the window. This needs to be changed to support access to the shared globals.

"Per-Window" Specific State

The question of how to create state that is per-window is a special case of the general question: How can (possibly unrelated) components share state?

The proposed solution uses interfaces:

interface SharedState {
    property <string> shared-property;
}

A component can implement the interface:

export component Dialog implements SharedState { 
}

That means it provides the declared properties and callbacks.

A component can require that instantiating it requires access to a single instance of an interface:

component DialogSpecificButton requires SharedState {
    Text {
        text: SharedState.shared-property;
    }
}

This requirement propagates to anyone using this component:

component DialogButtonBox {
    VerticalLayout {
        DialogSpecificButton {}
    }
}
export component Dialog implements SharedState {
    Window {
        DialogButtonBox { } // OK! At this point we can reference all of SharedState's properties and
                            // pass them to DialogButtonBox, which can pass them to
                            // DialogSpecificButton
    }
}

Optional Exposure to Rust

We could also consider exposing SharedState to the Rust API if a component becomes exposed to Rust:

main.slint:

export component App {
    Window {
        DialogButtonBox { } // ERROR? App does not implement SharedState.
    }
}

// generated
mod ui {
    trait SharedState {
        fn get_shared_property(&self) -> SharedString;
        fn set_shared_property(&self, SharedString);
    }

    struct App {
        // ...
    }

    impl App {
        pub fn create(required_shared_state: &Rc<dyn SharedState>)
    }
}

struct AppState {
    // ...
}

impl ui::SharedState for AppState {
    ...
}

fn main() {
    let shared_state = Rc::new(AppState{});
    let app = ui::main::App::create(&shared_state);
}
preland commented 1 year ago

Has there been any progress towards this issue as of late?

ogoffart commented 1 year ago

Not much progress. We are still wondering what to do with the globals: When you declare a global in slint, is the global global for the one component (this is the case right now) or is it global for the whole application? And if it is global for the whole application where do we keep the global state? One way would be to use some kind of thread local storage for them. Another way would be to have new_with_shared_globals and somehow have a slint::GlobalContext or some generated container to contain the globals. We are still undecded.

@preland : since you are asking, could you elaborate on your exact use case, this would maybe help to design this feature

preland commented 1 year ago

The use case is for an open source Rust DAW which is going through an overhaul of the entire codebase, so we are looking into alternative UI frameworks.

One req for the project’s UI is support for multiple windows

andrew-otiv commented 1 year ago

RE use cases, I'm evaluating UI toolkits for an industrial use case where we would most likey drive 6 full-screened displays full of video feeds, indicators and buttons, ideally from one app (though we might have to split it up). multi-window support is also not in egui yet, so we're probably going to have to go with bindings to one of the huge mature UI frameworks.

ogoffart commented 1 year ago

Note that multi-window do work in Slint if you either

This commit show how to do it with one of our demo: https://github.com/slint-ui/slint/pull/2094

The limitations are:

We need to improve on that. But I think for the use case of @andrew-otiv, the current situation might actually be good enough.

ChronosWS commented 10 months ago

I think there is another, possibly more serious issue with this workaround: If you want two components that refer to the same struct, you will get duplicate definitions. For example, with a main window defined here:

import { GlobalConfiguration } from "models/global_configuration.slint";

export component AppWindow inherits Window {
    in-out property<GlobalConfiguration> global_config;
...

and a second window defined here:

import { GlobalConfiguration } from "../models/global_configuration.slint";

export component GlobalSettings inherits Window {
    in-out property <GlobalConfiguration> config;
...

with the workaround above instantiating is as follows:

slint::slint! {
        import { GlobalSettings } from "ui/windows/global_settings.slint";

        export component GlobalSettingsWindow inherits GlobalSettings {

        }
    }

    let global_settings_window = GlobalSettings::new();

You get the following, because GlobalConfiguration gets a definition from two separate places.

mismatched types
`GlobalConfiguration` and `GlobalConfiguration` have similar names, but are actually distinct types
arguments to this method are incorrect
`GlobalConfiguration` is defined in module `crate::main::slint_generatedGlobalSettingsWindow` of the current crate
`GlobalConfiguration` is defined in module `crate::slint_generatedAppWindow` of the current crate

This seems to mean you can't even have multiple windows that use any of the same structs (and I wonder about other components)?

Am I missing some detail of the workaround, or is this just straight up impossible?

EDIT: It looks like ultimately I'm running into the "globals aren't shared", which makes using this for any but trivial multi-window applications (that is, things like confirmatory dialogs) seemingly impossible. 😭

melMass commented 10 months ago

From my little experience with slint I see this as one of the biggest drawbacks. I pretty much just went the same path as @ChronosWS

In my case using the main window as a kind of "controller" is not a big deal (I use dialogs that just go over the content of the current window) but the main use case I see for multiple components definition is to have some shared logic in a widget.

For instance I'm making a pretty dumb PathEdit (the classic, line edit, browse button). I would like to make the callback logic reusable (using rfd to spawn a file dialog and return the path to the widget), what would be the idiomatic way to go about this currently?

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

export component PathEdit inherits HorizontalBox {
    in property <string> label: "Path:";
    in property <string> default_path: "/some/path";

    max-height: 96px;
    Text {
        text: root.label;
        vertical-alignment: center;
        color:gray;
    }

    TextInput { 
        text: root.default-path;
        horizontal-alignment: left;
        vertical-alignment: center;
        horizontal-stretch: 1;
        font-size: 1.5rem;
        padding: 1.5rem;

     }
     Button {
        text:"...";
        vertical-stretch: 1;

     }
}

I tried what is suggested in #2094, but I couldn't make it work, I'll try the exact example now.

What I tried that didn't work:

Reimport Error (TextStyle is defined multiple times):

Only the last is exposed

chenyanchen commented 9 months ago

Is Slint have a Dialog component or others, I just want to popup a box to show some message.

chenyanchen commented 9 months ago

Is Slint have a Dialog component or others, I just want to popup a box to show some message.

I found that: https://slint.dev/releases/1.3.2/docs/slint/src/language/builtins/elements#dialog

jamesstidard commented 5 months ago

Just to add in my strong interest in this feature and my use case.

The application we'd build will need the ability to have components within the main window be able to be popped out, so the user can multitask and make use of multiple monitors/OS-level window management. So in this case the component being popped out would want to have a shared state with the parent window/component.

Screen Recording 2024-04-24 at 14 31 17 mov

Also generally interested in the use cases described above as well.

Another thing I would mention related to the global state, and this may be wrong because I'm still learning the framework, but because the main window and global states need to both be exported in the same .slint file, it means that you cannot import (as far as I'm currently aware) the global state into child components defined in other .slint file - as it causes a circular dependancy. So you are instead force to pass through all state via component properties, which is ok and preferred in a lot of cases, but if you have things like Pallet you dont want to have to hand all these properties all the way through your component hierarchy. If this doesn't make sense, let me know and I can make an example.

melMass commented 3 months ago

Glad to see this closed! Is it documented yet?

ogoffart commented 3 months ago

No, it is not yet documented.

Cf also https://github.com/slint-ui/slint/issues/5467