strand

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:

clock.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:

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:

volume.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:

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:

battery.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:

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
      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:

dot.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.

0.23 s
Drawn by Strand from the config above.
0.02 s
Drawn by Strand from the config above.
FileHolds
bar.strandthe Top bar
dot.strandDot
clock.strandClock and Calendar
volume.strandVolume
battery.strandBattery

Next, level 4 builds surfaces that come and go: an on-screen display for volume, notifications, an app launcher and a lock screen.

This page on GitHubThis page as Markdownllms.txt