replace the old dotfiles with the sakura line rice

The bare $HOME repo is gone. This is now the rice: install.sh, rice-*
scripts in bin/, configs in home/ linked into $HOME, theme templates, st
and the particle wallpaper. Clone to ~/rice and run install.sh.
This commit is contained in:
Sarthak 2026-09-14 16:12:09 +05:30
commit 7f41cf7a82
104 changed files with 8077 additions and 0 deletions

3
bin/rice-about Executable file
View file

@ -0,0 +1,3 @@
#!/usr/bin/env bash
# rice-about - system details (fastfetch) in a floating terminal. Any key closes it.
exec st -c rice-about -e bash -c 'clear; fastfetch; printf "\n \e[2mpress any key\e[0m"; read -rsn1'

61
bin/rice-audio Executable file
View file

@ -0,0 +1,61 @@
#!/usr/bin/env bash
# rice-audio - audio state for the eww panel, and the actions it runs.
#
# rice-audio watch JSON on every change (eww deflisten)
# rice-audio sink <name> default output; playing streams move with it
# rice-audio source <name> default input
# rice-audio app-volume <index> <pct> volume of one playing app
# rice-audio app-mute <index>
# rice-audio mic <pct> | mic-mute
set -uo pipefail
snapshot() {
jq -cn \
--arg dsink "$(pactl get-default-sink 2>/dev/null)" \
--arg dsrc "$(pactl get-default-source 2>/dev/null)" \
--argjson sinks "$(pactl -f json list sinks 2>/dev/null || echo '[]')" \
--argjson sources "$(pactl -f json list sources 2>/dev/null || echo '[]')" \
--argjson apps "$(pactl -f json list sink-inputs 2>/dev/null || echo '[]')" '
def pct: [.volume[]?.value_percent | rtrimstr("%") | tonumber] | if length > 0 then (add / length | round) else 0 end;
def level: {name: (.description // ""), vol: pct, muted: (.mute // false)};
{
sink: (($sinks | map(select(.name == $dsink)) | .[0]) // {} | level),
source: (($sources | map(select(.name == $dsrc)) | .[0]) // {} | level),
sinks: [ $sinks[] | {index, name, desc: .description, default: (.name == $dsink)} ],
sources: [ $sources[] | select(.name | endswith(".monitor") | not)
| {index, name, desc: .description, default: (.name == $dsrc)} ],
apps: [ $apps[] | {id: .index,
name: (.properties["application.name"] // .properties["media.name"] // "app"),
vol: pct, muted: .mute} ]
}'
}
watch() {
snapshot
pactl subscribe 2>/dev/null |
grep --line-buffered -E "'(change|new|remove)' on (sink|source|sink-input|server)" |
while read -r _; do
# A slider drag fires dozens of events; wait for the burst to end.
while read -r -t 0.08 _; do :; done
snapshot
done
}
# Panel buttons pass numeric indexes, never device names, so nothing a
# device reports about itself ends up inside a shell command.
num() { [[ ${1:-} =~ ^[0-9]+$ ]] || { echo "rice-audio: expected a number, got '${1:-}'" >&2; exit 2; }; }
case "${1:-}" in
watch) watch ;;
sink)
num "${2:-}"
pactl set-default-sink "$2"
pactl -f json list sink-inputs | jq -r '.[].index' | while read -r i; do pactl move-sink-input "$i" "$2"; done
;;
source) num "${2:-}"; pactl set-default-source "$2" ;;
app-volume) num "${2:-}"; num "${3:-}"; pactl set-sink-input-volume "$2" "$3%" ;;
app-mute) num "${2:-}"; pactl set-sink-input-mute "$2" toggle ;;
mic) num "${2:-}"; wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SOURCE@ "$2%" ;;
mic-mute) rice-volume mic-mute ;;
*) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac

21
bin/rice-bar Executable file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
# rice-bar - polybar on every connected monitor.
#
# rice-bar start (re)launch the bar
# rice-bar toggle hide or show it without restarting
set -uo pipefail
start() {
pkill -u "$UID" -x polybar
while pgrep -u "$UID" -x polybar >/dev/null; do sleep 0.1; done
local m
for m in $(xrandr --query | awk '/ connected/ {print $1}'); do
MONITOR="$m" setsid -f polybar --reload top >/dev/null 2>&1
done
}
case "${1:-start}" in
start) start ;;
toggle) if pgrep -u "$UID" -x polybar >/dev/null; then polybar-msg cmd toggle >/dev/null; else start; fi ;;
*) echo "usage: rice-bar start|toggle" >&2; exit 2 ;;
esac

100
bin/rice-bt Executable file
View file

@ -0,0 +1,100 @@
#!/usr/bin/env bash
# rice-bt - bluetooth state for the eww panel, and the actions it runs.
#
# rice-bt list JSON: power, discovery, paired and nearby devices
# rice-bt power on|off|toggle
# rice-bt scan look for new devices for 15 seconds
# rice-bt toggle <MAC> connect or disconnect; new devices are paired and trusted first
# rice-bt forget <MAC>
#
# Device names are chosen by the device, so the panel only ever passes MAC
# addresses, and every MAC is checked before it is used.
set -uo pipefail
run="${XDG_RUNTIME_DIR:-/tmp}/rice"
mkdir -p "$run"
say() { rice-notify send "$1" "${2:-}" -i bluetooth; }
poke() { date +%s%N > "$run/bt.rev"; }
mac_ok() { [[ ${1:-} =~ ^([0-9A-F]{2}:){5}[0-9A-F]{2}$ ]] || { echo "rice-bt: not a MAC address: '${1:-}'" >&2; exit 2; }; }
powered() { bluetoothctl show 2>/dev/null | grep -q 'Powered: yes'; }
glyph() {
case "$1" in
audio-headset|audio-headphones) echo 󰋋 ;;
audio-card) echo 󰓃 ;;
input-keyboard) echo 󰌌 ;;
input-mouse) echo 󰍽 ;;
input-gaming) echo 󰊴 ;;
phone) echo 󰏲 ;;
computer) echo 󰍹 ;;
*) echo 󰂯 ;;
esac
}
device_json() {
local mac="$1" info name icon paired connected battery
info=$(bluetoothctl info "$mac" 2>/dev/null)
name=$(sed -n 's/^\s*Alias: //p' <<<"$info" | head -n1)
icon=$(sed -n 's/^\s*Icon: //p' <<<"$info" | head -n1)
paired=$(grep -q 'Paired: yes' <<<"$info" && echo true || echo false)
connected=$(grep -q 'Connected: yes' <<<"$info" && echo true || echo false)
battery=$(sed -n 's/^\s*Battery Percentage: .*(\([0-9]*\))/\1/p' <<<"$info" | head -n1)
jq -cn --arg mac "$mac" --arg name "${name:-$mac}" --arg glyph "$(glyph "$icon")" \
--argjson paired "$paired" --argjson connected "$connected" --arg battery "$battery" \
'{mac: $mac, name: $name, glyph: $glyph, paired: $paired, connected: $connected,
battery: (if $battery == "" then "" else $battery + "%" end),
sub: (if $connected then "connected" elif $paired then "paired" else "tap to pair" end)}'
}
list() {
local on=false discovering=false paired="[]" nearby="[]" mac
if powered; then
on=true
bluetoothctl show 2>/dev/null | grep -q 'Discovering: yes' && discovering=true
paired=$(bluetoothctl devices Paired 2>/dev/null | awk '{ print $2 }' | grep -E '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' |
while read -r mac; do device_json "$mac"; done | jq -cs 'sort_by(.connected | not)')
nearby=$(comm -23 <(bluetoothctl devices 2>/dev/null | awk '{ print $2 }' | sort) \
<(bluetoothctl devices Paired 2>/dev/null | awk '{ print $2 }' | sort) |
grep -E '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' | head -n 8 |
while read -r mac; do device_json "$mac"; done | jq -cs '[.[] | select(.name != .mac)]')
fi
jq -cn --argjson powered "$on" --argjson discovering "$discovering" \
--argjson paired "$paired" --argjson nearby "$nearby" \
'{powered: $powered, discovering: $discovering, paired: $paired, nearby: $nearby,
connected: ([$paired[] | select(.connected)] | length)}'
}
case "${1:-}" in
list) list ;;
power)
case "${2:-toggle}" in
on) rfkill unblock bluetooth 2>/dev/null; bluetoothctl power on >/dev/null ;;
off) bluetoothctl power off >/dev/null ;;
*) if powered; then bluetoothctl power off >/dev/null; else rfkill unblock bluetooth 2>/dev/null; bluetoothctl power on >/dev/null; fi ;;
esac
poke ;;
scan)
powered || bluetoothctl power on >/dev/null
setsid -f bluetoothctl --timeout 15 scan on >/dev/null 2>&1
poke ;;
toggle)
mac_ok "${2:-}"; mac="$2"
info=$(bluetoothctl info "$mac" 2>/dev/null)
name=$(sed -n 's/^\s*Alias: //p' <<<"$info" | head -n1)
if grep -q 'Connected: yes' <<<"$info"; then
bluetoothctl disconnect "$mac" >/dev/null && say "Disconnected" "$name"
else
if ! grep -q 'Paired: yes' <<<"$info"; then
bluetoothctl --timeout 20 pair "$mac" >/dev/null 2>&1 || { say "Pairing failed" "$name"; exit 1; }
bluetoothctl trust "$mac" >/dev/null
fi
bluetoothctl --timeout 15 connect "$mac" >/dev/null 2>&1 && say "Connected" "$name" || say "Could not connect" "$name"
fi
poke ;;
forget)
mac_ok "${2:-}"
bluetoothctl remove "$2" >/dev/null && say "Forgot device" "$2"
poke ;;
*) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac

219
bin/rice-cal Executable file
View file

@ -0,0 +1,219 @@
#!/usr/bin/env bash
# rice-cal - data and actions for the calendar panel. Events live in khal,
# synced with the CalDAV server by vdirsyncer; reminders come from rice-reminder.
#
# rice-cal month month grid JSON (ISO weeks, Monday first); marks days with events
# rice-cal agenda events and reminders for the selected day
# rice-cal select <YYYY-MM-DD> pick a day
# rice-cal shift <-1|1> previous / next month
# rice-cal today
# rice-cal add new event on the selected day, typed in rofi:
# "15:00 dentist" "15:00-16:30 review" "holiday" (all day)
# timed events remind you 10 minutes before; end with !30m or !no
# rice-cal sync sync with the server in the background
# rice-cal setup connect a CalDAV server (asks for URL, user, password; run in a terminal)
set -uo pipefail
e() { eww -c "$HOME/.config/eww" "$@"; }
var() { e get "$1" 2>/dev/null; }
have_khal() { command -v khal >/dev/null && [[ -f ${XDG_CONFIG_HOME:-$HOME/.config}/khal/config ]]; }
isodate() { [[ ${1:-} =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] && date -d "$1" +%F >/dev/null 2>&1; }
selected() { local d; d=$(var cal_day); isodate "$d" && echo "$d" || date +%F; }
offset() { local o; o=$(var cal_offset); [[ $o =~ ^-?[0-9]+$ ]] && echo "$o" || echo 0; }
reminders() { rice-reminder json 2>/dev/null || echo '[]'; }
month() {
local off="${1:-$(offset)}" sel="${2:-$(selected)}" first start end counts="{}"
first=$(date -d "$(date +%Y-%m-01) $off month" +%F)
start=$(date -d "$first -$(( $(date -d "$first" +%u) - 1 )) days" +%F)
end=$(date -d "$start +42 days" +%F)
if have_khal; then
counts=$(khal list --once --day-format "" --format "{start-date}" "$start" "$end" 2>/dev/null |
grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' | sort | uniq -c |
jq -R -s -c 'split("\n") | map(select(length > 0) | ltrimstr(" ") | capture("^ *(?<n>[0-9]+) (?<d>.+)$") | {(.d): (.n | tonumber)}) | add // {}')
fi
for i in $(seq 0 41); do date -d "$start +$i days" '+%F %-d %m %u %V'; done |
jq -R -s -c --arg title "$(date -d "$first" '+%B %Y')" --arg today "$(date +%F)" --arg sel "$sel" \
--arg month "$(date -d "$first" +%m)" --argjson counts "$counts" --argjson rem "$(reminders)" '
($rem | group_by(.date) | map({(.[0].date): length}) | add // {}) as $rc |
split("\n") | map(select(length > 0) | split(" ")) as $days |
{
title: $title,
dow: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"],
weeks: [ range(0; 6) as $w | $days[$w * 7:($w + 1) * 7] | {
wk: (.[0][4] | tonumber | tostring),
days: map({
date: .[0], d: .[1],
other: (.[2] != $month),
weekend: ((.[3] | tonumber) >= 6),
today: (.[0] == $today),
selected: (.[0] == $sel and .[0] != $today),
events: (($counts[.[0]] // 0) + ($rc[.[0]] // 0))
})
} ]
}'
}
agenda() {
local day="${1:-$(selected)}" heading items="[]" status="" sep=$'\x1f'
heading=$(date -d "$day" '+%A %-d %B')
[[ $day == "$(date +%F)" ]] && heading="Today · $heading"
if have_khal; then
items=$(khal list --day-format "" --format "{start-time}${sep}{end-time}${sep}{title}${sep}{location}" "$day" 1d 2>/dev/null |
jq -R -s -c --arg sep "$sep" '
split("\n") | map(select(length > 0) | split($sep) | {
kind: "event", id: "",
time: (if .[0] == "" then "all day" else .[0] end),
title: .[2],
sub: ([(if .[0] != "" and .[1] != "" then "until " + .[1] else empty end),
(if (.[3] // "") != "" then .[3] else empty end)] | join(" · "))
})')
else
status="Events need khal set up (run install.sh)"
fi
jq -cn --arg heading "$heading" --arg status "$status" --arg day "$day" \
--argjson items "${items:-[]}" --argjson rem "$(reminders)" '
{heading: $heading, status: $status,
items: ($items + [ $rem[] | select(.date == $day) | {kind: "reminder", id: .id, time: .time, title: .text, sub: "reminder"} ]
| sort_by(if .time == "all day" then "00:00" else .time end))}'
}
# Push new data straight into eww so clicks feel instant.
refresh() {
local off="$1" day="$2"
e update cal_offset="$off" cal_day="$day"
e update month="$(month "$off" "$day")" agenda="$(agenda "$day")"
}
add() {
have_khal || { rice-notify send "Calendar" "Events need khal set up (run install.sh)"; exit 1; }
local day input remind=10m start="" end="" title
day=$(selected)
input=$(rofi -dmenu -theme "$HOME/.config/rofi/prompt.rasi" -p "$(date -d "$day" '+%a %-d %b')" \
-mesg "15:00 dentist · 15:00-16:30 review · holiday · end with !30m or !no") || exit 0
[[ -n $input ]] || exit 0
if [[ $input =~ ^(.*[^[:space:]])[[:space:]]+!(no|[0-9]+[mhd])$ ]]; then
input="${BASH_REMATCH[1]}"; remind="${BASH_REMATCH[2]}"
fi
if [[ $input =~ ^([0-9]{1,2}:[0-9]{2})(-([0-9]{1,2}:[0-9]{2}))?[[:space:]]+(.+)$ ]]; then
start="${BASH_REMATCH[1]}"; end="${BASH_REMATCH[3]}"; title="${BASH_REMATCH[4]}"
khal new "$day" "$start" "${end:-1h}" "$title" >/dev/null 2>&1 || { rice-notify send "Calendar" "khal could not add that"; exit 1; }
if [[ $remind != no ]]; then
local amount="${remind%?}" unit
case "${remind: -1}" in m) unit=minutes ;; h) unit=hours ;; d) unit=days ;; esac
at=$(date -d "$day $start $amount $unit ago" '+%F %H:%M' 2>/dev/null) &&
rice-reminder add "$at" "$title at $start" >/dev/null 2>&1
fi
else
title="$input"
khal new "$day" "$title" >/dev/null 2>&1 || { rice-notify send "Calendar" "khal could not add that"; exit 1; }
fi
rice-notify send "Event added" "$title · $(date -d "$day" '+%a %-d %b') ${start:-all day}"
setsid -f vdirsyncer sync >/dev/null 2>&1
refresh "$(offset)" "$day"
}
setup() {
local conf="${XDG_CONFIG_HOME:-$HOME/.config}" share="${XDG_DATA_HOME:-$HOME/.local/share}"
local secrets="$conf/rice/secrets" data="$share/calendars" url user pass first
command -v vdirsyncer >/dev/null && command -v khal >/dev/null ||
{ echo "rice-cal: install khal and vdirsyncer first"; return 1; }
read -rp "CalDAV URL [https://dav.srtk.in/dav.php/]: " url </dev/tty
url="${url:-https://dav.srtk.in/dav.php/}"
[[ $url =~ ^https://[^\"[:space:]]+$ ]] || { echo "rice-cal: use an https:// URL"; return 1; }
read -rp "Username: " user </dev/tty
[[ $user =~ ^[A-Za-z0-9._@+-]+$ ]] || { echo "rice-cal: that username has characters vdirsyncer can't take"; return 1; }
read -rsp "Password: " pass </dev/tty; echo
# The password stays in a private file; the configs only point at it.
mkdir -p "$secrets" && chmod 700 "$secrets"
(umask 077; printf '%s' "$pass" > "$secrets/caldav")
unset pass
mkdir -p "$conf/vdirsyncer" "$conf/khal" "$data" "$share/vdirsyncer/status"
cat > "$conf/vdirsyncer/config" <<EOF
# Written by rice-cal setup. The password is read from $secrets/caldav.
[general]
status_path = "$share/vdirsyncer/status/"
[pair calendar]
a = "calendar_local"
b = "calendar_remote"
collections = ["from a", "from b"]
metadata = ["displayname", "color"]
conflict_resolution = "b wins"
[storage calendar_local]
type = "filesystem"
path = "$data/"
fileext = ".ics"
[storage calendar_remote]
type = "caldav"
url = "$url"
username = "$user"
password.fetch = ["command", "cat", "$secrets/caldav"]
EOF
echo "Connecting to $url ..."
yes | vdirsyncer discover calendar >/dev/null || { echo "rice-cal: could not sign in to $url with those details"; return 1; }
vdirsyncer metasync >/dev/null 2>&1
vdirsyncer sync || { echo "rice-cal: first sync failed"; return 1; }
first=$(find "$data" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort | head -n1)
[[ -n $first ]] || { first=personal; mkdir -p "$data/$first"; }
cat > "$conf/khal/config" <<EOF
# Written by rice-cal setup.
[calendars]
[[synced]]
path = $data/*
type = discover
[locale]
timeformat = %H:%M
dateformat = %Y-%m-%d
longdateformat = %Y-%m-%d
datetimeformat = %Y-%m-%d %H:%M
longdatetimeformat = %Y-%m-%d %H:%M
firstweekday = 0
[default]
default_calendar = $first
highlight_event_days = True
EOF
systemctl --user enable --now vdirsyncer.timer >/dev/null 2>&1
echo "Calendar ready: new events go to '$first', and it syncs every 15 minutes."
}
case "${1:-}" in
setup) setup ;;
month) month ;;
agenda) agenda ;;
select)
isodate "${2:-}" || { echo "rice-cal: select takes YYYY-MM-DD" >&2; exit 2; }
now=$(date +%Y*12+%-m | sed 's/^0*//'); then=$(date -d "$2" +%Y*12+%-m)
refresh "$(( then - now ))" "$2"
;;
shift)
[[ ${2:-} =~ ^-?[0-9]+$ ]] || { echo "rice-cal: shift takes a number" >&2; exit 2; }
off=$(( $(offset) + $2 ))
if (( off == 0 )); then day=$(date +%F); else day=$(date -d "$(date +%Y-%m-01) $off month" +%F); fi
refresh "$off" "$day"
;;
today) refresh 0 "$(date +%F)" ;;
add) add ;;
sync) setsid -f vdirsyncer sync >/dev/null 2>&1 ;;
*) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac

212
bin/rice-capture Executable file
View file

@ -0,0 +1,212 @@
#!/usr/bin/env bash
# rice-capture - screenshots, recordings, OCR, QR codes, colours, webcam, transcoding.
#
# rice-capture region flameshot as usual: drag, annotate, Ctrl+C copies, Ctrl+S saves
# rice-capture screen the whole screen, saved and copied
# rice-capture text copy the text inside a region (OCR)
# rice-capture qr copy what a QR code inside a region says
# rice-capture color copy the colour under the cursor
# rice-capture record start a recording (asks what to record), or stop the running one
# rice-capture webcam webcam overlay on/off, for recordings
# rice-capture transcode shrink a recent picture or video for sharing
#
# Full-screen shots land in Pictures/Screenshots, recordings in Videos/Recordings.
# Recordings and transcodes end with a notification: click it to open the file.
set -uo pipefail
run="${XDG_RUNTIME_DIR:-/tmp}/rice"
mkdir -p "$run"
pics="$(xdg-user-dir PICTURES 2>/dev/null || echo "$HOME/Pictures")"; pics="${pics%/}"
vids="$(xdg-user-dir VIDEOS 2>/dev/null || echo "$HOME/Videos")"; vids="${vids%/}"
shots="$pics/Screenshots"
recordings="$vids/Recordings"
stamp() { date +%Y-%m-%d_%H-%M-%S; }
menu() { rofi -dmenu -i -no-custom -format i -theme "$HOME/.config/rofi/menu.rasi" -p "$1"; }
# The selection rectangle uses the theme's accent.
slop_opts() {
local hex; hex=$(rice-theme get accent); hex=${hex#\#}
awk -v r=$((16#${hex:0:2})) -v g=$((16#${hex:2:2})) -v b=$((16#${hex:4:2})) \
'BEGIN { printf "-b 2 -l -c %.2f,%.2f,%.2f,0.18", r / 255, g / 255, b / 255 }'
}
# Select a region into a temp PNG; prints its path, or nothing if cancelled.
grab_region() {
local img; img=$(mktemp --suffix .png -p "$run")
# shellcheck disable=SC2046
maim -s -u $(slop_opts) "$img" 2>/dev/null
if [[ -s $img ]]; then echo "$img"; else rm -f "$img"; fi
}
# announce <file> <title> [icon] - notification whose click opens the file.
announce() {
local file="$1" title="$2" icon="${3:-$1}"
(
action=$(notify-send -a rice -i "$icon" -A default=Open -A folder="Show in folder" -A delete=Delete \
"$title" "$(basename "$file") · copied")
case "$action" in
default) xdg-open "$file" ;;
folder) thunar "$(dirname "$file")" ;;
delete) rm -f -- "$file" && notify-send -a rice -u low "Deleted" "$(basename "$file")" ;;
esac
) >/dev/null 2>&1 &
}
# Screenshots reach the clipboard through flameshot, never xclip: flameshot (Qt)
# answers paste requests properly, while pasting a PNG held by xclip crashed
# Brave and st. Any flag on `flameshot gui` (--raw, --path, --clipboard) also
# swaps its Copy/Save/Pin buttons and Ctrl+C for a single accept button.
region() {
flameshot gui
}
screen() {
mkdir -p "$shots"
flameshot full --clipboard --path "$shots/screenshot-$(stamp).png"
}
text() {
command -v tesseract >/dev/null || { rice-notify send "Text capture" "Install tesseract to read text"; exit 1; }
local img out
img=$(grab_region); [[ -n $img ]] || exit 0
out=$(tesseract "$img" stdout -l "${RICE_OCR_LANGS:-eng}" --psm 6 2>/dev/null | sed -e 's/[[:space:]]*$//' -e '/./,$!d')
rm -f "$img"
[[ -n $out ]] || { rice-notify send "Text capture" "No text found in that region"; exit 1; }
printf '%s' "$out" | xclip -selection clipboard
rice-notify send "Text copied" "$(head -c 160 <<<"$out")"
}
qr() {
local img out
img=$(grab_region); [[ -n $img ]] || exit 0
out=$(zbarimg -q --raw -Sdisable -Sqrcode.enable "$img" 2>/dev/null)
rm -f "$img"
[[ -n $out ]] || { rice-notify send "QR code" "No QR code in that region"; exit 1; }
printf '%s' "$out" | xclip -selection clipboard
rice-notify send "QR code copied" "It is on the clipboard (and in clipboard history)"
}
color() {
local hex swatch="$run/swatch.png"
hex=$(xcolor --format hex 2>/dev/null) || exit 0
[[ $hex =~ ^#[0-9a-fA-F]{6}$ ]] || exit 0
printf '%s' "$hex" | xclip -selection clipboard
magick -size 64x64 "xc:$hex" "$swatch" 2>/dev/null
notify-send -a rice -u low -i "$swatch" "Colour copied" "$hex"
}
record() {
if pgrep -x gpu-screen-reco >/dev/null; then
local file thumb="$run/recording.jpg"
file=$(cat "$run/recording.path" 2>/dev/null)
pkill -INT -x gpu-screen-reco
for _ in {1..80}; do pgrep -x gpu-screen-reco >/dev/null || break; sleep 0.1; done
rm -f "$run/recording"
if [[ -s $file ]]; then
ffmpeg -y -loglevel error -ss 1 -i "$file" -frames:v 1 -vf scale=320:-1 "$thumb" 2>/dev/null
printf 'file://%s\n' "$file" | xclip -selection clipboard -t text/uri-list
announce "$file" "Recording saved" "$thumb"
fi
return
fi
local audio target file args
audio=$(printf '%s\n' "󰝟 No audio" "󰕾 Desktop audio" "󰍬 Desktop audio and microphone" | menu "Record") || exit 0
target=$(printf '%s\n' "󰍹 Whole screen" "󰩭 A region (drag it)" "󰖯 One window (click it)" | menu "Record") || exit 0
mkdir -p "$recordings"
file="$recordings/recording-$(stamp).mp4"
args=(-f 60 -c mp4 -o "$file")
case "$audio" in
1) args+=(-a default_output) ;;
2) args+=(-a "default_output|default_input") ;;
esac
case "$target" in
0)
# gpu-screen-recorder 6 records a monitor by name: pick the one under the pointer.
local X Y mon
eval "$(xdotool getmouselocation --shell 2>/dev/null)"
mon=$(xrandr --query | awk -v x="${X:-0}" -v y="${Y:-0}" '
/ connected/ { for (i = 3; i <= NF; i++) if ($i ~ /^[0-9]+x[0-9]+\+[0-9]+\+[0-9]+$/) {
split($i, g, /[x+]/)
if (x >= g[3] && x < g[3] + g[1] && y >= g[4] && y < g[4] + g[2]) { print $1; exit }
} }')
[[ -n $mon ]] || mon=$(gpu-screen-recorder --list-monitors 2>/dev/null | head -n1 | cut -d'|' -f1)
[[ -n $mon ]] || { rice-notify send "Recording" "Found no monitor to record"; exit 1; }
args+=(-w "$mon")
;;
1)
local geo
# shellcheck disable=SC2046
geo=$(slop $(slop_opts) -f '%wx%h+%x+%y' 2>/dev/null) || exit 0
args+=(-w region -region "$geo")
;;
2)
local wid
wid=$(xdotool selectwindow 2>/dev/null) || exit 0
args+=(-w "$wid")
;;
esac
echo "$file" > "$run/recording.path"
touch "$run/recording"
setsid -f gpu-screen-recorder "${args[@]}" > "$run/recording.log" 2>&1
notify-send -a rice -u low -t 1500 "Recording" "Stop with alt + PrtSc or the REC indicator"
}
webcam() {
local pid dev
if [[ -f $run/webcam.pid ]] && pid=$(cat "$run/webcam.pid") && kill -0 "$pid" 2>/dev/null; then
kill "$pid"; rm -f "$run/webcam.pid"; return
fi
dev=$(ls /dev/video* 2>/dev/null | head -n1)
[[ -n $dev ]] || { rice-notify send "Webcam" "No camera found"; exit 1; }
mpv --x11-name=rice-webcam --title=rice-webcam --profile=low-latency --untimed --no-osc --no-audio \
--no-input-default-bindings --input-conf=/dev/null --no-border --ontop \
--geometry=260x260-24-24 --vf=crop=ih:ih "av://v4l2:$dev" >/dev/null 2>&1 &
echo $! > "$run/webcam.pid"
}
transcode() {
local files choice file fmt out base
mapfile -t files < <(find "$pics" "$vids" -maxdepth 2 -type f \
\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.webp' -o -iname '*.heic' \
-o -iname '*.mp4' -o -iname '*.mov' -o -iname '*.mkv' -o -iname '*.webm' \) \
-printf '%T@\t%p\n' 2>/dev/null | sort -rn | head -n 40 | cut -f2-)
(( ${#files[@]} > 0 )) || { rice-notify send "Transcode" "No pictures or videos in Pictures or Videos"; exit 1; }
choice=$(for f in "${files[@]}"; do basename "$f"; done | menu "Transcode") || exit 0
file="${files[$choice]}"
base="${file%.*}"
case "$(file -b --mime-type "$file")" in
image/*)
fmt=$(printf '%s\n' "jpg · 2160px wide" "jpg · 1080px wide" "png · 1080px wide" | menu "Picture") || exit 0
case "$fmt" in
0) out="$base-2160px.jpg"; magick "$file" -resize '2160x>' -quality 85 -strip "$out" ;;
1) out="$base-1080px.jpg"; magick "$file" -resize '1080x>' -quality 85 -strip "$out" ;;
2) out="$base-1080px.png"; magick "$file" -resize '1080x>' -strip "$out" ;;
esac
;;
video/*)
fmt=$(printf '%s\n' "mp4 · 1080p" "mp4 · 720p" "gif · 720p" | menu "Video") || exit 0
rice-notify send "Transcoding" "$(basename "$file")"
case "$fmt" in
0) out="$base-1080p.mp4"; ffmpeg -y -loglevel error -i "$file" -vf "scale=-2:'min(1080,ih)'" -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k -movflags +faststart "$out" ;;
1) out="$base-720p.mp4"; ffmpeg -y -loglevel error -i "$file" -vf "scale=-2:'min(720,ih)'" -c:v libx264 -crf 24 -preset medium -c:a aac -b:a 128k -movflags +faststart "$out" ;;
2) out="$base-720p.gif"; ffmpeg -y -loglevel error -i "$file" -vf "fps=15,scale=-2:'min(720,ih)':flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse" "$out" ;;
esac
;;
*) rice-notify send "Transcode" "That is not a picture or a video"; exit 1 ;;
esac
if [[ -s ${out:-} ]]; then
printf 'file://%s\n' "$out" | xclip -selection clipboard -t text/uri-list
announce "$out" "Transcoded"
else
rice-notify send "Transcode" "Conversion failed"
fi
}
case "${1:-}" in
region|screen|text|qr|color|record|webcam|transcode) "$1" ;;
*) sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac

18
bin/rice-clip Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# rice-clip copy|paste - one copy/paste shortcut for terminals and GUI apps.
# Terminals want Ctrl+Shift+C/V; everything else wants Ctrl+C/V.
set -uo pipefail
class=$(xdotool getactivewindow getwindowclassname 2>/dev/null)
case "${class,,}" in
st|st-256color|kitty|alacritty|xterm|urxvt|org.wezfurlong.wezterm|com.mitchellh.ghostty|rice-float|rice-scratchpad|rice-about)
mods=ctrl+shift ;;
*)
mods=ctrl ;;
esac
case "${1:-}" in
copy) xdotool key --clearmodifiers "$mods+c" ;;
paste) xdotool key --clearmodifiers "$mods+v" ;;
*) echo "usage: rice-clip copy|paste" >&2; exit 2 ;;
esac

11
bin/rice-clipboard Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
# rice-clipboard - clipboard history (greenclip). Picking an entry puts it back on
# the clipboard, ready for super + v. rofi runs in dmenu mode so the header reads
# "Clipboard": custom rofi modes ignore a custom title and show their mode name.
pgrep -u "$UID" -x greenclip >/dev/null || setsid -f greenclip daemon >/dev/null 2>&1
choice=$(greenclip print | sed '/^$/d' |
rofi -dmenu -i -no-custom -p "Clipboard" -theme "$HOME/.config/rofi/menu.rasi" \
-theme-str 'window { width: 640px; }') || exit 0
[[ -n $choice ]] && greenclip print "$choice"

51
bin/rice-dictate Executable file
View file

@ -0,0 +1,51 @@
#!/usr/bin/env bash
# rice-dictate - offline voice typing into the focused window (nerd-dictation).
#
# rice-dictate toggle start listening, or stop and finish typing
# rice-dictate status on / off
#
# The first run downloads a small English speech model (about 40 MB) into
# ~/.config/nerd-dictation/model. Drop a different VOSK model there to change language.
set -uo pipefail
run="${XDG_RUNTIME_DIR:-/tmp}/rice"
model_dir="${XDG_CONFIG_HOME:-$HOME/.config}/nerd-dictation/model"
model_url="https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip"
mkdir -p "$run"
listening() { pgrep -u "$UID" -f 'nerd-dictation begin' >/dev/null; }
poke() { date +%s%N > "$run/dictation.rev"; }
fetch_model() {
local tmp
rice-notify send "Dictation" "Downloading the speech model (about 40 MB)…"
tmp=$(mktemp -d) || return 1
if curl -fsSL -o "$tmp/model.zip" "$model_url" && unzip -q "$tmp/model.zip" -d "$tmp"; then
mkdir -p "$(dirname "$model_dir")"
mv "$tmp"/vosk-model-* "$model_dir"
rm -rf "$tmp"
rice-notify send "Dictation" "Model ready. Press super + ctrl + x again to start."
else
rm -rf "$tmp"
rice-notify send "Dictation" "Could not download the speech model"
return 1
fi
}
case "${1:-toggle}" in
toggle)
command -v nerd-dictation >/dev/null || { rice-notify send "Dictation" "Install nerd-dictation-git to dictate"; exit 1; }
if listening; then
nerd-dictation end
poke
elif [[ ! -d $model_dir ]]; then
fetch_model
else
setsid -f nerd-dictation begin --simulate-input-tool=XDOTOOL >/dev/null 2>&1
poke
notify-send -a rice -u low -t 1500 "Dictation" "Listening. super + ctrl + x to stop."
fi
;;
status) listening && echo on || echo off ;;
*) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac

112
bin/rice-display Executable file
View file

@ -0,0 +1,112 @@
#!/usr/bin/env bash
# rice-display - monitor state for the eww panel, and the actions it runs.
#
# rice-display info JSON: output, modes, refresh rates, DDC brightness
# rice-display brightness <0-100>
# rice-display mode <id> switch resolution (reverts in 15s unless you keep it)
# rice-display rate <id> switch refresh rate at the current resolution
set -uo pipefail
run="${XDG_RUNTIME_DIR:-/tmp}/rice"
modes_tsv="$run/modes.tsv"
rates_tsv="$run/rates.tsv"
bright_cache="$run/brightness"
mkdir -p "$run"
say() { rice-notify send "$1" "${2:-}" -i video-display; }
primary() {
xrandr --query | awk '/ connected primary/ { print $1; exit } / connected/ && !first { first = $1 } END { if (first) print first }' | head -n1
}
# "WxH<TAB>rate<TAB>current(1/0)" for every mode of the output, xrandr order.
mode_lines() {
xrandr --query | awk -v out="$1" '
$1 == out { inblock = 1; next }
inblock && /^[^ ]/ { exit }
inblock {
mode = $1
for (i = 2; i <= NF; i++) {
r = $i; cur = (r ~ /\*/) ? 1 : 0; gsub(/[*+]/, "", r)
if (r != "") printf "%s\t%s\t%d\n", mode, r, cur
}
}'
}
# DDC is slow (up to a second per read), so reads are cached for a minute.
brightness() {
if [[ -f $bright_cache ]] && (( $(date +%s) - $(stat -c %Y "$bright_cache") < 60 )); then
cat "$bright_cache"; return
fi
local v
v=$(ddcutil getvcp 10 --brief 2>/dev/null | awk '$1 == "VCP" { print $4; exit }')
[[ -n $v ]] && echo "$v" | tee "$bright_cache"
}
info() {
local out cur_mode cur_rate
out=$(primary)
mode_lines "$out" > "$run/all-modes.tsv"
IFS=$'\t' read -r cur_mode cur_rate _ < <(awk -F'\t' '$3 == 1' "$run/all-modes.tsv")
awk -F'\t' '!seen[$1]++ { print $1 }' "$run/all-modes.tsv" | head -n 6 |
awk -v cur="$cur_mode" '{ printf "%d\t%s\t%d\n", NR - 1, $1, ($1 == cur) }' > "$modes_tsv"
# One entry per rounded rate (monitors list 120.00, 119.98 and 119.88), keeping the active one.
awk -F'\t' -v cur="$cur_mode" '$1 == cur { printf "%d\t%s\t%d\n", n++, $2, $3 }' "$run/all-modes.tsv" |
sort -t$'\t' -k3,3nr -k2,2nr | awk -F'\t' '!seen[int($2 + 0.5)]++' |
sort -t$'\t' -k2,2nr | head -n 5 > "$rates_tsv"
local b ddc=false
if command -v ddcutil >/dev/null; then
b=$(brightness)
[[ -n $b ]] && ddc=true
fi
jq -cn --arg output "$out" --arg mode "$cur_mode" --arg rate "$cur_rate" \
--argjson ddc "$ddc" --argjson brightness "${b:-0}" \
--argjson bar "$(pgrep -x polybar >/dev/null && echo true || echo false)" \
--argjson modes "$(jq -R -s -c 'split("\n") | map(select(length > 0) | split("\t") | {id: (.[0] | tonumber), mode: .[1], current: (.[2] == "1")})' "$modes_tsv")" \
--argjson rates "$(jq -R -s -c 'split("\n") | map(select(length > 0) | split("\t") | {id: (.[0] | tonumber), label: ((.[1] | tonumber | round | tostring) + "Hz"), current: (.[2] == "1")})' "$rates_tsv")" \
'{output: $output, mode: $mode, rate: $rate, ddc: $ddc, brightness: $brightness, bar: $bar, modes: $modes, rates: $rates,
ddcHint: "Needs ddcutil and the i2c-dev module"}'
}
# Apply an xrandr change, then revert unless it is confirmed within 15 seconds.
apply_with_revert() {
local out="$1" old_mode="$2" old_rate="$3" new_mode="$4" new_rate="$5"
xrandr --output "$out" --mode "$new_mode" --rate "$new_rate" || { say "Display" "That mode did not apply"; return 1; }
local keep
keep=$(printf 'Keep %s @ %sHz\nRevert\n' "$new_mode" "$new_rate" |
timeout 15 rofi -dmenu -theme "$HOME/.config/rofi/menu.rasi" -p "Display" -mesg "Reverting in 15 seconds") || keep=""
if [[ $keep != Keep* ]]; then
xrandr --output "$out" --mode "$old_mode" --rate "$old_rate"
say "Display" "Reverted to $old_mode @ ${old_rate}Hz"
fi
}
case "${1:-}" in
info) info ;;
brightness)
[[ ${2:-} =~ ^[0-9]+$ ]] && (( $2 <= 100 )) || { echo "rice-display: brightness 0-100" >&2; exit 2; }
echo "$2" > "$bright_cache"
# Sliders fire many values; only the newest one talks to the monitor.
pkill -u "$UID" -f 'ddcutil setvcp 10' 2>/dev/null
setsid -f ddcutil setvcp 10 "$2" --noverify >/dev/null 2>&1
;;
mode|rate)
[[ ${2:-} =~ ^[0-9]+$ ]] || { echo "rice-display: $1 takes an id" >&2; exit 2; }
out=$(primary)
IFS=$'\t' read -r old_mode old_rate _ < <(mode_lines "$out" | awk -F'\t' '$3 == 1')
if [[ $1 == mode ]]; then
new_mode=$(awk -F'\t' -v id="$2" '$1 == id { print $2 }' "$modes_tsv")
new_rate=$(mode_lines "$out" | awk -F'\t' -v m="$new_mode" '$1 == m { print $2 }' | sort -nr | head -n1)
else
new_mode="$old_mode"
new_rate=$(awk -F'\t' -v id="$2" '$1 == id { print $2 }' "$rates_tsv")
fi
[[ -n $new_mode && -n $new_rate ]] || exit 1
rice-panel close
apply_with_revert "$out" "$old_mode" "$old_rate" "$new_mode" "$new_rate"
;;
*) sed -n '2,8p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac

12
bin/rice-emoji Executable file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env bash
# rice-emoji - emoji picker that types the emoji into the focused window.
theme="$HOME/.config/rofi/menu.rasi"
if [[ -f /usr/lib/rofi/emoji.so ]]; then
# The "Emoji" header comes from config.rasi (emoji { display-name }).
exec rofi -modi emoji -show emoji -emoji-mode insert \
-theme "$theme" -theme-str 'window { width: 640px; } listview { columns: 2; lines: 12; }'
elif command -v rofimoji >/dev/null; then
exec rofimoji --action type --selector-args="-theme $theme -p Emoji"
else
rice-notify send "Emoji" "Install rofi-emoji to use the picker"
fi

33
bin/rice-font Executable file
View file

@ -0,0 +1,33 @@
#!/usr/bin/env bash
# rice-font [family] - one monospace font for the whole rice: terminals, bar,
# menus, popups and notifications. Without an argument, pick from the
# installed Nerd Fonts. The choice is kept in ~/.config/rice/theme.override.toml,
# so it survives theme changes.
set -uo pipefail
override="${XDG_CONFIG_HOME:-$HOME/.config}/rice/theme.override.toml"
current=$(rice-theme get font 2>/dev/null)
mapfile -t fonts < <(fc-list :spacing=mono family | tr ',' '\n' | grep -i 'nerd font$' | grep -vi 'mono$\|propo$' | sort -u)
(( ${#fonts[@]} > 0 )) || { rice-notify send "Font" "No Nerd Fonts installed"; exit 1; }
if [[ -n ${1:-} ]]; then
font="$1"
else
active=""
for i in "${!fonts[@]}"; do [[ ${fonts[$i]} == "$current" ]] && active=$i; done
choice=$(printf '%s\n' "${fonts[@]}" |
rofi -dmenu -i -no-custom -format i -theme "$HOME/.config/rofi/menu.rasi" -p "Font" ${active:+-a "$active"}) || exit 0
font="${fonts[$choice]}"
fi
printf '%s\n' "${fonts[@]}" | grep -Fxq -- "$font" || { rice-notify send "Font" "\"$font\" is not an installed Nerd Font"; exit 1; }
mkdir -p "$(dirname "$override")"
touch "$override"
sed -i '/^font *=/d' "$override"
printf 'font = "%s"\n' "$font" >> "$override"
rice-theme reload
rice-hook font-set "$font"
rice-notify send "Font" "$font · open terminals pick it up when reopened"

28
bin/rice-hook Executable file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# rice-hook <event> [args...] - run your own scripts when something happens.
#
# Every executable file in ~/.config/rice/hooks/<event>.d/ runs, in name order,
# with the event's arguments. Events:
# post-boot the desktop has started
# theme-set <name> a theme was applied
# font-set <family> the font changed
# wallpaper-set <image> the wallpaper changed
# lock / unlock the screen locked or unlocked
# update install.sh --update finished
set -uo pipefail
event="${1:?usage: rice-hook <event> [args...]}"
shift
[[ $event =~ ^[a-z-]+$ ]] || { echo "rice-hook: bad event name '$event'" >&2; exit 2; }
dir="${XDG_CONFIG_HOME:-$HOME/.config}/rice/hooks/$event.d"
log="${XDG_STATE_HOME:-$HOME/.local/state}/rice/hooks.log"
[[ -d $dir ]] || exit 0
mkdir -p "$(dirname "$log")"
for hook in "$dir"/*; do
[[ -f $hook && -x $hook && $hook != *.sample ]] || continue
if ! "$hook" "$@" >>"$log" 2>&1; then
printf '%s %s failed (exit %s)\n' "$(date -Is)" "$hook" "$?" >>"$log"
fi
done

58
bin/rice-idle Executable file
View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
# rice-idle - screensaver, then lock, when you walk away.
#
# rice-idle start start the idle chain (rice-session does this)
# rice-idle toggle stay awake on/off
# rice-idle status JSON for the control centre
#
# Timings live in ~/.config/rice/idle.conf (seconds):
# SCREENSAVER=300 idle time before the screensaver
# LOCK=300 further idle time before the lock
# Nothing fires while a window is fullscreen or audio is playing. Suspending
# always locks first.
set -uo pipefail
run="${XDG_RUNTIME_DIR:-/tmp}/rice"
mkdir -p "$run"
SCREENSAVER=300
LOCK=300
conf="${XDG_CONFIG_HOME:-$HOME/.config}/rice/idle.conf"
[[ -f $conf ]] && . "$conf"
chain_running() { pgrep -u "$UID" -x xidlehook >/dev/null; }
start_chain() {
xset s off
chain_running || setsid -f xidlehook \
--not-when-fullscreen --not-when-audio \
--timer "$SCREENSAVER" 'rice-screensaver start' 'rice-screensaver stop' \
--timer "$LOCK" 'rice-lock' '' >/dev/null 2>&1
}
start_sleep_lock() {
pgrep -u "$UID" -x xss-lock >/dev/null || setsid -f xss-lock --transfer-sleep-lock -- rice-lock >/dev/null 2>&1
}
case "${1:-status}" in
start)
start_sleep_lock
[[ -f $run/stay-awake ]] || start_chain
;;
toggle)
if [[ -f $run/stay-awake ]]; then
rm -f "$run/stay-awake"
start_chain
rice-notify send "Stay awake off" "Screensaver after $((SCREENSAVER / 60)) min, lock $((LOCK / 60)) min later"
else
touch "$run/stay-awake"
pkill -u "$UID" -x xidlehook
rice-notify send "Stay awake on" "No screensaver or lock until you turn this off"
fi
;;
status)
jq -cn --argjson awake "$([[ -f $run/stay-awake ]] && echo true || echo false)" \
--argjson screensaver "$SCREENSAVER" --argjson lock "$LOCK" \
'{awake: $awake, screensaver: $screensaver, lock: ($screensaver + $lock)}'
;;
*) sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac

27
bin/rice-keys Executable file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env bash
# rice-keys - searchable cheat sheet of every keybinding, read from sxhkdrc.
# A "## Section" line groups bindings; a "# text" line right above a hotkey
# describes it.
set -uo pipefail
rc="${XDG_CONFIG_HOME:-$HOME/.config}/sxhkd/sxhkdrc"
dim=$(rice-theme get dim)
accent=$(rice-theme get accent)
pretty() {
sed -E 's/@//g; s/\{_,/{/g; s/\b(super|ctrl|shift|alt)\b/\u\1/g; s/XF86Audio/Media /g; s/Print/PrtSc/g'
}
escape() { sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g'; }
awk '
/^## / { section = substr($0, 4); next }
/^# / { desc = substr($0, 3); next }
/^$/ { desc = ""; next }
/^[^#[:space:]]/ && desc != "" { printf "%s\t%s\t%s\n", section, $0, desc; desc = "" }
' "$rc" |
while IFS=$'\t' read -r section keys desc; do
printf '<span foreground="%s">%-30s</span> %s <span foreground="%s">· %s</span>\n' \
"$accent" "$(pretty <<<"$keys" | escape)" "$(escape <<<"$desc")" "$dim" "$(escape <<<"$section")"
done |
rofi -dmenu -i -markup-rows -no-custom -theme "$HOME/.config/rofi/menu.rasi" -p "Keybindings" \
-theme-str 'window { width: 900px; } listview { lines: 16; }' >/dev/null

29
bin/rice-lock Executable file
View file

@ -0,0 +1,29 @@
#!/usr/bin/env bash
# rice-lock - lock the screen with xsecurelock, in the rice's colours, with a
# random xscreensaver hack behind the password prompt. Notifications are held
# back while locked and shown once you unlock.
set -uo pipefail
pgrep -u "$UID" -x xsecurelock >/dev/null && exit 0
theme="${XDG_STATE_HOME:-$HOME/.local/state}/rice/theme"
[[ -f $theme/xsecurelock.env ]] && . "$theme/xsecurelock.env"
# xsecurelock's screensaver picks a random hack from a folder. Give it one that
# holds only the hacks from ~/.config/rice/screensavers (or rice's defaults).
hacks="${XDG_RUNTIME_DIR:-/tmp}/rice/hacks"
rm -rf "$hacks" && mkdir -p "$hacks"
for h in $(rice-screensaver list); do ln -s "/usr/lib/xscreensaver/$h" "$hacks/$h"; done
export XSECURELOCK_XSCREENSAVER_PATH="$hacks"
rice-screensaver stop >/dev/null 2>&1
rice-panel close >/dev/null 2>&1
level=$(dunstctl get-pause-level 2>/dev/null || echo 0)
dunstctl set-pause-level 100 >/dev/null 2>&1
xsecurelock
status=$?
dunstctl set-pause-level "$level" >/dev/null 2>&1
exit "$status"

75
bin/rice-media Executable file
View file

@ -0,0 +1,75 @@
#!/usr/bin/env bash
# rice-media - now-playing state for the eww panel and bar.
#
# rice-media watch JSON on every track or play-state change (eww deflisten)
# rice-media position JSON {pos, text} for the seek bar (polled while the panel is open)
# rice-media seek <seconds>
#
# Album art is copied into ~/.cache/rice/art under a hash of its URL, so the
# panel only ever points at a file name it created itself.
set -uo pipefail
art_dir="${XDG_CACHE_HOME:-$HOME/.cache}/rice/art"
mkdir -p "$art_dir"
clock() { local s=$1; printf '%d:%02d' $((s / 60)) $((s % 60)); }
art_path() {
local url="$1" key file path=""
[[ -n $url ]] || return 0
key="$url"
if [[ $url == file://* ]]; then
path="${url#file://}"
path=$(printf '%b' "${path//%/\\x}")
# Browsers reuse one temp file for every track, so local art is keyed by mtime too.
key+=":$(stat -c %Y -- "$path" 2>/dev/null)"
fi
file="$art_dir/$(printf '%s' "$key" | sha1sum | cut -c1-16)"
if [[ ! -s $file ]]; then
if [[ -n $path ]]; then
cp -- "$path" "$file" 2>/dev/null
elif [[ $url == http*://* ]]; then
curl -fsSL --max-time 5 -o "$file" -- "$url" 2>/dev/null
fi
fi
[[ -s $file ]] && printf '%s' "$file"
}
emit() {
local status="$1" player="$2" title="$3" artist="$4" album="$5" url="$6" length="$7"
local secs=$(( ${length:-0} / 1000000 ))
jq -cn --arg status "$status" --arg player "$player" --arg title "$title" \
--arg artist "$artist" --arg album "$album" --arg art "$(art_path "$url")" \
--argjson length "$secs" --arg lengthText "$( (( secs > 0 )) && clock "$secs")" \
'{status: $status, player: $player, title: $title, artist: $artist, album: $album,
art: $art, length: $length, lengthText: $lengthText}'
}
watch() {
# Unit separator, not tab: read collapses runs of whitespace separators, so an
# empty field (browsers often send no album) would shift every later field.
local sep=$'\x1f'
while :; do
emit "Stopped" "" "" "" "" "" 0
playerctl --follow metadata --format \
"{{status}}${sep}{{playerName}}${sep}{{title}}${sep}{{artist}}${sep}{{album}}${sep}{{mpris:artUrl}}${sep}{{mpris:length}}" 2>/dev/null |
while IFS="$sep" read -r status player title artist album url length; do
emit "${status:-Stopped}" "$player" "$title" "$artist" "$album" "$url" "${length:-0}"
done
sleep 2
done
}
case "${1:-}" in
watch) watch ;;
position)
pos=$(playerctl position 2>/dev/null | cut -d. -f1)
pos=${pos:-0}
jq -cn --argjson pos "$pos" --arg text "$(clock "$pos")" '{pos: $pos, text: $text}'
;;
seek)
[[ ${2:-} =~ ^[0-9]+$ ]] || { echo "rice-media: seek takes seconds" >&2; exit 2; }
playerctl position "$2"
;;
*) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac

132
bin/rice-menu Executable file
View file

@ -0,0 +1,132 @@
#!/usr/bin/env bash
# rice-menu <menu> - every rofi menu in the rice.
# main power toggles capture style theme wallpaper screensavers setup
set -uo pipefail
R="$HOME/.config/rofi"
run="${XDG_RUNTIME_DIR:-/tmp}/rice"
RICE_DIR="${RICE_DIR:-$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)}"
# show <title> <active-rows> <row>... rows are "glyph|label|command";
# active rows (comma-separated indexes) get the accent colour, like panel rows.
show() {
local title="$1" active="$2"; shift 2
local choice row cmd
choice=$(for row in "$@"; do IFS='|' read -r g l _ <<<"$row"; printf '%s %s\n' "$g" "$l"; done |
rofi -dmenu -i -no-custom -format i -theme "$R/menu.rasi" -p "$title" ${active:+-a "$active"}) || exit 0
[[ $choice =~ ^[0-9]+$ ]] || exit 0
row="${*:$((choice + 1)):1}"
cmd="${row#*|}"; cmd="${cmd#*|}"
eval "$cmd"
}
# Open a file in the editor in a floating terminal, then run a follow-up.
edit() { setsid -f st -c rice-float -e sh -c "${EDITOR:-nvim} \"\$1\"; ${2:-true}" _ "$1" >/dev/null 2>&1; }
# Image grid: <title> <command> then lines of "label<TAB>image<TAB>value".
grid() {
local title="$1" action="$2" lines choice value
lines=$(cat)
[[ -n $lines ]] || { rice-notify send "$title" "Nothing to pick from"; exit 0; }
choice=$(while IFS=$'\t' read -r label image _; do printf '%s\0icon\x1f%s\n' "$label" "$image"; done <<<"$lines" |
rofi -dmenu -i -no-custom -format i -show-icons -theme "$R/grid.rasi" -p "$title") || exit 0
[[ $choice =~ ^[0-9]+$ ]] || exit 0
value=$(sed -n "$((choice + 1))p" <<<"$lines" | cut -f3)
"$action" "$value"
}
rice-theme-set() { rice-theme set "$1"; }
rice-wallpaper-set() { rice-wallpaper set "$1"; }
case "${1:-main}" in
main)
show "Menu" "" \
"󰀻|Apps|rofi -show drun" \
"󰍜|Control centre|rice-panel hub" \
"󰃭|Calendar|rice-panel calendar" \
"󰂚|Notifications|rice-panel notifications" \
"󰀠|New reminder|rice-reminder new" \
"󰄀|Capture|exec rice-menu capture" \
"󰔎|Toggles|exec rice-menu toggles" \
"󰏘|Style|exec rice-menu style" \
"󰞅|Emoji|rice-emoji" \
"󰅌|Clipboard|rice-clipboard" \
"󰌌|Keybindings|rice-keys" \
"󰒓|Setup|exec rice-menu setup" \
"󰐥|Power|exec rice-menu power"
;;
power)
show "Power" "" \
"󰌾|Lock|rice-lock" \
"󱄄|Screensaver|rice-screensaver now" \
"󰒲|Suspend|systemctl suspend" \
"󰍃|Log out|rice-power logout" \
"󰜉|Reboot|rice-power reboot" \
"󰐥|Shut down|rice-power shutdown"
;;
toggles)
active=()
[[ $(rice-notify dnd status) == on ]] && active+=(0)
[[ -f $run/stay-awake ]] && active+=(1)
pgrep -f 'nerd-dictation begin' >/dev/null && active+=(3)
pgrep -x particles >/dev/null && active+=(4)
[[ -f $run/webcam.pid ]] && active+=(5)
show "Toggles" "$(IFS=,; echo "${active[*]}")" \
"󰂛|Do not disturb|rice-notify dnd toggle" \
"󰅶|Stay awake|rice-idle toggle" \
"󰍜|Bar|rice-bar toggle" \
"󰍬|Dictation|rice-dictate toggle" \
"󰝨|Particles|setsid -f particles" \
"󰖠|Webcam overlay|rice-capture webcam" \
"󱂬|Drop-down terminal|rice-scratchpad"
;;
capture)
rows=("󰹑|Screenshot a region|rice-capture region" "󰍹|Screenshot the screen|rice-capture screen")
if pgrep -x gpu-screen-reco >/dev/null; then rows+=("󰓛|Stop recording|rice-capture record")
else rows+=("󰑋|Record the screen|rice-capture record"); fi
rows+=("󰴑|Copy text from a region|rice-capture text" \
"󰐲|Read a QR code|rice-capture qr" \
"󰏘|Pick a colour|rice-capture color" \
"󰖠|Webcam overlay|rice-capture webcam" \
"󰧸|Transcode a picture or video|rice-capture transcode")
show "Capture" "" "${rows[@]}"
;;
style)
show "Style" "" \
"󰏘|Theme|exec rice-menu theme" \
"󰸉|Wallpaper|exec rice-menu wallpaper" \
"󰛖|Font|rice-font" \
"󱄄|Screensavers|exec rice-menu screensavers" \
"󰋽|About this machine|rice-about"
;;
theme)
rice-theme list | while read -r t; do
dir="$RICE_DIR/themes/$t"; [[ -d $dir ]] || dir="${XDG_CONFIG_HOME:-$HOME/.config}/rice/themes/$t"
img="$dir/preview.png"
[[ -f $img ]] || img="$RICE_DIR/wallpapers/$(sed -n 's/^wallpaper *= *"\(.*\)"/\1/p' "$dir/colors.toml")"
printf '%s\t%s\t%s\n' "$(sed -n 's/^name *= *"\(.*\)"/\1/p' "$dir/colors.toml")" "$(rice-wallpaper thumb "$img")" "$t"
done | grid "Theme" rice-theme-set
;;
wallpaper)
rice-wallpaper list | while read -r img; do
printf '%s\t%s\t%s\n' "$(basename "${img%.*}")" "$(rice-wallpaper thumb "$img")" "$img"
done | grid "Wallpaper" rice-wallpaper-set
;;
screensavers)
mapfile -t hacks < <(rice-screensaver list)
rows=(); for h in "${hacks[@]}"; do rows+=("󱄄|$h|rice-screensaver now $h"); done
show "Screensavers" "" "${rows[@]}"
;;
setup)
show "Setup" "" \
"󰌌|Edit keybindings|edit \"\$HOME/.config/sxhkd/sxhkdrc\" 'pkill -USR1 -x sxhkd'" \
"󰕮|Edit bspwm|edit \"\$HOME/.config/bspwm/bspwmrc\" 'bspc wm -r'" \
"󰔟|Idle timings|edit \"\$HOME/.config/rice/idle.conf\" 'pkill -x xidlehook; rice-idle start'" \
"󱄄|Screensaver list|edit \"\$HOME/.config/rice/screensavers\"" \
"󰈙|Machine-specific settings|edit \"\$HOME/.config/rice/local.sh\"" \
"󰛢|Hooks folder|setsid -f thunar \"\$HOME/.config/rice/hooks\"" \
"󰑓|Restart rice daemons|rice-session restart" \
"󰚰|Update the rice|setsid -f st -c rice-float -e sh -c 'cd \"\$1\" && git pull --ff-only && ./install.sh --update; printf \"\\npress enter\"; read -r _' _ \"$RICE_DIR\""
;;
*) sed -n '2,3p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac

152
bin/rice-net Executable file
View file

@ -0,0 +1,152 @@
#!/usr/bin/env bash
# rice-net - network state for the eww panel, and the actions it runs.
#
# rice-net list JSON: Wi-Fi radio, active connection, networks, VPNs
# rice-net radio on|off|toggle
# rice-net connect <id> id from the last list; asks for a password in rofi if needed
# rice-net disconnect
# rice-net rescan
# rice-net vpn <id> bring a VPN / WireGuard connection up or down
# rice-net share show the current Wi-Fi network as a QR code
#
# SSIDs are chosen by whoever broadcasts them, so the panel only ever passes
# the numeric id; the SSID is looked up again here from the cached list.
set -uo pipefail
run="${XDG_RUNTIME_DIR:-/tmp}/rice"
cache="$run/wifi.tsv"
vpncache="$run/vpn.tsv"
mkdir -p "$run"
say() { rice-notify send "$1" "${2:-}" -i network-wireless; }
poke() { date +%s%N > "$run/net.rev"; }
signal_glyph() {
local s=$1
if (( s >= 75 )); then echo 󰤨
elif (( s >= 50 )); then echo 󰤥
elif (( s >= 25 )); then echo 󰤢
else echo 󰤟; fi
}
list() {
local radio=false has_wifi=false active_type="" active_name="" active_dev=""
nmcli -g TYPE device 2>/dev/null | grep -qx wifi && has_wifi=true
[[ $has_wifi == true && $(nmcli -t radio wifi 2>/dev/null) == enabled ]] && radio=true
IFS=: read -r active_type active_name active_dev < <(nmcli -g TYPE,STATE,CONNECTION,DEVICE device status 2>/dev/null |
awk -F: '$2 == "connected" && ($1 == "wifi" || $1 == "ethernet") { print $1 ":" $3 ":" $4; exit }')
local known
known=$(nmcli -g NAME,TYPE connection show 2>/dev/null | awk -F: '$2 == "802-11-wireless" { print $1 }')
# IN-USE:SIGNAL:SECURITY:SSID, strongest first, one row per SSID, top 8.
: > "$cache"
local inuse signal security ssid id=0 seen=$'\n'
while IFS=: read -r inuse signal security ssid; do
ssid=${ssid//\\:/:}
[[ -z $ssid || $seen == *$'\n'"$ssid"$'\n'* ]] && continue
seen+="$ssid"$'\n'
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$id" "$ssid" "$signal" "${security:---}" \
"$([[ $inuse == '*' ]] && echo 1 || echo 0)" \
"$(grep -Fxq -- "$ssid" <<<"$known" && echo 1 || echo 0)" >> "$cache"
(( ++id >= 8 )) && break
done < <(nmcli -g IN-USE,SIGNAL,SECURITY,SSID device wifi list --rescan no 2>/dev/null | sort -t: -k2,2nr)
# VPN and WireGuard profiles known to NetworkManager.
: > "$vpncache"
local active_names name type vid=0
active_names=$(nmcli -g NAME connection show --active 2>/dev/null)
while IFS=: read -r name type; do
[[ $type == vpn || $type == wireguard ]] || continue
printf '%s\t%s\t%s\n' "$vid" "$name" "$(grep -Fxq -- "$name" <<<"$active_names" && echo 1 || echo 0)" >> "$vpncache"
(( vid++ ))
done < <(nmcli -g NAME,TYPE connection show 2>/dev/null)
jq -cn --argjson radio "$radio" --argjson haswifi "$has_wifi" \
--arg atype "$active_type" --arg aname "$active_name" \
--argjson wifi "$(while IFS=$'\t' read -r i s sig sec use kn; do
jq -cn --argjson id "$i" --arg ssid "$s" --argjson signal "$sig" --arg sec "$sec" \
--argjson active "$([[ $use == 1 ]] && echo true || echo false)" \
--argjson known "$([[ $kn == 1 ]] && echo true || echo false)" \
--arg glyph "$(signal_glyph "$sig")" \
'{id: $id, ssid: $ssid, signal: $signal, active: $active, known: $known, glyph: $glyph,
sub: ([(if $active then "connected" elif $known then "saved" else empty end),
(if $sec == "--" then "open" else "󰌾 " + $sec end)] | join(" · "))}'
done < "$cache" | jq -cs '.')" \
--argjson vpn "$(while IFS=$'\t' read -r i n a; do
jq -cn --argjson id "$i" --arg name "$n" --argjson active "$([[ $a == 1 ]] && echo true || echo false)" \
'{id: $id, name: $name, active: $active}'
done < "$vpncache" | jq -cs '.')" \
'{radio: $radio,
active: {type: $atype, name: $aname},
hasWifi: $haswifi,
wifi: $wifi, vpn: $vpn}'
}
field() { awk -F'\t' -v id="$1" -v col="$2" '$1 == id { print $col; exit }' "$3"; }
connect() {
[[ ${1:-} =~ ^[0-9]+$ ]] || { echo "rice-net: connect takes an id" >&2; exit 2; }
local ssid security known pass
ssid=$(field "$1" 2 "$cache"); security=$(field "$1" 4 "$cache"); known=$(field "$1" 6 "$cache")
[[ -n $ssid ]] || { say "Wi-Fi" "That network is gone; rescan and try again"; exit 1; }
if [[ $known == 1 ]]; then
nmcli connection up id "$ssid" >/dev/null 2>&1 && say "Connected" "$ssid" || say "Could not connect" "$ssid"
elif [[ $security == "--" ]]; then
nmcli device wifi connect "$ssid" >/dev/null 2>&1 && say "Connected" "$ssid" || say "Could not connect" "$ssid"
elif [[ $security == *802.1X* ]]; then
say "Enterprise network" "Opening the connection editor for $ssid"
setsid -f nm-connection-editor >/dev/null 2>&1
else
pass=$(rofi -dmenu -password -theme "$HOME/.config/rofi/prompt.rasi" -p "$ssid" -mesg "Wi-Fi password") || exit 0
[[ -n $pass ]] || exit 0
local mgmt=wpa-psk
[[ $security == *WPA3* && $security != *WPA2* ]] && mgmt=sae
# The password reaches nmcli through a file descriptor, not the command line.
nmcli connection add type wifi con-name "$ssid" ssid "$ssid" wifi-sec.key-mgmt "$mgmt" >/dev/null 2>&1
if nmcli connection up id "$ssid" passwd-file <(printf '802-11-wireless-security.psk:%s\n' "$pass") >/dev/null 2>&1; then
say "Connected" "$ssid"
else
nmcli connection delete id "$ssid" >/dev/null 2>&1
say "Could not connect" "Wrong password for $ssid?"
fi
fi
poke
}
case "${1:-}" in
list) list ;;
radio)
case "${2:-toggle}" in
on) nmcli radio wifi on ;;
off) nmcli radio wifi off ;;
*) [[ $(nmcli -t radio wifi) == enabled ]] && nmcli radio wifi off || nmcli radio wifi on ;;
esac
poke ;;
connect) connect "${2:-}" ;;
disconnect)
dev=$(nmcli -g TYPE,DEVICE,STATE device status | awk -F: '$1 == "wifi" && $3 == "connected" { print $2; exit }')
[[ -n $dev ]] && nmcli device disconnect "$dev" >/dev/null && say "Wi-Fi" "Disconnected"
poke ;;
rescan) nmcli device wifi rescan >/dev/null 2>&1; poke ;;
vpn)
[[ ${2:-} =~ ^[0-9]+$ ]] || { echo "rice-net: vpn takes an id" >&2; exit 2; }
name=$(field "$2" 2 "$vpncache"); active=$(field "$2" 3 "$vpncache")
[[ -n $name ]] || exit 1
if [[ $active == 1 ]]; then nmcli connection down id "$name" >/dev/null && say "VPN off" "$name"
else nmcli connection up id "$name" >/dev/null && say "VPN on" "$name" || say "VPN failed" "$name"; fi
poke ;;
share)
name=$(nmcli -g TYPE,STATE,CONNECTION device status | awk -F: '$1 == "wifi" && $2 == "connected" { print $3; exit }')
[[ -n $name ]] || { say "Wi-Fi" "Not connected to a Wi-Fi network"; exit 1; }
ssid=$(nmcli -g 802-11-wireless.ssid connection show id "$name")
psk=$(nmcli -s -g 802-11-wireless-security.psk connection show id "$name")
esc() { sed -e 's/[\\;,:"]/\\&/g' <<<"$1"; }
png="$run/wifi-qr.png"
printf 'WIFI:T:%s;S:%s;P:%s;;' "$([[ -n $psk ]] && echo WPA || echo nopass)" "$(esc "$ssid")" "$(esc "$psk")" |
qrencode -o "$png" -s 10 -m 2 --foreground="$(rice-theme get bg | tr -d '#')" --background="$(rice-theme get fg | tr -d '#')"
setsid -f feh --title "Wi-Fi: $ssid" --geometry 420x420 -Z "$png" >/dev/null 2>&1
;;
*) sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac

95
bin/rice-notify Executable file
View file

@ -0,0 +1,95 @@
#!/usr/bin/env bash
# rice-notify - one front door for notifications.
#
# rice-notify send <title> [body] [-i icon] [-u low|normal|critical]
# rice-notify dismiss | dismiss-all | action | restore
# rice-notify dnd on|off|toggle|status
# rice-notify remove <id> | clear
# rice-notify watch JSON stream for the notification centre (eww)
#
# Do-not-disturb is dunst pause level 60, not "paused": rules in dunstrc give
# rice's own confirmations and critical CLI alerts level 100 so they still show.
set -uo pipefail
DND_LEVEL=60
rev="${XDG_RUNTIME_DIR:-/tmp}/rice/notify.rev"
mkdir -p "${rev%/*}"
poke() {
date +%s%N > "$rev"
pgrep -x polybar >/dev/null && polybar-msg action indicators hook 0 >/dev/null 2>&1
return 0
}
dnd_on() { (( $(dunstctl get-pause-level 2>/dev/null || echo 0) > 0 )); }
history_json() {
local uptime
uptime=$(cut -d' ' -f1 /proc/uptime)
dunstctl history 2>/dev/null | jq -c --argjson up "$uptime" '
[ .data[0][]? | {
id: .id.data,
app: .appname.data,
summary: .summary.data,
body: (.body.data | gsub("<[^>]*>"; "") | split("\n")
| map(gsub("^\\s+|\\s+$"; "") | select(length > 0)) | .[0:2] | join(" · ")
| if length > 110 then .[0:109] + "…" else . end),
icon: (.icon_path.data // ""),
urgency: (.urgency.data | tostring | ascii_upcase),
age: (($up - (.timestamp.data / 1000000)) | floor)
}
| .ago = (if .age < 60 then "now"
elif .age < 3600 then "\(.age / 60 | floor)m"
elif .age < 86400 then "\(.age / 3600 | floor)h"
else "\(.age / 86400 | floor)d" end)
]' || echo '[]'
}
snapshot() {
local items
items=$(history_json)
jq -cn --argjson items "$items" \
--argjson dnd "$(dnd_on && echo true || echo false)" \
--argjson waiting "$(dunstctl count waiting 2>/dev/null || echo 0)" \
'{count: ($items | length), dnd: $dnd, waiting: $waiting, items: $items[:8]}'
}
cmd="${1:-}"; shift || true
case "$cmd" in
send)
title="${1:-}"; body="${2:-}"; shift 2 2>/dev/null || shift $#
notify-send -a rice "$@" "$title" "$body"
;;
dismiss) dunstctl close; poke ;;
dismiss-all) dunstctl close-all; poke ;;
action) dunstctl action 0; poke ;;
restore) dunstctl history-pop; poke ;;
remove) [[ ${1:-} =~ ^[0-9]+$ ]] && dunstctl history-rm "$1"; poke ;;
open) [[ ${1:-} =~ ^[0-9]+$ ]] && dunstctl history-pop "$1"; poke ;;
clear) dunstctl history-clear; poke ;;
dnd)
case "${1:-toggle}" in
on) dunstctl set-pause-level "$DND_LEVEL" ;;
off) dunstctl set-pause-level 0 ;;
toggle) if dnd_on; then dunstctl set-pause-level 0; else dunstctl set-pause-level "$DND_LEVEL"; fi ;;
status) dnd_on && echo on || echo off; exit 0 ;;
esac
if dnd_on; then
notify-send -a rice -u low -h string:x-dunst-stack-tag:dnd "Do not disturb" "Only alarms and critical alerts will show"
else
notify-send -a rice -u low -h string:x-dunst-stack-tag:dnd "Do not disturb off" "$(dunstctl count waiting) held back"
fi
poke
;;
watch)
[[ -f $rev ]] || poke
while :; do
snapshot
inotifywait -qq -t 5 -e close_write "$rev" 2>/dev/null
done
;;
*)
sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'
[[ -z $cmd ]] || exit 2
;;
esac

6
bin/rice-notify-hook Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Run by dunst for every notification ([notify_centre] in dunstrc). It only
# marks "something changed" so the notification centre and bar refresh.
rev="${XDG_RUNTIME_DIR:-/tmp}/rice/notify.rev"
mkdir -p "${rev%/*}"
date +%s%N > "$rev"

14
bin/rice-osd Executable file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env bash
# rice-osd <glyph> <label> <percent|text>
# An on-screen display that replaces itself in place (dunst [osd] rule).
# A number draws dunst's progress bar; anything else is shown as text.
glyph="${1:?usage: rice-osd <glyph> <label> <percent|text>}"
label="${2:-}"
value="${3:-}"
tag="osd-${label,,}"
if [[ $value =~ ^[0-9]+$ ]]; then
notify-send -a osd -u low -h "int:value:$value" -h "string:x-dunst-stack-tag:$tag" "$glyph $label $value%"
else
notify-send -a osd -u low -h "string:x-dunst-stack-tag:$tag" "$glyph $label $value"
fi

64
bin/rice-panel Executable file
View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
# rice-panel <name> | close | list
#
# Opens one eww popup at a time (opening one closes the other). A transparent
# full-screen "closer" window sits underneath, so a click anywhere else closes
# it, and a throwaway sxhkd grabs Escape for as long as a panel is open.
set -uo pipefail
cfg="$HOME/.config/eww"
panels="hub audio network bluetooth display power media calendar notifications"
e() { eww -c "$cfg" "$@"; }
ensure_daemon() {
pgrep -u "$UID" -x eww >/dev/null && return 0
setsid -f eww -c "$cfg" daemon >/dev/null 2>&1
for _ in {1..50}; do e ping >/dev/null 2>&1 && return 0; sleep 0.1; done
}
open_windows() { e active-windows 2>/dev/null | cut -d: -f1; }
# Line panels up with tiled windows. bspwm draws the border inside the gap, so a
# window's outer edge is exactly one gap below the bar and in from the screen's
# right edge; ask it for the live numbers instead of hard-coding them.
position() {
local pt pr gap
read -r pt pr gap < <(bspc query -T -m 2>/dev/null |
jq -r '[.padding.top, .padding.right, .windowGap] | @tsv' 2>/dev/null)
pt=${pt:-37} pr=${pr:-0} gap=${gap:-10}
case "$1" in
media|calendar) echo "0x$(( pt + gap ))" ;;
*) echo "$(( -(pr + gap) ))x$(( pt + gap ))" ;;
esac
}
close_all() {
local w
for w in $(open_windows); do
[[ " $panels closer " == *" $w "* ]] && e close "$w" >/dev/null 2>&1
done
pkill -u "$UID" -f "sxhkd -c $cfg/panel-keys" 2>/dev/null
pgrep -u "$UID" -x eww >/dev/null && e update open_panel="" >/dev/null 2>&1
return 0
}
case "${1:-close}" in
close) close_all ;;
list) tr ' ' '\n' <<<"$panels" ;;
*)
name="$1"
[[ " $panels " == *" $name "* ]] || { echo "rice-panel: unknown panel '$name' (try: rice-panel list)" >&2; exit 2; }
ensure_daemon
if open_windows | grep -qx "$name"; then
close_all
else
close_all
[[ $name == calendar ]] && e update cal_offset=0 cal_day=""
e update open_panel="$name"
e open closer
e open "$name" --pos="$(position "$name")"
setsid -f sxhkd -c "$cfg/panel-keys" >/dev/null 2>&1
fi
;;
esac

79
bin/rice-power Executable file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env bash
# rice-power - system stats and power actions for the eww panel and power menu.
#
# rice-power info JSON: CPU/GPU load and temperature, memory, uptime, profile
# rice-power profile <name> power-saver | balanced | performance
# rice-power lock|suspend|logout|reboot|shutdown
# logout, reboot and shutdown ask first
set -uo pipefail
run="${XDG_RUNTIME_DIR:-/tmp}/rice"
mkdir -p "$run"
# CPU load since the previous call (the panel polls every few seconds).
cpu_load() {
local now prev idle total pidle ptotal
read -r _ user nice system idle iowait irq softirq steal _ < /proc/stat
total=$((user + nice + system + idle + iowait + irq + softirq + steal))
idle=$((idle + iowait))
if [[ -f $run/cpu.prev ]]; then
read -r pidle ptotal < "$run/cpu.prev"
(( total > ptotal )) && echo $(( 100 * ((total - ptotal) - (idle - pidle)) / (total - ptotal) )) || echo 0
else
echo 0
fi
echo "$idle $total" > "$run/cpu.prev"
}
info() {
local sensors gpu_dev profile profiles
sensors=$(sensors -j 2>/dev/null || echo '{}')
gpu_dev=$(ls -d /sys/class/drm/card*/device 2>/dev/null | while read -r d; do [[ -f $d/gpu_busy_percent ]] && echo "$d" && break; done)
profile=$(powerprofilesctl get 2>/dev/null || echo "")
profiles=$(powerprofilesctl list 2>/dev/null | sed -n 's/^[* ]*\([a-z-]*\):$/\1/p' | jq -R -s -c 'split("\n") | map(select(length > 0))')
jq -cn \
--argjson sensors "$sensors" \
--argjson cpu "$(cpu_load)" \
--arg gpubusy "$( [[ -n $gpu_dev ]] && cat "$gpu_dev/gpu_busy_percent" 2>/dev/null)" \
--arg vramused "$( [[ -n $gpu_dev ]] && cat "$gpu_dev/mem_info_vram_used" 2>/dev/null)" \
--arg vramtotal "$( [[ -n $gpu_dev ]] && cat "$gpu_dev/mem_info_vram_total" 2>/dev/null)" \
--arg mem "$(free -b | awk '/^Mem:/ { print $3 " " $2 }')" \
--arg uptime "$(uptime -p | sed 's/^up //; s/ hours\?/h/; s/ minutes\?/m/; s/ days\?/d/; s/,//g')" \
--arg profile "$profile" --argjson profiles "${profiles:-[]}" '
def temp($pat): [$sensors | to_entries[] | select(.key | test($pat)) | .value | to_entries[]
| select(.key | test("^(Tctl|Tdie|edge|Package id 0|temp1)$")) | .value
| to_entries[] | select(.key | endswith("_input")) | .value] | first // null;
def gib: (. / 1073741824 * 10 | round / 10 | tostring) + "G";
($mem | split(" ") | map(tonumber)) as [$mused, $mtotal] |
{
cpu: {load: $cpu, temp: (temp("k10temp|coretemp|zenpower") | if . then round else null end)},
gpu: {load: ($gpubusy | tonumber? // null),
temp: (temp("amdgpu|radeon|nouveau") | if . then round else null end),
vram: (if $vramtotal != "" then (($vramused | tonumber | gib) + " / " + ($vramtotal | tonumber | gib)) else "" end)},
memory: {used: ($mused | gib), total: ($mtotal | gib), percent: ($mused * 100 / $mtotal | round)},
uptime: $uptime,
profile: $profile,
profiles: [ $profiles[] | {name: ., label: ({"power-saver": "saver", "balanced": "balanced", "performance": "performance"}[.] // .), active: (. == $profile)} ]
}'
}
confirm() {
local answer
answer=$(printf '%s\nCancel\n' "$1" | rofi -dmenu -theme "$HOME/.config/rofi/menu.rasi" -p "$2" -mesg "Are you sure?") || return 1
[[ $answer == "$1" ]]
}
case "${1:-}" in
info) info ;;
profile)
[[ ${2:-} =~ ^(power-saver|balanced|performance)$ ]] || { echo "rice-power: unknown profile '${2:-}'" >&2; exit 2; }
powerprofilesctl set "$2" && rice-notify send "Power profile" "$2" -i power-profile-"$2"-symbolic
;;
lock) rice-panel close; rice-lock ;;
suspend) rice-panel close; systemctl suspend ;;
logout) rice-panel close; confirm "󰍃 Log out" "Log out" && bspc quit ;;
reboot) rice-panel close; confirm "󰜉 Reboot" "Reboot" && systemctl reboot ;;
shutdown) rice-panel close; confirm "󰐥 Shut down" "Shut down" && systemctl poweroff ;;
*) sed -n '2,8p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac

42
bin/rice-preview Executable file
View file

@ -0,0 +1,42 @@
#!/usr/bin/env bash
# rice-preview <file> - show a file in the terminal. Pictures and video frames
# are drawn as real images (kitty's protocol in kitty, sixel in st), folders as
# a tree, and everything else through bat. Used by `ff` (fzf) and `img`.
set -uo pipefail
file="${1:?usage: rice-preview <file>}"
[[ -e $file ]] || exit 0
cols="${FZF_PREVIEW_COLUMNS:-$(tput cols)}"
lines="${FZF_PREVIEW_LINES:-$(( $(tput lines) - 2 ))}"
cache="${XDG_CACHE_HOME:-$HOME/.cache}/rice/preview"
picture() {
if [[ -n ${KITTY_WINDOW_ID:-} ]]; then
kitty +kitten icat --clear --transfer-mode=memory --stdin=no --place="${cols}x${lines}@0x0" -- "$1"
elif [[ $TERM == st* ]]; then
chafa --format sixel --size "${cols}x${lines}" -- "$1"
else
chafa --format symbols --size "${cols}x${lines}" -- "$1"
fi
}
if [[ -d $file ]]; then
eza --tree --level=2 --icons=always --color=always -- "$file" 2>/dev/null || ls -la -- "$file"
exit 0
fi
case "$(file -b --mime-type -- "$file")" in
image/*)
picture "$file"
;;
video/*)
mkdir -p "$cache"
frame="$cache/$(printf '%s:%s' "$(realpath -- "$file")" "$(stat -c %Y -- "$file")" | sha1sum | cut -c1-16).jpg"
[[ -s $frame ]] || ffmpeg -loglevel error -y -ss 3 -i "$file" -frames:v 1 -vf scale=640:-1 "$frame" 2>/dev/null
[[ -s $frame ]] && picture "$frame"
;;
*)
bat --style=numbers --color=always --line-range :300 -- "$file" 2>/dev/null || head -n 300 -- "$file"
;;
esac

107
bin/rice-reminder Executable file
View file

@ -0,0 +1,107 @@
#!/usr/bin/env bash
# rice-reminder - desktop reminders backed by systemd user timers.
#
# rice-reminder new ask in rofi
# rice-reminder add <when> <text> when: 20m | 1h30m | 15:30 | "tomorrow 9:00" | "2026-09-15 09:00"
# rice-reminder list upcoming reminders as a notification
# rice-reminder json upcoming reminders as JSON (calendar panel)
# rice-reminder cancel <id> | clear
#
# In rofi, type "20m tea", "15:30 standup" or "tomorrow 9:00 | dentist".
# Reminders use app name "rice", so they still show during do-not-disturb.
set -uo pipefail
self="$(readlink -f "$0")"
run="${XDG_RUNTIME_DIR:-/tmp}/rice"
mkdir -p "$run"
poke() { date +%s%N > "$run/reminder.rev"; }
say() { notify-send -a rice -u low -i alarm-symbolic "$@"; }
# Resolve a "when" into epoch seconds, or fail.
resolve() {
local when="$1" now t
now=$(date +%s)
if [[ $when =~ ^([0-9]+[smhd])+$ ]]; then
local secs=0 part
for part in $(grep -oE '[0-9]+[smhd]' <<<"$when"); do
case "${part: -1}" in
s) secs=$((secs + ${part%?})) ;;
m) secs=$((secs + ${part%?} * 60)) ;;
h) secs=$((secs + ${part%?} * 3600)) ;;
d) secs=$((secs + ${part%?} * 86400)) ;;
esac
done
echo $((now + secs)); return
fi
if [[ $when =~ ^[0-9]{1,2}:[0-9]{2}$ ]]; then
t=$(date -d "today $when" +%s 2>/dev/null) || return 1
(( t <= now )) && t=$(date -d "tomorrow $when" +%s)
echo "$t"; return
fi
t=$(date -d "$when" +%s 2>/dev/null) || return 1
(( t > now )) || return 1
echo "$t"
}
add() {
local when="$1" text="${2:-Reminder}" at unit
at=$(resolve "$when") || { say "Reminder" "Couldn't read \"$when\" as a time"; return 1; }
unit="rice-reminder-$(date +%s%N)"
systemd-run --user --quiet --collect --unit="$unit" --description="$text" \
--on-calendar="$(date -d "@$at" '+%Y-%m-%d %H:%M:%S')" \
--timer-property=AccuracySec=1s \
-- "$self" fire "$text" || { say "Reminder" "Could not schedule it"; return 1; }
say "Reminder set" "$text · $(date -d "@$at" '+%a %H:%M')"
poke
}
upcoming() {
local unit next
systemctl --user list-timers 'rice-reminder-*' --all -o json 2>/dev/null |
jq -r '.[] | [.unit, (.next / 1000000 | floor)] | @tsv' |
while IFS=$'\t' read -r unit next; do
(( next > 0 )) || continue
printf '%s\t%s\t%s\n' "${unit%.timer}" "$next" "$(systemctl --user show -p Description --value "$unit")"
done | sort -t$'\t' -k2,2n
}
case "${1:-}" in
new)
input=$(rofi -dmenu -theme "$HOME/.config/rofi/prompt.rasi" -p "Reminder" \
-mesg "20m tea · 15:30 standup · tomorrow 9:00 | dentist") || exit 0
[[ -n $input ]] || exit 0
if [[ $input == *"|"* ]]; then
when="${input%%|*}"; text="${input#*|}"
else
when="${input%% *}"; text="${input#"$when"}"
fi
when="$(sed 's/^ *//; s/ *$//' <<<"$when")"; text="$(sed 's/^ *//; s/ *$//' <<<"$text")"
add "$when" "${text:-Reminder}"
;;
add) add "${2:?when}" "${3:-Reminder}" ;;
fire)
notify-send -a rice -u critical -i alarm-symbolic "Reminder" "${2:-Reminder}"
command -v paplay >/dev/null && [[ -f /usr/share/sounds/freedesktop/stereo/complete.oga ]] &&
paplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null
poke
;;
list)
body=$(upcoming | while IFS=$'\t' read -r _ next text; do printf '%s %s\n' "$(date -d "@$next" '+%a %H:%M')" "$text"; done)
say "Upcoming reminders" "${body:-Nothing scheduled}"
;;
json)
upcoming | jq -R -s -c 'split("\n") | map(select(length > 0) | split("\t")
| {id: .[0], at: (.[1] | tonumber), time: (.[1] | tonumber | strflocaltime("%H:%M")),
date: (.[1] | tonumber | strflocaltime("%Y-%m-%d")), text: .[2]})'
;;
cancel)
[[ ${2:-} =~ ^rice-reminder-[0-9]+$ ]] || { echo "rice-reminder: cancel takes a reminder id" >&2; exit 2; }
systemctl --user stop "$2.timer" && poke
;;
clear)
systemctl --user list-timers 'rice-reminder-*' --all --no-legend 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i ~ /\.timer$/) print $i }' |
xargs -r systemctl --user stop
say "Reminders" "All cleared"; poke
;;
*) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac

6
bin/rice-scratchpad Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
# rice-scratchpad - drop-down terminal on super + `. It is a persistent tmux
# session, so whatever runs in it survives being hidden or closed.
command -v tdrop >/dev/null || { rice-notify send "Drop-down terminal" "Install tdrop to use it"; exit 1; }
exec tdrop -a -w 70% -h 45% -x 15% -y 45 --class rice-scratchpad \
st -c rice-scratchpad -e tmux new-session -A -s scratchpad

53
bin/rice-screensaver Executable file
View file

@ -0,0 +1,53 @@
#!/usr/bin/env bash
# rice-screensaver - classic xscreensaver hacks, full screen.
#
# rice-screensaver start [hack] random hack from the list (rice-idle runs this)
# rice-screensaver stop
# rice-screensaver now [hack] start by hand; press q or Escape in it to stop
# rice-screensaver list hacks available on this machine
#
# Pick your own set by listing hack names, one per line, in
# ~/.config/rice/screensavers.
set -uo pipefail
hackdir=/usr/lib/xscreensaver
run="${XDG_RUNTIME_DIR:-/tmp}/rice"
pidfile="$run/screensaver.pid"
mkdir -p "$run"
defaults="glmatrix flurry pipes fireworkx galaxy atlantis lament gears starfish xmatrix boxfit"
user_list="${XDG_CONFIG_HOME:-$HOME/.config}/rice/screensavers"
available() {
local names h
if [[ -f $user_list ]]; then names=$(grep -v '^\s*#' "$user_list"); else names=$defaults; fi
for h in $names; do [[ -x $hackdir/$h ]] && echo "$h"; done
}
stop() {
[[ -f $pidfile ]] && kill "$(cat "$pidfile")" 2>/dev/null
rm -f "$pidfile"
}
start() {
local hack="${1:-}" pid wid
stop
if [[ -z $hack ]]; then hack=$(available | shuf -n1); fi
[[ -n $hack && -x $hackdir/$hack ]] || { echo "rice-screensaver: no hack available (install xscreensaver)" >&2; exit 1; }
[[ $hack =~ ^[a-z0-9_-]+$ ]] || exit 2
"$hackdir/$hack" >/dev/null 2>&1 &
pid=$!
echo "$pid" > "$pidfile"
# Hacks open an ordinary window; hand it to bspwm as a fullscreen node.
wid=$(xdotool search --sync --onlyvisible --pid "$pid" 2>/dev/null | head -n1)
[[ -n $wid ]] && bspc node "$wid" -t fullscreen -f 2>/dev/null
}
case "${1:-}" in
start) start "${2:-}" ;;
now) start "${2:-}" ;;
stop) stop ;;
list) available ;;
*) sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac

81
bin/rice-session Executable file
View file

@ -0,0 +1,81 @@
#!/usr/bin/env bash
# rice-session - start everything the desktop needs, exactly once.
#
# rice-session start called by bspwmrc (safe to call again on bspwm restart)
# rice-session restart stop and start the rice daemons
#
# Output goes to ~/.local/state/rice/session.log.
set -u
state="${XDG_STATE_HOME:-$HOME/.local/state}/rice"
theme="$state/theme"
mkdir -p "$state"
exec >>"$state/session.log" 2>&1
running() { pgrep -u "$UID" -x "$1" >/dev/null; }
# start <process-name> <command...>: launch detached unless already running
launch() {
local name="$1"; shift
if running "$name"; then return 0; fi
command -v "$1" >/dev/null || { echo "missing: $1"; return 0; }
setsid -f "$@" >/dev/null 2>&1 && echo "started $name"
}
daemons=(xsettingsd picom sxhkd dunst greenclip flameshot eww polybar xidlehook xss-lock)
start() {
echo "== $(date -Is) session start"
systemctl --user import-environment DISPLAY XAUTHORITY XDG_CURRENT_DESKTOP PATH QT_QPA_PLATFORMTHEME GTK_THEME
dbus-update-activation-environment --systemd DISPLAY XAUTHORITY XDG_CURRENT_DESKTOP
[[ -d $theme ]] || rice-theme render
[[ -f $theme/Xresources ]] && xrdb -merge "$theme/Xresources"
# Daemons left over from another setup keep their old config files, so a
# bspwm restart alone would never switch them. Replace those.
local pid args
for pid in $(pgrep -u "$UID" -x sxhkd); do
args=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)
if [[ $args == *" -c "* && $args != *panel-keys* ]]; then kill "$pid" && echo "replaced: $args"; fi
done
for pid in $(pgrep -u "$UID" -x picom); do
args=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)
if [[ $args != *"$HOME/.config/picom/picom.conf"* ]]; then kill "$pid" && echo "replaced: $args"; fi
done
for _ in {1..20}; do
pgrep -u "$UID" -f 'sxhkd -c .*bspwm|picom --config .*bspwm' >/dev/null || break
sleep 0.1
done
pgrep -u "$UID" -x dunst >/dev/null && dunstctl reload
launch xsettingsd xsettingsd -c "$theme/xsettingsd.conf"
launch picom picom --config "$HOME/.config/picom/picom.conf"
launch sxhkd sxhkd
launch dunst dunst
launch greenclip greenclip daemon
launch flameshot flameshot
launch eww eww daemon
if ! pgrep -u "$UID" -f polkit-gnome-authentication-agent-1 >/dev/null; then
setsid -f /usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1 >/dev/null 2>&1
fi
rice-wallpaper restore
rice-bar start
rice-idle start
(sleep 2 && rice-hook post-boot) &
}
stop() {
echo "== $(date -Is) session stop"
for d in "${daemons[@]}"; do pkill -u "$UID" -x "$d"; done
}
case "${1:-start}" in
start) start ;;
stop) stop ;;
restart) stop; sleep 0.5; start ;;
*) echo "usage: rice-session start|stop|restart" >&2; exit 2 ;;
esac

102
bin/rice-status Executable file
View file

@ -0,0 +1,102 @@
#!/usr/bin/env bash
# rice-status <module> - live text for polybar's rice modules (tail = true).
# Modules: net bt vol notif indicators media. Each prints a line whenever its
# state changes, in the same "LABEL value" style as the cpu/ram/gpu modules.
set -uo pipefail
run="${XDG_RUNTIME_DIR:-/tmp}/rice"
mkdir -p "$run"
accent=$(rice-theme get accent) dim=$(rice-theme get dim)
urgent=$(rice-theme get urgent) green=$(rice-theme get green)
yellow=$(rice-theme get yellow) magenta=$(rice-theme get magenta)
# label <colour> <LABEL> <value>
label() { printf '%%{F%s}%s%%{F-} %s\n' "$1" "$2" "$3"; }
short() { local s="$1" n="$2"; (( ${#s} > n )) && s="${s:0:n-1}…"; printf '%s' "$s"; }
# wait for anything under $run to change, or give up after $1 seconds
settle() { inotifywait -qq -t "$1" -e close_write,create,delete "$run" 2>/dev/null; return 0; }
net() {
show() {
local type conn
IFS=: read -r type conn < <(nmcli -t -f TYPE,STATE,CONNECTION device status 2>/dev/null |
awk -F: '$2 == "connected" && ($1 == "wifi" || $1 == "ethernet") { print $1 ":" $3; exit }')
case "${type:-}" in
wifi) label "$accent" NET "$(short "$conn" 16)" ;;
ethernet) label "$accent" NET eth ;;
*) label "$urgent" NET off ;;
esac
}
show
nmcli monitor 2>/dev/null | while read -r _; do show; done
}
bt() {
while :; do
if ! bluetoothctl show 2>/dev/null | grep -q 'Powered: yes'; then
label "$dim" BT off
else
local dev
dev=$(bluetoothctl devices Connected 2>/dev/null | head -n1 | cut -d' ' -f3-)
label "$accent" BT "$(short "${dev:-on}" 14)"
fi
settle 10
done
}
vol() {
show() {
local v m
read -r _ v m < <(wpctl get-volume @DEFAULT_AUDIO_SINK@ 2>/dev/null)
if [[ -n ${m:-} ]]; then label "$urgent" VOL muted
else label "$green" VOL "$(awk -v v="${v:-0}" 'BEGIN { printf "%d%%", v * 100 + 0.5 }')"; fi
}
show
pactl subscribe 2>/dev/null | grep --line-buffered -E "'change' on (sink|server)" | while read -r _; do show; done
}
notif() {
while :; do
local n level
n=$(dunstctl count history 2>/dev/null || echo 0)
level=$(dunstctl get-pause-level 2>/dev/null || echo 0)
if (( level > 0 )); then printf '%%{F%s}󰂛%%{F-}\n' "$yellow"
elif (( n > 0 )); then printf '%%{F%s}󰂚%%{F-} %s\n' "$accent" "$n"
else printf '%%{F%s}󰂚%%{F-}\n' "$dim"; fi
settle 5
done
}
indicators() {
while :; do
local out="" r
[[ -f $run/stay-awake ]] && out+="%{A1:rice-idle toggle:}%{F$yellow}󰅶%{F-}%{A} "
pgrep -x gpu-screen-reco >/dev/null && out+="%{A1:rice-capture record:}%{F$urgent}󰑊 REC%{F-}%{A} "
pgrep -f 'nerd-dictation begin' >/dev/null && out+="%{A1:rice-dictate toggle:}%{F$magenta}󰍬%{F-}%{A} "
r=$(systemctl --user list-timers 'rice-reminder-*' --no-legend 2>/dev/null | grep -c .)
(( r > 0 )) && out+="%{A1:rice-reminder list:}%{F$accent}󰢌 $r%{F-}%{A} "
printf '%s\n' "${out% }"
settle 5
done
}
media() {
while :; do
echo ""
playerctl --follow metadata --format '{{status}}|{{artist}}|{{title}}' 2>/dev/null |
while IFS='|' read -r status artist title; do
case "$status" in
Playing) printf '%%{F%s}󰎆%%{F-} %s\n' "$magenta" "$(short "${artist:+$artist - }$title" 40)" ;;
Paused) printf '%%{F%s}󰏤 %s%%{F-}\n' "$dim" "$(short "$title" 30)" ;;
*) echo "" ;;
esac
done
sleep 2
done
}
case "${1:-}" in
net|bt|vol|notif|indicators|media) "$1" ;;
*) echo "usage: rice-status net|bt|vol|notif|indicators|media" >&2; exit 2 ;;
esac

162
bin/rice-sysinfo Executable file
View file

@ -0,0 +1,162 @@
#!/usr/bin/env bash
# Default values
MODE=""
NORMAL_COLOR=""
CRITICAL_COLOR=""
CRITICAL_THRESHOLD=80
CLICK_ACTION=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--cpu)
MODE="cpu"
shift
;;
--ram)
MODE="ram"
shift
;;
--gpu)
MODE="gpu"
shift
;;
--normal-color)
NORMAL_COLOR="$2"
shift 2
;;
--critical-color)
CRITICAL_COLOR="$2"
shift 2
;;
--click)
CLICK_ACTION=true
shift
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
# Function to get CPU usage
get_cpu_usage() {
# Using top to get CPU usage
local cpu_usage=$(top -bn2 -d 0.5 | grep "Cpu(s)" | tail -n1 | awk '{print $2}' | cut -d'%' -f1)
# If top doesn't work, try mpstat
if [ -z "$cpu_usage" ]; then
if command -v mpstat >/dev/null 2>&1; then
cpu_usage=$(mpstat 1 1 | awk '/Average:/ {print 100 - $NF}')
fi
fi
# Round to integer
printf "%.0f" "$cpu_usage"
}
# Function to get RAM usage
get_ram_usage() {
local mem_info=$(free | grep Mem)
local total=$(echo "$mem_info" | awk '{print $2}')
local used=$(echo "$mem_info" | awk '{print $3}')
local percentage=$(awk "BEGIN {printf \"%.0f\", ($used/$total)*100}")
echo "$percentage"
}
# Function to get GPU usage (NVIDIA)
get_gpu_usage() {
# amdgpu (and i915 on newer kernels) expose load directly; reading it is
# free, unlike radeontop, which spawns a sampling process on every tick.
local busy
busy=$(cat /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | head -n1)
if [[ -n $busy ]]; then
echo "$busy"
elif command -v nvidia-smi >/dev/null 2>&1; then
local gpu_usage=$(nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits | head -n1)
echo "$gpu_usage"
elif command -v intel_gpu_top >/dev/null 2>&1; then
# For Intel GPUs - approximate method
echo "0"
else
echo "N/A"
fi
}
# Function to format output with color
format_output() {
local value="$1"
local normal_color="$2"
local critical_color="$3"
# Check if value is a number
if [[ "$value" =~ ^[0-9]+$ ]]; then
if [ "$value" -ge "$CRITICAL_THRESHOLD" ] && [ -n "$critical_color" ]; then
echo "%{F${critical_color}}${value}%%{F-}"
elif [ -n "$normal_color" ]; then
echo "%{F${normal_color}}${value}%%{F-}"
else
echo "${value}%"
fi
else
# Not a number (like N/A)
echo "$value"
fi
}
# Handle click actions
handle_click() {
case $MODE in
cpu)
# Open system monitor or htop
if command -v htop >/dev/null 2>&1; then
alacritty -e htop &
elif command -v gnome-system-monitor >/dev/null 2>&1; then
gnome-system-monitor &
elif command -v xfce4-taskmanager >/dev/null 2>&1; then
xfce4-taskmanager &
else
notify-send "System Info" "No system monitor found"
fi
;;
gpu)
# Open nvidia-smi or GPU monitor
if command -v nvidia-smi >/dev/null 2>&1; then
alacritty -e watch -n 1 nvidia-smi &
else
notify-send "System Info" "No GPU monitor available"
fi
;;
esac
}
# Main logic
main() {
if [ "$CLICK_ACTION" = true ]; then
handle_click
exit 0
fi
case $MODE in
cpu)
usage=$(get_cpu_usage)
format_output "$usage" "$NORMAL_COLOR" "$CRITICAL_COLOR"
;;
ram)
usage=$(get_ram_usage)
format_output "$usage" "$NORMAL_COLOR" "$CRITICAL_COLOR"
;;
gpu)
usage=$(get_gpu_usage)
format_output "$usage" "$NORMAL_COLOR" "$CRITICAL_COLOR"
;;
*)
echo "Error: Specify --cpu, --ram, or --gpu"
exit 1
;;
esac
}
main

170
bin/rice-theme Executable file
View file

@ -0,0 +1,170 @@
#!/usr/bin/env bash
# rice-theme - render and apply a colour theme to every surface of the rice.
#
# rice-theme set <name> render themes/<name> and reload everything
# rice-theme reload re-render the current theme (after editing a template)
# rice-theme list available themes
# rice-theme current name of the active theme
# rice-theme get <key> one value from the active colors.toml (for scripts)
#
# Templates live in $RICE_DIR/templates/*.tpl and ~/.config/rice/templates/*.tpl
# (yours win). Placeholders, for a key like accent = "#92b9ea":
# {{ accent }} #92b9ea
# {{ accent_strip }} 92b9ea
# {{ accent_rgb }} 146,185,234
# A theme can ship a finished file (e.g. themes/foo/btop.theme); it is used
# as-is instead of the template with the same output name.
set -euo pipefail
RICE_DIR="${RICE_DIR:-$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)}"
STATE="${XDG_STATE_HOME:-$HOME/.local/state}/rice"
CURRENT="$STATE/theme"
USER_TEMPLATES="${XDG_CONFIG_HOME:-$HOME/.config}/rice/templates"
HOOKS="${XDG_CONFIG_HOME:-$HOME/.config}/rice/hooks/theme-set.d"
OVERRIDE="${XDG_CONFIG_HOME:-$HOME/.config}/rice/theme.override.toml"
die() { printf 'rice-theme: %s\n' "$*" >&2; exit 1; }
theme_dir() {
local user="${XDG_CONFIG_HOME:-$HOME/.config}/rice/themes/$1"
if [[ -d $user ]]; then echo "$user"
elif [[ -d $RICE_DIR/themes/$1 ]]; then echo "$RICE_DIR/themes/$1"
else return 1; fi
}
# Emit "key<TAB>value" for every key = value line of a colors.toml.
# Quoted values are taken whole (a colour's # is not a comment); bare values
# stop at a trailing # comment.
parse_colors() {
sed -n -E \
-e 's/^[[:space:]]*([a-z_][a-z0-9_]*)[[:space:]]*=[[:space:]]*"([^"]*)".*$/\1\t\2/p' \
-e 't' \
-e 's/^[[:space:]]*([a-z_][a-z0-9_]*)[[:space:]]*=[[:space:]]*([^"#[:space:]]([^#]*[^#[:space:]])?)[[:space:]]*(#.*)?$/\1\t\2/p' \
"$1"
}
# Build a sed program that replaces every placeholder form for every key.
sed_program() {
local key value esc r g b
while IFS=$'\t' read -r key value; do
esc=$(printf '%s' "$value" | sed -e 's/[\\&|]/\\&/g')
printf 's|{{ *%s *}}|%s|g\n' "$key" "$esc"
if [[ $value =~ ^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$ ]]; then
r=$((16#${BASH_REMATCH[1]})); g=$((16#${BASH_REMATCH[2]})); b=$((16#${BASH_REMATCH[3]}))
printf 's|{{ *%s_strip *}}|%s|g\n' "$key" "${value#\#}"
printf 's|{{ *%s_rgb *}}|%s,%s,%s|g\n' "$key" "$r" "$g" "$b"
fi
done < <(parse_colors "$1" | tac | awk -F'\t' '!seen[$1]++') # a later line wins (overrides)
}
render() {
local name="$1" src out next prog tpl base
src=$(theme_dir "$name") || die "no theme called '$name' (try: rice-theme list)"
[[ -f $src/colors.toml ]] || die "$src has no colors.toml"
mkdir -p "$STATE"
next=$(mktemp -d "$STATE/.theme.XXXXXX")
cp -a "$src/." "$next/"
# Your overrides (the font from rice-font, or any colour) apply on top of every theme.
if [[ -f $OVERRIDE ]]; then
printf '\n# --- from %s ---\n' "$OVERRIDE" >> "$next/colors.toml"
cat "$OVERRIDE" >> "$next/colors.toml"
fi
prog="$next/.render.sed"
sed_program "$next/colors.toml" > "$prog"
# User templates first, so a same-named built-in is skipped.
for tpl in "$USER_TEMPLATES"/*.tpl "$RICE_DIR"/templates/*.tpl; do
[[ -f $tpl ]] || continue
base=$(basename "$tpl" .tpl)
out="$next/$base"
[[ -e $out ]] && continue
sed -f "$prog" "$tpl" > "$out"
if grep -q '{{' "$out"; then
printf 'rice-theme: %s has unknown placeholders: %s\n' "$base" \
"$(grep -o '{{[^}]*}}' "$out" | sort -u | tr '\n' ' ')" >&2
fi
done
rm -f "$prog"
rm -rf "$CURRENT.old"
[[ -d $CURRENT ]] && mv "$CURRENT" "$CURRENT.old"
mv "$next" "$CURRENT"
rm -rf "$CURRENT.old"
printf '%s\n' "$name" > "$STATE/theme.name"
}
# signal_if_caught <SIGNAL> <process-name>: send the signal only to processes
# whose SigCgt mask says they install a handler for it.
signal_if_caught() {
local sig="$1" name="$2" num pid mask
num=$(kill -l "$sig")
for pid in $(pgrep -u "$UID" -x "$name"); do
mask=$(awk '/^SigCgt:/ { print $2 }' "/proc/$pid/status" 2>/dev/null)
[[ -n $mask ]] && (( (16#$mask >> (num - 1)) & 1 )) && kill -"$sig" "$pid"
done
return 0
}
# Push the freshly rendered files into running programs. Each step is
# best-effort: a program that isn't running simply has nothing to reload.
apply() {
local t="$CURRENT"
[[ -f $t/bspwm-colors.sh ]] && pgrep -x bspwm >/dev/null && bash "$t/bspwm-colors.sh"
[[ -f $t/Xresources ]] && command -v xrdb >/dev/null && xrdb -merge "$t/Xresources"
# One cursor for X, GTK and Qt apps: the default cursor theme follows the rice's.
local cursor; cursor=$("$0" get cursor_theme 2>/dev/null)
if [[ -n $cursor ]]; then
mkdir -p "$HOME/.icons/default"
printf '[Icon Theme]\nInherits=%s\n' "$cursor" > "$HOME/.icons/default/index.theme"
fi
pgrep -x polybar >/dev/null && polybar-msg cmd restart >/dev/null 2>&1 || true
pgrep -x dunst >/dev/null && dunstctl reload >/dev/null 2>&1 || true
pgrep -x eww >/dev/null && eww reload >/dev/null 2>&1 || true
# USR1 makes kitty and the rice's st re-read their colours, but it kills a
# process that doesn't handle it (an older st build), so check first.
signal_if_caught USR1 kitty
signal_if_caught USR1 st
pgrep -x xsettingsd >/dev/null && pkill -HUP -x xsettingsd || true
if command -v flameshot >/dev/null; then
flameshot config --maincolor "$("$0" get accent)" --contrastcolor "$("$0" get bg)" \
--showhelp false --trayicon false >/dev/null 2>&1 || true
fi
if [[ -d $HOOKS ]]; then
for h in "$HOOKS"/*; do [[ -x $h ]] && "$h" "$(cat "$STATE/theme.name")" || true; done
fi
}
cmd="${1:-}"
case "$cmd" in
set)
[[ -n ${2:-} ]] || die "usage: rice-theme set <name>"
exec 9>"${XDG_RUNTIME_DIR:-/tmp}/rice-theme.lock"; flock 9
render "$2"; apply
# Only a theme change swaps the wallpaper; `reload` keeps the one you picked.
command -v rice-wallpaper >/dev/null && { rice-wallpaper theme-default >/dev/null 2>&1 || true; }
command -v notify-send >/dev/null && notify-send -a rice -u low "Theme" "$(grep -m1 '^name' "$CURRENT/colors.toml" | cut -d'"' -f2)" || true
;;
render)
# Render only, touch no running program (used by install.sh and for testing).
render "${2:-$(cat "$STATE/theme.name" 2>/dev/null || echo sakura-line)}"
;;
reload)
exec 9>"${XDG_RUNTIME_DIR:-/tmp}/rice-theme.lock"; flock 9
render "$(cat "$STATE/theme.name" 2>/dev/null || echo sakura-line)"; apply
;;
list)
{ ls -1 "$RICE_DIR/themes"; ls -1 "${XDG_CONFIG_HOME:-$HOME/.config}/rice/themes" 2>/dev/null; } | sort -u
;;
current)
cat "$STATE/theme.name" 2>/dev/null || echo sakura-line
;;
get)
[[ -n ${2:-} ]] || die "usage: rice-theme get <key>"
parse_colors "$CURRENT/colors.toml" | awk -F'\t' -v k="$2" '$1 == k { v = $2; found = 1 } END { if (found) print v; exit !found }'
;;
*)
sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'
[[ -z $cmd ]] || exit 2
;;
esac

28
bin/rice-volume Executable file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# rice-volume up|down|mute|mic-mute|set <percent> - volume with an on-screen display.
set -uo pipefail
sink=@DEFAULT_AUDIO_SINK@
src=@DEFAULT_AUDIO_SOURCE@
step=5
# prints "<percent> <muted|on>"
level() { wpctl get-volume "$1" | awk '{ printf "%d %s\n", $2 * 100 + 0.5, ($3 == "[MUTED]") ? "muted" : "on" }'; }
case "${1:-}" in
up) wpctl set-mute "$sink" 0; wpctl set-volume -l 1.0 "$sink" "${step}%+" ;;
down) wpctl set-volume "$sink" "${step}%-" ;;
mute) wpctl set-mute "$sink" toggle ;;
# set comes from the panel slider, which already shows the level: no OSD.
set) wpctl set-volume -l 1.0 "$sink" "${2:?usage: rice-volume set <percent>}%"; exit 0 ;;
mic-mute)
wpctl set-mute "$src" toggle
read -r v s < <(level "$src")
if [[ $s == muted ]]; then rice-osd 󰍭 Microphone muted; else rice-osd 󰍬 Microphone "$v"; fi
exit 0
;;
*) echo "usage: rice-volume up|down|mute|mic-mute|set <percent>" >&2; exit 2 ;;
esac
read -r v s < <(level "$sink")
if [[ $s == muted ]]; then rice-osd 󰝟 Volume muted; else rice-osd 󰕾 Volume "$v"; fi

56
bin/rice-wallpaper Executable file
View file

@ -0,0 +1,56 @@
#!/usr/bin/env bash
# rice-wallpaper - the desktop background.
#
# rice-wallpaper set <image> set it and remember it
# rice-wallpaper restore set the remembered one (session start)
# rice-wallpaper theme-default set the active theme's own wallpaper
# rice-wallpaper next the next image in the list
# rice-wallpaper list images from the rice, ~/.config/rice/wallpapers and ~/Pictures/Wallpapers
# rice-wallpaper thumb <image> a cached 400x250 thumbnail for the pickers
set -uo pipefail
RICE_DIR="${RICE_DIR:-$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)}"
state="${XDG_STATE_HOME:-$HOME/.local/state}/rice"
remembered="$state/wallpaper"
thumbs="${XDG_CACHE_HOME:-$HOME/.cache}/rice/thumbs"
mkdir -p "$state" "$thumbs"
list() {
find -L "$RICE_DIR/wallpapers" "${XDG_CONFIG_HOME:-$HOME/.config}/rice/wallpapers" "$HOME/Pictures/Wallpapers" \
-maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \) 2>/dev/null | sort
}
apply() {
[[ -f $1 ]] || { echo "rice-wallpaper: no such image: $1" >&2; return 1; }
feh --no-fehbg --bg-fill "$1" && realpath -- "$1" > "$remembered"
}
case "${1:-}" in
set) apply "${2:?usage: rice-wallpaper set <image>}" ;;
restore)
if [[ -f $remembered && -f $(cat "$remembered") ]]; then apply "$(cat "$remembered")"; else "$0" theme-default; fi
;;
theme-default)
name=$(rice-theme get wallpaper 2>/dev/null)
[[ -n $name && -f $RICE_DIR/wallpapers/$name ]] && apply "$RICE_DIR/wallpapers/$name"
;;
next)
mapfile -t all < <(list)
(( ${#all[@]} > 0 )) || exit 1
current=$(cat "$remembered" 2>/dev/null)
for i in "${!all[@]}"; do
if [[ ${all[$i]} == "$current" ]]; then apply "${all[$(( (i + 1) % ${#all[@]} ))]}"; exit; fi
done
apply "${all[0]}"
;;
list) list ;;
thumb)
img="${2:?usage: rice-wallpaper thumb <image>}"
out="$thumbs/$(printf '%s:%s' "$img" "$(stat -c %Y -- "$img" 2>/dev/null)" | sha1sum | cut -c1-16).jpg"
if [[ ! -s $out ]] && command -v magick >/dev/null; then
magick "${img}[0]" -thumbnail '400x250^' -gravity center -extent 400x250 -quality 85 "$out" 2>/dev/null
fi
if [[ -s $out ]]; then echo "$out"; else echo "$img"; fi
;;
*) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac

5
bin/rice-zoom Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
# rice-zoom - zoom into the screen. Scroll to zoom, drag to pan, q or Escape to leave.
command -v boomer >/dev/null || { rice-notify send "Zoom" "Install boomer-git to zoom"; exit 1; }
pgrep -x boomer >/dev/null && exit 0
exec boomer