UI Stub

A simple stub made for making simple UI capable binaries with lulu using rust(egui library).

Setup

To get started, you have to use the lulu-ui stub from github.

  • First, setup your environment. You might need to install some native libraries to make this work.

    <!-- tabs:start -->

    Linux/Arch

    sudo pacman -S mesa libdrm libglvnd libxkbcommon cairo fontconfig freetype zlib \
                wayland wayland-protocols libdecor alsa-lib dbus ibus systemd libusb

    Linux/Debian

    sudo apt install libgl1-mesa-dev libdrm-dev libglvnd-dev libxkbcommon-dev libcairo2-dev \
                 libfontconfig1-dev libfreetype6-dev zlib1g-dev \
                 libwayland-dev wayland-protocols libdecor-0-dev \
                 libasound2-dev libdbus-1-dev ibus systemd libusb-1.0-0-dev

    Linux/Fedora

    sudo dnf install mesa-libGL mesa-libGLU libdrm libglvnd libxkbcommon cairo fontconfig freetype zlib \
                wayland-devel wayland-protocols-devel libdecor-devel \
                alsa-lib dbus ibus systemd libusb1-devel

    Linux/Nix

    A simple flake.nix.

    {
      inputs = {
        utils.url = "github:numtide/flake-utils";
      };
    
      outputs = { self, nixpkgs, utils }:
        utils.lib.eachDefaultSystem (system:
          let
            pkgs = nixpkgs.legacyPackages.${system};
            libraries = with pkgs; [
    
              # Core graphics + rendering
              mesa
              libdrm
              libGL
              libGLU
              cairo
              libglvnd
              libxkbcommon
              fontconfig
              freetype
              zlib
    
              # Wayland
              wayland
              wayland-protocols
              libdecor
    
              # Audio
              alsa-lib
              # pulseaudio
              # pipewire
              # pipewire.jack
    
              # Input / misc
              dbus
              ibus
              systemd
              libusb1
            ];
          in
          {
            devShell = pkgs.mkShell {
              buildInputs = libraries;
              nativeBuildInputs = [ pkgs.pkg-config ];
    
              shellHook = ''
                export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
                echo "DevShell ready with full X11/Wayland/EGL/Vulkan/Audio stack"
              '';
            };
          }
        );
    }

    Windows

    You should be good to go for windows by default, no requirements or anything.

    Darwin

    Macos has all the libraries needed by default, so just pass into the next step.

    <!-- tabs:end -->
  • Second, add this to your build function.

    build = function()
      -- ...
      stubs {
        -- Windows binary
        windows = "https://github.com/kevinJ045/lulu-ui-stub/releases/download/v0.1.35/lulu-ui.exe",
    
        -- Linux x86_64 bin
        ["linux-x86_64"] = "https://github.com/kevinJ045/lulu-ui-stub/releases/download/v0.1.35/lulu-ui-linux-x86_64",
    
        -- Linux aarch64 bin (never tried this)
        ["linux-aarch64"] = "https://github.com/kevinJ045/lulu-ui-stub/releases/download/v0.1.35/lulu-ui-linux-aarch64",
    
        -- Darwin Executable (never tried this either)
        darwin = "https://github.com/kevinJ045/lulu-ui-stub/releases/download/v0.1.35/lulu-ui-darwin"
      }
      -- ...
    end
  • Third, Add a demo code into your main.lua as:

    local btn = ui.Button { text = "hi" }
    
    btn:into_root()
  • Last, Run the binary as:

      # -b for build
      lulu run -b

Basics

Now that you've setup you're environment, here's a few basics about the lulu UI api.

Elements

Lulu UI comes with a few basic elements, they are accessible either through ui.ElementName or lml.

local instance = ui.Button { text = "Click Me!" }
-- or
local instance = lml! {
  <button text="Click Me!" />
}

-- use as:
instance:into_root()

Available Elements:

Text Elements

ElementPropsDescription
ColoredLabeltext: string, color: {r,g,b,a}Displays text in the given RGBA color.
Labeltext: stringDisplays normal body text.
Headingtext: stringLarge bold heading.
Smalltext: stringSmall text variant.
Weaktext: stringMuted/low-contrast text.
Strongtext: stringBold/strong emphasis.
Monospacetext: stringDisplays text in a monospace font (for code).
Hyperlinktext: string, url?: stringClickable hyperlink. If url provided → hyperlink_to, else → hyperlink.
Linktext: stringStyled like a link, but not necessarily clickable.
Codetext: stringDisplays inline code-like text.

Interactive Elements

ElementPropsDescription
Buttontext: string, style?: tableSimple clickable button.
Checkboxtext: string, checked: booleanToggles a boolean value. checked is automatically updated.
Dragvaluetext: string, min: number, max: number, value: numberNumeric value with draggable input.
Slidertext: string, value: numberSlider input (alias of drag_value).
Comboboxtext: string, selected: string, values: table, render_item?: fnDropdown selector. Returns the selected item.
Inputvalue: string, placeholder: string, multiline: bool, interactive: bool, frame: bool, code_editor: bool, password: bool, clip_text: bool, cursor_at_end: boolText input field. Supports multiline, password mode, or code-editor style.
CodeEditortext: stringText editor specialized for code. Two-way binding via handle_change.
ProgressBarvalue: number, text: stringDisplays progress between 0.0 and 1.0.

Visual Elements

ElementPropsDescription
Separator(none)Horizontal separator line.
Spinner(none)Animated loading spinner.
Imagesrc: stringDisplays an image from the given source.

Layout Containers

ElementPropsDescription
Alignlayout: string, align: stringAligns children using layout direction ("left_to_right", "top_down") and alignment ("start", "center", "end").
HBox(none)Horizontal container (ui:horizontal).
VBox(none)Vertical container (ui:vertical).
VBoxCentered(none)Vertically centered container.
VBoxCenterJustified(none)Vertically centered and justified container.
ScrollAreastick_to_right: bool, stick_to_bottom: bool, horizontal: bool, auto_shrink: bool, vscroll: bool, drag_to_scroll: bool, animated: boolScrollable container.
CollapsingHeadertext: stringCollapsible header containing nested content.
Framestyle: tableFramed block with custom style around children.

Drawing / Custom Rendering

ElementPropsDescription
Painterrender: function(painter, ui)Provides low-level access to ui:painter() for custom drawing. You can use this for lines, shapes, etc.

Styles and Scopes

ElementPropsDescription
Style(none)Applies local style overrides to child elements.
Scoperender: function(ui)Executes a function with a fresh UI scope.
Handlerender: function(ui)Inline function element (directly runs render function).

Dynamic / Data-Driven Elements

ElementPropsDescription
Eachitems: table or Vec, render: function(item, index, array, ui)Iterates over items and renders children dynamically. Useful for loops/lists.
VListitems: Vec or table, render: function(item, index, array)Stateful list that automatically rerenders when items change (reactive). Uses State and Vec under the hood.

Simple Usage:

ui.VBox {
  -- you can either use `children` or 
  children = {
    ui.Heading { text = "Settings" },
    ui.Separator(),
    ui.Checkbox { text = "Enable shadows", checked = true },
    ui.Slider { text = "Volume", value = 0.75 },
    -- pass an array of elements to
    -- automatically convert to: { children = {...} }
    ui.HBox {
      ui.Button { text = "Apply" },
      ui.Button { text = "Cancel" },
    }
  }
}

Using with function components

Now, let's render our elements through a function.

-- using the namespace is not necessary, but it strips
-- the need for prefixing everything with `ui`.
() @namespace(ui) =>

  -- Now, this would normally be a simple function that would
  -- throw an error if you tried to access `self` and it's not 
  -- passed.
  (self) AppRoot =>
    return Button { text = "Click Me!" }
  end

  -- So, we make it into a component as such:
  (self) @Component() AppRoot =>
    return Button { text = "Click Me!" }
  end

  -- Now render as:
  AppRoot({}):into_root()

  -- If you are making the root component,
  -- you can just use `AutoRender` to auto
  -- render the component without calling it.
  (self) @Component() @AutoRender AppRoot =>
    return Button { text = "Click Me!" }
  end
end

Using States

States are simple value containers that let you change values dynamically from your UI nest.

Simple Data:

(self) @Component() @AutoRender AppRoot =>
  local clicked = State(0)

  return Button {
    text = clicked:format("Clicked: {}"),
    on_clicked = function()
      clicked:add(1)
    end    
  }
end

Binding to input:

(self) @Component() @AutoRender AppRoot =>
  local inputText = State("val")

  return VBox {
    Label { text = inputText },
    Input { value = inputText }
  }
end
(self) @Component() @AutoRender AppRoot =>
  local ch = State(false)

  return VBox {
    Label { text = "This text will be hidden", inactive = ch },
    Checkbox { checked = ch, text = "Hide text" }
  }
end

Component States

Instead of declaring each state in your main function, you can make it Component-Wide by using StatedComponent decorator.

(self) @Component() @StatedComponent({
  clicked = 0
}) @AutoRender AppRoot =>
  return Button {
    text = self.clicked:format("Clicked: {}"),
    on_clicked = function()
      self.clicked:add(1)
    end    
  }
end

Component Values

You may want static component properties, and for that you can use the ComponentValues decorator.

(self) @Component() @StatedComponent({
  selected = "red"
}) @ComponentValues({
  colors = { red = "Red", blue = "Blue", green = "Green" }
}) @AutoRender AppRoot =>
  return VBox {
    Label { text = self.selected:inside(self.colors) },
    Combobox { values = self.colors, selected = self.selected }
  }
end

Custom Components as Elements

Just as most Ui libraries, you can make quick reusable components using the Component decorator.

local (self, props) @Component() MyComponent =>
  return VBox {
    Label {
      text = "My Component"
    },
    -- this way, you can pass the children to the next draw buffer.
    -- in this case, the next draw buffer is VBox
    props.children
  }
end

(self) @Component() @AutoRender AppRoot =>
  return VBox {
    Header { text = "Components inside components" },
    MyComponent {} -- this props is required!
  }
end

Class Components

If you would like to, you can use inheritance with classes and make class components.

-- Simple class component, allows for inheritance.
local class! @Component() AppRoot:Widget, {
  build(){
    return VBox {
      Button { text = "Click Me!" }
    }
  }
}
-- or lml
local class! @Component() AppRoot:Widget, {
  build(){
    return lml! {
      <vbox>
        <button text="Click Me!" />
      </vbox>
    }
  }
}

-- Auto Render
local class! @Component() @AutoRender AppRoot:Widget, {
  build(){
    return VBox {
      Button { text = "Click Me!" }
    }
  }
}

-- State
local class! @Component() @StatedComponent({
  clicked = 0
}) @AutoRender AppRoot:Widget, {
  build(){
    return Button {
      text = clicked:format("Clicked: {}"),
      on_clicked = function()
        clicked:add(1)
      end    
    }
  }
}

Component Prepare

Before a component is built, you can run a prepare function that runs before the build of a component.

-- Class Components
class! @Component() @StatedComponent({
  loading = true
}) AppRoot:Widget, {
  prepare(props){
    () async =>
      sleep(1)
      self.loading:set(false)
    end
  }
  build(){
    return VBox {
      Spinner { inactive = self.loading:inverse() },
      Label { text = "App Content", inactive = self.loading }
    }
  }
}

-- Function Components
local (self) @Component() @UIOverride('prepare', function(self, props)
  () async =>
    sleep(1)
    self.loading:set(false)
  end
end) @StatedComponent({
  loading = true
}) AppRoot =>
  return VBox {
    Spinner { inactive = self.loading:inverse() },
    Label { text = "App Content", inactive = self.loading }
  }
end 

-- For both
(self, props) AppRoot:prepare =>
  () async =>
    sleep(1)
    self.loading:set(false)
  end
end

Rebuilds

By default, everytime a state is changed the component is rebuilt. This is not necessary to update the states, but it can be useful for components that use the state values directly instead of mapping them. However, to disable rebuilds, you can UIOverride('rebuild')

-- Class Components
class! @Component() @StatedComponent(...) AppRoot:Widget, {
  -- this is an override
  rebuild(){}
  build(){
    return ...
  }
}

-- Function Components
local (self)  @Component() @UIOverride('rebuild') @StatedComponent(...) AppRoot =>
  return ...
end 

This way, you override rebuild, and so now, everytime a state changes, rebuild will be called.

To call the default rebuild, set self.__built = nil and it will still rebuild the component.

Styles

Styling is component-based on some components, and global for some. Here is an example style:

You can use colors as c"#ffffffff".

Style {
  style = {
    spacing = {
      button_padding = { 15, 5 },
    },
    visuals = {
      panel_fill = c"#1e1e2e",
      noninteractive = {
        bg_fill = c"#181825",
        weak_bg_fill = c"#181825",
      },
      inactive = {
        bg_fill = c"#181825",
        weak_bg_fill = c"#181825",
        rounding = { 20, 20, 20, 20 }
      },
      active = {
        bg_fill = c"#cba6f7",
        weak_bg_fill = c"#cba6f7",
        rounding = { 20, 20, 20, 20 }
      },
      open = {
        bg_fill = c"#1e1e2e",
        weak_bg_fill = c"#1e1e2e",
        fg_stroke = {
          width = 1.0,
          color = c"#cdd6f4"
        },
        rounding = { 20, 20, 20, 20 }
      },
      hovered = {
        bg_fill = c"#cba6f7",
        weak_bg_fill = c"#cba6f7",
        bg_stroke = {
          width = 1.0,
          color = c"#181825"
        },
        fg_stroke = {
          width = 1.0,
          color = c"#181825"
        },
        rounding = { 20, 20, 20, 20 }
      }
    }
  },
  children = {...}
}