Packages

ui

Build interfaces from tags written in OSL, with components, state, styles and themes, shown as live web pages or native windows.

import "std:ui"

std:ui builds interfaces out of tags written straight into OSL: <column>, <text>, <button> and the rest. A component is a class that holds the state and a view() that returns the tags. There is no HTML, CSS or JavaScript to write. The same component can be a live web page with serve, a native desktop window with window, or a plain string with ui.html.

You can't see a window in this page, but you can run everything else: call a component's methods, print its fields, and measure where its tags land with layout.

Your first component#

A class with a view() is a component. Its fields are its state, its methods are its actions:

main.osl
import "std:ui"

class Counter (
  int count = 0

  def add() (
    self.count += 1
  )

  def view() ui.node (
    return <column pad=24 gap=12 align=center>
      <text size=28 weight=bold>${self.count}</text>
      <button on:click=self.add>Add one</button>
    </column>
  )
)

Counter counter = Counter()
counter.add()
counter.add()
log counter.count
Click the code to edit it

on:click=self.add runs the method when the button is clicked, and the view draws again with the new state. Here there is no screen, so we call add() ourselves. Call it five times and run it again.

Seeing it#

ui.html(component) renders a component as a whole HTML page, with fresh state. It's a string, so print a check instead of the page:

main.osl
import "std:ui"

class Hello (
  string name = "world"
  def view() ui.node -> <text>Hello, ${self.name}</text>
)

string page = ui.html(Hello(), {title: "Greeting"})
log page.contains("<title>Greeting</title>")
log page.contains("Hello, world")
Click the code to edit it

Use it for tests and static files. The page has no live script, so it doesn't update itself.

Showing it in a window or on the web#

On the web, app.UI serves a component at a path. In a window, show gives it to a window. They take the same thing. You will need a machine to run these on:

main.osl
import "std:ui"
import "std:serve"

class Counter (
  int count = 0
  def add() ( self.count += 1 )
  def view() ui.node (
    return <column pad=24 gap=12>
      <text size=28>${self.count}</text>
      <button on:click=self.add>Add one</button>
    </column>
  )
)

*serve.Router app = serve.new()
app.UI("/", def() -> Counter(), {title: "Counter"})
app.run(":8080").unwrap()
Click the code to edit it

Open it in two browser tabs. def() -> Counter() is a factory, so every visitor gets their own counter. Give app.UI the object itself (Counter counter = Counter()) and everyone shares one. A page updates live over a websocket, and still works with JavaScript off: it is a form that posts, and the server answers with a redirect. For a window, see window.

Quick check

app.UI("/", def() -> Counter(), ...) and two people open the page. Whose clicks does each see?

Tags#

Lower-case tags are built in. <column> stacks, <row> lines up, <text> shows text, <button> runs an action. ${...} puts a value in the text. A tag that starts with a capital letter calls a component:

main.osl
import "std:ui"

def Badge(string label) ui.node (
  return <text size=13 pad.x=8 radius=999>${label}</text>
)

def Header() ui.node (
  return <row gap=8>
    <text size=20 weight=bold>Inbox</text>
    <Badge label="3 new"/>
  </row>
)

log ui.html(Header).contains("3 new")
Click the code to edit it

Badge is a function component: a def that returns tags. It has no state, and runs again on every update. <Counter/> with a class makes the instance once at that place and keeps it, so its state survives updates.

Layout: hug, fill and fixed#

Every size follows one of three rules. A tag hugs its content by default. width=fill takes the space left in its parent. A number is pixels. You can measure the result with layout:

main.osl
import "std:ui"
import "std:layout"

def Bar() ui.node (
  return <row width=300 gap=10 pad=10>
    <box width=60 height=20/>
    <box width=fill height=20/>
    <box width=fill height=20/>
  </row>
)

for box in layout.boxes(Bar(), {width: 400, height: 100}) (
  log box.tag box.x box.width
)
Click the code to edit it

The fixed box takes 60. The two fill boxes share what's left of 300 after the padding and gaps. Change gap=10 to gap=0 and the fill boxes grow.

Gap, pad and align#

gap goes between children and pad inside the edge (pad.x, pad.y, pad.top and so on for one side). align places children in nine points (top-left, center, bottom-right...). A single word such as top or left sets that edge, and center centres both:

main.osl
import "std:ui"
import "std:layout"

def Card() ui.node (
  return <column width=200 height=100 align=bottom-right>
    <box width=60 height=20/>
  </column>
)

log layout.boxes(Card())[2]
Click the code to edit it

Try align=top-left, then center. distribute=between spreads children along the container's own axis instead, and <spacer/> takes all the room between two siblings, which pushes one to the far end.

Containers#

<column>, <row>, <box> (a plain column that groups), <scroll> (a column that scrolls), <stack> (children on top of each other), <grid columns=3> (or columns=auto minColumn=200) and <absolute> (outside the flow, placed by its pad from the parent's edges). A row or column turns with direction=.

main.osl
import "std:ui"
import "std:layout"

def Cells() ui.node (
  return <grid columns=3 gap=4>
    <box width=30 height=30/>
    <box width=30 height=30/>
    <box width=30 height=30/>
    <box width=30 height=30/>
  </grid>
)

for box in layout.boxes(Cells()) (
  log box.tag box.x box.y
)
Click the code to edit it

The grid spreads three equal columns across the 800 pixel screen, so the fourth cell wraps to the next row.

Your turn

Two buttons side by side

Make a <row> with gap=10 holding two <button>s with the text Yes and No. Print the tag and x position of every box (the row comes first).

main.osl
Ctrl Enter runs
Expected output
row 1
button 1
button 67.48

State, input and lists#

bind=self.draft ties an input to a field: typing writes to it, and the field's value is what the input shows. An if and a for work inside tags. key= names a child so its state stays with the right item when a list changes, and on:click=self.remove(i) passes an argument:

main.osl
import "std:ui"
import "std:layout"

class Todo (
  string draft = "write docs"
  string[] items = ["a", "b"]

  def add() (
    self.items.append(self.draft)
    self.draft = ""
  )

  def remove(int i) (
    self.items.delete(i)
  )

  def view() ui.node (
    return <column gap=8>
      <input bind=self.draft placeholder="New item"/>
      <button on:click=self.add>Add</button>
      if self.items.len == 0 (
        <text>Nothing yet</text>
      )
      for i, item in self.items (
        <row key=item gap=8>
          <text>${item}</text>
          <button on:click=self.remove(i)>x</button>
        </row>
      )
    </column>
  )
)

Todo todo = Todo()
log layout.findAll(todo.view(), n -> n.tag == "row").len
todo.add()
log todo.items
log layout.findAll(todo.view(), n -> n.tag == "row").len
todo.remove(1)
log todo.items
Click the code to edit it

A permission is a plain if around a button. The server only runs actions that it rendered for that viewer, so nobody can run one they weren't shown.

Styles#

A style gathers props under a name. Spread it with ...name. Props written on the tag win over the spread:

main.osl
import "std:ui"

style card = (pad: 20, radius: 14, border: 1, borderColor: "#e3e6ea")

def Panel() ui.node (
  return <column ...card gap=12 pad=8>
    <text>hello</text>
  </column>
)

log ui.html(Panel).contains("hello")
Click the code to edit it

Every prop also takes a state prefix: hover:bg="#f0f3f6", focus:borderColor="#2f6feb", pressed:opacity=0.8, disabled:opacity=0.5, and narrow: for a page narrower than 600 pixels. transition="smooth" animates between states. Add hover:bg="#eee" to the <column> above and look at it in a browser.

Themes#

A theme names a colour or a radius once. ui.newTheme makes one. theme.accent reads a field wherever a prop takes a value. On the web the fields are CSS variables, so changing one updates every view that reads it without a redraw:

main.osl
import "std:ui"

*ui.Theme theme = ui.newTheme({accent: "#7c3aed", radius: 8})
log theme.accent theme.radius

theme.accent = "#0969da"
log theme.accent
Click the code to edit it

In a tag, write bg=theme.accent and radius=theme.radius. A theme field can hold a font made with ui.font("fonts/Inter.ttf").

Quick check

A theme field changes while the program runs: theme.accent = "#0969da". What has to happen for web pages that use it?

Props at a glance#

The props you reach for first: width and height (fill, hug or pixels), minWidth and maxWidth, bg, color, opacity, radius, border, shadow, size, weight (normal, medium, semibold, bold), italic, underline, font, hidden, wrap, key and transition. Other tags: <input type="text"> (also password, email, number, search), <checkbox checked=...>, <image src="..."> (a path, a URL, or a *img.Image from img or qr) and <link href="...">.

When the screen can't see a change#

A screen draws again after a handler runs and after a function sets a global. A thread that fetches something shows it as soon as it assigns it. A change the compiler can't see, like an item pushed onto a list a global holds, needs ui.refresh(). Don't call it from inside a view, which would draw forever.

main.osl
import "std:ui"
import "std:thread"

string[] messages = []

thread.new(def() (
  thread.sleep(1000)
  messages.append("hello")
  ui.refresh()
))
Click the code to edit it

Building nodes in code#

When a tree comes from data, ui.el(tag, props, content) builds a node without a tag, and node.with(props) copies a node with props changed. Nodes never change after they're built:

main.osl
import "std:ui"

auto label = ui.el("text", {size: 20}, "hi")
log label.tag label.props.size label.text

auto bigger = label.with({size: 30})
log bigger.props.size label.props.size
Click the code to edit it
Your turn

A greeting component

Give Greeter a method rename(newName) that sets its name, then call it with "Ada" and print name.

main.osl
Ctrl Enter runs
Expected output
Ada

API reference#

11 members, generated from the std:ui source of OSL 9.3.0.

Functions

#ui.bind(any target, string field) ui.Binding

bind ties an input to field of target, which must be a class instance.

#ui.el(string tag, object props, any ...content) ui.Node

el builds the element tag with props. content is the element's text, or a function filling its children with add, which runs straight away.

#ui.font(string path) *ui.Font

font names the font in the TrueType or OpenType file at path, for a theme's font or a tag's font prop. Pages serve the file to the browser; windows draw with it.

#ui.handler(any fn, any ...args) ui.Handler

handler wraps fn, a method or function, with the arguments it is called

#ui.html(any target, object? options) string

html renders target, with fresh state, as a whole HTML page, for a test or a static file. target is shown by the same rule as app.UI's. opts.title is the page's title, "OSL" by default.

#ui.newTheme(object values) *ui.Theme

newTheme makes a theme holding values, such as {accent: "#7c3aed", radius: 8, font: ui.font("fonts/Inter.ttf")}. Read a value with theme.accent and use it in a tag as bg=theme.accent.

#ui.part(string name, any factory, object props) ui.Node

part builds the component a capitalised tag mounts outside a container, such as a component's root.

#ui.path(any value, any base, string key) any

path is what an attribute set to a dotted path, such as bg=theme.accent, holds: the value the path reads, or, when base is a theme, the theme's field itself, so a page shows its current value.

#ui.refresh()

refresh draws every screen again from the state it reads. Setting a global and a handler's changes draw by themselves; refresh is for a change the UI can't see, such as an item added to a list a global holds. Call it from threads and callbacks, not from a view, which would draw forever. Calls within one frame draw once.

*ui.Node

#node.with(object props) ui.Node

with returns a copy of the node with props set on top of its own, as attributes would be: a function under on:click becomes its handler. The copy keeps the node's children. On a class component the props set its fields when it renders; a function component's inputs are fixed when its tag runs.

*ui.Children

#children.add(any ...nodes)

add appends nodes, each a ui.node, to the container being built. Throws TypeError for anything else.

*ui.Handler

A handle with no methods of its own: pass it to the package's functions.

*ui.Binding

A handle with no methods of its own: pass it to the package's functions.

*ui.Theme

A handle with no methods of its own: pass it to the package's functions.

*ui.Font

A handle with no methods of its own: pass it to the package's functions.