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.
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.
200 true true
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.
Format seconds
Make clock return 25:00, 1:05 and 0:05.
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)
)
)Start and pause
State is just fields. running decides whether tick does anything, and toggle() flips it.
Reset
Add a reset() method that puts the time back to 1500 and stops the timer.
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)
)
)Highlighted lines are new in this step.
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.
What happens after the button calls toggle?
Show the state
Make the view say Paused when running is false and Running when it's true.
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>
)
)Highlighted lines are new in this step.
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.
Use the theme
Greeting ignores the theme. Colour its text with the theme's accent so the page contains #d9480f.
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>
)
)Highlighted lines are new in this step.
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.
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.
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>
)
)Highlighted lines are new in this step.
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.
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"})Highlighted lines are new in this step.
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.
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()Highlighted lines are new in this step.