Build

Build a desktop app

Build a pomodoro timer with std:ui and std:window: a class with state and actions, a view in tags, styles and a theme, then show it on the web and in a window.

7 steps · about 25 minutes · uses ui, window, serve

You'll build a pomodoro timer: 25 minutes of focus, a 5 minute break, a count of sessions. It's one class that holds the state and the actions, and a view that draws it. The same class ends up in a native window and on a web page.

A window can't open on this page, so every step tests the class by calling its methods and printing, or by rendering it with ui.html, which gives the page's HTML without opening anything.

What you'll have at the end
200
true
true
Step 1 of 7

Count down

A timer is a class with a field and two methods: tick() takes a second off and label() shows mm:ss. Nothing here is about drawing yet, so it's an ordinary program. The try-it tab ticks it 90 times.

Try changing the number of ticks in the try-it tab.

Your turn

Format seconds

Make clock return 25:00, 1:05 and 0:05.

main.osl
Ctrl Enter runs
Expected output
25:00
1:05
0:05
def two(int n) string -> ("" ++ n).padStart("0", 2)

class Timer (
  int remaining = 1500

  def tick() (
    if self.remaining > 0 (
      self.remaining -= 1
    )
  )

  def label() string (
    int minutes = floor(self.remaining / 60).as<int>
    return two(minutes) ++ ":" ++ two(self.remaining % 60)
  )
)
Click the code to edit it
Step 2 of 7

Start and pause

State is just fields. running decides whether tick does anything, and toggle() flips it.

Your turn

Reset

Add a reset() method that puts the time back to 1500 and stops the timer.

main.osl
Ctrl Enter runs
Expected output
1500 false
def two(int n) string -> ("" ++ n).padStart("0", 2)

class Timer (
  int remaining = 1500
  bool running = false

  def tick() (
    if !self.running return
    if self.remaining > 0 (
      self.remaining -= 1
    )
  )

  def toggle() (
    self.running = !self.running
  )

  def label() string (
    int minutes = floor(self.remaining / 60).as<int>
    return two(minutes) ++ ":" ++ two(self.remaining % 60)
  )
)
Click the code to edit it

Highlighted lines are new in this step.

Step 3 of 7

Draw it

A component is a class with a view() method that returns tags. <column> stacks, <text> shows text and ${...} inserts a value. <button on:click=self.toggle> calls the method when clicked, and the view runs again afterwards, so it always matches the state. ui.html(timer) renders the page for a test.

Quick check

What happens after the button calls toggle?

Your turn

Show the state

Make the view say Paused when running is false and Running when it's true.

main.osl
Ctrl Enter runs
Expected output
true
import "std:ui"

def two(int n) string -> ("" ++ n).padStart("0", 2)

class Timer (
  int remaining = 1500
  bool running = false

  def tick() (
    if !self.running return
    if self.remaining > 0 (
      self.remaining -= 1
    )
  )

  def toggle() (
    self.running = !self.running
  )

  def label() string (
    int minutes = floor(self.remaining / 60).as<int>
    return two(minutes) ++ ":" ++ two(self.remaining % 60)
  )

  def view() ui.node (
    return <column gap=12 pad=24 align=center>
      <text size=48 weight=bold>${self.label()}</text>
      <button on:click=self.toggle>${if self.running then "Pause" else "Start"}</button>
    </column>
  )
)
Click the code to edit it

Highlighted lines are new in this step.

Step 4 of 7

Style and theme

Layout uses three sizing rules: elements hug their content by default, width=fill takes the space left, and a number is pixels. gap, pad and align place children. A style is a named set of attributes, spread into a tag with ...card. A theme holds shared values: on the web they become CSS variables, so changing theme.accent recolours every view.

Try changing the accent colour in newTheme.

Your turn

Use the theme

Greeting ignores the theme. Colour its text with the theme's accent so the page contains #d9480f.

main.osl
Ctrl Enter runs
Expected output
true
import "std:ui"

*ui.Theme theme = ui.newTheme({accent: "#d9480f", surface: "#fff4e6", muted: "#868e96"})

style card = (pad: 32, gap: 16, radius: 16, bg: white)

def two(int n) string -> ("" ++ n).padStart("0", 2)

class Timer (
  int remaining = 1500
  bool running = false

  def tick() (
    if !self.running return
    if self.remaining > 0 (
      self.remaining -= 1
    )
  )

  def toggle() (
    self.running = !self.running
  )

  def label() string (
    int minutes = floor(self.remaining / 60).as<int>
    return two(minutes) ++ ":" ++ two(self.remaining % 60)
  )

  def view() ui.node (
    return <column width=fill height=fill align=center bg=theme.surface>
      <column ...card align=center>
        <text size=64 weight=bold color=theme.accent>${self.label()}</text>
        <row gap=8>
          <button
          on:click=self.toggle
          bg=theme.accent
        >${if self.running then "Pause" else "Start"}</button>
        </row>
      </column>
    </column>
  )
)
Click the code to edit it

Highlighted lines are new in this step.

Step 5 of 7

Focus and break

When the time hits zero, next() flips between focus and break, resets the time, and counts a session when focus ends. The view shows which mode it's in. In the try-it tab, three ticks run a 3 second focus to the end and start a 5 minute break.

Your turn

Longer breaks

Every 4th break should be 15 minutes. Write the rule: lengthOf(mode, sessions) returns 25 for focus, 15 for a break after a multiple of 4 sessions, else 5.

main.osl
Ctrl Enter runs
Expected output
25
5
15
import "std:ui"

*ui.Theme theme = ui.newTheme({accent: "#d9480f", surface: "#fff4e6", muted: "#868e96"})

style card = (pad: 32, gap: 16, radius: 16, bg: white)

def two(int n) string -> ("" ++ n).padStart("0", 2)

class Timer (
  int remaining = 1500
  bool running = false
  bool onBreak = false
  int sessions = 0

  def tick() (
    if !self.running return
    if self.remaining > 0 (
      self.remaining -= 1
    )
    if self.remaining == 0 (
      self.next()
    )
  )

  def next() (
    if !self.onBreak (
      self.sessions += 1
    )
    self.onBreak = !self.onBreak
    self.remaining = if self.onBreak then 300 else 1500
  )

  def toggle() (
    self.running = !self.running
  )

  def label() string (
    int minutes = floor(self.remaining / 60).as<int>
    return two(minutes) ++ ":" ++ two(self.remaining % 60)
  )

  def view() ui.node (
    return <column width=fill height=fill align=center bg=theme.surface>
      <column ...card align=center>
        <text color=theme.muted>${if self.onBreak then "Break" else "Focus"}</text>
        <text size=64 weight=bold color=theme.accent>${self.label()}</text>
        <button
          on:click=self.toggle
          bg=theme.accent
        >${if self.running then "Pause" else "Start"}</button>
        <text color=theme.muted>${self.sessions} sessions done</text>
      </column>
    </column>
  )
)
Click the code to edit it

Highlighted lines are new in this step.

Step 6 of 7

Put it on the web

std:serve can show the same class. app.UI(path, target, options) makes a page that updates live over a WebSocket, and still works as a plain form without JavaScript. An object target, like timer, is one state shared by every visitor. A function that returns a new Timer() gives each visitor their own. The try-it tab fetches the page.

Quick check

You pass def() -> Timer() to app.UI instead of timer. What changes?

import "std:ui"
import "std:serve"
import "std:requests"

*ui.Theme theme = ui.newTheme({accent: "#d9480f", surface: "#fff4e6", muted: "#868e96"})

style card = (pad: 32, gap: 16, radius: 16, bg: white)

def two(int n) string -> ("" ++ n).padStart("0", 2)

class Timer (
  int remaining = 1500
  bool running = false
  bool onBreak = false
  int sessions = 0

  def tick() (
    if !self.running return
    if self.remaining > 0 (
      self.remaining -= 1
    )
    if self.remaining == 0 (
      self.next()
    )
  )

  def next() (
    if !self.onBreak (
      self.sessions += 1
    )
    self.onBreak = !self.onBreak
    self.remaining = if self.onBreak then 300 else 1500
  )

  def toggle() (
    self.running = !self.running
  )

  def label() string (
    int minutes = floor(self.remaining / 60).as<int>
    return two(minutes) ++ ":" ++ two(self.remaining % 60)
  )

  def view() ui.node (
    return <column width=fill height=fill align=center bg=theme.surface>
      <column ...card align=center>
        <text color=theme.muted>${if self.onBreak then "Break" else "Focus"}</text>
        <text size=64 weight=bold color=theme.accent>${self.label()}</text>
        <button
          on:click=self.toggle
          bg=theme.accent
        >${if self.running then "Pause" else "Start"}</button>
        <text color=theme.muted>${self.sessions} sessions done</text>
      </column>
    </column>
  )
)

Timer timer = Timer()

*serve.Router app = serve.new()
app.UI("/", timer, {title: "Pomodoro"})
Click the code to edit it

Highlighted lines are new in this step.

Step 7 of 7

Open a window

window.create opens a window and main.show(timer) puts the component in it. The window and the web page share the one timer. Something has to tick it: an async function loops, sleeps a second, ticks and calls ui.refresh() to redraw every screen. Windows use SDL2, which needs its development files to build.

Save it as pomodoro.osl and run it with osl run pomodoro.osl: the window opens and http://localhost:8095 shows the same timer. Next: ui, window, or a game.

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

*ui.Theme theme = ui.newTheme({accent: "#d9480f", surface: "#fff4e6", muted: "#868e96"})

style card = (pad: 32, gap: 16, radius: 16, bg: white)

def two(int n) string -> ("" ++ n).padStart("0", 2)

class Timer (
  int remaining = 1500
  bool running = false
  bool onBreak = false
  int sessions = 0

  def tick() (
    if !self.running return
    if self.remaining > 0 (
      self.remaining -= 1
    )
    if self.remaining == 0 (
      self.next()
    )
  )

  def next() (
    if !self.onBreak (
      self.sessions += 1
    )
    self.onBreak = !self.onBreak
    self.remaining = if self.onBreak then 300 else 1500
  )

  def toggle() (
    self.running = !self.running
  )

  def label() string (
    int minutes = floor(self.remaining / 60).as<int>
    return two(minutes) ++ ":" ++ two(self.remaining % 60)
  )

  def view() ui.node (
    return <column width=fill height=fill align=center bg=theme.surface>
      <column ...card align=center>
        <text color=theme.muted>${if self.onBreak then "Break" else "Focus"}</text>
        <text size=64 weight=bold color=theme.accent>${self.label()}</text>
        <button
          on:click=self.toggle
          bg=theme.accent
        >${if self.running then "Pause" else "Start"}</button>
        <text color=theme.muted>${self.sessions} sessions done</text>
      </column>
    </column>
  )
)

Timer timer = Timer()

async clock(Timer t) (
  while true (
    thread.sleep(1000)
    t.tick()
    ui.refresh()
  )
)
clock(timer)

*serve.Router app = serve.new()
app.UI("/", timer, {title: "Pomodoro"})
app.start(":8095").unwrap()

*window.Window main = window.create({title: "Pomodoro", width: 360, height: 360}).unwrap()
main.show(timer)
window.run()
Click the code to edit it

Highlighted lines are new in this step.