# Level 3: things that remember

You learn: `state`, `<->`, `if`, `popup`, `enter`.
You build: a clock that opens a calendar, a volume control with a slider
that slides out on hover, and a battery that shows only when there is
one. At the end of this level your bar is complete, split into one file
per component.

## A clock with a calendar

Make `clock.strand`:

```strand
component Clock {
  state open = false                               // one per monitor
  text clock.format("%a %d  %H:%M") {              // wakes once a minute
    on click { open = !open }
    popup { open: <-> open; Calendar }             // anchored here; Esc or click-away closes
  }
}

component Calendar {
  state month = clock.today.month_start()
  col { pad: $space.3; gap: $space.2; bg: $surface; radius: $radius.lg; shadow: $elevation.lg
    row { align: center
      button "‹" { on click { month = month.add(months: -1) } }
      text month.format("%B %Y") { grow: 1; align: center }
      button "›" { on click { month = month.add(months: 1) } }
    }
    grid { columns: 7; gap: $space.1
      for d in calendar.days(month) {
        text d.date.format("%e") { width: 28; height: 28; align: center
          when !d.in_month { color: $fg.faint }
          when d.date == clock.today { bg: $accent; color: $on_accent; radius: full }
        }
      }
    }
  }
}
```

Then put the clock in the centre of the bar:

```strand
// bar.strand: one instance per monitor; `screen` is in scope
bar Top {
  edge: top; height: 36; margin: $space.2, $space.2, 0
  bg: $surface.alpha(0.72); blur: 24; radius: $radius.lg; shadow: $elevation.md
  font: $font.ui; color: $fg                      // inherited by every child
  split { pad: 0, $space.3                         // centre is truly centred
    start { gap: $space.3
      row { gap: $space.1
        for ws in workspaces.on(screen) { Dot ws } // keyed by ws.id, no max
      }
      text windows.focused?.title ?? "" { color: $fg.muted; max_width: 40%; ellipsis: end }
    }
    center { Clock }
    end { gap: $space.3
      text pct(battery.percent)
      for item in tray.items {
        image item.icon { size: 16
          on click     { item.activate() }
          on secondary { item.menu.open() }
        }
      }
    }
  }
}
```

Click the clock and a calendar opens, anchored to it. Press Escape, or click
anywhere else, and it closes.

**`state open = false` is a value your shell can change.** Everything
you have read so far comes from a service; `state` is your own. It
lives on the component, so each monitor's `Clock` has its own `open`:
opening the calendar on one monitor leaves the others alone.

**`open = !open` writes it.** `!` is "not", so a click flips it.
Writes happen in handlers (`on click { … }` and the like). A prop is a
live expression, never an assignment.
Writing a number has shorthands: `x += 1`, `x -= d`, `x *= 2`, `x /= 2`.

**`popup { … }` is a small surface anchored to its parent**, here the
clock's text. It shows while its `open` prop is true.

**`open: <-> open` is a two-way binding.** The popup reads `open` to
know whether to show, and writes it back when it closes itself (Escape,
a click away). One line replaces a prop plus a "closed" handler. The
right-hand side must be something writable: a `state`, a settings field,
or a service field that can be written.

**`Calendar` keeps its own `state month`.** The arrows move it with
`month.add(months: -1)`: a call can name its arguments. The grid shows
one cell per day from `calendar.days(month)`, padded with the days of
the months around it (`d.in_month` tells them apart), and today is
filled with the accent colour. `$on_accent` is the colour for text on
`$accent`, and `$fg.faint` is a fainter foreground still.

The clock's format changed too, to show the day: `%a %d  %H:%M`. It
still wakes only once a minute.

**Moving nodes into a new component starts them fresh.** The old
centre `text` is gone and `Clock` is new, so nothing carried over. That
is fine here, since the text had no state. In general a save keeps every
node whose source still maps to it, with its state, and wrapping a node
in a container such as `row` keeps it too; but a node you move into a
new component, or into another one, is a new node.

## Volume

Make `volume.strand`:

```strand
component Volume {
  row { gap: $space.1
    on scroll(dy) { audio.sink.volume -= dy * 0.05 }
    icon audio.sink.icon { on click { audio.sink.muted = !audio.sink.muted } }
    if hover {                                     // the row's hover, latched while dragging
      slider { width: 90; value: <-> audio.sink.volume
               enter { width: 0; opacity: 0 } }
    }
  }
}
```

Then add it to the end of the bar:

```strand
// bar.strand: one instance per monitor; `screen` is in scope
bar Top {
  edge: top; height: 36; margin: $space.2, $space.2, 0
  bg: $surface.alpha(0.72); blur: 24; radius: $radius.lg; shadow: $elevation.md
  font: $font.ui; color: $fg                      // inherited by every child
  split { pad: 0, $space.3                         // centre is truly centred
    start { gap: $space.3
      row { gap: $space.1
        for ws in workspaces.on(screen) { Dot ws } // keyed by ws.id, no max
      }
      text windows.focused?.title ?? "" { color: $fg.muted; max_width: 40%; ellipsis: end }
    }
    center { Clock }
    end { gap: $space.3
      Volume
      text pct(battery.percent)
      for item in tray.items {
        image item.icon { size: 16
          on click     { item.activate() }
          on secondary { item.menu.open() }
        }
      }
    }
  }
}
```

Point at the speaker icon and a slider slides out; drag it and the
volume follows. Scroll anywhere on the row to change the volume, and
click the icon to mute.

**`on scroll(dy) { … }` takes a parameter.** `dy` is how far the wheel
moved. Some events carry values like this; you name them in the
parentheses.

**`audio.sink.volume -= dy * 0.05` writes a service.** Some service
fields can be written, like `state`: the default output's volume and
its mute switch are two. Your write goes to the sound server, and when
the volume changes from anywhere else (a media key, another app) the
slider follows.

**`if cond { … }` adds and removes nodes.** `when` changes props of a
node that is always there; `if` decides whether its children exist at
all. `if hover` reads the row's `hover`. It stays true while you drag
the slider, even when the pointer leaves the row, so the slider never
vanishes under you. `if` can have an `else { … }` block for when the
condition is false.

**`trigger: hover` opens a popup by pointing.** The slider sits inside
the row, so `if hover` keeps it while you use it. A `popup` is a surface
of its own: an `if hover` popup would close on the pointer's way to it.
Give the popup `trigger: hover` instead and Strand opens and closes it:
it opens while the pointer is on the element it belongs to, stays while
the pointer is on either, and 200 ms more, long enough to cross the gap:

```
box { size: 28
  when popout.open { bg: $surface.hi }      // lit while its popup shows
  popup { id: popout; trigger: hover; pad: $space.3
    text "More here"
  }
}
```

The pointer is what opens such a popup. Its `open:` opens it from code
as well, and holds it open while true (a peek handing over to it);
once that is false, the pointer holds it as usual. `delay:`
makes the pointer rest first, and `grace:` changes the 200 ms. Pointing
at another `trigger: hover` element on the same bar swaps the popups at
once. `popout.open` reads whether a popup is open, whatever opens it.

**`enter { width: 0; opacity: 0 }` is where a node animates in from.**
When the slider appears it starts at no width and transparent, and
springs to its real size and full opacity. When it goes, it plays the
same pose in reverse before it is removed: `exit` mirrors `enter`
unless you write an `exit { … }` block of its own.

**`value: <-> audio.sink.volume`** is the same two-way binding as the
popup's: the slider shows the volume and writes it when dragged. A
slider runs from 0 to 1, as the volume does. For a value with other
ends, give them: with `state percent = 50`, `slider { value: <->
percent; range: 0, 100 }` shows `percent` and writes 0 to 100 back to
it. `step: 5` beside the range moves the slider in whole steps of 5,
and the value it writes is a whole step. A slider laid out taller than
wide (`width: 24; height: 160`) runs upward.

## Battery

Make `battery.strand`:

```strand
component Battery {
  row { gap: $space.1
    icon battery.icon
    text join(" · ", pct(battery.percent), dur(battery.time_left))
    when battery.percent < 0.15 && !battery.charging { color: $error }
  }
}
```

Then show it only when there is a battery:

```strand
// bar.strand: one instance per monitor; `screen` is in scope
bar Top {
  edge: top; height: 36; margin: $space.2, $space.2, 0
  bg: $surface.alpha(0.72); blur: 24; radius: $radius.lg; shadow: $elevation.md
  font: $font.ui; color: $fg                      // inherited by every child
  split { pad: 0, $space.3                         // centre is truly centred
    start { gap: $space.3
      row { gap: $space.1
        for ws in workspaces.on(screen) { Dot ws } // keyed by ws.id, no max
      }
      text windows.focused?.title ?? "" { color: $fg.muted; max_width: 40%; ellipsis: end }
    }
    center { Clock }
    end { gap: $space.3
      Volume
      if battery.present { Battery }
      for item in tray.items {
        image item.icon { size: 16
          on click     { item.activate() }
          on secondary { item.menu.open() }
        }
      }
    }
  }
}
```

On a desktop with no battery, nothing shows. On a laptop you see an
icon, the charge and the time left, joined by a dot. `join` skips any
part that is `null`, so when UPower does not know the time left you see
only the charge. Below 15% and not charging, it turns red.

## Dots that grow in

Last, let new workspace dots grow in instead of popping. Replace
`dot.strand`:

```strand
component Dot(ws: Workspace) {
  box { size: 8; radius: full; bg: $fg.alpha(0.25); hit: grow(6)
    when ws.occupied { bg: $fg.muted }
    when ws.focused  { width: 24; bg: $accent }    // later `when` wins, like CSS
    when ws.urgent   { bg: $error }
    when hover       { bg: $accent.hover }
    when pressed     { scale: 0.9 }
    enter { width: 0; opacity: 0 }                 // exit mirrors enter
    on click { ws.focus() }
  }
}
```

Make a new workspace: its dot grows from nothing, and the dots beside
it glide aside. Close it and the dot shrinks away.

## What you have

The whole bar, on every monitor: workspace dots, the focused
window's title, a clock with its calendar, volume, battery and tray, in
five files and about 80 lines.

`bar.strand`:

```strand
// bar.strand: one instance per monitor; `screen` is in scope
bar Top {
  edge: top; height: 36; margin: $space.2, $space.2, 0
  bg: $surface.alpha(0.72); blur: 24; radius: $radius.lg; shadow: $elevation.md
  font: $font.ui; color: $fg                      // inherited by every child
  split { pad: 0, $space.3                         // centre is truly centred
    start { gap: $space.3
      row { gap: $space.1
        for ws in workspaces.on(screen) { Dot ws } // keyed by ws.id, no max
      }
      text windows.focused?.title ?? "" { color: $fg.muted; max_width: 40%; ellipsis: end }
    }
    center { Clock }
    end { gap: $space.3
      Volume
      if battery.present { Battery }
      for item in tray.items {
        image item.icon { size: 16
          on click     { item.activate() }
          on secondary { item.menu.open() }
        }
      }
    }
  }
}
```

`dot.strand`:

```strand
component Dot(ws: Workspace) {
  box { size: 8; radius: full; bg: $fg.alpha(0.25); hit: grow(6)
    when ws.occupied { bg: $fg.muted }
    when ws.focused  { width: 24; bg: $accent }    // later `when` wins, like CSS
    when ws.urgent   { bg: $error }
    when hover       { bg: $accent.hover }
    when pressed     { scale: 0.9 }
    enter { width: 0; opacity: 0 }                 // exit mirrors enter
    on click { ws.focus() }
  }
}
```

`clock.strand`:

```strand
component Clock {
  state open = false                               // one per monitor
  text clock.format("%a %d  %H:%M") {              // wakes once a minute
    on click { open = !open }
    popup { open: <-> open; Calendar }             // anchored here; Esc or click-away closes
  }
}

component Calendar {
  state month = clock.today.month_start()
  col { pad: $space.3; gap: $space.2; bg: $surface; radius: $radius.lg; shadow: $elevation.lg
    row { align: center
      button "‹" { on click { month = month.add(months: -1) } }
      text month.format("%B %Y") { grow: 1; align: center }
      button "›" { on click { month = month.add(months: 1) } }
    }
    grid { columns: 7; gap: $space.1
      for d in calendar.days(month) {
        text d.date.format("%e") { width: 28; height: 28; align: center
          when !d.in_month { color: $fg.faint }
          when d.date == clock.today { bg: $accent; color: $on_accent; radius: full }
        }
      }
    }
  }
}
```

`volume.strand`:

```strand
component Volume {
  row { gap: $space.1
    on scroll(dy) { audio.sink.volume -= dy * 0.05 }
    icon audio.sink.icon { on click { audio.sink.muted = !audio.sink.muted } }
    if hover {                                     // the row's hover, latched while dragging
      slider { width: 90; value: <-> audio.sink.volume
               enter { width: 0; opacity: 0 } }
    }
  }
}
```

`battery.strand`:

```strand
component Battery {
  row { gap: $space.1
    icon battery.icon
    text join(" · ", pct(battery.percent), dur(battery.time_left))
    when battery.percent < 0.15 && !battery.charging { color: $error }
  }
}
```

`bar.strand`:

```strand
// bar.strand: one instance per monitor; `screen` is in scope
bar Top {
  edge: top; height: 36; margin: $space.2, $space.2, 0
  bg: $surface.alpha(0.72); blur: 24; radius: $radius.lg; shadow: $elevation.md
  font: $font.ui; color: $fg                      // inherited by every child
  split { pad: 0, $space.3                         // centre is truly centred
    start { gap: $space.3
      row { gap: $space.1
        for ws in workspaces.on(screen) { Dot ws } // keyed by ws.id, no max
      }
      text windows.focused?.title ?? "" { color: $fg.muted; max_width: 40%; ellipsis: end }
    }
    center { Clock }
    end { gap: $space.3
      Volume
      if battery.present { Battery }
      for item in tray.items {
        image item.icon { size: 16
          on click     { item.activate() }
          on secondary { item.menu.open() }
        }
      }
    }
  }
}
```

`dot.strand`:

```strand
component Dot(ws: Workspace) {
  box { size: 8; radius: full; bg: $fg.alpha(0.25); hit: grow(6)
    when ws.occupied { bg: $fg.muted }
    when ws.focused  { width: 24; bg: $accent }    // later `when` wins, like CSS
    when ws.urgent   { bg: $error }
    when hover       { bg: $accent.hover }
    when pressed     { scale: 0.9 }
    enter { width: 0; opacity: 0 }                 // exit mirrors enter
    on click { ws.focus() }
  }
}
```

`clock.strand`:

```strand
component Clock {
  state open = false                               // one per monitor
  text clock.format("%a %d  %H:%M") {              // wakes once a minute
    on click { open = !open }
    popup { open: <-> open; Calendar }             // anchored here; Esc or click-away closes
  }
}

component Calendar {
  state month = clock.today.month_start()
  col { pad: $space.3; gap: $space.2; bg: $surface; radius: $radius.lg; shadow: $elevation.lg
    row { align: center
      button "‹" { on click { month = month.add(months: -1) } }
      text month.format("%B %Y") { grow: 1; align: center }
      button "›" { on click { month = month.add(months: 1) } }
    }
    grid { columns: 7; gap: $space.1
      for d in calendar.days(month) {
        text d.date.format("%e") { width: 28; height: 28; align: center
          when !d.in_month { color: $fg.faint }
          when d.date == clock.today { bg: $accent; color: $on_accent; radius: full }
        }
      }
    }
  }
}
```

`volume.strand`:

```strand
component Volume {
  row { gap: $space.1
    on scroll(dy) { audio.sink.volume -= dy * 0.05 }
    icon audio.sink.icon { on click { audio.sink.muted = !audio.sink.muted } }
    if hover {                                     // the row's hover, latched while dragging
      slider { width: 90; value: <-> audio.sink.volume
               enter { width: 0; opacity: 0 } }
    }
  }
}
```

`battery.strand`:

```strand
component Battery {
  row { gap: $space.1
    icon battery.icon
    text join(" · ", pct(battery.percent), dur(battery.time_left))
    when battery.percent < 0.15 && !battery.charging { color: $error }
  }
}
```

| File | Holds |
| --- | --- |
| `bar.strand` | the `Top` bar |
| `dot.strand` | `Dot` |
| `clock.strand` | `Clock` and `Calendar` |
| `volume.strand` | `Volume` |
| `battery.strand` | `Battery` |

Next, [level 4](/guide/level-4.html.md) builds surfaces that come and go: an
on-screen display for volume, notifications, an app launcher and a lock
screen.
