#!/bin/bash
# timer: timers, alarms and the look of the clock in the bar.
# Written by Wren Linux install-base.sh. Needs systemd and date, and cal (ncal) for
# the month in its screen.
#
#   timer                       a small menu (also: click the time in the bar, or Super+t)
#   timer 10m [NAME]            a timer: 90s, 10m, 1h30m, 1.5h (a bare number is minutes)
#   timer at 7:30 [NAME]        an alarm at the next 7:30 (7am, 7:30 pm, tomorrow 8am)
#   timer list                  what is running
#   timer cancel [N|all]        cancel one, or all of them
#   timer stop                  stop the sound of an alarm that is ringing (else: cancel)
#   timer sound [NAME|list]     which sound it rings with: choose one, and hear it
#   timer clock                 whether the bar shows the date (the time is always am/pm)
#   timer clock date on|date off
#   timer lock [MINUTES]        screen off after MINUTES idle, locked a minute later (1 to 120; alone: how long now)
#
# A timer or alarm rings with a notification that stays until you dismiss it
# and a sound: an alarm repeats it for up to 5 minutes (to wake you), a timer for
# about 20 seconds. Click the notification, dismiss it (Super+n) or run
# timer stop to end the sound sooner. They last until you log out or reboot,
# and a laptop that is asleep does not wake up for them.

set -u

STATE=${XDG_RUNTIME_DIR:-/tmp}/wren-timers   # lives as long as the timers do
CONF=${XDG_CONFIG_HOME:-$HOME/.config}/wren/clock
SELF=$(readlink -f -- "$0")
MAX=604800                                      # longest timer: a week

err() { printf 'timer: %s\n' "$*" >&2; }

# --- the clock setting, shared with sway-status ---------------------------

conf_get() { sed -n "s/^$1=//p" "$CONF" 2>/dev/null | tail -n 1; }

conf_set() { # KEY VALUE
    mkdir -p "${CONF%/*}"
    touch "$CONF"
    { grep -v "^$1=" "$CONF"; echo "$1=$2"; } >"$CONF.new" && mv "$CONF.new" "$CONF"
}

# clock_at EPOCH: the time of day (with the weekday if it is not today)
clock_at() {
    local f='%-I:%M %p'
    [ "$(date -d "@$1" +%F)" = "$(date +%F)" ] || f="%a $f"
    date -d "@$1" +"$f"
}

# tell sway-status to redraw now (only if that PID really is sway-status)
refresh_bar() {
    local pid
    pid=$(cat "${XDG_RUNTIME_DIR:-/tmp}/sway-status.pid" 2>/dev/null)
    if [ -n "$pid" ] && tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null | grep -q sway-status; then
        kill -USR1 "$pid" 2>/dev/null
    fi
    return 0
}

# --- reading what a person types -------------------------------------------

# parse_duration TEXT: the seconds in 90s, 10m, 1h30m, 1h30, 1.5h, 90 (minutes)
parse_duration() {
    printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | awk -v max="$MAX" '
        {
            s = $0; total = 0; last = ""; n = 0
            while (s != "") {
                if (!match(s, /^[0-9]+(\.[0-9]+)?[hms]?/)) exit 1
                tok = substr(s, 1, RLENGTH); s = substr(s, RLENGTH + 1); n++
                u = substr(tok, length(tok))
                if (u ~ /[hms]/) v = substr(tok, 1, length(tok) - 1)
                else { v = tok; u = (last == "" || last == "h") ? "m" : "s" }   # 1h30 is 1h30m, 5m30 is 5m30s
                total += v * ((u == "h") ? 3600 : (u == "m") ? 60 : 1)
                last = u
            }
            if (n == 0 || total <= 0 || total > max) exit 1
            printf "%d\n", total + 0.5
        }'
}

# parse_alarm TEXT: the epoch of the next 7:30, 7am, 7:30 pm, tomorrow 8am
parse_alarm() {
    local t=$1 now e
    [[ $t =~ ^(tomorrow\ )?[0-9]{1,2}(:[0-9]{2})?(\ ?[aApP][mM])?$ ]] || return 1
    now=$(date +%s)
    e=$(date -d "$t" +%s 2>/dev/null) || return 1
    if [ "$e" -le "$now" ]; then
        e=$(date -d "tomorrow $t" +%s 2>/dev/null) || return 1
    fi
    echo "$e"
}

# --- the timers themselves --------------------------------------------------
# One small file per timer in $STATE, and a transient systemd user timer to ring it

# load [check]: IDS, KINDS, DUES, LABELS of what is running, soonest first
load() {
    IDS=(); KINDS=(); DUES=(); LABELS=()
    [ -d "$STATE" ] || return 0
    local now due id f
    now=$(date +%s)
    while read -r due id; do
        [ -n "$id" ] || continue
        f=$STATE/$id
        # long gone (the system was off, say), or its systemd timer is not there any more
        if [ "$due" -lt $((now - 120)) ] ||
            { [ "${1:-}" = check ] && ! systemctl --user is-active --quiet "wren-timer-$id.timer"; }; then
            rm -f "$f"
            continue
        fi
        IDS+=("$id"); DUES+=("$due")
        KINDS+=("$(sed -n 's/^kind=//p' "$f")")
        LABELS+=("$(sed -n 's/^label=//p' "$f")")
    done < <(for f in "$STATE"/*; do
        [ -f "$f" ] && printf '%s %s\n' "$(sed -n 's/^due=//p' "$f")" "${f##*/}"
    done | sort -n)
}

# left_text SECONDS: 1 h 05 min, 4 min, 20 sec
left_text() {
    if [ "$1" -ge 3600 ]; then printf '%d h %02d min' $(($1 / 3600)) $(($1 % 3600 / 60))
    elif [ "$1" -ge 60 ]; then printf '%d min' $((($1 + 59) / 60))
    else printf '%d sec' "$1"; fi
}

# show_list: the numbered list, one line per timer
show_list() {
    local i now left name
    now=$(date +%s)
    for i in "${!IDS[@]}"; do
        left=$((DUES[i] - now)); [ "$left" -ge 0 ] || left=0
        name=${LABELS[i]:-${KINDS[i]}}
        if [ "${KINDS[i]}" = alarm ]; then
            printf '    %d) %-18.18s alarm at %s  (in %s)\n' $((i + 1)) "$name" "$(clock_at "${DUES[i]}")" "$(left_text "$left")"
        else
            printf '    %d) %-18.18s %s left  (rings at %s)\n' $((i + 1)) "$name" "$(left_text "$left")" "$(clock_at "${DUES[i]}")"
        fi
    done
}

# add KIND DUE LABEL
add() {
    local kind=$1 due=$2 label=$3 id when
    id=$(date +%s)-$$-$RANDOM
    mkdir -p "$STATE"
    printf 'kind=%s\ndue=%s\nlabel=%s\n' "$kind" "$due" "$label" >"$STATE/$id"
    when=$(date -d "@$due" '+%Y-%m-%d %H:%M:%S')
    if ! systemd-run --user --quiet --unit="wren-timer-$id" --on-calendar="$when" \
        --timer-property=AccuracySec=1s --timer-property=RemainAfterElapse=no \
        --description="Wren Linux $kind: $label" "$SELF" fire "$id" 2>/dev/null; then
        rm -f "$STATE/$id"
        err "systemd could not start it (is this a normal login session?)"
        return 1
    fi
    refresh_bar
}

# set_cmd WORDS...: "10m tea", "at 7:30 wake up", "7am", "tomorrow 8am dentist"
set_cmd() {
    local first=${1:-} secs due t label kind
    [ $# -gt 0 ] || return 1
    if secs=$(parse_duration "$first") && [ -n "$secs" ]; then
        shift
        label=$(printf '%s' "$*" | tr -d '\r\n' | cut -c1-40)
        add timer $(($(date +%s) + secs)) "$label" || return 1
        printf 'Timer set%s: rings in %s, at %s.\n' "${label:+ ($label)}" "$(left_text "$secs")" "$(clock_at $(($(date +%s) + secs)))"
        return 0
    fi
    case $first in at | alarm) shift ;; esac
    [ $# -gt 0 ] || { err "at what time? For example: timer at 7:30"; return 1; }
    t=$1; shift
    case $t in tomorrow) t="$t ${1:-}"; shift ;; esac
    case ${1:-} in [aApP][mM]) t="$t $1"; shift ;; esac
    if ! due=$(parse_alarm "$t"); then
        err "I could not read '$t'. Try 10m for a timer, or 7:30, 7am, 7:30 pm, tomorrow 8am for an alarm."
        return 1
    fi
    kind=alarm
    label=$(printf '%s' "$*" | tr -d '\r\n' | cut -c1-40)
    add $kind "$due" "$label" || return 1
    printf 'Alarm set%s: %s.\n' "${label:+ ($label)}" "$(clock_at "$due")"
}

# cancel_cmd WHICH: a number from the list, or all
cancel_cmd() {
    local i n=0
    load check
    [ ${#IDS[@]} -gt 0 ] || { echo "Nothing is running."; return 0; }
    for i in "${!IDS[@]}"; do
        if [ "$1" = all ] || [ "$1" = $((i + 1)) ]; then
            systemctl --user stop "wren-timer-${IDS[i]}.timer" 2>/dev/null
            rm -f "$STATE/${IDS[i]}"
            n=$((n + 1))
        fi
    done
    refresh_bar
    if [ "$n" -eq 0 ]; then err "no timer number '$1' (timer list shows them)"; return 1; fi
    echo "Cancelled $n."
}

# The alarm sound is the wren, unless you chose another (timer sound). The sounds are put
# here by parts/core-commands.sh; no sound theme package is needed. An alarm
# plays it over and over for about RING_ALARM seconds, a timer for RING_TIMER.
# (The WREN_ ones: a test points these at pretend values)
SOUND_DIR=${WREN_SOUND_DIR:-$HOME/.local/share/wren}
RING_ALARM=${WREN_RING_ALARM:-300}
RING_TIMER=${WREN_RING_TIMER:-20}

# kill_ring PID: ends a ringing sound (the player first, then the loop around it)
kill_ring() { pkill -P "$1" 2>/dev/null; kill "$1" 2>/dev/null; }

# stop_ringing: ends the sound of whatever is ringing now; fails if nothing is
stop_ringing() {
    local pid
    pid=$(cat "$STATE/ringing" 2>/dev/null) || return 1
    rm -f "$STATE/ringing"
    # (only a process that really is a ring of this script, not a stale number)
    if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && { tr '\0' ' ' <"/proc/$pid/cmdline"; } 2>/dev/null | grep -q timer; then
        kill_ring "$pid"
        return 0
    fi
    return 1
}

# fire ID: what systemd runs when the time comes
fire() {
    local f=$STATE/$1 kind label title sound='' hint='' body total plays ringpid=
    [ -f "$f" ] || exit 0
    kind=$(sed -n 's/^kind=//p' "$f"); label=$(sed -n 's/^label=//p' "$f")
    rm -f "$f"
    refresh_bar
    if [ "$kind" = alarm ]; then title="Alarm"; else title="Timer done"; fi
    # It is played with pw-play, through your own PipeWire (the sound extra).
    # Without that there is only the notification, so it says why instead of
    # leaving you wondering.
    if ! can_play; then
        hint=" (no sound: wrenctl --apps, then check pipewire-audio)"
    elif [ ! -r "$SOUND_DIR/alarm.wav" ]; then
        hint=" (no sound: the alarm sound is missing; run wrenctl --update)"
    else
        sound=$(sound_file)
    fi
    body="${label:-$(date +'%-I:%M %p')}$hint"
    if [ -n "$sound" ]; then
        # An alarm rings long, to wake you; a timer a little. Each play is 8 s plus a pause.
        if [ "$kind" = alarm ]; then total=$RING_ALARM; else total=$RING_TIMER; fi
        plays=$((total / 10))
        [ "$plays" -ge 1 ] || plays=1
        body="$body. Click or dismiss this to stop the sound."
        mkdir -p "$STATE"
        (
            for ((i = 0; i < plays; i++)); do
                pw-play "$sound" 2>/dev/null
                sleep 2
            done
        ) &
        ringpid=$!
        echo "$ringpid" >"$STATE/ringing"
    fi
    if command -v notify-send >/dev/null; then
        if [ -n "$ringpid" ] && notify-send --help 2>&1 | grep -q -e '--wait'; then
            # Clicking the notification or dismissing it ends the notify-send, and the sound with it
            (
                notify-send --wait -A default=Stop -u critical -t 0 -a wren "$title" "$body" >/dev/null 2>&1
                kill_ring "$ringpid"
            ) &
        else
            notify-send -u critical -t 0 -a wren "$title" "$body"
        fi
    fi
    if [ -n "$ringpid" ]; then
        wait "$ringpid" 2>/dev/null
        rm -f "$STATE/ringing"
        # the notification stays until dismissed, but the sound is over
    fi
    exit 0
}

# next: the short text sway-status puts in the bar ("tea 4m", "alarm 7:30 AM"), or nothing
next() {
    load
    [ ${#IDS[@]} -gt 0 ] || return 0
    local left=$((DUES[0] - $(date +%s))) name=${LABELS[0]} text more=
    [ "$left" -ge 0 ] || left=0
    if [ "${KINDS[0]}" = alarm ]; then
        text="${name:-alarm} $(clock_at "${DUES[0]}")"
    else
        if [ "$left" -ge 3600 ]; then text=$(printf '%dh%02d' $((left / 3600)) $((left % 3600 / 60)))
        elif [ "$left" -ge 60 ]; then text="$(((left + 59) / 60))m"
        else text="<1m"; fi
        text="${name:-timer} $text"
    fi
    [ ${#IDS[@]} -gt 1 ] && more=" +$((${#IDS[@]} - 1))"
    printf '%s%s\n' "$text" "$more"
}

# --- which sound it rings with ---------------------------------------
# bell is data/alarm.wav. The others are made by lib/alarm-sounds.py, into SOUND_DIR/alarms,
# at install (parts/core-commands.sh), or the first time they're wanted.

# sound_name: what rings: the one you chose, else the wren; the bell if that one's file is
# gone: never silence
sound_name() {
    local name
    name=$(conf_get sound)
    name=${name:-wren}
    if [[ $name =~ ^[a-z]+$ ]] && [ "$name" != bell ] && [ -r "$SOUND_DIR/alarms/$name.wav" ]; then
        echo "$name"
    else
        echo bell
    fi
}
# can_play: a sound can be heard: pw-play is there and so is your own sound server. pw-play
# alone isn't enough: the sandboxed browsers install it for sound servers of their own, while
# yours runs only with the sound extra (lib/core.sh user_audio_sync), and then it plays nothing
# (asked of systemd: a socket file can be left behind by a sound server that stopped)
can_play() {
    command -v pw-play >/dev/null || return 1
    systemctl --user -q is-active pipewire.socket 2>/dev/null || systemctl --user -q is-active pipewire.service 2>/dev/null
}
sound_file() {
    local name
    name=$(sound_name)
    if [ "$name" = bell ]; then echo "$SOUND_DIR/alarm.wav"; else echo "$SOUND_DIR/alarms/$name.wav"; fi
}

sound_cmd() {
    local maker names=(bell) made=() n i
    maker=$SOUND_DIR/alarm-sounds.py
    # (the first one the maker lists, then the bell, then the rest)
    if [ -r "$maker" ] && command -v python3 >/dev/null; then mapfile -t made < <(python3 -I "$maker" list); fi
    if [ ${#made[@]} -gt 0 ]; then names=("${made[0]}" bell "${made[@]:1}"); fi
    # choose NAME: it's kept and played
    choose() {
        local c found=
        for c in "${names[@]}"; do [ "$c" != "$1" ] || found=1; done
        [ -n "$found" ] || { err "there is no sound \"$1\". They are: ${names[*]}"; return 1; }
        if [ "$1" != bell ] && [ ! -r "$SOUND_DIR/alarms/$1.wav" ]; then
            python3 -I "$maker" make "$SOUND_DIR/alarms" || { err "couldn't make the sound."; return 1; }
        fi
        conf_set sound "$1"
        echo "Rings with: $1"
        if can_play; then pw-play "$(sound_file)" 2>/dev/null || true
        else echo "(You'd hear it with the sound extra: wrenctl --apps, then check pipewire-audio.)"; fi
    }
    show() {
        local cur
        cur=$(sound_name)
        for i in "${!names[@]}"; do
            if [ "${names[i]}" = "$cur" ]; then printf '* %d) %s\n' $((i + 1)) "${names[i]}"
            else printf '  %d) %s\n' $((i + 1)) "${names[i]}"; fi
        done
    }
    case ${1:-} in
        list) show ;;
        "")
            while :; do
                show
                read -r -p "Number to hear and choose it, Enter when you're done: " n || return 0
                [ -n "$n" ] || return 0
                if [[ $n =~ ^[0-9]{1,2}$ ]] && [ "$n" -ge 1 ] && [ "$n" -le ${#names[@]} ]; then choose "${names[n - 1]}"
                else echo "Type one of the numbers."; fi
            done ;;
        *) choose "$1" ;;
    esac
}

# --- how the clock looks in the bar ---------------------------------------

clock_cmd() {
    local d
    case ${1:-}${2:+ $2} in
        "date on") conf_set date yes ;;
        "date off") conf_set date no ;;
        "")
            echo "The bar shows: $(date +"$(bar_format)")"
            read -r -p "Show the date? [y/n, Enter keeps $([ "$(conf_get date)" = no ] && echo n || echo y)] " d || return 0
            case $d in y* | Y*) conf_set date yes ;; n* | N*) conf_set date no ;; esac
            ;;
        *) err "usage: timer clock [date on|date off]"; return 1 ;;
    esac
    refresh_bar
    echo "The bar shows: $(date +"$(bar_format)")"
}

# bar_format: the date(1) format for the setting (sway-status does the same)
bar_format() {
    if [ "$(conf_get date)" = no ]; then echo '%a %-I:%M %p'; else echo '%a %Y-%m-%d %-I:%M %p'; fi
}

# --- how long before the screen locks ---------------------------------------
# bin/wren-idle (parts/swayidle.sh) reads the minutes from this file

LOCK_CONF=${XDG_CONFIG_HOME:-$HOME/.config}/wren/lock
IDLE=${WREN_IDLE:-$HOME/.local/bin/wren-idle}   # (a test points this elsewhere)

lock_cmd() {
    local m
    if [ ! -x "$IDLE" ]; then
        err "the screen doesn't lock by itself here (wrenctl part swayidle sets that up)"
        return 1
    fi
    if [ -z "${1:-}" ]; then
        echo "The screen turns off after $("$IDLE" --minutes) minutes idle and locks a minute later. Change it: timer lock MINUTES (1 to 120)"
        return 0
    fi
    m=${1%[mM]}
    if [[ ! $m =~ ^[0-9]{1,3}$ ]] || [ "$((10#$m))" -lt 1 ] || [ "$((10#$m))" -gt 120 ]; then
        err "'$1' isn't a number of minutes from 1 to 120, e.g. timer lock 10"
        return 1
    fi
    m=$((10#$m))
    mkdir -p "${LOCK_CONF%/*}"
    printf '# Written by Wren Linux timer lock: minutes idle before the screen turns off (it locks a minute later)\nminutes=%s\n' "$m" >"$LOCK_CONF"
    # swayidle keeps the timeouts it started with, so start it again
    if [ -n "${SWAYSOCK:-}" ] && command -v swaymsg >/dev/null; then
        pkill -x swayidle 2>/dev/null
        swaymsg exec "$IDLE" >/dev/null 2>&1
    fi
    echo "The screen now turns off after $m minutes idle (fading down for the 30 seconds before) and locks a minute later."
}

# --- the menu's screen -------------------------------------------------------
#
# One box, 72 columns by 19 rows (it fits the menu window on a 1366x768 screen
# too), centred in the window:
#   the title and today's date and time
#   this month (ncal's cal, today highlighted) | the timers and alarms running
#   what to type, on a grid of two columns, and how to close it

BOX_W=72 BOX_H=20 CAL_W=24
if [[ ${LC_ALL:-${LC_CTYPE:-${LANG:-}}} =~ [Uu][Tt][Ff]-?8 ]]; then
    B_H=─ B_V=│ B_TL=╭ B_TR=╮ B_BL=╰ B_BR=╯ B_L=├ B_R=┤ B_T=┬ B_B=┴
else
    B_H=- B_V='|' B_TL=+ B_TR=+ B_BL=+ B_BR=+ B_L=+ B_R=+ B_T=+ B_B=+
fi
BOLD=$'\033[1m' DIM=$'\033[2m' REV=$'\033[7m' OFF=$'\033[0m'

# pad TEXT WIDTH: TEXT cut or filled with spaces to WIDTH characters
pad() { local s=${1:0:$2}; printf '%s%*s' "$s" $(($2 - ${#s})) ''; }
# center TEXT WIDTH
center() { local s=${1:0:$2} l; l=$((($2 - ${#s}) / 2)); printf '%*s%s%*s' "$l" '' "$s" $(($2 - ${#s} - l)) ''; }
# rule N: N of the line character
rule() { local s; printf -v s '%*s' "$1" ''; printf '%s' "${s// /$B_H}"; }

# calendar_lines: this month as 8 lines of CAL_W columns (in CAL), today in
# reverse. cal prints a month 20 columns wide, 3 to a day.
calendar_lines() {
    local line out cell k today i=0
    CAL=()
    today=$(date +%-d)
    if command -v cal >/dev/null; then
        # (no highlighting of its own: cal marks today only on a terminal)
        while IFS= read -r line && [ $i -lt 8 ]; do
            line=$(pad "$line" 20)
            if [ $i -ge 2 ]; then
                out=''
                for k in 0 1 2 3 4 5 6; do
                    cell=${line:$((k * 3)):2}
                    if [ "${cell// /}" = "$today" ]; then out+=$REV$cell$OFF; else out+=$cell; fi
                    [ $k -eq 6 ] || out+=' '
                done
                line=$out
            elif [ $i -eq 1 ]; then
                line=$DIM$line$OFF
            else
                line=$BOLD$line$OFF
            fi
            CAL+=("  $line  ")
            i=$((i + 1))
        done < <(cal 2>/dev/null | sed -e 's/\x1b\[[0-9;]*m//g' -e 's/.\x08//g')
    else
        CAL=("$(pad '' $CAL_W)" "$(pad '' $CAL_W)" "$(center 'No calendar yet:' $CAL_W)" "$(center 'wrenctl --update' $CAL_W)")
    fi
    while [ ${#CAL[@]} -lt 8 ]; do CAL+=("$(pad '' $CAL_W)"); done
}

# running_lines: the timers and alarms, as 8 lines of the box's right column (in RUN)
running_lines() {
    local w=$((BOX_W - 3 - CAL_W)) i now left name
    RUN=("$BOLD$(pad '  Timers and alarms' "$w")$OFF")
    if [ ${#IDS[@]} -eq 0 ]; then
        RUN+=("$(pad '' "$w")" "$(pad '' "$w")" "$DIM$(center 'Nothing set yet:' "$w")$OFF" "$DIM$(center 'type a time below.' "$w")$OFF")
    else
        # columns: the number, the name, the time left, when it rings (Fri 12:30 PM)
        RUN+=("$DIM$(pad "     $(pad name 13)$(pad left 13)" $((w - 14)))$(printf '%12s' 'rings at')  $OFF")
        now=$(date +%s)
        for i in "${!IDS[@]}"; do
            if [ "$i" -ge 6 ] && [ ${#IDS[@]} -gt 6 ]; then
                RUN[7]=$(pad "     ...and $((${#IDS[@]} - 5)) more: timer list" "$w")
                break
            fi
            left=$((DUES[i] - now)); [ "$left" -ge 0 ] || left=0
            name=${LABELS[i]:-${KINDS[i]}}
            if [ "$left" -eq 0 ]; then left=ringing; else left=$(left_text "$left"); fi
            RUN+=("$(pad "  $((i + 1))) $(pad "$name" 12) $(pad "$left" 13)" $((w - 14)))$(printf '%12s' "$(clock_at "${DUES[i]}")")  ")
        done
    fi
    while [ ${#RUN[@]} -lt 8 ]; do RUN+=("$(pad '' "$w")"); done
}

# settle: a new window starts small and sway stretches it a moment later;
# waits (up to 1.5 s) until its size stops changing, so the screen is drawn
# for the size it ends up (as wren-menu does). Before sway has given it a
# size at all a window reports 0x0, which never counts as settled: drawn for
# that, the box sat at the left of the window.
settle() {
    local prev cur same=0 i
    prev=$(stty size 2>/dev/null)
    for ((i = 0; i < 50; i++)); do
        /bin/sleep 0.03 2>/dev/null || return 0
        cur=$(stty size 2>/dev/null)
        case $cur in '' | '0 '* | *' 0') same=0; prev=$cur; continue ;; esac
        if [ "$cur" = "$prev" ]; then
            same=$((same + 1))
            [ "$same" -lt 4 ] || return 0
        else
            same=0
            prev=$cur
        fi
    done
}

# draw_compact COLS ROWS: the same, without the box, for a window too small for
# it (a small screen, or a VM's window): every line fits COLS, nothing wraps
draw_compact() {
    local cols=$1 rows=$2 w i
    w=$((cols - 2)); [ "$w" -ge 20 ] || w=20
    MARGIN=''
    calendar_lines
    printf '\n  %s%s%s\n' "$BOLD" "$(pad "TIMER AND ALARM  $(date +'%a %Y-%m-%d  %-I:%M %p')" "$w")" "$OFF"
    # the month, if there is room for it as well as the rest
    if [ "$rows" -ge 24 ]; then
        echo
        for i in 0 1 2 3 4 5 6 7; do printf '%s\n' "${CAL[i]}"; done
    fi
    echo
    if [ ${#IDS[@]} -eq 0 ]; then
        printf '  %s\n' "$(pad 'Nothing set yet.' "$w")"
    else
        local now left name
        now=$(date +%s)
        for i in "${!IDS[@]}"; do
            left=$((DUES[i] - now)); [ "$left" -ge 0 ] || left=0
            name=${LABELS[i]:-${KINDS[i]}}
            if [ "$left" -eq 0 ]; then left=ringing; else left=$(left_text "$left"); fi
            printf '  %s\n' "$(pad "$((i + 1))) $name: $left, at $(clock_at "${DUES[i]}")" "$w")"
        done
    fi
    echo
    printf '  %s\n' "$(pad 'A time: 10m  1h30  7:30  7am (Enter)' "$w")"
    printf '  %s\n' "$(pad 'c cancel  s stop  f date  a sound' "$w")"
    if [ -x "$IDLE" ]; then printf '  %s\n' "$(pad "l lock (now $("$IDLE" --minutes) min idle)" "$w")"; fi
    printf '  %s%s%s\n' "$DIM" "$(pad 'Esc or q   close' "$w")" "$OFF"
}

# draw_screen: the whole menu screen, without the prompt
draw_screen() {
    local cols=${COLUMNS:-} rows=${LINES:-} m top i in=$((BOX_W - 2)) half
    [ -n "$cols" ] && [ -n "$rows" ] || read -r rows cols < <(stty size 2>/dev/null)
    [ "${cols:-0}" -gt 0 ] || cols=80
    [ "${rows:-0}" -gt 0 ] || rows=24
    # Too small for the box (it would wrap): the plain layout
    if [ "$cols" -lt "$BOX_W" ] || [ "$rows" -lt $((BOX_H + 2)) ]; then
        draw_compact "$cols" "$rows"
        return
    fi
    m=$(((cols - BOX_W) / 2)); [ $m -ge 0 ] || m=0
    top=$(((rows - BOX_H - 2) / 2)); [ $top -ge 0 ] || top=0
    MARGIN=$(printf '%*s' "$m" '')
    half=$((in / 2))
    calendar_lines
    running_lines
    # line TEXT: one row of the box (TEXT already IN columns wide)
    line() { printf '%s%s%s%s\n' "$MARGIN" "$B_V" "$1" "$B_V"; }
    two() { line "$(pad "    $1" "$half")$(pad "    $2" $((in - half)))"; }
    for ((i = 0; i < top; i++)); do echo; done
    printf '%s%s%s%s\n' "$MARGIN" "$B_TL" "$(rule "$in")" "$B_TR"
    local now_text
    now_text=$(date +'%a %Y-%m-%d  %-I:%M %p')
    line "  $BOLD$(pad 'TIMER AND ALARM' $((in - 4 - ${#now_text})))$OFF$now_text  "
    printf '%s%s%s%s%s%s\n' "$MARGIN" "$B_L" "$(rule $CAL_W)" "$B_T" "$(rule $((in - CAL_W - 1)))" "$B_R"
    for i in 0 1 2 3 4 5 6 7; do
        printf '%s%s%s%s%s%s\n' "$MARGIN" "$B_V" "${CAL[i]}" "$B_V" "${RUN[i]}" "$B_V"
    done
    printf '%s%s%s%s%s%s\n' "$MARGIN" "$B_L" "$(rule $CAL_W)" "$B_B" "$(rule $((in - CAL_W - 1)))" "$B_R"
    line "  $BOLD$(pad 'Type a time and press Enter, or a letter' $((in - 2)))$OFF"
    two '10m   1h30   45s' 'a timer   ("10m tea" names it)'
    two '7:30  7am    7:30pm' 'an alarm, at the next 7:30'
    two 'c    cancel one' 's    stop a ringing alarm'
    if [ -x "$IDLE" ]; then
        two 'f    date in the bar' "l    dark after $("$IDLE" --minutes) min idle"
    else
        two 'f    date in the bar' ''
    fi
    two 'a    choose its sound' ''
    line "$DIM$(center 'Esc or q   close' "$in")$OFF"
    printf '%s%s%s%s\n' "$MARGIN" "$B_BL" "$(rule "$in")" "$B_BR"
}

# --- the menu ---------------------------------------------------------------

menu() {
    local input n inputrc
    # Esc closes the menu, like every Wren Linux screen. readline reads its keys from
    # this file: your usual ones, then a lone Esc (told apart from an arrow key
    # after a tenth of a second) types q and Enter.
    inputrc=$(mktemp)
    trap 'rm -f "$inputrc"; trap - RETURN' RETURN
    # shellcheck disable=SC2016  # $include is readline's own directive
    {
        echo '$include /etc/inputrc'
        [ ! -f "$HOME/.inputrc" ] || echo "\$include $HOME/.inputrc"
        echo 'set keyseq-timeout 100'
        printf '%s\n' '"\e": "\C-a\C-kq\C-m"'
    } >"$inputrc"
    export INPUTRC=$inputrc
    settle
    while :; do
        load check
        printf '\033[H\033[2J'
        # The date is always shown here, even when the bar leaves it out: a click
        # on the time is the quick way to see today's date (Esc closes it again)
        draw_screen
        read -r -e -p "$MARGIN  > " input || return 0
        # shellcheck disable=SC2086  # the words are meant to split
        set -- $input
        case ${1:-} in
            "" | q | quit | exit) return 0 ;;
            s | stop) if stop_ringing; then echo "  Stopped the sound."; else echo "  Nothing is ringing."; fi ;;
            c | cancel)
                if [ ${#IDS[@]} -eq 0 ]; then echo "  Nothing to cancel."
                else
                    n=${2:-}
                    [ ${#IDS[@]} -gt 1 ] || n=1
                    [ -n "$n" ] || read -r -p "  Which number (or all)? " n || continue
                    cancel_cmd "$n"
                fi ;;
            f | format | clock) echo; clock_cmd ;;
            a | sound) echo; sound_cmd ;;
            l | lock)
                n=${2:-}
                [ -n "$n" ] || read -r -p "  Screen off after how many minutes idle, locked a minute later (1 to 120)? " n || continue
                [ -z "$n" ] || lock_cmd "$n" ;;
            *) set_cmd "$@" ;;
        esac
        sleep 1.6
    done
}

usage() { sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; }

case ${1:-} in
    "") menu ;;
    -h | --help | help) usage ;;
    list | ls) load check; if [ ${#IDS[@]} -gt 0 ]; then show_list; else echo "Nothing is running."; fi ;;
    cancel | stop)
        # "stop" ends a ringing sound first; with nothing ringing it cancels, like "cancel"
        if [ "$1" = stop ] && stop_ringing; then echo "Stopped the sound."; exit 0; fi
        if [ -n "${2:-}" ]; then cancel_cmd "$2"
        else
            load check
            if [ ${#IDS[@]} -eq 1 ]; then cancel_cmd 1
            elif [ ${#IDS[@]} -eq 0 ]; then echo "Nothing is running."
            else show_list; err "which one? timer cancel N, or timer cancel all"; exit 1; fi
        fi ;;
    clock) shift; clock_cmd "$@" ;;
    sound) shift; sound_cmd "$@" ;;
    lock) shift; lock_cmd "$@" ;;
    fire) fire "${2:-}" ;;
    next) next ;;
    seconds) parse_duration "${2:-}" ;;   # (used by the tests)
    screen) load check; draw_screen ;;    # (used by the tests)
    *) set_cmd "$@" ;;
esac
