← Otto User GuideDesktop Widgets

Clocks, system monitors and dashboards that sit on the desktop behind your windows, using the wlr-layer-shell protocol Otto implements. This page walks through setting up eww on Otto, from installing it to a complete working configuration you can copy.

An eww system HUD on the Otto desktop, with clock, CPU, memory, network and process panels over the wallpaper

The layers

Layer-shell clients ask to sit on one of four layers. Otto stacks them like this, bottom to top:

LayerWhat belongs thereAbove the wallpaperBelow windows
backgroundWallpaper daemons (wpaperd, swaybg)Yes
bottomDesktop widgetsYesYes
topPanels and barsYesNo
overlayNotifications, lock screens, OSDsYesNo

Desktop widgets belong on bottom. They are drawn over the wallpaper and covered by ordinary windows, so they behave like part of the desktop rather than like a floating panel.

Two details are specific to Otto:

Two more options matter for widgets:

A quick check: wlr-sunclock

Before configuring anything, confirm the layer works on your machine:

sudo pacman -S wlr-sunclock
wlr-sunclock -l bottom -s -a tr -m 40,40,60,40 -w 480

-l bottom picks the layer, -a tr anchors it top-right, -s draws the sun at the subsolar point. Drag a window over it and away again — the widget should still be there. It only repaints every few minutes, so it proves the layer works and little else.

Setting up eww

eww (ElKowar’s Wacky Widgets) is the usual choice: a standalone GTK3 widget system with its own layout language and no dependency on any particular compositor. Most desktop-widget configurations you find online are written for it.

Installing eww

eww is not in the Arch repositories, so it comes from the AUR:

yay -S eww           # or: paru -S eww

It pulls in gtk3 and gtk-layer-shell and builds from source, which takes a few minutes. Verify it landed:

eww --version

For the block-character graphs used below, install a Nerd Font as well (ttf-jetbrains-mono-nerd is a good default).

The config directory

A config directory holds three things:

FilePurpose
eww.yuckWidget tree and window definitions
eww.scssStyling, compiled to GTK CSS
scripts/Whatever produces the data

Keep each set of widgets in its own directory and pass --config, so several configurations can coexist without fighting over one daemon:

mkdir -p ~/.config/eww-hud/scripts

Your first widget

Two files are enough for something on screen. ~/.config/eww-hud/eww.yuck:

(defpoll time :interval "1s" `date +"%H:%M:%S"`)

(defwidget clock []
  (box :class "panel"
    (label :show-truncated false :class "time" :text time)))

(defwindow hud_clock
  :monitor 0
  :stacking "bottom"        ; behind windows, above the wallpaper
  :focusable false          ; never steals keyboard focus
  :exclusive false          ; reserves no screen space
  :geometry (geometry :anchor "top left" :x "1050px" :y "70px"
                      :width "320px" :height "78px")
  (clock))

~/.config/eww-hud/eww.scss:

* { all: unset; font-family: monospace; color: #d3e2f2; }

window { background-color: transparent; }

.panel {
  background-color: rgba(9, 13, 20, 0.58);
  border: 1px solid rgba(211, 226, 242, 0.16);
  border-radius: 3px;
  padding: 9px;
}
.time { font-size: 30px; font-weight: 200; letter-spacing: 3px; }

Then:

eww --config ~/.config/eww-hud daemon
eww --config ~/.config/eww-hud open hud_clock

The commands you will use constantly:

CommandWhat it does
eww --config <dir> daemonStart the daemon (nothing appears yet)
eww --config <dir> open <window>Show one window
eww --config <dir> open-many a b cShow several at once
eww --config <dir> reloadRe-read config after an edit
eww --config <dir> active-windowsList what is currently open
eww --config <dir> killStop the daemon and close everything

Anchors, or why your widget is in the middle of the screen

Without :anchor, eww centres the geometry and reads :x/:y as offsets from the centre. Set it explicitly whenever you place panels by absolute position:

:geometry (geometry :anchor "top left" :x "1050px" :y "70px" ...)

Geometry is in logical pixels

Otto scales the desktop by screen_scale. Layer-shell clients are laid out in logical pixels — physical pixels divided by that scale — so a 2880x1920 panel at screen_scale = 2.0 gives you a 1440x960 canvas to place widgets on. Check your value with:

grep screen_scale ~/.config/otto/config.toml

This is the most common reason a configuration copied from someone else’s setup looks wrong. A dashboard drawn for a 1920x1080 desktop occupies a third more of the screen on a 1440-logical one, so its text and panels look oversized even though nothing is scaled incorrectly. Multiply positions and font sizes by your_logical_width / 1920, or lay it out again for your own canvas.

Otto exports the same scale to GTK, and gtk-xft-dpi should stay at the standard 96 DPI (98304). If you have also raised GTK’s text scaling, fonts get scaled twice and every GTK app looks huge:

gsettings get org.gnome.desktop.interface text-scaling-factor   # want 1.0

A worked example: a system HUD

A clock, a CPU sparkline, and live memory, temperature and network readouts. Three files, roughly 100 lines, no dependencies beyond eww itself. The screenshot at the top of this page is an extended version of exactly this — same structure and styling, with more panels hung off the same data source.

One data source, not twenty pollers

The obvious way to feed a dashboard is a defpoll per value. Twenty pollers is twenty shell pipelines a second. A single script emitting one JSON line per second, read by a single deflisten, is dramatically cheaper — and every panel stays consistent because they all read the same sample.

~/.config/eww-hud/scripts/metrics (chmod +x it):

#!/bin/bash
# One JSON line per second: everything the HUD needs, from one process.
BLOCKS=(▁ ▂ ▃ ▄ ▅ ▆ ▇ █)
HIST=(); HIST_N=44
NET_IF=$(ip route show default | awk '{print $5; exit}')
PREV_TOTAL=0; PREV_BUSY=0; PREV_RX=0

blk() { echo -n "${BLOCKS[$(( $1 * 8 / 101 ))]}"; }

while :; do
  # CPU: busy fraction since the previous sample
  read -r _ u n s idle iow irq sirq st _ < /proc/stat
  total=$((u+n+s+idle+iow+irq+sirq+st)); busy=$((total-idle-iow))
  dt=$((total-PREV_TOTAL)); db=$((busy-PREV_BUSY))
  cpu=0; (( dt > 0 )) && cpu=$(( 100*db/dt ))
  PREV_TOTAL=$total; PREV_BUSY=$busy

  HIST+=("$cpu"); (( ${#HIST[@]} > HIST_N )) && HIST=("${HIST[@]:1}")
  spark=""; for v in "${HIST[@]}"; do spark+=$(blk "$v"); done

  while read -r k v _; do
    case $k in MemTotal:) mt=$v ;; MemAvailable:) ma=$v ;; esac
  done < /proc/meminfo
  mem=$(( (mt-ma)*100/mt ))
  memtxt=$(awk -v u=$((mt-ma)) -v t=$mt 'BEGIN{printf "%.1f/%.1fG", u/1048576, t/1048576}')

  temp=$(awk '{printf "%d", $1/1000}' /sys/class/thermal/thermal_zone0/temp)

  rx=$(< /sys/class/net/$NET_IF/statistics/rx_bytes)
  rxr=$(awk -v b=$((rx-PREV_RX)) 'BEGIN{
    if (b<0) b=0
    if (b>1048576) printf "%.1fMB", b/1048576; else printf "%.1fKB", b/1024}')
  PREV_RX=$rx

  printf '{"time":"%s","cpu":%d,"spark":"%s","mem":%d,"memtxt":"%s","temp":%d,"rx":"%s"}\n' \
    "$(date +%H:%M:%S)" "$cpu" "$spark" "$mem" "$memtxt" "$temp" "$rxr"
  sleep 1
done

The block characters (▁▂▃▄▅▆▇█) are the whole trick behind the sparkline: bucket a percentage into eight and print the matching glyph. No drawing code, and it costs nothing to redraw.

The widgets

~/.config/eww-hud/eww.yuck:

;; One process feeds every widget, sampled once a second.
(deflisten M :initial `{"time":"--:--:--","cpu":0,"spark":"","mem":0,"memtxt":"","temp":0,"rx":""}`
  "scripts/metrics")

(defwidget panel [title]
  (box :class "panel" :orientation "v" :space-evenly false
    (box :class "panel-head"
      (label :show-truncated false :class "panel-title" :halign "start" :text title))
    (box :class "panel-body" :orientation "v" :space-evenly false
      (children))))

(defwidget row [k v]
  (box :orientation "h" :space-evenly false :spacing 8
    (label :show-truncated false :class "k" :text k)
    (box :hexpand true)
    (label :show-truncated false :class "v" :text v)))

(defwidget w_clock []
  (panel :title "CHRONO"
    (label :show-truncated false :class "clock" :halign "start" :text {M.time})))

(defwidget w_system []
  (panel :title "SYSTEM"
    (box :orientation "v" :space-evenly false :spacing 6
      (label :show-truncated false :class "graph" :halign "start" :text {M.spark})
      (box :class {M.cpu > 85 ? "gauge warn" : "gauge"} :valign "center"
        (scale :value {M.cpu} :min 0 :max 101 :active false))
      (row :k "CPU"  :v "${M.cpu}%")
      (row :k "MEM"  :v {M.memtxt})
      (row :k "TEMP" :v "${M.temp}C")
      (row :k "NET"  :v "${M.rx}/s"))))

;; Bottom layer, no exclusive zone, never takes focus.
(defwindow hud_clock
  :monitor 0 :stacking "bottom" :focusable false :exclusive false
  :geometry (geometry :anchor "top left" :x "1050px" :y "70px"
                      :width "320px" :height "78px")
  (w_clock))

(defwindow hud_system
  :monitor 0 :stacking "bottom" :focusable false :exclusive false
  :geometry (geometry :anchor "top left" :x "1050px" :y "162px"
                      :width "320px" :height "196px")
  (w_system))

defwidget takes parameters and (children), so panel is a reusable frame — add a third panel by writing four more lines, not by copying the styling.

The stylesheet

~/.config/eww-hud/eww.scss. Note the warning in the first line; it is the single most confusing failure mode in eww, and it is explained under Troubleshooting below.

/* ASCII only: a single non-ascii byte makes grass emit @charset,
   which GTK rejects, and the whole stylesheet is dropped. */
$ink:    #d3e2f2;
$dim:    rgba(211, 226, 242, 0.45);
$faint:  rgba(211, 226, 242, 0.16);
$accent: #6fe3c4;
$warn:   #f2a35e;

* {
  all: unset;
  font-family: "JetBrainsMono Nerd Font", monospace;
  font-size: 11px;
  color: $ink;
}

window { background-color: transparent; }

.panel {
  background-color: rgba(9, 13, 20, 0.58);
  border: 1px solid $faint;
  border-radius: 3px;
}
.panel-head {
  padding: 6px 9px 5px 9px;
  border-bottom: 1px solid $faint;
}
.panel-title { color: $accent; font-size: 10px; font-weight: bold; letter-spacing: 2px; }
.panel-body  { padding: 9px; }

.clock { font-size: 30px; font-weight: 200; letter-spacing: 3px; }
.graph { color: $accent; font-size: 14px; letter-spacing: -1px; }
.k { color: $dim; letter-spacing: 1px; }

.gauge scale trough {
  background-color: rgba(211, 226, 242, 0.10);
  border: 1px solid $faint;
  border-radius: 0;
  min-height: 5px;
  min-width: 80px;
}
.gauge scale trough highlight { background-color: $accent; border-radius: 0; min-height: 5px; }
.gauge scale slider { background-color: transparent; min-height: 0; min-width: 0; margin: 0; }
.gauge.warn scale trough highlight { background-color: $warn; }

Two rules are both required for transparency: GTK paints an opaque window background from the active theme unless window clears it, and the panel then supplies its own translucent fill. Otto composites the result over the wallpaper, so the alpha you choose is what you get — around 0.50.6 stays readable over a busy wallpaper while still reading as glass.

Running it

chmod +x ~/.config/eww-hud/scripts/metrics
eww --config ~/.config/eww-hud daemon
eww --config ~/.config/eww-hud open-many hud_clock hud_system

The :x values above assume a 1440-logical-pixel-wide screen. If your panels land off-screen or in the wrong place, that is the scaling section above, not a bug.

Autostarting widgets

Add them to your Otto config so they come back with the session:

# ~/.config/otto/config.toml

[[exec_once]]
cmd = "eww"
args = ["--config", "/home/you/.config/eww-hud", "daemon"]

[[exec_once]]
cmd = "eww"
args = ["--config", "/home/you/.config/eww-hud", "open-many", "hud_clock", "hud_system"]

args are passed straight to exec with no shell in between, so ~ is not expanded — write the path out in full. See Autostart for the ordering rules and the XDG alternative.

Troubleshooting

The stylesheet is ignored entirely

Symptoms: default theme colors, sans-serif font, opaque backgrounds, visible slider knobs — as if eww.scss did not exist.

eww compiles SCSS with grass, which prepends @charset "UTF-8"; as soon as the file contains a single non-ASCII byte. GTK’s CSS parser rejects @charset and discards the whole sheet. An em dash or a degree sign in a comment is enough to do it. Keep eww.scss strictly ASCII and put Unicode in eww.yuck or in script output instead:

LC_ALL=C grep -nP '[^\x00-\x7F]' ~/.config/eww-hud/eww.scss

Labels are cut short with an ellipsis

CHRON… instead of CHRONO. eww ellipsizes labels by default; pass :show-truncated false on the label.

Config errors do not appear in the log

~/.cache/eww/eww_*.log does not capture SCSS and parse errors — those go to the daemon’s stderr, which is discarded when it forks into the background. Run it in the foreground to see them:

eww --config ~/.config/eww-hud daemon --no-daemonize 2>&1 | tee /tmp/eww.log

The widget does not appear at all

Check whether it actually opened, and on which output:

eww --config ~/.config/eww-hud active-windows

:monitor indexes the outputs in the order Otto advertises them, which need not match the order they appear in your config. On a multi-monitor setup a widget placed on the wrong output is easy to mistake for one that failed to open. See Display.

The widget hides behind the wallpaper

The wallpaper daemon is on background and the widget should be on bottom — check :stacking. Two clients on the same layer stack in the order they connected.

Maximized windows leave a gap

Something has an exclusive zone; set :exclusive false.

See also