~umgeher/navi

fork from https://gitlab.com/vikingmakt/navi
Merge branch 'hotfix/dpi_scaling' into 'master'
5b22d8de — Felipe Paolozze 2 months ago
remove scaling on scene event

refs

master
browse log

clone

read-only
https://git.sr.ht/~umgeher/navi
read/write
git@git.sr.ht:~umgeher/navi

You can also use your local clone with git send-email.

Navi - C++/Lua GUI framework

Building

First, you need a C++17 compiler of your choice and SDL2 installed.

Run: make build

Getting started

Navi is composed of a handful of things to build a GUI:

Widgets

Widgets are the building blocks of any user interface, they contain the logic for displaying or interacting with the data of an application. In Navi, you can create a widget by calling it's constructor and passing a table of props that will dictate it's appearance and/or behavior. For example, to create a Label:

Label{id='myLabel', text='Hello World!'}

There are some properties that are common to every widget:

  • id (string) -> Unique identifier to later reference that widget
  • script (string | table) -> A string or table containing the script of the widget

To see a list of every widget available, see the Widgets List. To create a custom widget in C++, see Creating a Custom Widget.

Scenes

A scene in Navi is just a widget hierarchy specified directly in Lua, suppose we have a file called Main.scene.lua:

--| Thanks to Lua's flexible tables, we can specify the properties of the widget
-- using the hash part of the table, and add the child widgets in the array part
return Window{
    -- Properties
    title='My Application',
    noMenuBar=true,
    noMove=true,
    width=1024,
    height=768,
    
    -- Children
    VerticalLayout{padding=5,
        
        HorizontalLayout{
            Label{text='Username'},
            InputText{id='inputUsername'},
        },
        HorizontalLayout{
            Label{text='Password'},
            InputText{id='inputPassword', password=true},
        },
        Button{id='loginBtn', text='Login'},
    },
}

We can then load the scene by passing the file as an argument of the Player, e.g.:

$ ./Player Main.scene.lua

Or we can use the SceneInstance widget to load as a subscene inside another scene.

Scripts

Scripts are pieces of Lua code that will drive the application behavior, by binding to the widgets signals, or connecting to a server, or anything. Conceptually, scripts belong to a widget and has access to the widget hierachy below the widget which it was attached to. For example, going back to the file Main.scene.lua above, we could have:

--| We are specifying a Script directly as a Lua table for simplicity, but we could also have
-- a string with the filename containing the script, and in the future we'll load it as a separate resource.
local Script = {
    props = {},
    
    setup = function(self)
        self.w.loginBtn.clicked:connect(self, function(self, data)
            print('Username: ', ~self.w.inputUsername.value)
            print('Password: ', ~self.w.inputPassword.value)
        end)
    end,
}

return Window{
    -- Properties
    title='My Application',
    noMenuBar=true,
    noMove=true,
    width=1024,
    height=768,
    script=Script,
    
    -- Children
    VerticalLayout{padding=5,
        
        HorizontalLayout{
            Label{text='Username'},
            InputText{id='inputUsername'},
        },
        HorizontalLayout{
            Label{text='Password'},
            InputText{id='inputPassword', password=true},
        },
        Button{id='loginBtn', text='Login'},
    },
}

This would print the username and password everytime the user clicked the button.

As you can see, the script is very simple and there is only a handful of special properties. setup is a function that gets called before the widget is first rendered into the screen, and is used to initialize any values and bind to signals. It receives only one argument which is an instance of the script itself. There is also props which acts like public variables of the script, but we'll get into those later.

When we have self.w.loginBtn for example, we are acessing a widget instance, self.w is a special table that can lookup widgets by ID given that the widget it refers to is below the widget the script itself is attached to.

Signals

Signals are Navi's way of emitting and subscribing to events, they allow a widget to send out a message that scripts can listen and respond to.

Going back to our previous example again, there is this expression:

self.w.loginBtn.clicked:connect(self, function(self, data)
    print('Username: ', ~self.w.inputUsername.value)
    print('Password: ', ~self.w.inputPassword.value)
end)

What is happening here is that self.w.loginBtn is a widget instance, and clicked is a signal. Every signal has a connect method that receives a script instance and a function that gets called whenever that signal gets emitted, that function is called a slot.

When the signal gets emitted, it calls all connected slots with the receiver script instance and any data the signal was emitted with. In the case of a button click, there is no meaningful data, but the argument is there anyway.

Observables

Observables are Navi's implementation of the observer pattern, they can hold any kind of data and propagate any changes through signals. To create an Observable:

local obs = Observable.new('Furrys are great!')
obs.valueChanged:connect(nil, function(_, value)
    print('Value changed: ', value)
end)
obs:setValue('Furrys are even more great!')
obs:setValue('Furrys are too great to be real!')

That would print the value everytime it changed.

There is also an Observable table, which propagate the changes when any key gets assigned to, for example:

local person = Observable.table{name='John Furry', age=24}
person.name.valueChanged:connect(nil, function(_, value)
    print('Name changed: ', value)
end)
person.name = 'Mary Furry'
person.name = 'Alex Furry'

We can also bind two table properties simply by assigning one to another, given that they are both an Observable table.

local person = Observable.table{name='John Furry', age=24}
local clone = Observable.table{name='John Furry Clone', age=0}
clone.name.valueChanged:connect(nil, function(_, value)
    print('Name changed: ', value)
end)
clone.name = person.name
clone.age = person.age

person.name = 'Mary Furry'

That's a one-way binding, meaning that if we were to change the clone's name, the original person name would not change back. To create a two-way binding there's a special syntax:

local person = Observable.table{name='John Furry', age=24}
person.name.valueChanged:connect(nil, function(_, value)
    print('Name changed: ', value)
end)
local clone = Observable.table{name='John Furry Clone', age=0}
-- ...

-- Note the =_& syntax
clone.name =_& person.name

person.name = 'Mary Furry'
clone.name = 'The clone can change the name also!'

MVVM

Now that everything is laid out, we can see that all of the elements explained above put together, create a pattern very similar to the Model View View-Model design pattern:

  • Model -> Observables
  • View -> Scenes/Widgets
  • View-Model -> Scripts

Applying everything to a minimal login screen GUI, we have:

local Script = {
    -- Create an observable table
    data = Observable.table{
        username = '',
        password = '',
    },
    
    -- Function to perform the login
    doLogin = function(self)
        -- Observable.unwrap returns a pure lua table with the underlying values
        someFunctionToLogin(Observable.unwrap(self.data))
        
        -- Clean the values, automatically clear the inputs
        self.data.username = ''
        self.data.password = ''
    end,
    
    -- Setup the UI
    setup = function(self)
        -- Two-way binding for the inputs, whenever the user types something our data will be automatically synced
        self.w.inputUsername.value =_& self.data.username
        self.w.inputPassword.value =_& self.data.password
        
        -- Listen to the button click
        self.w.loginBtn.clicked:connect(self, self.doLogin)
    end,
}

return Window{
    -- Properties
    title='My Application',
    noMenuBar=true,
    noMove=true,
    width=1024,
    height=768,
    script=Script,
    
    -- Children
    VerticalLayout{padding=5,
        
        HorizontalLayout{
            Label{text='Username'},
            InputText{id='inputUsername'},
        },
        HorizontalLayout{
            Label{text='Password'},
            InputText{id='inputPassword', password=true},
        },
        Button{id='loginBtn', text='Login'},
    },
}