commit 7f41cf7a822d42bb47c4d8a32cd640d8e364c9ce Author: Sarthak <118442597+sarthak1337-alt@users.noreply.github.com> Date: Mon Sep 14 16:12:09 2026 +0530 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81e0a65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# build output +particles/particles +particles/*.o + +# never commit private files, even if someone copies them in here +home/.config/rice/secrets/ +home/.config/rice/local.sh +home/.bashrc.local +home/.config/rice/theme.override.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..40ab8b8 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# Sakura Line + +A bspwm rice for Arch Linux where everything - bar, menus, popups, +notifications, lock screen, terminal and prompt - is drawn from one palette and +one set of shapes, so it feels like one system instead of a pile of tools. + +![Sakura Line](docs/old-screenshot.webp) + +## Install + +On a clean Arch install, as your normal user: + +```sh +curl -fsSL https://rice.srtk.in/install.sh | bash +``` + +or from a clone: + +```sh +git clone https://git.srtk.in/sarthak/dotfiles.git ~/rice +~/rice/install.sh # full install, safe to re-run +~/rice/install.sh --dry-run # print every step, change nothing +~/rice/install.sh --update # pull, re-link, re-render, rebuild what changed +``` + +The installer installs the packages (bootstrapping paru for the AUR), symlinks +every file in `home/` into `$HOME` and every script in `bin/` into +`~/.local/bin`, renders the theme, builds st with sixel images, and offers +calendar sync and a stock Plymouth boot splash. Anything it replaces is moved +to `~/.rice-backup-/`. Log in on tty1 and the desktop starts. + +## What you get + +| | | +|---|---| +| Window manager | bspwm + sxhkd | +| Bar | polybar, floating and rounded; every label opens its panel | +| Popups | eww: control centre, audio, network, bluetooth, display, power, media, calendar, notifications | +| Menus | rofi: launcher, rice menu, power, toggles, capture, theme and wallpaper pickers, emoji, clipboard, keybindings | +| Notifications | dunst, with a notification centre, per-notification dismiss and do-not-disturb | +| Compositor | picom 13, one motion language for windows and popups | +| Terminal | st (st-flexipatch with sixel, ligatures, scrollback) and kitty | +| Prompt | starship, one line: `user@host dir branch ❯` | +| Idle | xscreensaver hacks after 5 minutes, xsecurelock after 10, skipped during fullscreen video | +| Calendar | khal + vdirsyncer against any CalDAV server; reminders as systemd timers | +| Capture | flameshot, gpu-screen-recorder, OCR, QR codes, colour picker, webcam overlay, transcode | + +`super + k` lists every shortcut. The ones to learn first: + +| | | +|---|---| +| `super + Return` | terminal | +| `super + w` / `super + m` | app launcher / rice menu | +| `super + a` / `super + n` | control centre / notification centre | +| `super + ctrl + a / w / b / d / p / m` | audio / network / bluetooth / display / power / media | +| `super + ctrl + c` | calendar | +| `super + ctrl + r` | new reminder ("20m tea", "15:30 standup") | +| `super + c` / `super + v` | copy / paste, terminals included | +| `super + ctrl + v` / `super + ctrl + e` | clipboard history / emoji | +| `Print` or `super + alt + p` | screenshot a region | +| `shift + Print` or `super + alt + shift + p` | screenshot the whole screen | +| `ctrl + Print` or `super + alt + t` | copy text from a region | +| `alt + Print` or `super + alt + v` | start or stop recording | +| `super + shift + Print` or `super + alt + c` | pick a colour | +| `super + Print` or `super + alt + s` | capture menu | +| `` super + ` `` | drop-down terminal | +| `super + Escape` / `super + ctrl + l` | power menu / lock | + +## Theming + +A theme is one file, `themes//colors.toml`. `rice-theme set ` +renders every file in `templates/` from it into `~/.local/state/rice/theme/` +and reloads the running apps; configs include or link those rendered files. + +- Your own themes go in `~/.config/rice/themes//`. +- `~/.config/rice/theme.override.toml` wins over every theme (`rice-font` writes the font there). +- Your own templates go in `~/.config/rice/templates/*.tpl` and win over the built-in ones. + +Placeholders are `{{ key }}`, `{{ key_strip }}` (no `#`) and `{{ key_rgb }}` (`r,g,b`). + +## Your own settings + +These are never in the repo and never overwritten: + +| | | +|---|---| +| `~/.bashrc.local` | personal aliases, exports, SDK paths | +| `~/.config/rice/local.sh` | autostarted apps, per-app desktops, monitor tweaks (sourced by bspwmrc) | +| `~/.config/rice/idle.conf` | `SCREENSAVER=` and `LOCK=` in seconds | +| `~/.config/rice/screensavers` | the xscreensaver hacks to rotate through | +| `~/.config/rice/hooks/.d/` | scripts run on `post-boot`, `theme-set`, `font-set`, `wallpaper-set`, `lock`, `unlock`, `update` | +| `~/.config/rice/secrets/` | the CalDAV password (mode 600) | + +## Layout + +``` +install.sh one-shot installer +packages/ pacman.txt and aur.txt +bin/ rice-* scripts: panels' data and actions, menus, capture, idle, theme +home/ files linked into $HOME +templates/ *.tpl rendered from a theme's colors.toml +themes/ one directory per theme +st/ st-flexipatch patch list, config changes and build script +particles/ the particle wallpaper layer +wallpapers/ +``` diff --git a/bin/rice-about b/bin/rice-about new file mode 100755 index 0000000..c015207 --- /dev/null +++ b/bin/rice-about @@ -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' diff --git a/bin/rice-audio b/bin/rice-audio new file mode 100755 index 0000000..d554dfd --- /dev/null +++ b/bin/rice-audio @@ -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 default output; playing streams move with it +# rice-audio source default input +# rice-audio app-volume volume of one playing app +# rice-audio app-mute +# rice-audio mic | 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 diff --git a/bin/rice-bar b/bin/rice-bar new file mode 100755 index 0000000..70c7886 --- /dev/null +++ b/bin/rice-bar @@ -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 diff --git a/bin/rice-bt b/bin/rice-bt new file mode 100755 index 0000000..81e4f00 --- /dev/null +++ b/bin/rice-bt @@ -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 connect or disconnect; new devices are paired and trusted first +# rice-bt forget +# +# 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 diff --git a/bin/rice-cal b/bin/rice-cal new file mode 100755 index 0000000..ac37eb0 --- /dev/null +++ b/bin/rice-cal @@ -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 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("^ *(?[0-9]+) (?.+)$") | {(.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 "$secrets/caldav") + unset pass + mkdir -p "$conf/vdirsyncer" "$conf/khal" "$data" "$share/vdirsyncer/status" + + cat > "$conf/vdirsyncer/config" </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" </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 diff --git a/bin/rice-capture b/bin/rice-capture new file mode 100755 index 0000000..7ec1170 --- /dev/null +++ b/bin/rice-capture @@ -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 [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 diff --git a/bin/rice-clip b/bin/rice-clip new file mode 100755 index 0000000..d3b923e --- /dev/null +++ b/bin/rice-clip @@ -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 diff --git a/bin/rice-clipboard b/bin/rice-clipboard new file mode 100755 index 0000000..0702194 --- /dev/null +++ b/bin/rice-clipboard @@ -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" diff --git a/bin/rice-dictate b/bin/rice-dictate new file mode 100755 index 0000000..ef57f02 --- /dev/null +++ b/bin/rice-dictate @@ -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 diff --git a/bin/rice-display b/bin/rice-display new file mode 100755 index 0000000..db4bfc8 --- /dev/null +++ b/bin/rice-display @@ -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 diff --git a/bin/rice-emoji b/bin/rice-emoji new file mode 100755 index 0000000..299f638 --- /dev/null +++ b/bin/rice-emoji @@ -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 diff --git a/bin/rice-font b/bin/rice-font new file mode 100755 index 0000000..c873ba2 --- /dev/null +++ b/bin/rice-font @@ -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" diff --git a/bin/rice-hook b/bin/rice-hook new file mode 100755 index 0000000..44c944c --- /dev/null +++ b/bin/rice-hook @@ -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 diff --git a/bin/rice-idle b/bin/rice-idle new file mode 100755 index 0000000..5952112 --- /dev/null +++ b/bin/rice-idle @@ -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 diff --git a/bin/rice-keys b/bin/rice-keys new file mode 100755 index 0000000..de704d0 --- /dev/null +++ b/bin/rice-keys @@ -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/&/\&/g; s/</\</g; s/>/\>/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 diff --git a/bin/rice-lock b/bin/rice-lock new file mode 100755 index 0000000..17e2ec5 --- /dev/null +++ b/bin/rice-lock @@ -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" diff --git a/bin/rice-media b/bin/rice-media new file mode 100755 index 0000000..35e7f51 --- /dev/null +++ b/bin/rice-media @@ -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 diff --git a/bin/rice-menu b/bin/rice-menu new file mode 100755 index 0000000..b8c834a --- /dev/null +++ b/bin/rice-menu @@ -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 diff --git a/bin/rice-net b/bin/rice-net new file mode 100755 index 0000000..b13b5dc --- /dev/null +++ b/bin/rice-net @@ -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 diff --git a/bin/rice-notify b/bin/rice-notify new file mode 100755 index 0000000..1834e04 --- /dev/null +++ b/bin/rice-notify @@ -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 diff --git a/bin/rice-notify-hook b/bin/rice-notify-hook new file mode 100755 index 0000000..5b6e88e --- /dev/null +++ b/bin/rice-notify-hook @@ -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" diff --git a/bin/rice-osd b/bin/rice-osd new file mode 100755 index 0000000..b5a1342 --- /dev/null +++ b/bin/rice-osd @@ -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 diff --git a/bin/rice-panel b/bin/rice-panel new file mode 100755 index 0000000..ad22231 --- /dev/null +++ b/bin/rice-panel @@ -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 diff --git a/bin/rice-power b/bin/rice-power new file mode 100755 index 0000000..9c6c108 --- /dev/null +++ b/bin/rice-power @@ -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 diff --git a/bin/rice-preview b/bin/rice-preview new file mode 100755 index 0000000..6481a49 --- /dev/null +++ b/bin/rice-preview @@ -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 diff --git a/bin/rice-reminder b/bin/rice-reminder new file mode 100755 index 0000000..29dc425 --- /dev/null +++ b/bin/rice-reminder @@ -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 diff --git a/bin/rice-scratchpad b/bin/rice-scratchpad new file mode 100755 index 0000000..4c32d1c --- /dev/null +++ b/bin/rice-scratchpad @@ -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 diff --git a/bin/rice-screensaver b/bin/rice-screensaver new file mode 100755 index 0000000..68cdd16 --- /dev/null +++ b/bin/rice-screensaver @@ -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 diff --git a/bin/rice-session b/bin/rice-session new file mode 100755 index 0000000..791ecf0 --- /dev/null +++ b/bin/rice-session @@ -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 diff --git a/bin/rice-status b/bin/rice-status new file mode 100755 index 0000000..1bc175a --- /dev/null +++ b/bin/rice-status @@ -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 diff --git a/bin/rice-sysinfo b/bin/rice-sysinfo new file mode 100755 index 0000000..3369beb --- /dev/null +++ b/bin/rice-sysinfo @@ -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 diff --git a/bin/rice-theme b/bin/rice-theme new file mode 100755 index 0000000..5f2df5d --- /dev/null +++ b/bin/rice-theme @@ -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 diff --git a/bin/rice-volume b/bin/rice-volume new file mode 100755 index 0000000..52c1c6e --- /dev/null +++ b/bin/rice-volume @@ -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 diff --git a/bin/rice-wallpaper b/bin/rice-wallpaper new file mode 100755 index 0000000..5243096 --- /dev/null +++ b/bin/rice-wallpaper @@ -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 diff --git a/bin/rice-zoom b/bin/rice-zoom new file mode 100755 index 0000000..0a4403f --- /dev/null +++ b/bin/rice-zoom @@ -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 diff --git a/docs/old-screenshot.webp b/docs/old-screenshot.webp new file mode 100644 index 0000000..ef4edd2 Binary files /dev/null and b/docs/old-screenshot.webp differ diff --git a/home/.bash_profile b/home/.bash_profile new file mode 100644 index 0000000..962d796 --- /dev/null +++ b/home/.bash_profile @@ -0,0 +1,16 @@ +# ~/.bash_profile +[[ -f ~/.bashrc ]] && . ~/.bashrc + +# Auto-start the graphical session, but ONLY in the exact situation where it is +# safe to do so. Any one of these guards failing leaves you at a normal shell. +# - not already inside X +# - not an SSH session (never hijack a remote login) +# - physically on VT 1 +# - no Xorg already running for this user +# NOTE: deliberately NOT `exec startx`. If X dies or fails to start we fall back +# to an interactive shell on tty1 instead of a login loop. +if [[ -z "$DISPLAY" && -z "$SSH_CONNECTION" && "$XDG_VTNR" == "1" ]]; then + if ! pgrep -u "$USER" -x Xorg >/dev/null 2>&1; then + startx + fi +fi diff --git a/home/.bashrc b/home/.bashrc new file mode 100644 index 0000000..33471d8 --- /dev/null +++ b/home/.bashrc @@ -0,0 +1,47 @@ +# Sakura Line - bash. +# Aliases and functions live in ~/.config/rice/bash/. Anything personal or +# machine-only (work aliases, SDK paths, tokens) goes in ~/.bashrc.local, +# which is not part of the repo. + +export PATH="$HOME/.local/bin:$PATH" +[[ $- != *i* ]] && { [[ -f $HOME/.bashrc.local ]] && . "$HOME/.bashrc.local"; return; } + +export EDITOR=nvim +export VISUAL=nvim +export BAT_THEME=ansi +export MANPAGER="sh -c 'col -bx | bat -l man -p'" +export MANROFFOPT="-c" + +theme="${XDG_STATE_HOME:-$HOME/.local/state}/rice/theme" + +# --- history --- +HISTSIZE=50000 +HISTFILESIZE=50000 +HISTCONTROL=ignoreboth:erasedups +shopt -s histappend checkwinsize globstar +PROMPT_COMMAND="history -a${PROMPT_COMMAND:+; $PROMPT_COMMAND}" + +# --- completion --- +[[ -r /usr/share/bash-completion/bash_completion ]] && . /usr/share/bash-completion/bash_completion + +# --- prompt: two lines in the theme's colours --- +if command -v starship >/dev/null; then + export STARSHIP_CONFIG="$theme/starship.toml" + eval "$(starship init bash)" +fi + +# --- fzf: Ctrl+R history, Ctrl+T files, Alt+C directories, coloured like rofi --- +[[ -f $theme/fzf.sh ]] && . "$theme/fzf.sh" +[[ -r /usr/share/fzf/key-bindings.bash ]] && . /usr/share/fzf/key-bindings.bash +[[ -r /usr/share/fzf/completion.bash ]] && . /usr/share/fzf/completion.bash + +# --- zoxide: `z` jumps to directories you have been in before --- +command -v zoxide >/dev/null && eval "$(zoxide init bash)" + +# --- rice aliases and functions --- +for rc in "$HOME"/.config/rice/bash/*.bash; do + [[ -r $rc ]] && . "$rc" +done +unset rc + +[[ -f $HOME/.bashrc.local ]] && . "$HOME/.bashrc.local" diff --git a/home/.config/bspwm/bspwmrc b/home/.config/bspwm/bspwmrc new file mode 100755 index 0000000..f333e09 --- /dev/null +++ b/home/.config/bspwm/bspwmrc @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Sakura Line - bspwm. +# Colours, border width and gaps come from the theme (rice-theme); everything +# that runs for the session is started by rice-session. Machine-specific extras +# (autostarted apps, per-app desktops) go in ~/.config/rice/local.sh, which is +# not part of the repo. + +export PATH="$HOME/.local/bin:$PATH" +export XDG_CURRENT_DESKTOP=bspwm +export QT_QPA_PLATFORMTHEME=qt6ct +export GTK_THEME=Adwaita:dark +[[ -f $HOME/.env ]] && . "$HOME/.env" + +theme="${XDG_STATE_HOME:-$HOME/.local/state}/rice/theme" + +# ---------------------------------------------------------------- monitors +primary=$(xrandr --query | awk '/ connected primary/ {print $1; exit}') +[[ -z $primary ]] && primary=$(bspc query -M --names | head -n1) +bspc monitor "$primary" -d 1 2 3 4 5 6 + +# Run the primary output at its highest mode and refresh rate. +mode=$(xrandr --query | awk -v m="$primary" '$1 == m {getline; print $1; exit}') +rate=$(xrandr --query | awk -v m="$primary" '$1 == m {getline; for (i = 2; i <= NF; i++) { r = $i; gsub(/[*+]/, "", r); if (r + 0 > best + 0) best = r }; print best; exit}') +[[ -n $mode && -n $rate ]] && xrandr --output "$primary" --mode "$mode" --rate "$rate" + +# ------------------------------------------------------------------ layout +bspc config focus_follows_pointer true +bspc config borderless_monocle true +bspc config gapless_monocle false +bspc config split_ratio 0.52 +bspc config automatic_scheme longest_side +bspc config initial_polarity second_child +bspc config pointer_modifier mod4 +bspc config pointer_action1 move +bspc config pointer_action2 resize_side +bspc config pointer_action3 resize_corner + +# colours, border width, gaps +[[ -f $theme/bspwm-colors.sh ]] && . "$theme/bspwm-colors.sh" + +# ------------------------------------------------------------------- rules +# Restarting bspwm re-runs this file, so start from a clean rule list. +bspc rule -r '*' + +for app in feh imv mpv vlc Yad Zenity Xarchiver ark Blueman-manager; do + bspc rule -a "$app" state=floating center=on +done +bspc rule -a Pavucontrol state=floating center=on rectangle=920x450+0+0 +bspc rule -a qt5ct state=floating center=on rectangle=900x600+0+0 +bspc rule -a qt6ct state=floating center=on rectangle=900x600+0+0 +bspc rule -a Gnome-calculator state=floating center=on rectangle=360x500+0+0 +bspc rule -a loupe state=floating center=on rectangle=1200x800+0+0 +bspc rule -a '*:sun-awt-X11-XWindowPeer' manage=off + +# rice surfaces +bspc rule -a rice-float state=floating center=on rectangle=900x560+0+0 +bspc rule -a rice-about state=floating center=on rectangle=760x440+0+0 +bspc rule -a rice-scratchpad state=floating sticky=on layer=above +bspc rule -a '*:rice-webcam' state=floating sticky=on layer=above border=off focus=off +bspc rule -a boomer state=fullscreen + +# ------------------------------------------------------------------ session +xsetroot -cursor_name left_ptr +wmname LG3D + +[[ -f $HOME/.config/rice/local.sh ]] && . "$HOME/.config/rice/local.sh" + +rice-session start & diff --git a/home/.config/btop/btop.conf b/home/.config/btop/btop.conf new file mode 100644 index 0000000..7694e19 --- /dev/null +++ b/home/.config/btop/btop.conf @@ -0,0 +1,286 @@ +#? Config file for btop v.1.4.7 + +#* Name of a btop++/bpytop/bashtop formatted ".theme" file, "Default" and "TTY" for builtin themes. +#* Themes should be placed in "../share/btop/themes" relative to binary or "$HOME/.config/btop/themes" +color_theme = "rice" + +#* If the theme set background should be shown, set to False if you want terminal background transparency. +theme_background = false + +#* Sets if 24-bit truecolor should be used, will convert 24-bit colors to 256 color (6x6x6 color cube) if false. +truecolor = true + +#* Set to true to force tty mode regardless if a real tty has been detected or not. +#* Will force 16-color mode and TTY theme, set all graph symbols to "tty" and swap out other non tty friendly symbols. +force_tty = false + +#* Option to disable presets. Either the default preset, custom presets, or all presets. +#* "Off" All presets are enabled. +#* "Default" preset is disabled.#* "Custom" presets are disabled.#* "All" presets are disabled. +disable_presets = "Off" + +#* Define presets for the layout of the boxes. Preset 0 is always all boxes shown with default settings. Max 9 presets. +#* Format: "box_name:P:G,box_name:P:G" P=(0 or 1) for alternate positions, G=graph symbol to use for box. +#* Use whitespace " " as separator between different presets. +#* Example: "cpu:0:default,mem:0:tty,proc:1:default cpu:0:braille,proc:0:tty" +presets = "cpu:1:default,proc:0:default cpu:0:default,mem:0:default,net:0:default cpu:0:block,net:0:tty" + +#* Set to True to enable "h,j,k,l,g,G" keys for directional control in lists. +#* Conflicting keys for h:"help" and k:"kill" is accessible while holding shift. +vim_keys = false + +#* Disable all mouse events. +disable_mouse = false + +#* Rounded corners on boxes, is ignored if TTY mode is ON. +rounded_corners = true + +#* Use terminal synchronized output sequences to reduce flickering on supported terminals. +terminal_sync = true + +#* Default symbols to use for graph creation, "braille", "block" or "tty". +#* "braille" offers the highest resolution but might not be included in all fonts. +#* "block" has half the resolution of braille but uses more common characters. +#* "tty" uses only 3 different symbols but will work with most fonts and should work in a real TTY. +#* Note that "tty" only has half the horizontal resolution of the other two, so will show a shorter historical view. +graph_symbol = "braille" + +# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty". +graph_symbol_cpu = "default" + +# Graph symbol to use for graphs in gpu box, "default", "braille", "block" or "tty". +graph_symbol_gpu = "default" + +# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty". +graph_symbol_mem = "default" + +# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty". +graph_symbol_net = "default" + +# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty". +graph_symbol_proc = "default" + +#* Manually set which boxes to show. Available values are "cpu mem net proc" and "gpu0" through "gpu5", separate values with whitespace. +shown_boxes = "cpu mem net proc" + +#* Update time in milliseconds, recommended 2000 ms or above for better sample times for graphs. +update_ms = 1000 + +#* Processes sorting, "pid" "program" "arguments" "threads" "user" "memory" "cpu lazy" "cpu direct", +#* "cpu lazy" sorts top process over time (easier to follow), "cpu direct" updates top process directly. +proc_sorting = "cpu lazy" + +#* Reverse sorting order, True or False. +proc_reversed = false + +#* Show processes as a tree. +proc_tree = false + +#* Use the cpu graph colors in the process list. +proc_colors = true + +#* Use a darkening gradient in the process list. +proc_gradient = true + +#* If process cpu usage should be of the core it's running on or usage of the total available cpu power. +proc_per_core = false + +#* Show process memory as bytes instead of percent. +proc_mem_bytes = true + +#* Show cpu graph for each process. +proc_cpu_graphs = true + +#* Use /proc/[pid]/smaps for memory information in the process info box (very slow but more accurate) +proc_info_smaps = false + +#* Show proc box on left side of screen instead of right. +proc_left = false + +#* (Linux) Filter processes tied to the Linux kernel(similar behavior to htop). +proc_filter_kernel = false + +#* Should the process list follow the selected process when detailed view is open. +proc_follow_detailed = true + +#* In tree-view, always accumulate child process resources in the parent process. +proc_aggregate = false + +#* Should cpu and memory usage display be preserved for dead processes when paused. +keep_dead_proc_usage = false + +#* Sets the CPU stat shown in upper half of the CPU graph, "total" is always available. +#* Select from a list of detected attributes from the options menu. +cpu_graph_upper = "total" + +#* Sets the CPU stat shown in lower half of the CPU graph, "total" is always available. +#* Select from a list of detected attributes from the options menu. +cpu_graph_lower = "Auto" + +#* If gpu info should be shown in the cpu box. Available values = "Auto", "On" and "Off". +show_gpu_info = "Auto" + +#* Toggles if the lower CPU graph should be inverted. +cpu_invert_lower = true + +#* Set to True to completely disable the lower CPU graph. +cpu_single_graph = false + +#* Show cpu box at bottom of screen instead of top. +cpu_bottom = false + +#* Shows the system uptime in the CPU box. +show_uptime = true + +#* Shows the CPU package current power consumption in watts. Requires running `make setcap` or `make setuid` or running with sudo. +show_cpu_watts = true + +#* Show cpu temperature. +check_temp = true + +#* Which sensor to use for cpu temperature, use options menu to select from list of available sensors. +cpu_sensor = "Auto" + +#* Show temperatures for cpu cores also if check_temp is True and sensors has been found. +show_coretemp = true + +#* Set a custom mapping between core and coretemp, can be needed on certain cpus to get correct temperature for correct core. +#* Use lm-sensors or similar to see which cores are reporting temperatures on your machine. +#* Format "x:y" x=core with wrong temp, y=core with correct temp, use space as separator between multiple entries. +#* Example: "4:0 5:1 6:3" +cpu_core_map = "" + +#* Which temperature scale to use, available values: "celsius", "fahrenheit", "kelvin" and "rankine". +temp_scale = "celsius" + +#* Use base 10 for bits/bytes sizes, KB = 1000 instead of KiB = 1024. +base_10_sizes = false + +#* Show CPU frequency. +show_cpu_freq = true + +#* How to calculate CPU frequency, available values: "first", "range", "lowest", "highest" and "average". +freq_mode = "first" + +#* Draw a clock at top of screen, formatting according to strftime, empty string to disable. +#* Special formatting: /host = hostname | /user = username | /uptime = system uptime +clock_format = "%H:%M" + +#* Update main ui in background when menus are showing, set this to false if the menus is flickering too much for comfort. +background_update = true + +#* Custom cpu model name, empty string to disable. +custom_cpu_name = "" + +#* Optional filter for shown disks, should be full path of a mountpoint, separate multiple values with whitespace " ". +#* Only disks matching the filter will be shown. Prepend exclude= to only show disks not matching the filter. Examples: disk_filter="/boot /home/user", disks_filter="exclude=/boot /home/user" +disks_filter = "" + +#* Show graphs instead of meters for memory values. +mem_graphs = true + +#* Show mem box below net box instead of above. +mem_below_net = false + +#* Count ZFS ARC in cached and available memory. +zfs_arc_cached = true + +#* If swap memory should be shown in memory box. +show_swap = true + +#* Show swap as a disk, ignores show_swap value above, inserts itself after first disk. +swap_disk = true + +#* If mem box should be split to also show disks info. +show_disks = true + +#* Filter out non physical disks. Set this to False to include network disks, RAM disks and similar. +only_physical = true + +#* Read disks list from /etc/fstab. This also disables only_physical. +use_fstab = true + +#* Setting this to True will hide all datasets, and only show ZFS pools. (IO stats will be calculated per-pool) +zfs_hide_datasets = false + +#* Set to true to show available disk space for privileged users. +disk_free_priv = false + +#* Toggles if io activity % (disk busy time) should be shown in regular disk usage view. +show_io_stat = true + +#* Toggles io mode for disks, showing big graphs for disk read/write speeds. +io_mode = false + +#* Set to True to show combined read/write io graphs in io mode. +io_graph_combined = false + +#* Set the top speed for the io graphs in MiB/s (100 by default), use format "mountpoint:speed" separate disks with whitespace " ". +#* Example: "/mnt/media:100 /:20 /boot:1". +io_graph_speeds = "" + +#* Swap the positions of the upload and download speed graphs. When true, upload will be on top. +swap_upload_download = false + +#* Set fixed values for network graphs in Mebibits. Is only used if net_auto is also set to False. +net_download = 100 + +net_upload = 100 + +#* Use network graphs auto rescaling mode, ignores any values set above and rescales down to 10 Kibibytes at the lowest. +net_auto = true + +#* Sync the auto scaling for download and upload to whichever currently has the highest scale. +net_sync = true + +#* Starts with the Network Interface specified here. +net_iface = "" + +#* "True" shows bitrates in base 10 (Kbps, Mbps). "False" shows bitrates in binary sizes (Kibps, Mibps, etc.). "Auto" uses base_10_sizes. +base_10_bitrate = "Auto" + +#* Show battery stats in top right if battery is present. +show_battery = true + +#* Which battery to use if multiple are present. "Auto" for auto detection. +selected_battery = "Auto" + +#* Show power stats of battery next to charge indicator. +show_battery_watts = true + +#* Set loglevel for "~/.local/state/btop.log" levels are: "ERROR" "WARNING" "INFO" "DEBUG". +#* The level set includes all lower levels, i.e. "DEBUG" will show all logging info. +log_level = "WARNING" + +#* Automatically save current settings to config file on exit. +save_config_on_exit = false + +#* Measure PCIe throughput on NVIDIA cards, may impact performance on certain cards. +nvml_measure_pcie_speeds = true + +#* Measure PCIe throughput on AMD cards, may impact performance on certain cards. +rsmi_measure_pcie_speeds = true + +#* Horizontally mirror the GPU graph. +gpu_mirror_graph = true + +#* Set which GPU vendors to show. Available values are "nvidia amd intel apple" +shown_gpus = "nvidia amd intel apple" + +#* Custom gpu0 model name, empty string to disable. +custom_gpu_name0 = "" + +#* Custom gpu1 model name, empty string to disable. +custom_gpu_name1 = "" + +#* Custom gpu2 model name, empty string to disable. +custom_gpu_name2 = "" + +#* Custom gpu3 model name, empty string to disable. +custom_gpu_name3 = "" + +#* Custom gpu4 model name, empty string to disable. +custom_gpu_name4 = "" + +#* Custom gpu5 model name, empty string to disable. +custom_gpu_name5 = "" diff --git a/home/.config/dunst/dunstrc b/home/.config/dunst/dunstrc new file mode 100644 index 0000000..60e5ad6 --- /dev/null +++ b/home/.config/dunst/dunstrc @@ -0,0 +1,102 @@ +# Sakura Line - dunst behaviour. Colours and fonts come from the theme drop-in +# (dunstrc.d/50-theme.conf -> ~/.local/state/rice/theme/dunst.conf). + +[global] + monitor = 0 + follow = mouse + origin = top-right + # Same outer edges as tiled windows and popups. bspwm draws borders inside the + # gap, so that is one gap (10px) in from the right and bar (37px) + gap down. + offset = (10, 47) + width = (300, 400) + height = (0, 300) + notification_limit = 5 + gap_size = 6 + padding = 12 + horizontal_padding = 14 + text_icon_padding = 12 + frame_width = 1 + corner_radius = 12 + progress_bar = true + progress_bar_height = 6 + progress_bar_frame_width = 0 + progress_bar_min_width = 150 + progress_bar_max_width = 372 + progress_bar_corner_radius = 3 + indicate_hidden = yes + sort = urgency_descending + idle_threshold = 120 + line_height = 2 + markup = full + format = "<b>%s</b>\n%b" + alignment = left + vertical_alignment = center + show_age_threshold = 60 + ellipsize = middle + word_wrap = yes + ignore_newline = no + stack_duplicates = true + hide_duplicate_count = false + show_indicators = no + enable_recursive_icon_lookup = true + icon_position = left + min_icon_size = 32 + max_icon_size = 48 + sticky_history = yes + history_length = 50 + always_run_script = true + dmenu = /usr/bin/rofi -dmenu -theme ~/.config/rofi/menu.rasi -p "Actions" + browser = /usr/bin/xdg-open + title = Dunst + class = Dunst + default_pause_level = 0 + mouse_left_click = do_action, close_current + mouse_middle_click = close_all + mouse_right_click = close_current + +[urgency_low] + timeout = 4 + +[urgency_normal] + timeout = 6 + +[urgency_critical] + timeout = 0 + +# Do-not-disturb (rice-notify dnd) sets pause level 60. Your own confirmations +# and critical alerts sent from the command line still get through; chat apps +# that mark everything critical use their own app name, so they stay quiet. +[rice_feedback] + appname = "rice" + override_pause_level = 100 + +[critical_cli] + appname = "notify-send" + msg_urgency = critical + override_pause_level = 100 + +# Volume, brightness and recording progress: replaced in place, never kept. +[osd] + appname = "osd" + format = "<b>%s</b>" + icon_position = off + timeout = 2 + history_ignore = yes + override_pause_level = 100 + +# Every other notification pokes the notification centre so its count and list stay live. +[notify_centre] + appname = "*" + script = ~/.local/bin/rice-notify-hook + +[fullscreen_delay] + fullscreen = delay + +[fullscreen_critical] + msg_urgency = critical + fullscreen = show + +[flameshot_noise] + body = "grim's screenshot component is implemented based on wlroots*" + skip_display = true + history_ignore = yes diff --git a/home/.config/eww/eww.scss b/home/.config/eww/eww.scss new file mode 100644 index 0000000..da68098 --- /dev/null +++ b/home/.config/eww/eww.scss @@ -0,0 +1,217 @@ +// Sakura Line - eww design system. +// The same tokens as polybar, rofi and dunst: the theme's alpha, 1px border, +// 12px radius, rows with an 8px radius and a 17% accent tint on hover. +// colors.scss is a link to ~/.local/state/rice/theme/eww-colors.scss. +@import "colors"; + +$tint: rgba($accent, 0.17); + +* { + all: unset; + font-family: $font, "Symbols Nerd Font"; +} + +window, .closer { background-color: transparent; } + +tooltip { + background-color: rgba($bg, 0.95); + border: $border-width solid $border; + border-radius: $radius-small; + padding: 6px 10px; + color: $fg; + font-size: 11px; +} + +// ------------------------------------------------------------------ panel +.panel { + background-color: rgba($bg, $alpha); + border: $border-width solid $border; + border-radius: $radius; + padding: 4px 8px 10px 8px; + color: $fg; + font-size: 13px; +} + +.head { + padding: 10px 10px 12px 10px; + margin-bottom: 6px; + border-bottom: $border-width solid $border; +} +.title { font-weight: bold; font-size: 14px; } + +.head-action { + color: $dim; + font-size: 15px; + padding: 2px 7px; + margin-left: 2px; + border-radius: $radius-small; +} +.head-action:hover { color: $fg; background-color: $tint; } +.head-action.active { color: $accent; } + +.section { + color: $dim; + font-size: 11px; + font-weight: 600; + padding: 10px 10px 4px 10px; +} + +.sep { + min-height: 1px; + margin: 6px 4px; + background-color: $border; +} + +.empty { color: $dim; padding: 8px 10px; } + +// -------------------------------------------------------------------- rows +.row { + padding: 8px 10px; + border-radius: $radius-small; +} +.row:hover { background-color: $tint; } +.row.active .row-title, .row.active .glyph { color: $accent; } +.row.dimmed .row-title, .row.dimmed .glyph { color: $dim; } + +.glyph { font-size: 15px; min-width: 24px; margin-right: 10px; } +.row-title { color: $fg; } +.row-sub { color: $dim; font-size: 11px; margin-top: 1px; } +.row-value { color: $dim; margin-left: 10px; } +.chev { color: $dim; margin-left: 8px; font-size: 15px; } + +.accent { color: $accent; } +.urgent { color: $urgent; } +.good { color: $green; } +.warn { color: $yellow; } +.dim { color: $dim; } + +// ---------------------------------------------------------------- controls +.slider-row:hover { background-color: transparent; } +.slider-row .glyph { padding: 2px 0; border-radius: $radius-small; } +.slider-row .glyph:hover { color: $accent; } +.slider-value { min-width: 48px; } + +scale trough { + min-height: 6px; + min-width: 140px; + margin: 0 4px; + border-radius: 3px; + background-color: $selection; +} +scale highlight { + border-radius: 3px; + background-color: $accent; +} +scale slider { + min-width: 12px; + min-height: 12px; + margin: -3px; + border-radius: 6px; + background-color: $fg; +} + +.switch { + min-width: 30px; + min-height: 16px; + padding: 2px; + border-radius: 10px; + background-color: $selection; +} +.switch.on { background-color: rgba($accent, 0.55); } +.knob { + min-width: 12px; + min-height: 12px; + border-radius: 6px; + background-color: $dim; +} +.switch.on .knob { background-color: $fg; } + +.pill { + padding: 4px 10px; + margin: 0 3px; + border-radius: $radius-small; + color: $dim; +} +.pills { padding: 2px 6px 6px 6px; } +.pill:hover { color: $fg; background-color: $tint; } +.pill.active { color: $accent; background-color: $tint; } + +.field { + padding: 8px 10px; + border-radius: $radius-small; + background-color: rgba($surface, 0.8); + color: $fg; + caret-color: $accent; +} + +progressbar trough { + min-height: 4px; + border-radius: 2px; + background-color: $selection; +} +progressbar progress { + min-height: 4px; + border-radius: 2px; + background-color: $accent; +} + +// ---------------------------------------------------------------- calendar +.cal-title { font-weight: bold; } +.cal-grid { margin: 4px 4px 2px 4px; } +.cal-dow, .cal-wk { color: $dim; font-size: 10px; font-weight: bold; min-height: 22px; } +.cal-wk { min-width: 26px; } +.cal-day { + min-width: 34px; + min-height: 30px; + border-radius: $radius-small; + color: $fg; +} +.cal-day:hover { background-color: $tint; } +.cal-day.other { color: $muted; } +.cal-day.weekend { color: $fg-alt; } +.cal-day.selected { background-color: $tint; color: $accent; } +.cal-day.today { background-color: $accent; color: $bg; font-weight: bold; } +.cal-dot { color: $magenta; font-size: 8px; min-height: 8px; } +.cal-day.today .cal-dot { color: $bg; } + +.event-time { color: $accent; min-width: 52px; font-size: 12px; } +.event-bar { min-width: 3px; border-radius: 2px; margin-right: 10px; background-color: $magenta; } +.event-bar.reminder { background-color: $yellow; } + +// ------------------------------------------------------------------- media +.art { + min-width: 72px; + min-height: 72px; + border-radius: $radius-small; + background-size: cover; + background-position: center; + background-color: $surface; +} +.media-title { font-weight: bold; } +.media-artist { color: $dim; font-size: 12px; } +.media-btn { + font-size: 18px; + padding: 4px 12px; + border-radius: $radius-small; +} +.media-btn:hover { background-color: $tint; color: $accent; } +.media-time { color: $dim; font-size: 10px; } + +// ----------------------------------------------------------- notifications +// The event box takes the hover tint; GTK ignores padding on event boxes, +// so the box inside it carries the padding. +.notif-hit { border-radius: $radius-small; } +.notif-hit:hover { background-color: $tint; } +.notif { padding: 8px 10px; } +.notif.critical .notif-summary { color: $urgent; } +.notif-app { color: $dim; font-size: 10px; font-weight: bold; } +.notif-summary { font-weight: bold; } +.notif-body { color: $fg-alt; font-size: 12px; } +.notif-ago { color: $dim; font-size: 10px; } +.notif-close { + color: $dim; + padding: 0 6px; + border-radius: $radius-small; + font-size: 13px; +} +.notif-close:hover { color: $urgent; background-color: rgba($urgent, 0.15); } diff --git a/home/.config/eww/eww.yuck b/home/.config/eww/eww.yuck new file mode 100644 index 0000000..57c3c96 --- /dev/null +++ b/home/.config/eww/eww.yuck @@ -0,0 +1,91 @@ +;; Sakura Line - eww popups. +;; rice-panel opens one window at a time; every panel is built from the +;; widgets below so they all look and behave like the rofi menus. + +;; ------------------------------------------------------------------ state +(defvar open_panel "") ; set by rice-panel; list polls only run while their panel is open +(defvar cal_offset 0) ; months away from the current one +(defvar cal_day "") ; selected day as YYYY-MM-DD, "" = today +(defvar cal_input "") ; text typed into the calendar's add box + +(deflisten notifs :initial "{}" "rice-notify watch") +(deflisten audio :initial "{}" "rice-audio watch") +(deflisten media :initial "{}" "rice-media watch") + +(defpoll net :interval "4s" :initial "{}" :run-while {open_panel == "network" || open_panel == "hub"} "rice-net list") +(defpoll bt :interval "4s" :initial "{}" :run-while {open_panel == "bluetooth" || open_panel == "hub"} "rice-bt list") +(defpoll display :interval "10s" :initial "{}" :run-while {open_panel == "display" || open_panel == "hub"} "rice-display info") +(defpoll power :interval "3s" :initial "{}" :run-while {open_panel == "power" || open_panel == "hub"} "rice-power info") +;; rice-cal reads cal_offset / cal_day itself and pushes fresh data on every click. +(defpoll month :interval "60s" :initial "{}" :run-while {open_panel == "calendar"} "rice-cal month") +(defpoll agenda :interval "30s" :initial "{}" :run-while {open_panel == "calendar"} "rice-cal agenda") +(defpoll idle :interval "3s" :initial "{}" :run-while {open_panel == "hub"} "rice-idle status") + +;; ---------------------------------------------------------------- windows +;; A transparent layer under the open panel: clicking anywhere else closes it. +(defwindow closer + :monitor 0 + :geometry (geometry :x "0" :y "0" :width "100%" :height "100%") + :stacking "fg" + :wm-ignore true + (eventbox :class "closer" :onclick "rice-panel close" :onrightclick "rice-panel close")) + +;; -------------------------------------------------------- shared widgets +;; Header: title on the left, optional action buttons on the right. +(defwidget head [title] + (box :class "head" :space-evenly false + (label :class "title" :text title :hexpand true :xalign 0) + (children))) + +(defwidget head-action [glyph onclick ?tip ?active] + (button :class "head-action ${active == true ? 'active' : ''}" :onclick onclick :tooltip {tip ?: ""} glyph)) + +(defwidget section [text] + (label :class "section" :xalign 0 :text text)) + +(defwidget sep [] + (box :class "sep")) + +;; One list row: glyph, title and optional subtitle, value on the right. +(defwidget row [glyph title ?sub ?value ?onclick ?active ?chev ?dim] + (button :class "row ${active == true ? 'active' : ''} ${dim == true ? 'dimmed' : ''}" :onclick {onclick ?: ""} + (box :space-evenly false + (label :class "glyph" :text glyph) + (box :orientation "v" :hexpand true :space-evenly false :valign "center" + (label :class "row-title" :xalign 0 :limit-width 30 :text title) + (label :class "row-sub" :xalign 0 :limit-width 38 :visible {(sub ?: "") != ""} :text {sub ?: ""})) + (label :class "row-value" :visible {(value ?: "") != ""} :text {value ?: ""}) + (label :class "chev" :visible {chev == true} :text "›")))) + +;; A row with a pill switch. +(defwidget switch-row [glyph title on onclick ?sub] + (button :class "row" :onclick onclick + (box :space-evenly false + (label :class "glyph ${on == true ? 'accent' : ''}" :text glyph) + (box :orientation "v" :hexpand true :space-evenly false :valign "center" + (label :class "row-title" :xalign 0 :text title) + (label :class "row-sub" :xalign 0 :visible {(sub ?: "") != ""} :text {sub ?: ""})) + (box :class "switch ${on == true ? 'on' : ''}" :valign "center" :space-evenly false + (box :class "knob" :hexpand true :halign {on == true ? "end" : "start"}))))) + +;; A slider row: clicking the glyph runs onglyph (mute), dragging runs onchange. +(defwidget slider-row [glyph value onchange ?onglyph ?muted] + (box :class "row slider-row" :space-evenly false + (button :class "glyph ${muted == true ? 'urgent' : ''}" :onclick {onglyph ?: ""} glyph) + (scale :class "slider" :hexpand true :min 0 :max 101 :value {value ?: 0} :onchange onchange) + (label :class "row-value slider-value" :xalign 1 :text {muted == true ? "muted" : "${value ?: 0}%"}))) + +;; An empty-state line inside a list. +(defwidget empty [text] + (label :class "empty" :xalign 0 :text text)) + +;; ----------------------------------------------------------------- panels +(include "panels/hub.yuck") +(include "panels/audio.yuck") +(include "panels/network.yuck") +(include "panels/bluetooth.yuck") +(include "panels/display.yuck") +(include "panels/power.yuck") +(include "panels/media.yuck") +(include "panels/calendar.yuck") +(include "panels/notifications.yuck") diff --git a/home/.config/eww/panel-keys b/home/.config/eww/panel-keys new file mode 100644 index 0000000..8705459 --- /dev/null +++ b/home/.config/eww/panel-keys @@ -0,0 +1,3 @@ +# sxhkd config that rice-panel runs only while a panel is open. +Escape + rice-panel close diff --git a/home/.config/eww/panels/audio.yuck b/home/.config/eww/panels/audio.yuck new file mode 100644 index 0000000..b53f971 --- /dev/null +++ b/home/.config/eww/panels/audio.yuck @@ -0,0 +1,45 @@ +;; Audio: output and mic levels, device pickers, per-app mixer. +(defwindow audio + :monitor 0 + :geometry (geometry :x "-10px" :y "47px" :width "380px" :anchor "top right") + :stacking "fg" + :wm-ignore true + :resizable false + (box :class "panel" :orientation "v" :space-evenly false + (head :title "Audio" + (head-action :glyph "󰒓" :tip "Advanced mixer" :onclick "rice-panel close; setsid -f pavucontrol")) + + (slider-row :glyph {audio?.sink?.muted == true ? "󰝟" : "󰕾"} + :value {audio?.sink?.vol ?: 0} + :muted {audio?.sink?.muted ?: false} + :onglyph "rice-volume mute" + :onchange "rice-volume set {}") + (slider-row :glyph {audio?.source?.muted == true ? "󰍭" : "󰍬"} + :value {audio?.source?.vol ?: 0} + :muted {audio?.source?.muted ?: false} + :onglyph "rice-audio mic-mute" + :onchange "rice-audio mic {}") + + ;; eww appends a loop's items after their siblings, so every loop gets its own box. + (section :text "Output") + (box :orientation "v" :space-evenly false + (for s in {audio?.sinks ?: []} + (row :glyph "󰓃" :title {s.desc} :active {s.default} :onclick "rice-audio sink ${s.index}"))) + + (section :text "Input") + (box :orientation "v" :space-evenly false + (for s in {audio?.sources ?: []} + (row :glyph "󰍬" :title {s.desc} :active {s.default} :onclick "rice-audio source ${s.index}"))) + + (box :orientation "v" :space-evenly false :visible {arraylength(audio?.apps ?: []) > 0} + (section :text "Apps") + (box :orientation "v" :space-evenly false + (for a in {audio?.apps ?: []} + (box :class "row slider-row" :space-evenly false + (button :class "glyph ${a.muted ? 'urgent' : ''}" :tooltip {a.name} + :onclick "rice-audio app-mute ${a.id}" + {a.muted ? "󰝟" : "󰎆"}) + (label :class "row-title" :xalign 0 :width 90 :limit-width 11 :text {a.name}) + (scale :class "slider" :hexpand true :min 0 :max 101 :value {a.vol} + :onchange "rice-audio app-volume ${a.id} {}") + (label :class "row-value slider-value" :xalign 1 :text "${a.vol}%"))))))) diff --git a/home/.config/eww/panels/bluetooth.yuck b/home/.config/eww/panels/bluetooth.yuck new file mode 100644 index 0000000..d97568a --- /dev/null +++ b/home/.config/eww/panels/bluetooth.yuck @@ -0,0 +1,32 @@ +;; Bluetooth: power, paired devices with battery, nearby devices to pair. +(defwindow bluetooth + :monitor 0 + :geometry (geometry :x "-10px" :y "47px" :width "380px" :anchor "top right") + :stacking "fg" + :wm-ignore true + :resizable false + (box :class "panel" :orientation "v" :space-evenly false + (head :title "Bluetooth" + (head-action :glyph "󰑓" :tip "Look for new devices" :active {bt?.discovering == true} + :onclick "rice-bt scan")) + + (switch-row :glyph "󰂯" :title "Bluetooth" :on {bt?.powered == true} + :onclick "rice-bt power toggle" + :sub {bt?.powered != true ? "off" : (bt?.connected > 0 ? "${bt.connected} connected" : "on")}) + + (box :orientation "v" :space-evenly false :visible {bt?.powered == true} + (section :text "My devices") + (box :orientation "v" :space-evenly false + (for d in {bt?.paired ?: []} + (row :glyph {d.glyph} :title {d.name} :sub {d.sub} :value {d.battery} + :active {d.connected} :onclick "rice-bt toggle ${d.mac}"))) + (box :visible {arraylength(bt?.paired ?: []) == 0} + (empty :text "No paired devices")) + + (section :text {bt?.discovering == true ? "Nearby · scanning" : "Nearby"}) + (box :orientation "v" :space-evenly false + (for d in {bt?.nearby ?: []} + (row :glyph {d.glyph} :title {d.name} :sub {d.sub} :dim true + :onclick "rice-bt toggle ${d.mac}"))) + (box :visible {arraylength(bt?.nearby ?: []) == 0} + (empty :text {bt?.discovering == true ? "Looking…" : "Press 󰑓 to look for devices"}))))) diff --git a/home/.config/eww/panels/calendar.yuck b/home/.config/eww/panels/calendar.yuck new file mode 100644 index 0000000..92992ee --- /dev/null +++ b/home/.config/eww/panels/calendar.yuck @@ -0,0 +1,47 @@ +;; Calendar: month grid with ISO weeks, the selected day's events and reminders. +(defwindow calendar + :monitor 0 + :geometry (geometry :x "0" :y "47px" :width "360px" :anchor "top center") + :stacking "fg" + :wm-ignore true + :resizable false + (box :class "panel" :orientation "v" :space-evenly false + (head :title {month?.title ?: "Calendar"} + (head-action :glyph "󰅁" :tip "Previous month" :onclick "rice-cal shift -1") + (head-action :glyph "󰃭" :tip "Today" :onclick "rice-cal today") + (head-action :glyph "󰅂" :tip "Next month" :onclick "rice-cal shift 1")) + + (box :class "cal-grid" :orientation "v" :space-evenly false + (box :space-evenly false + (label :class "cal-wk" :text "Wk") + (for d in {month?.dow ?: []} + (label :class "cal-dow" :hexpand true :text d))) + (for w in {month?.weeks ?: []} + (box :space-evenly false + (label :class "cal-wk" :text {w.wk}) + (for d in {w.days} + (button :hexpand true :onclick "rice-cal select ${d.date}" + :class "cal-day ${d.other ? 'other' : ''} ${d.weekend ? 'weekend' : ''} ${d.selected ? 'selected' : ''} ${d.today ? 'today' : ''}" + (box :orientation "v" :space-evenly false :valign "center" + (label :text {d.d}) + (label :class "cal-dot" :text {d.events > 0 ? "●" : " "}))))))) + + (sep) + (box :space-evenly false + (label :class "section" :hexpand true :xalign 0 :text {agenda?.heading ?: "Today"}) + (head-action :glyph "󰃅" :tip "Add event" :onclick "rice-cal add") + (head-action :glyph "󰀠" :tip "Add reminder" :onclick "rice-panel close; rice-reminder new")) + + (box :orientation "v" :space-evenly false + (for ev in {agenda?.items ?: []} + (box :class "row" :space-evenly false + (box :class "event-bar ${ev.kind == 'reminder' ? 'reminder' : ''}") + (label :class "event-time" :xalign 0 :text {ev.time}) + (box :orientation "v" :hexpand true :space-evenly false :valign "center" + (label :class "row-title" :xalign 0 :limit-width 28 :text {ev.title}) + (label :class "row-sub" :xalign 0 :limit-width 36 :visible {ev.sub != ""} :text {ev.sub})) + (button :class "notif-close" :valign "center" :visible {ev.kind == "reminder"} + :tooltip "Cancel reminder" :onclick "rice-reminder cancel ${ev.id}" "󰅖")))) + + (box :orientation "v" :space-evenly false :visible {arraylength(agenda?.items ?: []) == 0} + (empty :text {(agenda?.status ?: "") != "" ? agenda.status : "Nothing planned"})))) diff --git a/home/.config/eww/panels/display.yuck b/home/.config/eww/panels/display.yuck new file mode 100644 index 0000000..8885dc0 --- /dev/null +++ b/home/.config/eww/panels/display.yuck @@ -0,0 +1,30 @@ +;; Display: brightness over DDC, resolution, refresh rate, bar, style shortcuts. +(defwindow display + :monitor 0 + :geometry (geometry :x "-10px" :y "47px" :width "380px" :anchor "top right") + :stacking "fg" + :wm-ignore true + :resizable false + (box :class "panel" :orientation "v" :space-evenly false + (head :title "Display" + (head-action :glyph "󰸉" :tip "Wallpaper" :onclick "rice-panel close; rice-menu wallpaper") + (head-action :glyph "󰏘" :tip "Theme" :onclick "rice-panel close; rice-menu theme")) + + (box :orientation "v" :space-evenly false :visible {display?.ddc == true} + (slider-row :glyph "󰃠" :value {display?.brightness ?: 0} + :onchange "rice-display brightness {}")) + (box :orientation "v" :space-evenly false :visible {display?.ddc != true} + (row :glyph "󰃠" :title "Brightness" :sub {display?.ddcHint ?: ""} :dim true)) + + (section :text {display?.output ?: "Output"}) + (box :orientation "v" :space-evenly false + (for m in {display?.modes ?: []} + (row :glyph "󰍹" :title {m.mode} :active {m.current} :onclick "rice-display mode ${m.id}"))) + + (section :text "Refresh rate") + (box :class "pills" :space-evenly true + (for r in {display?.rates ?: []} + (button :class "pill ${r.current ? 'active' : ''}" :onclick "rice-display rate ${r.id}" {r.label}))) + + (sep) + (switch-row :glyph "󰍜" :title "Bar" :on {display?.bar == true} :onclick "rice-bar toggle"))) diff --git a/home/.config/eww/panels/hub.yuck b/home/.config/eww/panels/hub.yuck new file mode 100644 index 0000000..bbda83e --- /dev/null +++ b/home/.config/eww/panels/hub.yuck @@ -0,0 +1,50 @@ +;; Control centre (super + x): levels, a line per subsystem, quick switches, now playing. +(defwindow hub + :monitor 0 + :geometry (geometry :x "-10px" :y "47px" :width "400px" :anchor "top right") + :stacking "fg" + :wm-ignore true + :resizable false + (box :class "panel" :orientation "v" :space-evenly false + (head :title "Control Centre" + (head-action :glyph "󰍜" :tip "Rice menu" :onclick "rice-panel close; rice-menu main") + (head-action :glyph "󰐥" :tip "Power menu" :onclick "rice-panel close; rice-menu power")) + + (slider-row :glyph {audio?.sink?.muted == true ? "󰝟" : "󰕾"} + :value {audio?.sink?.vol ?: 0} :muted {audio?.sink?.muted ?: false} + :onglyph "rice-volume mute" :onchange "rice-volume set {}") + (slider-row :glyph {audio?.source?.muted == true ? "󰍭" : "󰍬"} + :value {audio?.source?.vol ?: 0} :muted {audio?.source?.muted ?: false} + :onglyph "rice-audio mic-mute" :onchange "rice-audio mic {}") + (box :orientation "v" :space-evenly false :visible {display?.ddc == true} + (slider-row :glyph "󰃠" :value {display?.brightness ?: 0} :onchange "rice-display brightness {}")) + + (sep) + (row :glyph {net?.active?.type == "ethernet" ? "󰈀" : (net?.active?.type == "wifi" ? "󰖩" : "󰖪")} + :title "Network" :value {net?.active?.name ?: "offline"} :chev true :onclick "rice-panel network") + (row :glyph "󰂯" :title "Bluetooth" :chev true :onclick "rice-panel bluetooth" + :value {bt?.powered != true ? "off" : (bt?.connected > 0 ? "${bt.connected} connected" : "on")}) + (row :glyph "󰓃" :title "Sound" :value {audio?.sink?.name ?: ""} :chev true :onclick "rice-panel audio") + (row :glyph "󰍹" :title "Display" :chev true :onclick "rice-panel display" + :value "${display?.mode ?: ''} · ${round(display?.rate ?: 0, 0)} Hz") + (row :glyph "󰍛" :title "System" :chev true :onclick "rice-panel power" + :value "CPU ${power?.cpu?.load ?: 0}% · Memory ${power?.memory?.percent ?: 0}%") + (row :glyph "󰃭" :title "Calendar" :value {formattime(EWW_TIME, "%a %-d %b")} :chev true :onclick "rice-panel calendar") + + (sep) + (switch-row :glyph "󰂛" :title "Do not disturb" :on {notifs?.dnd == true} :onclick "rice-notify dnd toggle" + :sub {(notifs?.count ?: 0) > 0 ? "${notifs.count} in the notification centre" : ""}) + (switch-row :glyph "󰅶" :title "Stay awake" :on {idle?.awake == true} :onclick "rice-idle toggle" + :sub {idle?.awake == true ? "screensaver and lock paused" : ""}) + + (box :orientation "v" :space-evenly false :visible {media?.status == "Playing" || media?.status == "Paused"} + (sep) + (box :class "row" :space-evenly false + (label :class "glyph accent" :text "󰎆") + (eventbox :hexpand true :onclick "rice-panel media" + (box :orientation "v" :space-evenly false :valign "center" + (label :class "row-title" :xalign 0 :limit-width 22 :text {media?.title ?: ""}) + (label :class "row-sub" :xalign 0 :limit-width 26 :text {media?.artist ?: ""}))) + (button :class "media-btn" :onclick "playerctl previous" "󰒮") + (button :class "media-btn accent" :onclick "playerctl play-pause" {media?.status == "Playing" ? "󰏤" : "󰐊"}) + (button :class "media-btn" :onclick "playerctl next" "󰒭"))))) diff --git a/home/.config/eww/panels/media.yuck b/home/.config/eww/panels/media.yuck new file mode 100644 index 0000000..01f27f1 --- /dev/null +++ b/home/.config/eww/panels/media.yuck @@ -0,0 +1,39 @@ +;; Media: what is playing, a seek bar, and transport controls. +(defpoll media_pos :interval "1s" :initial "{}" :run-while {open_panel == "media"} "rice-media position") + +(defwindow media + :monitor 0 + :geometry (geometry :x "0" :y "47px" :width "380px" :anchor "top center") + :stacking "fg" + :wm-ignore true + :resizable false + (box :class "panel" :orientation "v" :space-evenly false + (head :title "Media" + (label :class "dim" :text {media?.player ?: ""})) + + (box :orientation "v" :space-evenly false + :visible {media?.status != "Playing" && media?.status != "Paused"} + (empty :text "Nothing is playing")) + + (box :orientation "v" :space-evenly false + :visible {media?.status == "Playing" || media?.status == "Paused"} + (box :class "row" :space-evenly false + (box :class "art" :visible {(media?.art ?: "") != ""} + :style "background-image: url('${media?.art ?: ''}');") + (box :orientation "v" :space-evenly false :valign "center" :hexpand true + (label :class "media-title" :xalign 0 :limit-width 26 :text {media?.title ?: ""}) + (label :class "media-artist" :xalign 0 :limit-width 32 :text {media?.artist ?: ""}) + (label :class "media-artist" :xalign 0 :limit-width 32 :visible {(media?.album ?: "") != ""} + :text {media?.album ?: ""}))) + + (box :class "row slider-row" :space-evenly false :visible {(media?.length ?: 0) > 0} + (label :class "media-time" :width 36 :xalign 0 :text {media_pos?.text ?: "0:00"}) + (scale :class "slider" :hexpand true :min 0 :max {media?.length ?: 1} + :value {media_pos?.pos ?: 0} :onchange "rice-media seek {}") + (label :class "media-time" :width 36 :xalign 1 :text {media?.lengthText ?: ""})) + + (box :class "row" :space-evenly true + (button :class "media-btn" :tooltip "Previous" :onclick "playerctl previous" "󰒮") + (button :class "media-btn accent" :tooltip "Play / pause" :onclick "playerctl play-pause" + {media?.status == "Playing" ? "󰏤" : "󰐊"}) + (button :class "media-btn" :tooltip "Next" :onclick "playerctl next" "󰒭"))))) diff --git a/home/.config/eww/panels/network.yuck b/home/.config/eww/panels/network.yuck new file mode 100644 index 0000000..ae2ef72 --- /dev/null +++ b/home/.config/eww/panels/network.yuck @@ -0,0 +1,37 @@ +;; Network: Wi-Fi radio, the active connection, nearby networks and VPNs. +(defwindow network + :monitor 0 + :geometry (geometry :x "-10px" :y "47px" :width "380px" :anchor "top right") + :stacking "fg" + :wm-ignore true + :resizable false + (box :class "panel" :orientation "v" :space-evenly false + (head :title "Network" + (head-action :glyph "󰑓" :tip "Rescan" :onclick "rice-net rescan") + (head-action :glyph "󰐲" :tip "Share Wi-Fi as a QR code" :onclick "rice-panel close; rice-net share") + (head-action :glyph "󰒓" :tip "Connection editor" :onclick "rice-panel close; setsid -f nm-connection-editor")) + + (box :orientation "v" :space-evenly false :visible {net?.active?.type == "ethernet"} + (row :glyph "󰈀" :title "Ethernet" :sub {net?.active?.name ?: ""} :active true)) + + ;; Machines without a Wi-Fi adapter (like a wired desktop) skip this part. + (box :orientation "v" :space-evenly false :visible {net?.hasWifi == true} + (switch-row :glyph "󰖩" :title "Wi-Fi" :on {net?.radio == true} + :onclick "rice-net radio toggle" + :sub {net?.active?.type == "wifi" ? net.active.name : (net?.radio == true ? "not connected" : "off")})) + + (box :orientation "v" :space-evenly false :visible {net?.hasWifi == true && net?.radio == true} + (section :text "Networks") + (box :orientation "v" :space-evenly false + (for w in {net?.wifi ?: []} + (row :glyph {w.glyph} :title {w.ssid} :sub {w.sub} :active {w.active} + :onclick {w.active ? "rice-net disconnect" : "rice-net connect ${w.id}"}))) + (box :visible {arraylength(net?.wifi ?: []) == 0} + (empty :text "No networks found yet"))) + + (box :orientation "v" :space-evenly false :visible {arraylength(net?.vpn ?: []) > 0} + (section :text "VPN") + (box :orientation "v" :space-evenly false + (for v in {net?.vpn ?: []} + (row :glyph {v.active ? "󰖂" : "󰕥"} :title {v.name} :sub {v.active ? "connected" : ""} + :active {v.active} :onclick "rice-net vpn ${v.id}")))))) diff --git a/home/.config/eww/panels/notifications.yuck b/home/.config/eww/panels/notifications.yuck new file mode 100644 index 0000000..c8a2f6d --- /dev/null +++ b/home/.config/eww/panels/notifications.yuck @@ -0,0 +1,37 @@ +;; Notification centre: recent notifications, dismiss one or all, do-not-disturb. +(defwindow notifications + :monitor 0 + :geometry (geometry :x "-10px" :y "47px" :width "380px" :anchor "top right") + :stacking "fg" + :wm-ignore true + :resizable false + (box :class "panel" :orientation "v" :space-evenly false + (head :title "Notifications" + (head-action :glyph {notifs?.dnd == true ? "󰂛" : "󰂚"} :active {notifs?.dnd == true} + :tip "Do not disturb" :onclick "rice-notify dnd toggle") + (head-action :glyph "󰎟" :tip "Clear all" :onclick "rice-notify clear")) + + (box :orientation "v" :space-evenly false :visible {notifs?.dnd == true && (notifs?.waiting ?: 0) > 0} + (row :glyph "󰂛" :title "Held back" :sub "They appear when do-not-disturb is off" + :value "${notifs?.waiting ?: 0}" :dim true)) + + (box :orientation "v" :space-evenly false :visible {(notifs?.count ?: 0) == 0} + (empty :text "You're all caught up")) + + (box :orientation "v" :space-evenly false + (for n in {notifs?.items ?: []} + (eventbox :class "notif-hit" + :onclick "rice-panel close; rice-notify open ${n.id}" + (box :class "notif ${n.urgency == 'CRITICAL' ? 'critical' : ''}" :space-evenly false + (box :orientation "v" :hexpand true :space-evenly false + (box :space-evenly false + (label :class "notif-app" :xalign 0 :hexpand true :limit-width 30 :text {n.app}) + (label :class "notif-ago" :text {n.ago})) + (label :class "notif-summary" :xalign 0 :limit-width 36 :text {n.summary}) + (label :class "notif-body" :xalign 0 :wrap true :limit-width 140 + :visible {n.body != ""} :text {n.body})) + (button :class "notif-close" :valign "start" :tooltip "Dismiss" + :onclick "rice-notify remove ${n.id}" "󰅖"))))) + + (box :orientation "v" :space-evenly false :visible {(notifs?.count ?: 0) > 8} + (row :glyph "󰇘" :title "${notifs?.count - 8} older" :sub "Clear all to start fresh" :dim true)))) diff --git a/home/.config/eww/panels/power.yuck b/home/.config/eww/panels/power.yuck new file mode 100644 index 0000000..27c34c9 --- /dev/null +++ b/home/.config/eww/panels/power.yuck @@ -0,0 +1,31 @@ +;; Power: what the machine is doing, the power profile, and session actions. +(defwindow power + :monitor 0 + :geometry (geometry :x "-10px" :y "47px" :width "380px" :anchor "top right") + :stacking "fg" + :wm-ignore true + :resizable false + (box :class "panel" :orientation "v" :space-evenly false + (head :title "Power" + (head-action :glyph "󰍛" :tip "Activity monitor" :onclick "rice-panel close; setsid -f st -c rice-float -e btop")) + + (row :glyph "󰍛" :title "CPU" + :value "${power?.cpu?.load ?: 0}%${power?.cpu?.temp != 'null' && power?.cpu?.temp != '' ? ' ' + power.cpu.temp + '°' : ''}") + (row :glyph "󰢮" :title "GPU" :sub {power?.gpu?.vram ?: ""} + :value "${power?.gpu?.load ?: 0}%${power?.gpu?.temp != 'null' && power?.gpu?.temp != '' ? ' ' + power.gpu.temp + '°' : ''}") + (row :glyph "󰘚" :title "Memory" :value "${power?.memory?.used ?: '-'} / ${power?.memory?.total ?: '-'}") + (row :glyph "󰔟" :title "Uptime" :value {power?.uptime ?: ""}) + + (box :orientation "v" :space-evenly false :visible {arraylength(power?.profiles ?: []) > 0} + (section :text "Power profile") + (box :class "row" :space-evenly true + (for p in {power?.profiles ?: []} + (button :class "pill ${p.active ? 'active' : ''}" :onclick "rice-power profile ${p.name}" {p.label})))) + + (sep) + (box :space-evenly true :class "row" + (button :class "media-btn" :tooltip "Lock" :onclick "rice-power lock" "󰌾") + (button :class "media-btn" :tooltip "Suspend" :onclick "rice-power suspend" "󰒲") + (button :class "media-btn" :tooltip "Log out" :onclick "rice-power logout" "󰍃") + (button :class "media-btn" :tooltip "Reboot" :onclick "rice-power reboot" "󰜉") + (button :class "media-btn urgent" :tooltip "Shut down" :onclick "rice-power shutdown" "󰐥")))) diff --git a/home/.config/fastfetch/config.jsonc b/home/.config/fastfetch/config.jsonc new file mode 100755 index 0000000..e97c4e5 --- /dev/null +++ b/home/.config/fastfetch/config.jsonc @@ -0,0 +1,42 @@ +{ + "$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json", + "logo": null, + "display": { + "separator": " › " + }, + "modules": [ + "break", + { + "type": "os", + "key": "OS ", + "keyColor": "31", // = color1 + }, + { + "type": "kernel", + "key": "KER ", + "keyColor": "32", + }, + { + "type": "packages", + "format": "{} (pacman)", + "key": "PKG ", + "keyColor": "33", + }, + { + "type": "shell", + "key": "SH ", + "keyColor": "34", + }, + { + "type": "terminal", + "key": "TER ", + "keyColor": "35", + }, + { + "type": "wm", + "key": "WM ", + "keyColor": "36", + }, + "break", + ] +} diff --git a/home/.config/fastfetch/linuximaginary.txt b/home/.config/fastfetch/linuximaginary.txt new file mode 100644 index 0000000..8e1e8d3 --- /dev/null +++ b/home/.config/fastfetch/linuximaginary.txt @@ -0,0 +1,16 @@ + .d8 .8uI + 88R m888 + 88R %d88" + 88R 88R + 88R 88R + 88R 88R + 88R 88R + 88R 88R + 88R 88R + 88B 88B + 888 88* + .88u + %88* + 8I* + J"* + diff --git a/home/.config/fastfetch/linuximaginary_small.txt b/home/.config/fastfetch/linuximaginary_small.txt new file mode 100644 index 0000000..99c3cde --- /dev/null +++ b/home/.config/fastfetch/linuximaginary_small.txt @@ -0,0 +1,7 @@ + .d8 .8u + 88R 888 + 88R d8" + 88R 88R + 88B 88* +.88u 8I* + 88R *IL diff --git a/home/.config/git/config b/home/.config/git/config new file mode 100644 index 0000000..6d1d266 --- /dev/null +++ b/home/.config/git/config @@ -0,0 +1,28 @@ +# Sakura Line - git defaults. Your name, email and credentials stay in +# ~/.gitconfig, which git reads after this file, so it always wins. +[init] + defaultBranch = main +[pull] + rebase = true +[push] + autoSetupRemote = true +[diff] + algorithm = histogram + colorMoved = plain + mnemonicPrefix = true +[commit] + verbose = true +[column] + ui = auto +[branch] + sort = -committerdate +[tag] + sort = -version:refname +[rerere] + enabled = true + autoupdate = true +[alias] + co = checkout + br = branch + ci = commit + st = status diff --git a/home/.config/kitty/kitty.conf b/home/.config/kitty/kitty.conf new file mode 100644 index 0000000..14ecf43 --- /dev/null +++ b/home/.config/kitty/kitty.conf @@ -0,0 +1,11 @@ +# Sakura Line - kitty. Font and colours come from the theme (rice-theme). +font_size 11.0 +hide_window_decorations yes +confirm_os_window_close 0 +window_padding_width 10 +enable_audio_bell no +cursor_shape block +cursor_blink_interval 0 +scrollback_lines 10000 + +include ~/.local/state/rice/theme/kitty.conf diff --git a/home/.config/particles.conf b/home/.config/particles.conf new file mode 100644 index 0000000..6da8da2 --- /dev/null +++ b/home/.config/particles.conf @@ -0,0 +1,33 @@ +# particles - wallpaper layer +# Tuned for bspwm/wallpaper/wallpaper.jpg: a bright, busy image, so the field +# is white and fairly opaque. Preview any change without touching the desktop: +# particles --snapshot /tmp/p.png --snapshot-bg ~/.config/bspwm/wallpaper/wallpaper.png +# Reload a running instance after editing: pkill -HUP particles + +density = 55 +radius = 2.4 +glow = 4.0 +opacity = 1.0 +color = ffffff + +link-distance = 140 +link-width = 1.6 +link-opacity = 0.85 +link-color = ffffff + +speed = 24 +edges = bounce + +cursor = grab,repel +cursor-distance = 180 +cursor-strength = 1.0 +cursor-response = 9 + +fps = 60 +keep-below = true +pause-when-covered = true + +mode = jigsaw +jigsaw-cols = 800 +jigsaw-rows = 500 +jigsaw-loose = 5 diff --git a/home/.config/picom/picom.conf b/home/.config/picom/picom.conf new file mode 100644 index 0000000..810af00 --- /dev/null +++ b/home/.config/picom/picom.conf @@ -0,0 +1,60 @@ +# Sakura Line - picom 13. +# One motion language for everything: windows, rofi, eww popups and dunst +# all appear with the same short scale-and-fade. + +backend = "glx"; +vsync = true; +use-damage = true; +detect-client-opacity = true; +detect-transient = true; +unredir-if-possible = true; +log-level = "warn"; + +shadow = false; +blur-background = false; +fading = true; +fade-in-step = 0.08; +fade-out-step = 0.08; +fade-delta = 8; +corner-radius = 12; + +animations = ( + { triggers = [ "open", "show" ]; preset = "appear"; scale = 0.94; duration = 0.16; }, + { triggers = [ "close", "hide" ]; preset = "disappear"; scale = 0.96; duration = 0.12; } +); + +rules = ( + # The bar and desktop layer draw their own shape and never animate. + { match = "window_type = 'dock' || window_type = 'desktop'"; corner-radius = 0; fade = false; animations = (); }, + + # Fullscreen video and games: square, and let them unredirect. + { match = "fullscreen"; corner-radius = 0; }, + + # Toolkit menus and tooltips pop instantly. + { match = "window_type = 'tooltip' || window_type = 'popup_menu' || window_type = 'dropdown_menu' || window_type = 'menu'"; corner-radius = 0; animations = (); }, + + # dunst rounds its own corners; picom rounding on top leaves a halo. + { match = "class_g = 'Dunst'"; corner-radius = 0; }, + + # Menus and popups share a quicker, flatter version of the window motion. + { match = "class_g = 'Rofi' || class_g ~= '^eww'"; + animations = ( + { triggers = [ "open", "show" ]; preset = "appear"; scale = 0.98; duration = 0.10; }, + { triggers = [ "close", "hide" ]; preset = "disappear"; scale = 0.98; duration = 0.08; } + ); + }, + + # The drop-down terminal slides from the top edge. + { match = "class_g = 'rice-scratchpad'"; + animations = ( + { triggers = [ "open", "show" ]; preset = "slide-in"; direction = "down"; duration = 0.18; }, + { triggers = [ "close", "hide" ]; preset = "slide-out"; direction = "up"; duration = 0.14; } + ); + }, + + # Capture overlays must not move or fade while you aim. + { match = "class_g = 'flameshot' || class_g = 'boomer' || class_g = 'slop'"; corner-radius = 0; fade = false; animations = (); }, + + # The webcam overlay stays a clean rounded tile. + { match = "class_i = 'rice-webcam'"; corner-radius = 16; } +); diff --git a/home/.config/polybar/config.ini b/home/.config/polybar/config.ini new file mode 100644 index 0000000..febdd21 --- /dev/null +++ b/home/.config/polybar/config.ini @@ -0,0 +1,121 @@ +; Sakura Line - polybar. Colours come from the rendered theme. +; Every label opens its panel: left click = panel, right click = quick action. + +include-file = ~/.local/state/rice/theme/polybar-colors.ini +include-file = ~/.local/state/rice/theme/polybar-modules.ini + +[bar/top] +monitor = ${env:MONITOR:} +wm-restack = bspwm +enable-ipc = true +; Edges line up with tiled windows: bspwm draws borders inside the gap, so their +; outer edges sit one gap (10px) in from each side, so 100% minus 20px. +width = 100%:-20 +height = 28 +radius = 10 +offset-x = 10 +offset-y = 5 +fixed-center = true +background = ${colors.bg-trans} +foreground = ${colors.text} +border-size = 2 +border-color = ${colors.border} +padding = 1 +module-margin = 0 +cursor-click = pointer +cursor-scroll = ns-resize +font-0 = ${fonts.main} +font-1 = ${fonts.large} +; The clock sits alone in the centre so nothing that comes and goes (the +; player, recording or reminder indicators) can push it off centre. +modules-left = menu bspwm media +modules-center = date +modules-right = indicators cpu sep ram sep gpu sep net sep bt sep vol sep notif + +[module/sep] +type = custom/text +format = "|" +format-foreground = ${colors.dim} +format-padding = 0 + +[module/menu] +type = custom/text +format = "󰣇" +format-foreground = ${colors.accent} +format-font = 2 +format-padding = 1 +click-left = rice-menu main +click-right = rice-panel hub + +[module/bspwm] +type = internal/bspwm +pin-workspaces = true +inline-mode = true +enable-click = true +enable-scroll = true +format = <label-state> +label-focused = "[%name%]" +label-focused-foreground = ${colors.accent} +label-focused-padding = 1 +label-occupied = "%name%" +label-occupied-foreground = ${colors.text} +label-occupied-padding = 1 +label-empty = "%name%" +label-empty-foreground = ${colors.dim} +label-empty-padding = 1 +label-urgent = "!%name%!" +label-urgent-foreground = ${colors.urgent} +label-urgent-padding = 1 + +[module/indicators] +type = custom/script +exec = rice-status indicators +tail = true +format-padding = 1 + +[module/media] +type = custom/script +exec = rice-status media +tail = true +format-padding = 1 +click-left = rice-panel media +click-right = playerctl play-pause +scroll-up = playerctl previous +scroll-down = playerctl next + +[module/net] +type = custom/script +exec = rice-status net +tail = true +format-padding = 1 +click-left = rice-panel network + +[module/bt] +type = custom/script +exec = rice-status bt +tail = true +format-padding = 1 +click-left = rice-panel bluetooth +click-right = rice-bt power toggle + +[module/vol] +type = custom/script +exec = rice-status vol +tail = true +format-padding = 1 +click-left = rice-panel audio +click-right = rice-volume mute +scroll-up = rice-volume up +scroll-down = rice-volume down + +[module/notif] +type = custom/script +exec = rice-status notif +tail = true +format-padding = 1 +click-left = rice-panel notifications +click-middle = rice-notify dnd toggle +click-right = rice-notify dismiss-all + +[settings] +screenchange-reload = true diff --git a/home/.config/rice/bash/aliases.bash b/home/.config/rice/bash/aliases.bash new file mode 100644 index 0000000..b6cbfd1 --- /dev/null +++ b/home/.config/rice/bash/aliases.bash @@ -0,0 +1,22 @@ +# Sakura Line - aliases. Personal ones belong in ~/.bashrc.local. + +if command -v eza >/dev/null; then + alias ls='eza -lh --group-directories-first --icons=auto' + alias lsa='ls -a' + alias lt='eza --tree --level=2 --long --icons --git' + alias lta='lt -a' +fi + +alias ..='cd ..' +alias ...='cd ../..' +alias ....='cd ../../..' + +alias g='git' +alias gs='git status -sb' +alias gcm='git commit -m' +alias gcam='git commit -a -m' +alias gcad='git commit -a --amend' + +alias d='docker' +alias t='tmux attach || tmux new -s work' +alias decompress='tar -xzf' diff --git a/home/.config/rice/bash/functions.bash b/home/.config/rice/bash/functions.bash new file mode 100644 index 0000000..85f0bb9 --- /dev/null +++ b/home/.config/rice/bash/functions.bash @@ -0,0 +1,18 @@ +# Sakura Line - everyday functions. + +# ff: fuzzy-find a file below the current directory, with a preview (pictures +# show inline in st and kitty). eff opens the pick in $EDITOR. +ff() { fzf --preview 'rice-preview {}' --preview-window 'right,55%' "$@"; } +eff() { local f; f=$(ff) && [[ -n $f ]] && "$EDITOR" "$f"; } + +# img <picture>...: show pictures right in the terminal. +img() { local f; for f in "$@"; do rice-preview "$f"; done; } + +# open <file|url>: open with the default app, detached from the terminal. +open() { xdg-open "$@" >/dev/null 2>&1 & disown; } + +# n: nvim, on the current directory when given nothing. +n() { if (( $# == 0 )); then nvim .; else nvim "$@"; fi; } + +# compress <file|dir>: make <name>.tar.gz next to it (decompress is tar -xzf). +compress() { tar -czf "${1%/}.tar.gz" "${1%/}"; } diff --git a/home/.config/rice/bash/git.bash b/home/.config/rice/bash/git.bash new file mode 100644 index 0000000..d600713 --- /dev/null +++ b/home/.config/rice/bash/git.bash @@ -0,0 +1,24 @@ +# Sakura Line - git worktree helpers. + +# ga <branch>: new branch in its own worktree next to the repository +# (../<repo>-<branch>), and cd into it. +ga() { + local branch="${1:-}" top main dir + [[ -n $branch ]] || { echo "usage: ga <branch>"; return 1; } + top=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "ga: not inside a git repository"; return 1; } + main=$(dirname "$(git -C "$top" rev-parse --path-format=absolute --git-common-dir)") + dir="$(dirname "$main")/$(basename "$main")-${branch//\//-}" + git -C "$top" worktree add -b "$branch" "$dir" && cd "$dir" +} + +# gd: remove the current worktree and its branch (asks first; never the main one). +gd() { + local top main branch answer + top=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "gd: not inside a git repository"; return 1; } + main=$(git worktree list --porcelain | awk '/^worktree / { print $2; exit }') + [[ $top != "$main" ]] || { echo "gd: this is the main worktree; not removing it"; return 1; } + branch=$(git branch --show-current) + read -rp "Remove worktree $top and branch $branch? [y/N] " answer + [[ $answer == [yY]* ]] || return 1 + cd "$main" && git worktree remove "$top" && git branch -D "$branch" +} diff --git a/home/.config/rice/bash/rsync.bash b/home/.config/rice/bash/rsync.bash new file mode 100644 index 0000000..02d43b8 --- /dev/null +++ b/home/.config/rice/bash/rsync.bash @@ -0,0 +1,34 @@ +# Sakura Line - rsync watchers. + +# rsw <source> <destination>: keep destination in sync with source in the +# background, re-syncing on every change. The destination can be remote +# (host:path); one SSH connection is reused, so you authenticate once. +rsw() { + (( $# == 2 )) || { echo "usage: rsw <source> <destination>"; return 1; } + local src="${1%/}" dest="$2" sockets="${XDG_RUNTIME_DIR:-$HOME/.ssh}" + local rsh="ssh -o ControlMaster=auto -o ControlPath=$sockets/rsw-%r@%h:%p -o ControlPersist=yes" + setsid --fork env RSYNC_RSH="$rsh" bash -c ' + rsync -a "$1/" "$2" + while inotifywait -r -q -e modify,create,delete,move "$1" >/dev/null; do rsync -a "$1/" "$2"; done + ' rsw-watch "$src" "$dest" >/dev/null 2>&1 + echo "watching $src -> $dest" +} + +# lsw: list watchers. dsw: stop them all. +lsw() { + local pid cmd rest found=0 + while read -r pid cmd; do + rest="${cmd##*rsw-watch }" + echo "$pid: ${rest% *} -> ${rest##* }" + found=1 + done < <(pgrep -af 'rsw-watch ') + (( found )) || echo "no active watchers" +} + +dsw() { + local pid found=0 + for pid in $(pgrep -f 'rsw-watch '); do + kill -- -"$pid" 2>/dev/null && echo "stopped $pid" && found=1 + done + (( found )) || echo "no active watchers" +} diff --git a/home/.config/rice/bash/ssh.bash b/home/.config/rice/bash/ssh.bash new file mode 100644 index 0000000..6397bf5 --- /dev/null +++ b/home/.config/rice/bash/ssh.bash @@ -0,0 +1,82 @@ +# Sakura Line - ssh helpers. + +# ssh: when an interactive session drops, tidy the terminal and reconnect until +# you press Ctrl-C. Only a real dropped shell reconnects: a remote command, +# piped input, or a failure within the first 30 seconds (bad host, bad key) +# returns normally, so nothing is ever replayed by accident. +ssh() { + local rc started=$SECONDS + command ssh "$@" + rc=$? + [[ -t 1 ]] || return $rc + _ssh_disarm + + if (( rc != 255 )) || [[ ! -t 0 ]] || ! _ssh_interactive "$@" || (( SECONDS - started < 30 )); then + return $rc + fi + + # A subshell, so Ctrl-C stops both the attempt and the loop. + ( + while :; do + echo "Connection lost. Reconnecting (Ctrl-C to stop)..." + sleep 2 + command ssh "$@" + rc=$? + _ssh_disarm + (( rc != 255 )) && exit $rc + done + ) +} + +# A connection that dies mid-session leaves the remote tmux or editor's +# terminal modes on (mouse reporting floods the prompt); switch them off. +_ssh_disarm() { + printf '\e[?1000l\e[?1002l\e[?1003l\e[?1006l\e[?1004l\e[?1049l\e[?25h' +} + +# True when the arguments open a shell: a destination and no remote command, +# counting a RemoteCommand set in ssh_config. +_ssh_interactive() { + local value_opts="BbcDEeFIiJLlmOoPpQRSWw" argv=("$@") arg letters i dest="" opts_done="" + while (( $# )); do + arg="$1"; shift + if [[ -z $opts_done && $arg == "--" ]]; then + opts_done=1 + elif [[ -z $opts_done && $arg == -?* ]]; then + letters="${arg#-}" + for (( i = 0; i < ${#letters}; i++ )); do + if [[ $value_opts == *"${letters:i:1}"* ]]; then + (( i == ${#letters} - 1 )) && shift + break + fi + done + elif [[ -z $dest ]]; then + dest="$arg" + else + return 1 + fi + done + [[ -n $dest ]] || return 1 + local resolved + resolved=$(command ssh -G "${argv[@]}" 2>/dev/null) || return 1 + ! grep -i '^remotecommand ' <<<"$resolved" | grep -qvi '^remotecommand none$' +} + +# fip <host> <port>...: forward remote ports to localhost (web dev against a remote box). +fip() { + (( $# >= 2 )) || { echo "usage: fip <host> <port>..."; return 1; } + local host="$1" port; shift + for port in "$@"; do + command ssh -f -N -L "$port:localhost:$port" "$host" && echo "localhost:$port -> $host:$port" + done +} + +# dip <port>...: stop those forwards. lip: list active forwards. +dip() { + (( $# >= 1 )) || { echo "usage: dip <port>..."; return 1; } + local port + for port in "$@"; do + pkill -f "ssh.*-L $port:localhost:$port" && echo "stopped $port" || echo "no forward on $port" + done +} +lip() { pgrep -af 'ssh.*-L [0-9]+:localhost:[0-9]+' || echo "no active forwards"; } diff --git a/home/.config/rofi/config.rasi b/home/.config/rofi/config.rasi new file mode 100644 index 0000000..8ea4fdb --- /dev/null +++ b/home/.config/rofi/config.rasi @@ -0,0 +1,22 @@ +/* Sakura Line - rofi defaults. Scripts pick a layout with -theme. */ +configuration { + modes: "drun,run,window"; + show-icons: true; + icon-theme: "Papirus-Dark"; + drun-display-format: "{name}"; + matching: "fuzzy"; + sort: true; + sorting-method: "fzf"; + hover-select: true; + me-select-entry: "MousePrimary"; + me-accept-entry: "!MousePrimary"; + kb-cancel: "Escape,Control+g,Control+bracketleft"; + + /* Header titles, one per mode (rofi 2 ignores -display-<mode> for some modes). */ + drun { display-name: "Apps"; } + run { display-name: "Run"; } + window { display-name: "Windows"; } + emoji { display-name: "Emoji"; } +} + +@theme "~/.config/rofi/launcher.rasi" diff --git a/home/.config/rofi/grid.rasi b/home/.config/rofi/grid.rasi new file mode 100644 index 0000000..55476e1 --- /dev/null +++ b/home/.config/rofi/grid.rasi @@ -0,0 +1,36 @@ +/* Image grid: wallpaper and theme pickers. */ +@import "~/.config/rofi/rice.rasi" + +window { + width: 980px; +} + +listview { + columns: 4; + lines: 2; + fixed-height: true; + flow: horizontal; + padding: 14px; + spacing: 10px; +} + +element { + orientation: vertical; + padding: 10px; + spacing: 8px; + children: [ element-icon, element-text ]; +} + +element-icon { + size: 190px; + horizontal-align: 0.5; +} + +element-text { + horizontal-align: 0.5; + text-color: @dim; +} + +element selected.normal element-text { + text-color: @fg; +} diff --git a/home/.config/rofi/launcher.rasi b/home/.config/rofi/launcher.rasi new file mode 100644 index 0000000..7972abf --- /dev/null +++ b/home/.config/rofi/launcher.rasi @@ -0,0 +1,6 @@ +/* App launcher (super + w). */ +@import "~/.config/rofi/rice.rasi" + +window { width: 620px; } +listview { lines: 9; fixed-height: true; } +element-icon { size: 1.6em; } diff --git a/home/.config/rofi/menu.rasi b/home/.config/rofi/menu.rasi new file mode 100644 index 0000000..95b028a --- /dev/null +++ b/home/.config/rofi/menu.rasi @@ -0,0 +1,7 @@ +/* Text menus: system menu, power, keybindings, clipboard, toggles. + Entries carry their own Nerd Font glyph, so rofi's icon column is off. */ +@import "~/.config/rofi/rice.rasi" + +window { width: 520px; } +listview { lines: 12; } +element-icon { enabled: false; } diff --git a/home/.config/rofi/prompt.rasi b/home/.config/rofi/prompt.rasi new file mode 100644 index 0000000..b0a0862 --- /dev/null +++ b/home/.config/rofi/prompt.rasi @@ -0,0 +1,17 @@ +/* Single text input: reminder text, event title, Wi-Fi password. */ +@import "~/.config/rofi/rice.rasi" + +window { width: 520px; } +mainbox { children: [ inputbar, message ]; } + +inputbar { + border: 0px; +} + +entry { + placeholder: ""; +} + +message { + border: 1px 0px 0px 0px; +} diff --git a/home/.config/rofi/rice.rasi b/home/.config/rofi/rice.rasi new file mode 100644 index 0000000..e2c3489 --- /dev/null +++ b/home/.config/rofi/rice.rasi @@ -0,0 +1,92 @@ +/* Sakura Line - shared rofi layout. Every menu imports this, then overrides + only size and position. Colours come from the rendered theme. */ +@import "~/.local/state/rice/theme/rofi-colors.rasi" + +* { + background-color: transparent; + text-color: @fg; + font: @font-main; +} + +window { + transparency: "real"; + location: center; + anchor: center; + width: 560px; + background-color: @bg-trans; + border: 1px; + border-color: @border-col; + border-radius: 12px; +} + +mainbox { + spacing: 0px; + children: [ inputbar, message, listview ]; +} + +inputbar { + padding: 14px 18px; + spacing: 0px; + border: 0px 0px 1px 0px; + border-color: @border-col; + children: [ prompt, entry ]; +} + +prompt { + text-color: @fg; + padding: 0px 10px 0px 0px; + vertical-align: 0.5; + font: @font-bold; +} + +entry { + placeholder: "Search"; + placeholder-color: @dim; + cursor: text; + vertical-align: 0.5; + font: @font-entry; +} + +message { + padding: 10px 18px; + border: 0px 0px 1px 0px; + border-color: @border-col; +} + +textbox { + text-color: @dim; + markup: true; +} + +listview { + lines: 10; + columns: 1; + fixed-height: false; + scrollbar: false; + padding: 8px; + spacing: 2px; +} + +element { + padding: 8px 10px; + spacing: 10px; + border-radius: 8px; +} + +element normal.active, element alternate.active { text-color: @accent; } +element normal.urgent, element alternate.urgent { text-color: @urgent; } +element selected.normal { background-color: @select-bg; text-color: @select-fg; } +element selected.active { background-color: @select-bg; text-color: @accent; } +element selected.urgent { background-color: @select-bg; text-color: @urgent; } + +element-icon { + size: 1.3em; + vertical-align: 0.5; +} + +element-text { + text-color: inherit; + vertical-align: 0.5; +} + +mode-switcher { enabled: false; } diff --git a/home/.config/sxhkd/sxhkdrc b/home/.config/sxhkd/sxhkdrc new file mode 100644 index 0000000..39b5868 --- /dev/null +++ b/home/.config/sxhkd/sxhkdrc @@ -0,0 +1,244 @@ +# Sakura Line - keybindings. +# `rice-keys` builds the cheat sheet (super + k) from this file: +# a "## Section" line starts a group, a "# text" line right above a +# hotkey is its description. Keep that shape when adding bindings. + +## Apps + +# Terminal +super + Return + st + +# Drop-down terminal (scratchpad) +super + grave + rice-scratchpad + +# App launcher +super + w + rofi -show drun + +# Rice menu +super + m + rice-menu main + +# Keybinding cheat sheet +super + k + rice-keys + +# Emoji picker +super + ctrl + e + rice-emoji + +# Clipboard history +super + ctrl + v + rice-clipboard + +## Panels + +# Control centre +super + a + rice-panel hub + +# Audio / network / bluetooth / display / power / media panel +super + ctrl + {a,w,b,d,p,m} + rice-panel {audio,network,bluetooth,display,power,media} + +# Calendar and reminders +super + ctrl + c + rice-panel calendar + +# Notification centre +super + n + rice-panel notifications + +# Toggle the bar +super + b + rice-bar toggle + +## Notifications + +# Dismiss the newest notification +super + comma + rice-notify dismiss + +# Dismiss all notifications +super + shift + comma + rice-notify dismiss-all + +# Do not disturb on/off +super + ctrl + comma + rice-notify dnd toggle + +# Run the newest notification's action +super + alt + comma + rice-notify action + +# Bring back the last dismissed notification +super + shift + alt + comma + rice-notify restore + +## Reminders + +# New reminder +super + ctrl + r + rice-reminder new + +# Show upcoming reminders +super + ctrl + alt + r + rice-reminder list + +## Capture + +# Screenshot a region (flameshot) +Print + rice-capture region + +# Screenshot the whole screen +shift + Print + rice-capture screen + +# Copy text from a region (OCR) +ctrl + Print + rice-capture text + +# Start or stop screen recording +alt + Print + rice-capture record + +# Pick a colour +super + shift + Print + rice-capture color + +# Capture menu: QR, webcam overlay, transcode and the rest +super + Print + rice-menu capture + +# Screenshot a region (no PrtSc key) +super + alt + p + rice-capture region + +# Screenshot the whole screen (no PrtSc key) +super + alt + shift + p + rice-capture screen + +# Copy text from a region (no PrtSc key) +super + alt + t + rice-capture text + +# Start or stop screen recording (no PrtSc key) +super + alt + v + rice-capture record + +# Pick a colour (no PrtSc key) +super + alt + c + rice-capture color + +# Capture menu (no PrtSc key) +super + alt + s + rice-menu capture + +# Zoom into the screen +super + ctrl + z + rice-zoom + +# Dictation on/off +super + ctrl + x + rice-dictate toggle + +## Clipboard + +# Copy (works in terminals too) +super + @c + rice-clip copy + +# Paste (works in terminals too) +super + @v + rice-clip paste + +## Style + +# Wallpaper picker +super + ctrl + space + rice-menu wallpaper + +# Theme picker +super + shift + ctrl + space + rice-menu theme + +# Quick toggles +super + ctrl + o + rice-menu toggles + +## Session + +# Power menu +super + Escape + rice-menu power + +# Lock +super + ctrl + l + rice-lock + +# Stay awake on/off +super + ctrl + i + rice-idle toggle + +# Reload keybindings +super + alt + Escape + pkill -USR1 -x sxhkd && rice-notify send "Keybindings" "Reloaded" + +# Restart bspwm +super + alt + r + bspc wm -r + +# Quit bspwm +super + Delete + bspc quit + +## Windows + +# Close window +super + q + bspc node -c + +# Float / tile +super + space + bspc node -t "~"{floating,tiled} + +# Fullscreen +super + f + bspc node -t \~fullscreen + +# Focus / swap window in a direction +super + {_,shift + }{Left,Down,Up,Right} + bspc node -{f,s} {west,south,north,east} + +# Previous window +alt + Tab + bspc node -f last + +# Go to desktop / send window to desktop +super + {_,shift + }{1-6} + bspc {desktop -f,node -d} '^{1-6}' + +# Previous / next desktop +super + ctrl + {Left,Right} + bspc desktop -f {prev,next}.local + +## Media + +# Volume up / down / mute +XF86Audio{RaiseVolume,LowerVolume,Mute} + rice-volume {up,down,mute} + +# Microphone mute +XF86AudioMicMute + rice-volume mic-mute + +# Play-pause / next / previous / stop +XF86Audio{Play,Pause,Next,Prev,Stop} + playerctl {play-pause,play-pause,next,previous,stop} + +# Play-pause / next / previous without media keys +ctrl + alt + {c,x,z} + playerctl {play-pause,next,previous} diff --git a/home/.inputrc b/home/.inputrc new file mode 100644 index 0000000..40bb896 --- /dev/null +++ b/home/.inputrc @@ -0,0 +1,24 @@ +# Sakura Line - readline. +$include /etc/inputrc + +set completion-ignore-case on +set completion-prefix-display-length 2 +set show-all-if-ambiguous on +set show-all-if-unmodified on +set menu-complete-display-prefix on +set mark-symlinked-directories on +set match-hidden-files off +set page-completions off +set completion-query-items 200 +set visible-stats on +set colored-stats on +set colored-completion-prefix on +set skip-completed-text on + +# Up and Down search the history for what is already typed. +"\e[A": history-search-backward +"\e[B": history-search-forward + +# Tab cycles through completions; Shift+Tab goes back. +TAB: menu-complete +"\e[Z": menu-complete-backward diff --git a/home/.xinitrc b/home/.xinitrc new file mode 100644 index 0000000..39de48f --- /dev/null +++ b/home/.xinitrc @@ -0,0 +1,11 @@ +#!/bin/sh +# Sakura Line - X session: pick up the system's session setup (D-Bus and the +# systemd user environment), then hand over to bspwm. +if [ -d /etc/X11/xinit/xinitrc.d ]; then + for f in /etc/X11/xinit/xinitrc.d/?*.sh; do + [ -x "$f" ] && . "$f" + done + unset f +fi + +exec bspwm diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..619aa8d --- /dev/null +++ b/install.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +# Sakura Line - install the rice on Arch Linux. +# +# curl -fsSL https://rice.srtk.in/install.sh | bash +# ~/rice/install.sh full install (safe to run again) +# ~/rice/install.sh --update pull, re-link, re-render, rebuild what changed; asks nothing +# ~/rice/install.sh --dry-run show what would happen, change nothing +# ~/rice/install.sh --skip-packages everything is installed already; no pacman +# +# Installs the packages (pacman, plus paru for the AUR), links every file in +# home/ into $HOME and every script in bin/ into ~/.local/bin, renders the +# theme, builds st with sixel images, and offers calendar sync and a boot +# splash. Anything it would replace is moved to ~/.rice-backup-<date>/ first. +set -uo pipefail + +RICE_REMOTE="${RICE_REMOTE:-https://git.srtk.in/sarthak/dotfiles.git}" +RICE_HOME="${RICE_HOME:-$HOME/rice}" +STATE="${XDG_STATE_HOME:-$HOME/.local/state}/rice" +BACKUP="$HOME/.rice-backup-$(date +%Y%m%d-%H%M%S)" +DRY=0 +UPDATE=0 +SKIP_PACKAGES=0 +FAILED=() + +for arg in "$@"; do + case "$arg" in + --update) UPDATE=1 ;; + --dry-run|-n) DRY=1 ;; + --skip-packages) SKIP_PACKAGES=1 ;; + -h|--help) sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown option: $arg (try --help)"; exit 2 ;; + esac +done + +c_hd=$'\e[1;38;2;146;185;234m'; c_ok=$'\e[38;2;143;174;116m'; c_wa=$'\e[38;2;216;168;124m' +c_er=$'\e[38;2;207;106;128m'; c_dim=$'\e[38;2;127;119;150m'; c_off=$'\e[0m' +say() { printf '\n%s>>%s %s\n' "$c_hd" "$c_off" "$*"; } +ok() { printf ' %s+%s %s\n' "$c_ok" "$c_off" "$*"; } +warn() { printf ' %s!%s %s\n' "$c_wa" "$c_off" "$*"; } +fail() { printf ' %sx%s %s\n' "$c_er" "$c_off" "$*"; FAILED+=("$*"); } +run() { if (( DRY )); then printf ' %s$ %s%s\n' "$c_dim" "$*" "$c_off"; else "$@"; fi; } +ask() { (( UPDATE )) && return 1; local a=""; { read -rp " $1 [y/N] " a </dev/tty; } 2>/dev/null || return 1; [[ $a == [yY]* ]]; } + +(( $(id -u) != 0 )) || { echo "Run this as your normal user; it asks for sudo when it needs it."; exit 1; } +command -v pacman >/dev/null || { echo "This rice installs on Arch Linux (pacman not found)."; exit 1; } + +# ------------------------------------------------------------ find the repo +# Piped from curl there is no checkout yet: clone it, then run its own copy. +self_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-.}")" 2>/dev/null && pwd)" +if [[ -f $self_dir/packages/pacman.txt && -d $self_dir/home ]]; then + RICE_DIR="$self_dir" +else + command -v git >/dev/null || sudo pacman -S --needed --noconfirm git + if [[ -d $RICE_HOME/.git ]]; then + git -C "$RICE_HOME" pull --ff-only + else + git clone "$RICE_REMOTE" "$RICE_HOME" + fi + exec bash "$RICE_HOME/install.sh" "$@" </dev/tty +fi +export PATH="$HOME/.local/bin:$PATH" + +# ---------------------------------------------------------------- steps +update_repo() { + (( UPDATE )) || return 0 + say "updating the rice" + if [[ -n $(git -C "$RICE_DIR" status --porcelain) ]]; then + warn "you have local changes in $RICE_DIR, so it was not pulled" + else + run git -C "$RICE_DIR" pull --ff-only || fail "git pull" + fi +} + +packages() { + say "packages" + local repo aur helper tmp + mapfile -t repo < <(sed 's/#.*//' "$RICE_DIR/packages/pacman.txt" | awk 'NF { print $1 }') + mapfile -t aur < <(sed 's/#.*//' "$RICE_DIR/packages/aur.txt" | awk 'NF { print $1 }') + + run sudo pacman -Syu --needed --noconfirm "${repo[@]}" || fail "pacman could not install everything" + + helper=$(command -v paru || command -v yay || true) + if [[ -z $helper ]]; then + warn "no AUR helper; building paru" + tmp=$(mktemp -d) + if run git clone --depth 1 https://aur.archlinux.org/paru-bin.git "$tmp/paru-bin" && + (cd "$tmp/paru-bin" && run makepkg -si --noconfirm); then + helper=$(command -v paru || true) + else + fail "could not build paru; AUR packages skipped" + fi + rm -rf "$tmp" + fi + [[ -n $helper ]] && { run "$helper" -S --needed --noconfirm "${aur[@]}" || fail "some AUR packages failed"; } + ok "packages done" +} + +# link_one <source> <target>: symlink, moving whatever was there into the backup. +link_one() { + local src="$1" dst="$2" rel + [[ -L $dst && $(readlink "$dst") == "$src" ]] && return 0 + if [[ -e $dst || -L $dst ]]; then + rel="${dst#"$HOME"/}" + run mkdir -p "$BACKUP/$(dirname "$rel")" + run mv "$dst" "$BACKUP/$rel" + warn "moved your ~/$rel to the backup" + fi + run mkdir -p "$(dirname "$dst")" + run ln -s "$src" "$dst" +} + +# A previous dotfiles setup kept its own installer in ~/.config/rice, which is +# where this rice keeps your hooks and local settings. Move those files aside. +legacy_rice() { + local old="$HOME/.config/rice" f moved=0 + for f in install.sh README.md packages.txt screenshot.webp particles st; do + [[ -e $old/$f && ! -L $old/$f ]] || continue + run mkdir -p "$BACKUP/.config/rice" + run mv "$old/$f" "$BACKUP/.config/rice/$f" + moved=1 + done + (( moved )) && warn "moved the previous rice installer out of ~/.config/rice into the backup" + return 0 +} + +# Swap the running session over: stop the old bar, notifications, compositor +# and key daemon, start the rice's, and re-run bspwmrc. Windows stay open. +apply_now() { + [[ -n ${DISPLAY:-} ]] && pgrep -x bspwm >/dev/null || return 1 + ask "Apply it to this session now? The bar, notifications, picom and sxhkd restart; windows stay open." || return 1 + say "applying to this session" + run rice-session restart && run bspc wm -r && ok "applied" || fail "could not restart the session daemons" +} + +link_home() { + say "linking configs and scripts" + local f t="$STATE/theme" + # bspwm runs bspwmrc as a program; without the executable bit it silently + # starts with no settings, rules or session. + run chmod +x "$RICE_DIR/home/.config/bspwm/bspwmrc" "$RICE_DIR"/bin/* + while IFS= read -r -d '' f; do + link_one "$f" "$HOME/${f#"$RICE_DIR"/home/}" + done < <(find "$RICE_DIR/home" \( -type f -o -type l \) -print0) + for f in "$RICE_DIR"/bin/*; do + link_one "$f" "$HOME/.local/bin/$(basename "$f")" + done + # These point at the rendered theme, which rice-theme rewrites on every change. + link_one "$t/dunst.conf" "$HOME/.config/dunst/dunstrc.d/50-theme.conf" + link_one "$t/eww-colors.scss" "$HOME/.config/eww/colors.scss" + link_one "$t/gtk.css" "$HOME/.config/gtk-3.0/gtk.css" + link_one "$t/gtk.css" "$HOME/.config/gtk-4.0/gtk.css" + link_one "$t/btop.theme" "$HOME/.config/btop/themes/rice.theme" + link_one "$t/imv-config" "$HOME/.config/imv/config" + [[ -d $BACKUP ]] && warn "replaced files are in $BACKUP" + ok "linked" +} + +local_files() { + say "your own settings" + local hooks="$HOME/.config/rice/hooks" e + for e in post-boot theme-set font-set wallpaper-set lock unlock update; do run mkdir -p "$hooks/$e.d"; done + if [[ ! -f $HOME/.bashrc.local ]] && (( ! DRY )); then + printf '%s\n' "# Personal shell settings: aliases, exports, SDK paths. Not part of the rice repo." > "$HOME/.bashrc.local" + ok "created ~/.bashrc.local" + fi + if [[ ! -f $HOME/.config/rice/local.sh ]] && (( ! DRY )); then + printf '%s\n' "# Machine-specific bspwm extras, sourced by bspwmrc: apps to autostart," \ + "# per-app desktop rules, monitor layouts. Not part of the rice repo." > "$HOME/.config/rice/local.sh" + ok "created ~/.config/rice/local.sh" + fi + ok "hooks live in ~/.config/rice/hooks/<event>.d/" +} + +theme() { + say "theme" + local name; name=$(rice-theme current) + if [[ -n ${DISPLAY:-} ]] && pgrep -x bspwm >/dev/null; then + run rice-theme reload && ok "re-rendered $name and reloaded running apps" || fail "theme reload" + else + run rice-theme render "$name" && ok "rendered $name" || fail "theme render" + fi +} + +build_st() { + say "st, with sixel images" + local stamp="$STATE/st.built" want + want=$(cat "$RICE_DIR"/st/build.sh "$RICE_DIR"/st/patches.list "$RICE_DIR"/st/config.sed | sha1sum | cut -c1-16) + if [[ -f $stamp && $(cat "$stamp") == "$want" ]] && command -v st >/dev/null; then + ok "st is up to date"; return + fi + if run bash "$RICE_DIR/st/build.sh" --install; then + (( DRY )) || { mkdir -p "$STATE"; echo "$want" > "$stamp"; } + ok "st installed" + else + fail "st build" + fi +} + +build_particles() { + say "particles wallpaper layer" + if run make -s -C "$RICE_DIR/particles" && run sudo make -s -C "$RICE_DIR/particles" install; then + ok "particles installed" + else + fail "particles build" + fi +} + +system_setup() { + say "services and hardware" + run sudo systemctl enable --now NetworkManager.service bluetooth.service power-profiles-daemon.service || + warn "some services did not start (normal without that hardware)" + if [[ ! -f /etc/modules-load.d/i2c-dev.conf ]]; then + run sudo sh -c 'echo i2c-dev > /etc/modules-load.d/i2c-dev.conf' && run sudo modprobe i2c-dev && + ok "i2c-dev loads at boot (monitor brightness over DDC)" + fi + if getent group i2c >/dev/null && ! id -nG | grep -qw i2c; then + run sudo usermod -aG i2c "$USER" && warn "added you to the i2c group; it applies at your next login" + fi +} + +calendar() { + say "calendar" + if [[ -f ${XDG_CONFIG_HOME:-$HOME/.config}/khal/config ]]; then ok "already set up"; return; fi + if ask "Sync the calendar with a CalDAV server (Baikal, Nextcloud, Fastmail, ...)?"; then + run rice-cal setup || fail "calendar setup" + else + warn "skipped; run 'rice-cal setup' whenever you like" + fi +} + +boot_splash() { + say "boot splash" + if grep -Eq '^HOOKS=.*\bplymouth\b' /etc/mkinitcpio.conf 2>/dev/null; then ok "plymouth is already in the initramfs"; return; fi + ask "Show Plymouth's stock splash at boot? This edits mkinitcpio.conf and the kernel options." || { warn "skipped"; return; } + + run sudo cp /etc/mkinitcpio.conf /etc/mkinitcpio.conf.rice-backup + run sudo sed -i -E '/^HOOKS=/ s/\b(systemd|udev)\b/\1 plymouth/' /etc/mkinitcpio.conf + + if [[ -f /etc/default/grub ]]; then + run sudo cp /etc/default/grub /etc/default/grub.rice-backup + grep -q 'splash' /etc/default/grub || + run sudo sed -i -E 's/^(GRUB_CMDLINE_LINUX_DEFAULT="[^"]*)"/\1 quiet splash"/' /etc/default/grub + run sudo grub-mkconfig -o /boot/grub/grub.cfg >/dev/null || fail "grub-mkconfig" + elif compgen -G "/boot/loader/entries/*.conf" >/dev/null; then + local entry + for entry in /boot/loader/entries/*.conf; do + grep -q splash "$entry" || run sudo sed -i -E '/^options / s/$/ quiet splash/' "$entry" + done + else + warn "add 'quiet splash' to your kernel command line by hand" + fi + run sudo mkinitcpio -P >/dev/null && ok "plymouth enabled from the next boot" || fail "mkinitcpio" +} + +# ----------------------------------------------------------------- main +printf '\n%s Sakura Line%s rice installer · %s\n' "$c_hd" "$c_off" "$RICE_DIR" +(( DRY )) && warn "dry run: nothing will be changed" + +update_repo +(( SKIP_PACKAGES )) || packages +legacy_rice +link_home +local_files +theme +build_st +build_particles +system_setup +calendar +boot_splash +(( UPDATE )) && run rice-hook update + +printf '\n' +if (( ${#FAILED[@]} == 0 )); then + say "done" +else + say "finished with ${#FAILED[@]} problem(s):" + for f in "${FAILED[@]}"; do printf ' %s- %s%s\n' "$c_er" "$f" "$c_off"; done +fi +if apply_now; then + printf '\n %ssuper + k%s lists every shortcut.\n\n' "$c_hd" "$c_off" +elif [[ -n ${DISPLAY:-} ]] && pgrep -x bspwm >/dev/null; then + printf ' To switch this session over later: %srice-session restart && bspc wm -r%s\n\n' "$c_hd" "$c_off" +else + printf ' Log in on tty1 and the desktop starts; %ssuper + k%s lists every shortcut.\n\n' "$c_hd" "$c_off" +fi diff --git a/packages/aur.txt b/packages/aur.txt new file mode 100644 index 0000000..5317022 --- /dev/null +++ b/packages/aur.txt @@ -0,0 +1,14 @@ +# Sakura Line - AUR packages (installed with paru or yay). +# One package per line; text after # is ignored. + +eww # popups: calendar, control panels, notification centre +xidlehook # idle chain that skips fullscreen video and playing audio +tdrop # drop-down scratchpad terminal +boomer-git # screen zoom +rofi-greenclip # clipboard history +zscroll-git # scrolling media title +nerd-dictation-git # offline voice dictation (vosk) +bibata-cursor-theme +cbonsai +pipes.sh +tty-clock diff --git a/packages/pacman.txt b/packages/pacman.txt new file mode 100644 index 0000000..19cdf4e --- /dev/null +++ b/packages/pacman.txt @@ -0,0 +1,126 @@ +# Sakura Line - official repo packages (pacman -S --needed). +# One package per line; text after # is ignored. + +# --- X session / window manager --- +xorg-server +xorg-xinit +xorg-xrandr +xorg-xprop +xorg-xrdb +xorg-xset +xorg-xsetroot +xorg-xwininfo +bspwm +sxhkd +polybar +picom +rofi +rofi-emoji +dunst +libnotify +feh +xdotool +xclip +xsettingsd +wmname +polkit-gnome +xdg-utils + +# --- build toolchain (st, particles) --- +base-devel +git +pkgconf +libx11 +libxext +libxft +libxrandr +libxfixes +libxinerama +cairo +harfbuzz +fontconfig +freetype2 +gd +imlib2 + +# --- fonts / theming --- +ttf-jetbrains-mono-nerd +noto-fonts +noto-fonts-cjk +noto-fonts-emoji +papirus-icon-theme +gnome-themes-extra +qt6ct + +# --- panels: audio, network, bluetooth, display, power, media --- +pipewire +pipewire-pulse +wireplumber +pamixer +playerctl +networkmanager +bluez +bluez-utils +ddcutil +power-profiles-daemon +lm_sensors +qrencode + +# --- calendar + reminders (Baikal CalDAV) --- +khal +vdirsyncer + +# --- idle, screensaver, lock --- +xss-lock +xsecurelock +xscreensaver + +# --- capture: screenshot, recording, OCR, QR, colour, transcode --- +flameshot +maim +slop +gpu-screen-recorder +ffmpeg +imagemagick +mpv +v4l-utils +tesseract +tesseract-data-eng +zbar +xcolor + +# --- shell --- +bash-completion +starship +fzf +eza +bat +zoxide +fd +ripgrep +tldr +jq +inotify-tools +socat + +# --- terminal images --- +chafa +ueberzugpp +yazi +imv + +# --- boot splash (stock Arch theme, no branding) --- +plymouth + +# --- apps --- +kitty +neovim +thunar +fastfetch +btop +cava + +# --- terminal toys --- +cmatrix +asciiquarium +onefetch diff --git a/particles/LICENSE b/particles/LICENSE new file mode 100644 index 0000000..5b765c9 --- /dev/null +++ b/particles/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/particles/Makefile b/particles/Makefile new file mode 100644 index 0000000..9154536 --- /dev/null +++ b/particles/Makefile @@ -0,0 +1,23 @@ +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin + +PKGS := cairo x11 xext xfixes xrender xrandr +CFLAGS ?= -O2 -Wall -Wextra +CFLAGS += $(shell pkg-config --cflags $(PKGS)) +LDLIBS := $(shell pkg-config --libs $(PKGS)) -lm + +all: particles + +particles: particles_wall.c + $(CC) $(CFLAGS) -o $@ $< $(LDLIBS) + +install: particles + install -Dm755 particles $(DESTDIR)$(BINDIR)/particles + +uninstall: + rm -f $(DESTDIR)$(BINDIR)/particles + +clean: + rm -f particles + +.PHONY: all install uninstall clean diff --git a/particles/particles_wall.c b/particles/particles_wall.c new file mode 100644 index 0000000..d312606 --- /dev/null +++ b/particles/particles_wall.c @@ -0,0 +1,1744 @@ +/* + * particle.X11 - an antialiased, click-through particle field that lives on the + * X11 wallpaper layer. + * + * Design notes: + * - The window is a 32-bit ARGB, override-redirect window kept at the bottom + * of the stack and tagged _NET_WM_WINDOW_TYPE_DESKTOP, so a compositor + * (picom, xcompmgr, ...) blends it over the root wallpaper and every real + * window draws on top of it. + * - Its XFixes input region is empty, so clicks, scrolls and keys go straight + * through to whatever is underneath. No pointer/keyboard grabs, ever. + * - The cursor is read with XQueryPointer against the root window, so the + * particles keep reacting to it even while it is over other applications. + * - Frames are drawn with cairo into a MIT-SHM XImage (zero-copy present) and + * only the damaged horizontal bands are cleared and uploaded. + * - Without a compositor it falls back to compositing the root wallpaper + * pixmap into its own buffer, so it still looks right. + * + * Build: make (or see the command at the bottom of README.md) + * License: MIT + */ + +#define _GNU_SOURCE + +#include <X11/Xlib.h> +#include <X11/Xatom.h> +#include <X11/Xutil.h> +#include <X11/extensions/XShm.h> +#include <X11/extensions/Xfixes.h> +#include <X11/extensions/Xrandr.h> +#include <X11/extensions/shape.h> + +#include <cairo.h> +#include <cairo-xlib.h> + +#include <ctype.h> +#include <errno.h> +#include <fcntl.h> +#include <math.h> +#include <signal.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdint.h> +#include <stdlib.h> +#include <string.h> +#include <sys/file.h> +#include <sys/ipc.h> +#include <sys/shm.h> +#include <sys/stat.h> +#include <sys/types.h> +#include <time.h> +#include <unistd.h> + +#define APP_NAME "particles" +#define APP_VERSION "2.0" + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +/* rows per damage band; a band is the granularity of clear + upload */ +#define BAND_H 16 + +/* number of pre-rendered dot sizes */ +#define SPRITE_LEVELS 16 + +/* ------------------------------------------------------------------ config */ + +enum { CUR_NONE = 0, CUR_GRAB = 1, CUR_REPEL = 2, CUR_ATTRACT = 4 }; +enum { EDGE_BOUNCE = 0, EDGE_WRAP = 1 }; + +typedef struct { + int count; /* 0 => derive from density */ + double density; /* particles per megapixel */ + double radius_min, radius_max; + double speed; /* px per second */ + double link_dist; + double link_width; + double link_opacity; + double opacity; /* particle core opacity */ + double glow; /* halo radius as a multiple of particle radius */ + int cursor_mode; /* bitmask of CUR_* */ + double cursor_dist; /* how far the grab links reach */ + double repel_dist; /* push radius; 0 => 0.6 * cursor_dist */ + double cursor_strength; /* 1.0 pushes a particle exactly onto the rim */ + double cursor_response; /* how fast the push settles / releases, 1/s */ + double cursor_ease; /* cursor position smoothing, 1/s */ + int edges; /* EDGE_BOUNCE | EDGE_WRAP */ + int fps; + double color[3]; + double link_color[3]; + int keep_below; + int pause_when_covered; + char log_path[512]; +} Config; + +static void config_defaults(Config *c) +{ + memset(c, 0, sizeof(*c)); + c->count = 0; + c->density = 70.0; + c->radius_min = 1.1; + c->radius_max = 2.6; + c->speed = 26.0; + c->link_dist = 130.0; + c->link_width = 1.1; + c->link_opacity = 0.55; + c->opacity = 0.85; + c->glow = 3.2; + c->cursor_mode = CUR_GRAB | CUR_REPEL; + c->cursor_dist = 170.0; + c->repel_dist = 0.0; /* auto: 0.6 * cursor_dist */ + c->cursor_strength = 1.0; + c->cursor_response = 9.0; + c->cursor_ease = 18.0; + c->edges = EDGE_BOUNCE; + c->fps = 60; + c->color[0] = 0.86; c->color[1] = 0.91; c->color[2] = 1.0; /* soft white-blue */ + c->link_color[0] = 0.68; c->link_color[1] = 0.82; c->link_color[2] = 1.0; + c->keep_below = 1; + c->pause_when_covered = 1; + c->log_path[0] = '\0'; +} + +static int parse_bool(const char *v, int *out) +{ + if (!strcasecmp(v, "1") || !strcasecmp(v, "true") || !strcasecmp(v, "yes") || + !strcasecmp(v, "on")) { *out = 1; return 0; } + if (!strcasecmp(v, "0") || !strcasecmp(v, "false") || !strcasecmp(v, "no") || + !strcasecmp(v, "off")) { *out = 0; return 0; } + return -1; +} + +static int parse_color(const char *v, double out[3]) +{ + unsigned r, g, b; + const char *s = (*v == '#') ? v + 1 : v; + if (strlen(s) == 6 && sscanf(s, "%2x%2x%2x", &r, &g, &b) == 3) { + out[0] = r / 255.0; out[1] = g / 255.0; out[2] = b / 255.0; + return 0; + } + if (strlen(s) == 3 && sscanf(s, "%1x%1x%1x", &r, &g, &b) == 3) { + out[0] = r / 15.0; out[1] = g / 15.0; out[2] = b / 15.0; + return 0; + } + return -1; +} + +static int parse_cursor_mode(const char *v, int *out) +{ + int mode = 0; + char buf[128], *tok, *save = NULL; + snprintf(buf, sizeof buf, "%s", v); + for (tok = strtok_r(buf, ",+ ", &save); tok; tok = strtok_r(NULL, ",+ ", &save)) { + if (!strcasecmp(tok, "none")) mode = 0; + else if (!strcasecmp(tok, "grab")) mode |= CUR_GRAB; + else if (!strcasecmp(tok, "repel")) mode |= CUR_REPEL; + else if (!strcasecmp(tok, "attract")) mode |= CUR_ATTRACT; + else if (!strcasecmp(tok, "both")) mode |= CUR_GRAB | CUR_REPEL; + else return -1; + } + if (mode & CUR_ATTRACT) mode &= ~CUR_REPEL; + *out = mode; + return 0; +} + +/* Shared by the config file and the command line so both speak the same + * vocabulary: "speed = 30" in the file is "--speed 30" on the CLI. */ +static int set_option(Config *c, const char *key, const char *val) +{ + if (!val) return -1; + if (!strcmp(key, "count")) c->count = atoi(val); + else if (!strcmp(key, "density")) c->density = atof(val); + else if (!strcmp(key, "radius-min")) c->radius_min = atof(val); + else if (!strcmp(key, "radius-max")) c->radius_max = atof(val); + else if (!strcmp(key, "radius")) { + double r = atof(val); c->radius_min = r * 0.6; c->radius_max = r * 1.4; + } + else if (!strcmp(key, "speed")) c->speed = atof(val); + else if (!strcmp(key, "link-distance")) c->link_dist = atof(val); + else if (!strcmp(key, "link-width")) c->link_width = atof(val); + else if (!strcmp(key, "link-opacity")) c->link_opacity = atof(val); + else if (!strcmp(key, "opacity")) c->opacity = atof(val); + else if (!strcmp(key, "glow")) c->glow = atof(val); + else if (!strcmp(key, "cursor-distance")) c->cursor_dist = atof(val); + else if (!strcmp(key, "repel-distance")) c->repel_dist = atof(val); + else if (!strcmp(key, "cursor-strength")) c->cursor_strength = atof(val); + else if (!strcmp(key, "cursor-response")) c->cursor_response = atof(val); + else if (!strcmp(key, "fps")) c->fps = atoi(val); + else if (!strcmp(key, "cursor")) return parse_cursor_mode(val, &c->cursor_mode); + else if (!strcmp(key, "color")) return parse_color(val, c->color); + else if (!strcmp(key, "link-color")) return parse_color(val, c->link_color); + else if (!strcmp(key, "keep-below")) return parse_bool(val, &c->keep_below); + else if (!strcmp(key, "pause-when-covered")) + return parse_bool(val, &c->pause_when_covered); + else if (!strcmp(key, "edges")) { + if (!strcasecmp(val, "bounce")) c->edges = EDGE_BOUNCE; + else if (!strcasecmp(val, "wrap")) c->edges = EDGE_WRAP; + else return -1; + } + else if (!strcmp(key, "log")) + snprintf(c->log_path, sizeof c->log_path, "%s", val); + else return -2; /* unknown key */ + return 0; +} + +static void config_path(char *buf, size_t n) +{ + const char *xdg = getenv("XDG_CONFIG_HOME"); + const char *home = getenv("HOME"); + if (xdg && *xdg) snprintf(buf, n, "%s/particles.conf", xdg); + else if (home) snprintf(buf, n, "%s/.config/particles.conf", home); + else snprintf(buf, n, "/dev/null"); +} + +static void load_config_file(Config *c, const char *path) +{ + char line[512]; + FILE *f = fopen(path, "r"); + int lineno = 0; + if (!f) return; + while (fgets(line, sizeof line, f)) { + char *key, *val, *hash, *end; + lineno++; + if ((hash = strchr(line, '#'))) *hash = '\0'; + key = line; + while (*key && isspace((unsigned char)*key)) key++; + if (!*key) continue; + val = strchr(key, '='); + if (!val) continue; + *val++ = '\0'; + end = key + strlen(key); + while (end > key && isspace((unsigned char)end[-1])) *--end = '\0'; + while (*val && isspace((unsigned char)*val)) val++; + end = val + strlen(val); + while (end > val && isspace((unsigned char)end[-1])) *--end = '\0'; + if (set_option(c, key, val) != 0) + fprintf(stderr, "%s: %s:%d: bad option '%s'\n", APP_NAME, path, lineno, key); + } + fclose(f); +} + +/* ------------------------------------------------------------------- state */ + +typedef struct { + float x, y; /* drifting base position */ + float vx, vy; + float ox, oy; /* cursor displacement, spring-damped */ + float sx, sy; /* where it is actually drawn: base + offset */ + float r; + float alpha; /* per-particle opacity jitter */ + short level; /* index into App.sprite */ +} Particle; + +typedef struct { + Display *dpy; + int screen; + Window root, win; + Visual *visual; + Colormap cmap; + int depth; + GC gc; + + int w, h; + + /* presentation buffer */ + XImage *ximg; + XShmSegmentInfo shm; + int use_shm; + unsigned char *pixels; + int stride; + cairo_surface_t *surface; + + /* wallpaper copy, used only when nothing is compositing for us */ + unsigned char *bg; + int composited; + + /* damage bands: x span per band, -1 == clean */ + int bands; + int *dmg_min, *dmg_max; /* current frame */ + int *prev_min, *prev_max; /* previous frame */ + + /* particles + uniform grid used for the link pass */ + Particle *p; + int n; + + /* pre-rendered A8 dot sprites, blitted as masks every frame */ + unsigned char *sprite[SPRITE_LEVELS]; + int sprite_size[SPRITE_LEVELS]; + int sprite_stride[SPRITE_LEVELS]; + double sprite_c[SPRITE_LEVELS]; /* centre offset in px */ + int *cell_of, *cell_start, *cell_cursor, *order; + int gw, gh; + double cell; + + /* cursor */ + double cx, cy; + int cursor_on; + + Atom atom_active, atom_state, atom_fullscreen; + + /* xrandr / event plumbing */ + int rr_event_base, rr_error_base; + int has_randr; + int obscured; + int covered; + + Config cfg; +} App; + +static volatile sig_atomic_t g_quit = 0; +static volatile sig_atomic_t g_reload = 0; + +static void on_signal(int sig) +{ + if (sig == SIGHUP) g_reload = 1; + else g_quit = 1; +} + +static double now_sec(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec + ts.tv_nsec * 1e-9; +} + +static double frand(void) { return rand() / (double)RAND_MAX; } + +static double frand_range(double a, double b) { return a + (b - a) * frand(); } + +/* --------------------------------------------------------------- particles */ + +static void sprites_free(App *a) +{ + int i; + for (i = 0; i < SPRITE_LEVELS; i++) { free(a->sprite[i]); a->sprite[i] = NULL; } +} + +/* One alpha-only sprite per radius level: a solid antialiased core wrapped in a + * soft halo. Drawn once here, then reused as a mask every frame. */ +static void sprites_build(App *a) +{ + int i; + sprites_free(a); + for (i = 0; i < SPRITE_LEVELS; i++) { + double r = SPRITE_LEVELS > 1 + ? a->cfg.radius_min + (a->cfg.radius_max - a->cfg.radius_min) + * i / (double)(SPRITE_LEVELS - 1) + : a->cfg.radius_min; + double halo = r * a->cfg.glow; + int size, c; + cairo_t *cr; + cairo_surface_t *surf; + cairo_pattern_t *pat; + + if (halo < r + 0.75) halo = r + 0.75; + size = 2 * (int)ceil(halo + 1.0) + 1; + c = size / 2; + + surf = cairo_image_surface_create(CAIRO_FORMAT_A8, size, size); + cr = cairo_create(surf); + cairo_set_antialias(cr, CAIRO_ANTIALIAS_BEST); /* costs nothing, done once */ + pat = cairo_pattern_create_radial(c, c, 0, c, c, halo); + cairo_pattern_add_color_stop_rgba(pat, 0.0, 1, 1, 1, 1.00); + cairo_pattern_add_color_stop_rgba(pat, (r * 0.72) / halo, 1, 1, 1, 1.00); + cairo_pattern_add_color_stop_rgba(pat, r / halo, 1, 1, 1, 0.42); + cairo_pattern_add_color_stop_rgba(pat, (r + (halo - r) * 0.45) / halo, 1, 1, 1, 0.10); + cairo_pattern_add_color_stop_rgba(pat, 1.0, 1, 1, 1, 0.00); + cairo_set_source(cr, pat); + cairo_arc(cr, c, c, halo, 0, 2.0 * M_PI); + cairo_fill(cr); + cairo_pattern_destroy(pat); + cairo_destroy(cr); + cairo_surface_flush(surf); + + { + int st = cairo_image_surface_get_stride(surf); + const unsigned char *src = cairo_image_surface_get_data(surf); + a->sprite[i] = malloc((size_t)st * size); + a->sprite_size[i] = size; + a->sprite_stride[i] = st; + a->sprite_c[i] = c; + if (a->sprite[i]) memcpy(a->sprite[i], src, (size_t)st * size); + } + cairo_surface_destroy(surf); + } +} + +static void particles_init(App *a) +{ + int i; + int n = a->cfg.count > 0 + ? a->cfg.count + : (int)(a->cfg.density * ((double)a->w * a->h) / 1.0e6 + 0.5); + + if (n < 1) n = 1; + if (n > 20000) n = 20000; + + free(a->p); + a->p = calloc(n, sizeof *a->p); + a->n = n; + + for (i = 0; i < n; i++) { + Particle *p = &a->p[i]; + double ang = frand() * 2.0 * M_PI; + double spd = a->cfg.speed * frand_range(0.45, 1.15); + p->x = frand() * a->w; + p->y = frand() * a->h; + p->vx = (float)(cos(ang) * spd); + p->vy = (float)(sin(ang) * spd); + p->r = (float)frand_range(a->cfg.radius_min, a->cfg.radius_max); + { + double span = a->cfg.radius_max - a->cfg.radius_min; + int lv = span > 1e-6 + ? (int)((p->r - a->cfg.radius_min) / span * (SPRITE_LEVELS - 1) + 0.5) + : 0; + p->level = (short)(lv < 0 ? 0 : lv > SPRITE_LEVELS - 1 ? SPRITE_LEVELS - 1 : lv); + } + p->alpha = (float)frand_range(0.65, 1.0); + p->ox = p->oy = 0.0f; + p->sx = p->x; + p->sy = p->y; + } + + free(a->cell_of); a->cell_of = malloc(n * sizeof(int)); + free(a->order); a->order = malloc(n * sizeof(int)); + a->gw = a->gh = 0; /* force grid realloc */ + + sprites_build(a); +} + +static void particles_step(App *a, double dt) +{ + const Config *c = &a->cfg; + const double rdist = c->repel_dist > 0 ? c->repel_dist : c->cursor_dist * 0.6; + const double rd2 = rdist * rdist; + const int repel = a->cursor_on && (c->cursor_mode & CUR_REPEL); + const int attract = a->cursor_on && (c->cursor_mode & CUR_ATTRACT); + /* frame-rate independent exponential approach */ + const double k = 1.0 - exp(-c->cursor_response * dt); + int i; + + for (i = 0; i < a->n; i++) { + Particle *p = &a->p[i]; + double tx = 0.0, ty = 0.0; + + p->x += (float)(p->vx * dt); + p->y += (float)(p->vy * dt); + + if (c->edges == EDGE_WRAP) { + double m = p->r * c->glow; + if (p->x < -m) p->x = (float)(a->w + m); + else if (p->x > a->w + m) p->x = (float)(-m); + if (p->y < -m) p->y = (float)(a->h + m); + else if (p->y > a->h + m) p->y = (float)(-m); + } else { + if (p->x < 0) { p->x = 0; p->vx = fabsf(p->vx); } + else if (p->x > a->w) { p->x = (float)a->w; p->vx = -fabsf(p->vx); } + if (p->y < 0) { p->y = 0; p->vy = fabsf(p->vy); } + else if (p->y > a->h) { p->y = (float)a->h; p->vy = -fabsf(p->vy); } + } + + /* The cursor does not accelerate particles - it bends the field. Each + * particle springs toward a displaced target and relaxes back once the + * cursor moves on, so a resting cursor holds a stable ring instead of + * flinging everything off screen. */ + if (repel || attract) { + double dx = p->x - a->cx, dy = p->y - a->cy; + double d2 = dx * dx + dy * dy; + if (d2 < rd2) { + double d = sqrt(d2); + if (d < 1e-3) { d = 1e-3; dx = 1e-3; dy = 0.0; } + if (repel) { + double push = (rdist - d) * c->cursor_strength; + tx = dx / d * push; + ty = dy / d * push; + } else { + double pull = c->cursor_strength * (1.0 - d / rdist); + if (pull > 0.85) pull = 0.85; + tx = -dx * pull; + ty = -dy * pull; + } + } + } + + p->ox += (float)((tx - p->ox) * k); + p->oy += (float)((ty - p->oy) * k); + p->sx = p->x + p->ox; + p->sy = p->y + p->oy; + } +} + +/* Bucket the particles into a uniform grid so the link pass only compares + * neighbours instead of every pair. */ +static void grid_build(App *a) +{ + int i, cells; + double cell = a->cfg.link_dist > 8.0 ? a->cfg.link_dist : 8.0; + int gw = (int)(a->w / cell) + 1; + int gh = (int)(a->h / cell) + 1; + + if (gw != a->gw || gh != a->gh) { + a->gw = gw; a->gh = gh; + free(a->cell_start); a->cell_start = malloc((gw * gh + 1) * sizeof(int)); + free(a->cell_cursor); a->cell_cursor = malloc((gw * gh + 1) * sizeof(int)); + } + a->cell = cell; + cells = gw * gh; + memset(a->cell_start, 0, (cells + 1) * sizeof(int)); + + for (i = 0; i < a->n; i++) { + int cx = (int)(a->p[i].sx / cell); + int cy = (int)(a->p[i].sy / cell); + if (cx < 0) cx = 0; else if (cx >= gw) cx = gw - 1; + if (cy < 0) cy = 0; else if (cy >= gh) cy = gh - 1; + a->cell_of[i] = cy * gw + cx; + a->cell_start[a->cell_of[i] + 1]++; + } + for (i = 0; i < cells; i++) a->cell_start[i + 1] += a->cell_start[i]; + memcpy(a->cell_cursor, a->cell_start, (cells + 1) * sizeof(int)); + for (i = 0; i < a->n; i++) a->order[a->cell_cursor[a->cell_of[i]]++] = i; +} + +/* ------------------------------------------------------------ damage bands */ + +static void damage_reset(App *a) +{ + int i; + for (i = 0; i < a->bands; i++) { a->dmg_min[i] = INT32_MAX; a->dmg_max[i] = -1; } +} + +static void damage_add(App *a, double x0, double y0, double x1, double y1) +{ + int ix0, ix1, b, b0, b1; + + if (x1 < 0 || y1 < 0 || x0 > a->w || y0 > a->h) return; + ix0 = (int)floor(x0) - 1; if (ix0 < 0) ix0 = 0; + ix1 = (int)ceil (x1) + 1; if (ix1 > a->w - 1) ix1 = a->w - 1; + if (ix1 < ix0) return; + + b0 = (int)floor(y0 - 1) / BAND_H; if (b0 < 0) b0 = 0; + b1 = (int)ceil (y1 + 1) / BAND_H; if (b1 > a->bands - 1) b1 = a->bands - 1; + + for (b = b0; b <= b1; b++) { + if (ix0 < a->dmg_min[b]) a->dmg_min[b] = ix0; + if (ix1 > a->dmg_max[b]) a->dmg_max[b] = ix1; + } +} + +/* Erase what the previous frame drew: transparent when a compositor blends us + * over the wallpaper, otherwise a copy of the wallpaper itself. */ +static void damage_clear_previous(App *a) +{ + int b; + for (b = 0; b < a->bands; b++) { + int y, y0, y1, x0 = a->prev_min[b], x1 = a->prev_max[b]; + if (x1 < x0) continue; + y0 = b * BAND_H; + y1 = y0 + BAND_H; if (y1 > a->h) y1 = a->h; + for (y = y0; y < y1; y++) { + unsigned char *dst = a->pixels + (size_t)y * a->stride + (size_t)x0 * 4; + size_t bytes = (size_t)(x1 - x0 + 1) * 4; + if (a->bg) memcpy(dst, a->bg + (size_t)y * a->stride + (size_t)x0 * 4, bytes); + else memset(dst, 0, bytes); + } + } +} + +/* Push the union of this frame's and last frame's damage to the server, merging + * consecutive bands so a full-screen frame costs a single upload. */ +static void present(App *a) +{ + int b = 0; + while (b < a->bands) { + int x0, x1, run_y0, run_y1; + int lo = a->dmg_min[b] < a->prev_min[b] ? a->dmg_min[b] : a->prev_min[b]; + int hi = a->dmg_max[b] > a->prev_max[b] ? a->dmg_max[b] : a->prev_max[b]; + if (hi < lo) { b++; continue; } + + x0 = lo; x1 = hi; + run_y0 = b * BAND_H; + while (b < a->bands) { + int nlo = a->dmg_min[b] < a->prev_min[b] ? a->dmg_min[b] : a->prev_min[b]; + int nhi = a->dmg_max[b] > a->prev_max[b] ? a->dmg_max[b] : a->prev_max[b]; + if (nhi < nlo) break; + if (nlo < x0) x0 = nlo; + if (nhi > x1) x1 = nhi; + b++; + } + run_y1 = b * BAND_H; if (run_y1 > a->h) run_y1 = a->h; + + if (a->use_shm) + XShmPutImage(a->dpy, a->win, a->gc, a->ximg, x0, run_y0, x0, run_y0, + x1 - x0 + 1, run_y1 - run_y0, False); + else + XPutImage(a->dpy, a->win, a->gc, a->ximg, x0, run_y0, x0, run_y0, + x1 - x0 + 1, run_y1 - run_y0); + } + + { int *t; + t = a->prev_min; a->prev_min = a->dmg_min; a->dmg_min = t; + t = a->prev_max; a->prev_max = a->dmg_max; a->dmg_max = t; } + damage_reset(a); +} + +/* ----------------------------------------------------------------- drawing */ + +/* ------------------------------------------------------- software raster */ +/* + * Cairo is excellent but its fixed cost per stroke (~11us here) dwarfs the two + * hundred pixels a link actually covers, and that cost is what pinned the old + * build at ~30% of a core. These two routines cover everything this program + * draws - antialiased segments and a pre-rendered dot - straight into the + * premultiplied ARGB buffer. Coverage comes from the true distance to the + * segment, so the edges stay as smooth as cairo's. + */ + +/* one premultiplied OVER blend; cov is 0..255 */ +static inline void blend_px(unsigned char *px, int R, int G, int B, int cov) +{ + unsigned ia; + if (cov <= 0) return; + if (cov > 255) cov = 255; + ia = 255u - (unsigned)cov; + px[0] = (unsigned char)((B * cov + px[0] * ia + 127) / 255); + px[1] = (unsigned char)((G * cov + px[1] * ia + 127) / 255); + px[2] = (unsigned char)((R * cov + px[2] * ia + 127) / 255); + px[3] = (unsigned char)((255 * cov + px[3] * ia + 127) / 255); +} + +static void draw_line_aa(App *a, double x0, double y0, double x1, double y1, + double hw, int R, int G, int B, double alpha) +{ + const double dx = x1 - x0, dy = y1 - y0; + const double len2 = dx * dx + dy * dy; + const double aa = hw + 0.5; /* coverage reaches half a pixel out */ + const double inv_len2 = len2 > 1e-9 ? 1.0 / len2 : 0.0; + unsigned char *base = a->pixels; + int i, i0, i1; + + if (alpha <= 0.002) return; + if (len2 <= 1e-9) return; + + if (fabs(dx) >= fabs(dy)) { /* iterate columns */ + double slope = dy / dx; + double ext = aa * sqrt(1.0 + slope * slope); + i0 = (int)floor((x0 < x1 ? x0 : x1) - aa); + i1 = (int)ceil ((x0 > x1 ? x0 : x1) + aa); + if (i0 < 0) i0 = 0; + if (i1 > a->w - 1) i1 = a->w - 1; + for (i = i0; i <= i1; i++) { + double px = i + 0.5; + double t = (px - x0) / dx; + double yc, lo, hi; + int j, j0, j1; + if (t < 0) t = 0; else if (t > 1) t = 1; + yc = y0 + t * dy; + lo = yc - ext; hi = yc + ext; + j0 = (int)floor(lo); j1 = (int)ceil(hi); + if (j0 < 0) j0 = 0; + if (j1 > a->h - 1) j1 = a->h - 1; + for (j = j0; j <= j1; j++) { + double vx = px - x0, vy = (j + 0.5) - y0; + double s = (vx * dx + vy * dy) * inv_len2; + double ex, ey, cov; + if (s < 0) s = 0; else if (s > 1) s = 1; + ex = vx - s * dx; ey = vy - s * dy; + cov = aa - sqrt(ex * ex + ey * ey); + if (cov <= 0) continue; + if (cov > 1) cov = 1; + blend_px(base + (size_t)j * a->stride + (size_t)i * 4, + R, G, B, (int)(cov * alpha * 255.0 + 0.5)); + } + } + } else { /* iterate rows */ + double slope = dx / dy; + double ext = aa * sqrt(1.0 + slope * slope); + i0 = (int)floor((y0 < y1 ? y0 : y1) - aa); + i1 = (int)ceil ((y0 > y1 ? y0 : y1) + aa); + if (i0 < 0) i0 = 0; + if (i1 > a->h - 1) i1 = a->h - 1; + for (i = i0; i <= i1; i++) { + double py = i + 0.5; + double t = (py - y0) / dy; + double xc, lo, hi; + int j, j0, j1; + unsigned char *row; + if (t < 0) t = 0; else if (t > 1) t = 1; + xc = x0 + t * dx; + lo = xc - ext; hi = xc + ext; + j0 = (int)floor(lo); j1 = (int)ceil(hi); + if (j0 < 0) j0 = 0; + if (j1 > a->w - 1) j1 = a->w - 1; + row = base + (size_t)i * a->stride; + for (j = j0; j <= j1; j++) { + double vx = (j + 0.5) - x0, vy = py - y0; + double s = (vx * dx + vy * dy) * inv_len2; + double ex, ey, cov; + if (s < 0) s = 0; else if (s > 1) s = 1; + ex = vx - s * dx; ey = vy - s * dy; + cov = aa - sqrt(ex * ex + ey * ey); + if (cov <= 0) continue; + if (cov > 1) cov = 1; + blend_px(row + (size_t)j * 4, R, G, B, + (int)(cov * alpha * 255.0 + 0.5)); + } + } + } +} + +/* Bilinear blit of an A8 dot sprite. The sample offset is constant across the + * whole sprite, so the four filter weights are computed once. */ +static void blit_sprite(App *a, int level, double x, double y, + int R, int G, int B, double alpha) +{ + const unsigned char *m = a->sprite[level]; + const int msz = a->sprite_size[level]; + const int mst = a->sprite_stride[level]; + double ux, uy, fu, fv, w00, w10, w01, w11; + int bx, by, ix, iy, ix0, ix1, iy0, iy1; + int ia = (int)(alpha * 255.0 + 0.5); + + if (!m || ia <= 0) return; + if (ia > 255) ia = 255; + + ux = -x; uy = -y; + bx = (int)floor(ux); by = (int)floor(uy); + fu = ux - bx; fv = uy - by; + w00 = (1 - fu) * (1 - fv); w10 = fu * (1 - fv); + w01 = (1 - fu) * fv; w11 = fu * fv; + + ix0 = -bx - 1; ix1 = -bx + msz; + iy0 = -by - 1; iy1 = -by + msz; + if (ix0 < 0) ix0 = 0; + if (iy0 < 0) iy0 = 0; + if (ix1 > a->w - 1) ix1 = a->w - 1; + if (iy1 > a->h - 1) iy1 = a->h - 1; + + for (iy = iy0; iy <= iy1; iy++) { + int sy = iy + by; + const unsigned char *r0 = (sy >= 0 && sy < msz) ? m + (size_t)sy * mst : NULL; + const unsigned char *r1 = (sy + 1 >= 0 && sy + 1 < msz) ? m + (size_t)(sy + 1) * mst : NULL; + unsigned char *dst = a->pixels + (size_t)iy * a->stride + (size_t)ix0 * 4; + if (!r0 && !r1) continue; + for (ix = ix0; ix <= ix1; ix++, dst += 4) { + int sx = ix + bx; + double v = 0.0; + int in0 = (sx >= 0 && sx < msz); + int in1 = (sx + 1 >= 0 && sx + 1 < msz); + if (r0) { if (in0) v += w00 * r0[sx]; if (in1) v += w10 * r0[sx + 1]; } + if (r1) { if (in0) v += w01 * r1[sx]; if (in1) v += w11 * r1[sx + 1]; } + if (v < 0.5) continue; + blend_px(dst, R, G, B, (int)(v * ia / 255.0 + 0.5)); + } + } +} + +/* ----------------------------------------------------------------- drawing */ + +static void render(App *a) +{ + const Config *c = &a->cfg; + const double lw = c->link_width, hw = lw * 0.5; + const double D = c->link_dist, D2 = D * D; + const int lR = (int)(c->link_color[0] * 255 + 0.5); + const int lG = (int)(c->link_color[1] * 255 + 0.5); + const int lB = (int)(c->link_color[2] * 255 + 0.5); + const int pR = (int)(c->color[0] * 255 + 0.5); + const int pG = (int)(c->color[1] * 255 + 0.5); + const int pB = (int)(c->color[2] * 255 + 0.5); + int gx, gy, i; + + damage_clear_previous(a); + + /* links between neighbouring particles */ + grid_build(a); + for (gy = 0; gy < a->gh; gy++) { + for (gx = 0; gx < a->gw; gx++) { + int cell = gy * a->gw + gx; + int s = a->cell_start[cell], e = a->cell_start[cell + 1]; + int k; + /* self + half the neighbourhood, so every pair is visited once */ + static const int nb[5][2] = { {0,0}, {1,0}, {-1,1}, {0,1}, {1,1} }; + for (k = 0; k < 5; k++) { + int ox = gx + nb[k][0], oy = gy + nb[k][1]; + int os, oe, ii, jj; + if (ox < 0 || ox >= a->gw || oy < 0 || oy >= a->gh) continue; + os = a->cell_start[oy * a->gw + ox]; + oe = a->cell_start[oy * a->gw + ox + 1]; + for (ii = s; ii < e; ii++) { + const Particle *p = &a->p[a->order[ii]]; + int start = (k == 0) ? ii + 1 : os; + for (jj = start; jj < oe; jj++) { + const Particle *q = &a->p[a->order[jj]]; + double dx = q->sx - p->sx, dy = q->sy - p->sy; + double d2 = dx * dx + dy * dy, t, alpha; + if (d2 >= D2) continue; + t = 1.0 - sqrt(d2) / D; + alpha = t * (0.45 + 0.55 * t) * c->link_opacity; + if (alpha < 0.004) continue; + draw_line_aa(a, p->sx, p->sy, q->sx, q->sy, hw, + lR, lG, lB, alpha); + damage_add(a, + (p->sx < q->sx ? p->sx : q->sx) - lw, + (p->sy < q->sy ? p->sy : q->sy) - lw, + (p->sx > q->sx ? p->sx : q->sx) + lw, + (p->sy > q->sy ? p->sy : q->sy) + lw); + } + } + } + } + } + + /* links from the cursor - the "grab" effect */ + if (a->cursor_on && (c->cursor_mode & CUR_GRAB)) { + double CD = c->cursor_dist, CD2 = CD * CD; + for (i = 0; i < a->n; i++) { + const Particle *p = &a->p[i]; + double dx = p->sx - a->cx, dy = p->sy - a->cy; + double d2 = dx * dx + dy * dy, t, alpha, w; + if (d2 >= CD2) continue; + t = 1.0 - sqrt(d2) / CD; + alpha = t * (0.5 + 0.5 * t) * c->link_opacity * 1.7; + if (alpha > 0.9) alpha = 0.9; + if (alpha < 0.004) continue; + w = hw * (0.85 + 0.5 * t); + draw_line_aa(a, a->cx, a->cy, p->sx, p->sy, w, lR, lG, lB, alpha); + damage_add(a, + (a->cx < p->sx ? a->cx : p->sx) - lw * 1.5, + (a->cy < p->sy ? a->cy : p->sy) - lw * 1.5, + (a->cx > p->sx ? a->cx : p->sx) + lw * 1.5, + (a->cy > p->sy ? a->cy : p->sy) + lw * 1.5); + } + } + + /* particles */ + for (i = 0; i < a->n; i++) { + const Particle *p = &a->p[i]; + int lv = p->level; + double off = a->sprite_c[lv]; + blit_sprite(a, lv, p->sx - off, p->sy - off, pR, pG, pB, + c->opacity * p->alpha); + damage_add(a, p->sx - off - 1, p->sy - off - 1, + p->sx + off + 1, p->sy + off + 1); + } +} + +/* ---------------------------------------------------------- buffers and X11 */ + +static int compositor_running(Display *dpy, int screen) +{ + char buf[32]; + snprintf(buf, sizeof buf, "_NET_WM_CM_S%d", screen); + return XGetSelectionOwner(dpy, XInternAtom(dpy, buf, False)) != None; +} + +/* Copy the root wallpaper into a scratch buffer so we can restore from it while + * running uncomposited. */ +static void grab_wallpaper(App *a) +{ + Atom actual; + int fmt; + unsigned long nitems, after; + unsigned char *data = NULL; + Pixmap pm = None; + Atom props[2]; + int i; + + if (!a->bg) return; + memset(a->bg, 0, (size_t)a->stride * a->h); + + props[0] = XInternAtom(a->dpy, "_XROOTPMAP_ID", True); + props[1] = XInternAtom(a->dpy, "ESETROOT_PMAP_ID", True); + for (i = 0; i < 2 && pm == None; i++) { + if (props[i] == None) continue; + if (XGetWindowProperty(a->dpy, a->root, props[i], 0, 1, False, XA_PIXMAP, + &actual, &fmt, &nitems, &after, &data) == Success) { + if (data && nitems == 1) pm = *(Pixmap *)data; + if (data) { XFree(data); data = NULL; } + } + } + if (pm == None) return; + + { + Window r; int x, y; unsigned int pw, ph, bw, pd; + cairo_surface_t *src, *dst; + cairo_t *cr; + if (!XGetGeometry(a->dpy, pm, &r, &x, &y, &pw, &ph, &bw, &pd)) return; + src = cairo_xlib_surface_create(a->dpy, pm, DefaultVisual(a->dpy, a->screen), + (int)pw, (int)ph); + dst = cairo_image_surface_create_for_data(a->bg, CAIRO_FORMAT_RGB24, + a->w, a->h, a->stride); + cr = cairo_create(dst); + cairo_set_source_surface(cr, src, 0, 0); + cairo_pattern_set_extend(cairo_get_source(cr), CAIRO_EXTEND_REPEAT); + cairo_paint(cr); + cairo_destroy(cr); + cairo_surface_flush(dst); + cairo_surface_destroy(dst); + cairo_surface_destroy(src); + } +} + +static void buffers_free(App *a) +{ + if (a->surface) { cairo_surface_destroy(a->surface); a->surface = NULL; } + if (a->ximg) { + if (a->use_shm) { + XShmDetach(a->dpy, &a->shm); + XDestroyImage(a->ximg); + shmdt(a->shm.shmaddr); + } else { + XDestroyImage(a->ximg); /* frees ximg->data too */ + } + a->ximg = NULL; + } + free(a->bg); a->bg = NULL; + free(a->dmg_min); a->dmg_min = NULL; + free(a->dmg_max); a->dmg_max = NULL; + free(a->prev_min); a->prev_min = NULL; + free(a->prev_max); a->prev_max = NULL; + a->pixels = NULL; +} + +static int buffers_alloc(App *a) +{ + size_t size; + cairo_format_t fmt = a->composited ? CAIRO_FORMAT_ARGB32 : CAIRO_FORMAT_RGB24; + + a->use_shm = XShmQueryExtension(a->dpy); + if (a->use_shm) { + a->ximg = XShmCreateImage(a->dpy, a->visual, a->depth, ZPixmap, NULL, + &a->shm, a->w, a->h); + if (!a->ximg) a->use_shm = 0; + } + if (a->use_shm) { + size = (size_t)a->ximg->bytes_per_line * a->ximg->height; + a->shm.shmid = shmget(IPC_PRIVATE, size, IPC_CREAT | 0600); + if (a->shm.shmid < 0) { + XDestroyImage(a->ximg); a->ximg = NULL; a->use_shm = 0; + } else { + a->shm.shmaddr = a->ximg->data = shmat(a->shm.shmid, NULL, 0); + a->shm.readOnly = False; + if (a->shm.shmaddr == (char *)-1 || !XShmAttach(a->dpy, &a->shm)) { + if (a->shm.shmaddr != (char *)-1) shmdt(a->shm.shmaddr); + shmctl(a->shm.shmid, IPC_RMID, NULL); + XDestroyImage(a->ximg); a->ximg = NULL; a->use_shm = 0; + } else { + XSync(a->dpy, False); + shmctl(a->shm.shmid, IPC_RMID, NULL); /* reclaimed on exit */ + } + } + } + if (!a->ximg) { + char *data = calloc(1, (size_t)a->w * a->h * 4); + if (!data) return -1; + a->ximg = XCreateImage(a->dpy, a->visual, a->depth, ZPixmap, 0, data, + a->w, a->h, 32, a->w * 4); + if (!a->ximg) { free(data); return -1; } + } + + a->pixels = (unsigned char *)a->ximg->data; + a->stride = a->ximg->bytes_per_line; + memset(a->pixels, 0, (size_t)a->stride * a->h); + + a->surface = cairo_image_surface_create_for_data(a->pixels, fmt, a->w, a->h, a->stride); + if (cairo_surface_status(a->surface) != CAIRO_STATUS_SUCCESS) return -1; + + a->bands = (a->h + BAND_H - 1) / BAND_H; + a->dmg_min = malloc(a->bands * sizeof(int)); + a->dmg_max = malloc(a->bands * sizeof(int)); + a->prev_min = malloc(a->bands * sizeof(int)); + a->prev_max = malloc(a->bands * sizeof(int)); + damage_reset(a); + { int i; for (i = 0; i < a->bands; i++) { a->prev_min[i] = INT32_MAX; a->prev_max[i] = -1; } } + + if (!a->composited) { + a->bg = calloc(1, (size_t)a->stride * a->h); + grab_wallpaper(a); + if (a->bg) memcpy(a->pixels, a->bg, (size_t)a->stride * a->h); + } + return 0; +} + +static void set_atom_prop(Display *d, Window w, const char *name, + const Atom *vals, int n) +{ + XChangeProperty(d, w, XInternAtom(d, name, False), XA_ATOM, 32, + PropModeReplace, (const unsigned char *)vals, n); +} + +static int window_create(App *a) +{ + XVisualInfo vi; + XSetWindowAttributes swa; + unsigned long mask; + Atom types[1], states[4]; + XClassHint ch; + XserverRegion region; + long pid = getpid(); + unsigned long all_desktops = 0xFFFFFFFFUL; + + a->composited = compositor_running(a->dpy, a->screen); + + if (a->composited && XMatchVisualInfo(a->dpy, a->screen, 32, TrueColor, &vi)) { + a->visual = vi.visual; + a->depth = 32; + } else { + if (a->composited) + fprintf(stderr, "%s: no 32-bit visual, falling back to wallpaper copy\n", + APP_NAME); + a->composited = 0; + a->visual = DefaultVisual(a->dpy, a->screen); + a->depth = DefaultDepth(a->dpy, a->screen); + } + + a->cmap = XCreateColormap(a->dpy, a->root, a->visual, AllocNone); + + memset(&swa, 0, sizeof swa); + swa.colormap = a->cmap; + swa.background_pixel = 0; + swa.border_pixel = 0; + swa.override_redirect = True; /* the WM must not touch or stack this */ + swa.event_mask = ExposureMask | StructureNotifyMask | + VisibilityChangeMask | PropertyChangeMask; + mask = CWColormap | CWBackPixel | CWBorderPixel | CWOverrideRedirect | CWEventMask; + + a->win = XCreateWindow(a->dpy, a->root, 0, 0, a->w, a->h, 0, a->depth, + InputOutput, a->visual, mask, &swa); + if (!a->win) return -1; + + types[0] = XInternAtom(a->dpy, "_NET_WM_WINDOW_TYPE_DESKTOP", False); + set_atom_prop(a->dpy, a->win, "_NET_WM_WINDOW_TYPE", types, 1); + + states[0] = XInternAtom(a->dpy, "_NET_WM_STATE_BELOW", False); + states[1] = XInternAtom(a->dpy, "_NET_WM_STATE_STICKY", False); + states[2] = XInternAtom(a->dpy, "_NET_WM_STATE_SKIP_TASKBAR", False); + states[3] = XInternAtom(a->dpy, "_NET_WM_STATE_SKIP_PAGER", False); + set_atom_prop(a->dpy, a->win, "_NET_WM_STATE", states, 4); + + XChangeProperty(a->dpy, a->win, XInternAtom(a->dpy, "_NET_WM_DESKTOP", False), + XA_CARDINAL, 32, PropModeReplace, + (unsigned char *)&all_desktops, 1); + XChangeProperty(a->dpy, a->win, XInternAtom(a->dpy, "_NET_WM_PID", False), + XA_CARDINAL, 32, PropModeReplace, (unsigned char *)&pid, 1); + + ch.res_name = (char *)"particles"; + ch.res_class = (char *)"Particles"; + XSetClassHint(a->dpy, a->win, &ch); + XStoreName(a->dpy, a->win, "particles wallpaper"); + + /* Empty input region: every click, scroll and key falls through to whatever + * is below us. This is what makes the layer non-interactive. */ + region = XFixesCreateRegion(a->dpy, NULL, 0); + XFixesSetWindowShapeRegion(a->dpy, a->win, ShapeInput, 0, 0, region); + XFixesDestroyRegion(a->dpy, region); + + a->atom_active = XInternAtom(a->dpy, "_NET_ACTIVE_WINDOW", True); + a->atom_state = XInternAtom(a->dpy, "_NET_WM_STATE", False); + a->atom_fullscreen = XInternAtom(a->dpy, "_NET_WM_STATE_FULLSCREEN", False); + + a->gc = XCreateGC(a->dpy, a->win, 0, NULL); + + XMapWindow(a->dpy, a->win); + XLowerWindow(a->dpy, a->win); + XFlush(a->dpy); + return 0; +} + +/* Is something covering the whole screen? + * + * VisibilityNotify cannot answer this: once a compositor redirects the windows + * the server reports everything as unobscured, so the event never arrives and a + * fullscreen game would leave us drawing frames nobody can see. Ask the window + * manager instead - the active window is either flagged fullscreen or simply + * big enough to hide us. Checking the compositor's own overlay window would be + * ambiguous here, and the active window never is. */ +static int screen_covered(App *a) +{ + Atom actual; + int fmt, x, y; + unsigned long nitems, after; + unsigned char *data = NULL; + Window active = None, child; + XWindowAttributes wa; + int covered = 0; + + if (a->atom_active == None) return 0; + if (XGetWindowProperty(a->dpy, a->root, a->atom_active, 0, 1, False, XA_WINDOW, + &actual, &fmt, &nitems, &after, &data) != Success) + return 0; + if (data) { + if (nitems == 1) active = *(Window *)data; + XFree(data); + } + if (active == None || active == a->win) return 0; + + data = NULL; + if (XGetWindowProperty(a->dpy, active, a->atom_state, 0, 32, False, XA_ATOM, + &actual, &fmt, &nitems, &after, &data) == Success && data) { + unsigned long i; + Atom *states = (Atom *)data; + for (i = 0; i < nitems; i++) + if (states[i] == a->atom_fullscreen) covered = 1; + XFree(data); + } + if (covered) return 1; + + if (!XGetWindowAttributes(a->dpy, active, &wa) || wa.map_state != IsViewable) + return 0; + if (!XTranslateCoordinates(a->dpy, active, a->root, 0, 0, &x, &y, &child)) + return 0; + return x <= 0 && y <= 0 && x + wa.width >= a->w && y + wa.height >= a->h; +} + +/* Some window managers restack on desktop switch; make sure we stay at the very + * bottom without fighting anything that is already below us. */ +static void keep_below(App *a) +{ + Window r, parent, *kids = NULL; + unsigned int nkids = 0; + if (!XQueryTree(a->dpy, a->root, &r, &parent, &kids, &nkids)) return; + if (nkids > 0 && kids[0] != a->win) XLowerWindow(a->dpy, a->win); + if (kids) XFree(kids); +} + +static int resize(App *a, int w, int h) +{ + if (w == a->w && h == a->h) return 0; + buffers_free(a); + a->w = w; a->h = h; + XMoveResizeWindow(a->dpy, a->win, 0, 0, w, h); + if (buffers_alloc(a) < 0) return -1; + particles_init(a); + return 0; +} + +static void damage_all_prev(App *a) +{ + int i; + for (i = 0; i < a->bands; i++) { a->prev_min[i] = 0; a->prev_max[i] = a->w - 1; } +} + +static void handle_events(App *a) +{ + while (XPending(a->dpy)) { + XEvent ev; + XNextEvent(a->dpy, &ev); + + if (a->has_randr && ev.type == a->rr_event_base + RRScreenChangeNotify) { + XRRUpdateConfiguration(&ev); + resize(a, DisplayWidth(a->dpy, a->screen), DisplayHeight(a->dpy, a->screen)); + continue; + } + + switch (ev.type) { + case Expose: + damage_all_prev(a); + break; + case ConfigureNotify: + if (ev.xconfigure.window == a->root) + resize(a, ev.xconfigure.width, ev.xconfigure.height); + else if (ev.xconfigure.window == a->win && + (ev.xconfigure.width != a->w || ev.xconfigure.height != a->h)) + resize(a, ev.xconfigure.width, ev.xconfigure.height); + break; + case VisibilityNotify: + a->obscured = (ev.xvisibility.state == VisibilityFullyObscured); + if (!a->obscured) damage_all_prev(a); + break; + case PropertyNotify: + /* wallpaper changed under us while running uncomposited */ + if (!a->composited && a->bg && ev.xproperty.window == a->root) { + Atom p = ev.xproperty.atom; + if (p == XInternAtom(a->dpy, "_XROOTPMAP_ID", True) || + p == XInternAtom(a->dpy, "ESETROOT_PMAP_ID", True)) { + grab_wallpaper(a); + damage_all_prev(a); + } + } + break; + default: + break; + } + } +} + +static void poll_cursor(App *a, double dt) +{ + Window r, child; + int rx, ry, wx, wy; + unsigned int mods; + double k; + + if (!XQueryPointer(a->dpy, a->root, &r, &child, &rx, &ry, &wx, &wy, &mods)) { + a->cursor_on = 0; /* pointer is on another screen */ + return; + } + if (!a->cursor_on) { a->cx = rx; a->cy = ry; a->cursor_on = 1; } + + /* Ease toward the real position so fast cursor jumps do not snap the field. */ + k = 1.0 - exp(-a->cfg.cursor_ease * dt); + a->cx += (rx - a->cx) * k; + a->cy += (ry - a->cy) * k; +} + +static void run(App *a) +{ + const double frame = 1.0 / (a->cfg.fps > 0 ? a->cfg.fps : 60); + double last = now_sec(); + double next = last; + double last_lower = last; + double last_cover = 0; + int was_paused = 0; + + XSelectInput(a->dpy, a->root, + StructureNotifyMask | (a->composited ? 0 : PropertyChangeMask)); + if (a->has_randr) XRRSelectInput(a->dpy, a->root, RRScreenChangeNotifyMask); + + while (!g_quit) { + double t, dt, interval; + int paused; + + handle_events(a); + + if (g_reload) { + char path[512]; + g_reload = 0; + config_path(path, sizeof path); + load_config_file(&a->cfg, path); + particles_init(a); + damage_all_prev(a); + } + + t = now_sec(); + dt = t - last; + last = t; + if (dt < 0) dt = 0; + if (dt > 0.1) dt = 0.1; /* survive suspend / long stalls */ + + /* Nothing to draw while something covers the whole screen - a + * fullscreen game, say - so idle instead of burning a core on it. */ + if (a->cfg.pause_when_covered && t - last_cover > 1.0) { + a->covered = screen_covered(a); + last_cover = t; + } + paused = a->cfg.pause_when_covered && (a->obscured || a->covered); + interval = paused ? 0.1 : frame; + + if (was_paused && !paused) damage_all_prev(a); + was_paused = paused; + + if (!paused) { + poll_cursor(a, dt); + particles_step(a, dt); + render(a); + present(a); + XSync(a->dpy, False); /* pace with the server, no frame pileup */ + } else { + particles_step(a, dt); + } + + if (a->cfg.keep_below && t - last_lower > 2.0) { + keep_below(a); + last_lower = t; + } + + next += interval; + if (next < t) next = t + interval; /* we fell behind; do not spiral */ + { + double sleep_for = next - now_sec(); + if (sleep_for > 0) { + struct timespec ts; + ts.tv_sec = (time_t)sleep_for; + ts.tv_nsec = (long)((sleep_for - ts.tv_sec) * 1e9); + nanosleep(&ts, NULL); + } + } + } +} + +/* ------------------------------------------------- single instance / toggle */ + +static void lock_path(char *buf, size_t n) +{ + const char *rt = getenv("XDG_RUNTIME_DIR"); + const char *dpy = getenv("DISPLAY"); + char safe[64], *p; + snprintf(safe, sizeof safe, "%s", dpy && *dpy ? dpy : "0"); + for (p = safe; *p; p++) if (*p == '/' || *p == ':') *p = '_'; + if (rt && *rt) snprintf(buf, n, "%s/particles-%s.lock", rt, safe); + else snprintf(buf, n, "/tmp/particles-%u-%s.lock", + (unsigned)getuid(), safe); +} + +/* Returns the fd holding the lock, or -1 when another instance owns it (with + * *other set to its pid). */ +static int lock_acquire(pid_t *other) +{ + char path[512]; + int fd; + lock_path(path, sizeof path); + fd = open(path, O_RDWR | O_CREAT, 0600); + if (fd < 0) { + fprintf(stderr, "%s: cannot open %s: %s\n", APP_NAME, path, strerror(errno)); + return -1; + } + if (flock(fd, LOCK_EX | LOCK_NB) == 0) return fd; + + if (other) { + char buf[32] = {0}; + lseek(fd, 0, SEEK_SET); + if (read(fd, buf, sizeof buf - 1) > 0) *other = (pid_t)atoi(buf); + else *other = 0; + } + close(fd); + return -1; +} + +static void lock_write_pid(int fd) +{ + char buf[32]; + int n = snprintf(buf, sizeof buf, "%d\n", (int)getpid()); + if (ftruncate(fd, 0) == 0 && lseek(fd, 0, SEEK_SET) == 0) { + ssize_t w = write(fd, buf, n); + (void)w; + } +} + +static int stop_running(pid_t pid) +{ + int i; + if (pid <= 0) return -1; + if (kill(pid, SIGTERM) != 0) return -1; + for (i = 0; i < 200; i++) { /* wait up to ~2s for a clean exit */ + struct timespec ts = { 0, 10 * 1000 * 1000 }; + if (kill(pid, 0) != 0) return 0; + nanosleep(&ts, NULL); + } + kill(pid, SIGKILL); + return 0; +} + +static int daemonize(const char *log_path) +{ + pid_t pid = fork(); + if (pid < 0) return -1; + if (pid > 0) _exit(0); /* the X fd lives on in the child */ + if (setsid() < 0) return -1; + pid = fork(); + if (pid < 0) return -1; + if (pid > 0) _exit(0); + + { + int null = open("/dev/null", O_RDWR); + int out = log_path && *log_path + ? open(log_path, O_WRONLY | O_CREAT | O_APPEND, 0644) : -1; + if (null >= 0) dup2(null, STDIN_FILENO); + dup2(out >= 0 ? out : (null >= 0 ? null : STDERR_FILENO), STDOUT_FILENO); + dup2(out >= 0 ? out : (null >= 0 ? null : STDERR_FILENO), STDERR_FILENO); + if (null > 2) close(null); + if (out > 2) close(out); + } + return 0; +} + +/* ---------------------------------------------------------------- snapshot */ + +/* Render a single settled frame to a PNG without touching the screen, so a + * config can be previewed before it goes live. */ +static int snapshot(Config *cfg, const char *path, int w, int h, const char *bg) +{ + App a; + int i, rc = 0; + cairo_surface_t *out; + cairo_t *cr; + + memset(&a, 0, sizeof a); + a.cfg = *cfg; + a.w = w; a.h = h; + a.stride = cairo_format_stride_for_width(CAIRO_FORMAT_ARGB32, w); + a.pixels = calloc(1, (size_t)a.stride * h); + if (!a.pixels) return -1; + + a.surface = cairo_image_surface_create_for_data(a.pixels, CAIRO_FORMAT_ARGB32, + w, h, a.stride); + a.bands = (h + BAND_H - 1) / BAND_H; + a.dmg_min = malloc(a.bands * sizeof(int)); + a.dmg_max = malloc(a.bands * sizeof(int)); + a.prev_min = malloc(a.bands * sizeof(int)); + a.prev_max = malloc(a.bands * sizeof(int)); + damage_reset(&a); + for (i = 0; i < a.bands; i++) { a.prev_min[i] = INT32_MAX; a.prev_max[i] = -1; } + + srand(1234); + particles_init(&a); + a.cx = w / 2.0; a.cy = h / 2.0; a.cursor_on = 1; + for (i = 0; i < 120; i++) particles_step(&a, 1.0 / 60.0); /* let it settle */ + render(&a); + cairo_surface_mark_dirty(a.surface); + + /* flatten over a backdrop - by default a dark ground, or the wallpaper + * itself, so the colours can be judged where they will actually live */ + out = cairo_image_surface_create(CAIRO_FORMAT_RGB24, w, h); + cr = cairo_create(out); + { + double col[3] = { 0.055, 0.06, 0.075 }; + cairo_surface_t *img = NULL; + if (bg && *bg == '#') parse_color(bg, col); + else if (bg) { + img = cairo_image_surface_create_from_png(bg); + if (cairo_surface_status(img) != CAIRO_STATUS_SUCCESS) { + cairo_surface_destroy(img); + img = NULL; + fprintf(stderr, "%s: cannot read %s as PNG, using a flat backdrop\n", + APP_NAME, bg); + } + } + if (img) { + int iw = cairo_image_surface_get_width(img); + int ih = cairo_image_surface_get_height(img); + double sc = (double)w / iw; + if ((double)h / ih > sc) sc = (double)h / ih; + cairo_save(cr); + cairo_scale(cr, sc, sc); + cairo_set_source_surface(cr, img, 0, 0); + cairo_paint(cr); + cairo_restore(cr); + cairo_surface_destroy(img); + } else { + cairo_set_source_rgb(cr, col[0], col[1], col[2]); + cairo_paint(cr); + } + } + cairo_set_source_surface(cr, a.surface, 0, 0); + cairo_paint(cr); + cairo_destroy(cr); + if (cairo_surface_write_to_png(out, path) != CAIRO_STATUS_SUCCESS) { + fprintf(stderr, "%s: cannot write %s\n", APP_NAME, path); + rc = -1; + } else { + printf("%s: wrote %s (%dx%d, %d particles)\n", APP_NAME, path, w, h, a.n); + } + cairo_surface_destroy(out); + + cairo_surface_destroy(a.surface); + free(a.pixels); free(a.p); free(a.cell_of); free(a.cell_start); + free(a.cell_cursor); free(a.order); + sprites_free(&a); + free(a.dmg_min); free(a.dmg_max); free(a.prev_min); free(a.prev_max); + return rc; +} + +/* -------------------------------------------------------------------- main */ + +static void usage(FILE *out) +{ + fprintf(out, +"%s %s - antialiased particle field on the X11 wallpaper layer\n" +"\n" +"Usage: %s [action] [options]\n" +"\n" +"Actions (default: toggle)\n" +" --toggle start if not running, stop if it is\n" +" --start start only (no-op if already running)\n" +" --stop stop a running instance\n" +" --restart stop then start, picking up new options\n" +" --status report whether an instance is running\n" +" -f, --foreground run in this terminal instead of detaching\n" +" --config FILE read options from FILE\n" +" --snapshot FILE render one frame to a PNG and exit (preview)\n" +" --snapshot-size WxH size for --snapshot [screen size]\n" +" --snapshot-bg X backdrop for --snapshot: a PNG path or #RRGGBB\n" +" --no-config ignore ~/.config/particles.conf\n" +" -h, --help this text\n" +" -V, --version version\n" +"\n" +"Options (also valid as 'key = value' lines in ~/.config/particles.conf)\n" +" --count N fixed particle count (0 = derive from --density)\n" +" --density N particles per megapixel [70]\n" +" --radius N particle radius, spread around N [1.8]\n" +" --radius-min N minimum radius [1.1]\n" +" --radius-max N maximum radius [2.6]\n" +" --speed N drift speed in px/s [26]\n" +" --link-distance N px at which particles connect [130]\n" +" --link-width N connecting line width [1.1]\n" +" --link-opacity N 0..1 [0.55]\n" +" --opacity N particle opacity, 0..1 [0.85]\n" +" --glow N halo radius as a multiple of the dot [3.2]\n" +" --color RRGGBB particle colour [dbe8ff]\n" +" --link-color RRGGBB line colour [aed1ff]\n" +" --cursor MODE none|grab|repel|attract, combine with ',' [grab,repel]\n" +" --cursor-distance N radius the cursor links reach [170]\n" +" --repel-distance N radius the cursor pushes within [0.6x cursor-distance]\n" +" --cursor-strength N 1.0 pushes particles onto the rim [1.0]\n" +" --cursor-response N how fast the push settles, 1/s [9]\n" +" --edges MODE bounce|wrap [bounce]\n" +" --fps N frame rate cap [60]\n" +" --keep-below BOOL re-lower the window periodically [true]\n" +" --pause-when-covered BOOL skip drawing while fully hidden [true]\n" +" --log FILE where the detached process writes messages\n" +"\n" +"Signals: SIGHUP re-reads the config file, SIGTERM/SIGINT exit cleanly.\n", + APP_NAME, APP_VERSION, APP_NAME); +} + +enum { ACT_TOGGLE, ACT_START, ACT_STOP, ACT_RESTART, ACT_STATUS }; + +int main(int argc, char **argv) +{ + App app; + Config cli; + Config cfg; + int action = ACT_TOGGLE; + int foreground = 0, use_config = 1; + const char *config_file = NULL; + const char *snapshot_file = NULL; + const char *snapshot_bg = NULL; + int snap_w = 0, snap_h = 0; + char cfgbuf[512]; + int lock_fd; + pid_t other = 0; + int i; + /* Options given on the command line must win over the config file, so they + * are collected separately and replayed after the file is read. */ + const char *ckey[64]; + const char *cval[64]; + int ncli = 0; + + config_defaults(&cli); + + for (i = 1; i < argc; i++) { + char *arg = argv[i]; + char keybuf[64]; + const char *key, *val = NULL; + char *eq; + + if (!strcmp(arg, "-h") || !strcmp(arg, "--help")) { usage(stdout); return 0; } + if (!strcmp(arg, "-V") || !strcmp(arg, "--version")) { + printf("%s %s\n", APP_NAME, APP_VERSION); return 0; + } + if (!strcmp(arg, "-f") || !strcmp(arg, "--foreground")) { foreground = 1; continue; } + if (!strcmp(arg, "--toggle")) { action = ACT_TOGGLE; continue; } + if (!strcmp(arg, "--start")) { action = ACT_START; continue; } + if (!strcmp(arg, "--stop")) { action = ACT_STOP; continue; } + if (!strcmp(arg, "--restart")) { action = ACT_RESTART; continue; } + if (!strcmp(arg, "--status")) { action = ACT_STATUS; continue; } + if (!strcmp(arg, "--no-config")) { use_config = 0; continue; } + if (!strcmp(arg, "--snapshot")) { + if (++i >= argc) { fprintf(stderr, "%s: --snapshot needs a path\n", APP_NAME); return 2; } + snapshot_file = argv[i]; + continue; + } + if (!strcmp(arg, "--snapshot-bg")) { + if (++i >= argc) { fprintf(stderr, "%s: --snapshot-bg needs a PNG or #RRGGBB\n", APP_NAME); return 2; } + snapshot_bg = argv[i]; + continue; + } + if (!strcmp(arg, "--snapshot-size")) { + if (++i >= argc || sscanf(argv[i], "%dx%d", &snap_w, &snap_h) != 2) { + fprintf(stderr, "%s: --snapshot-size wants WxH\n", APP_NAME); + return 2; + } + continue; + } + if (!strcmp(arg, "--config")) { + if (++i >= argc) { fprintf(stderr, "%s: --config needs a path\n", APP_NAME); return 2; } + config_file = argv[i]; + continue; + } + if (strncmp(arg, "--", 2) != 0) { + fprintf(stderr, "%s: unexpected argument '%s'\n", APP_NAME, arg); + usage(stderr); + return 2; + } + + snprintf(keybuf, sizeof keybuf, "%s", arg + 2); + if ((eq = strchr(keybuf, '='))) { *eq = '\0'; val = arg + 2 + (eq - keybuf) + 1; } + else { + if (++i >= argc) { + fprintf(stderr, "%s: option --%s needs a value\n", APP_NAME, keybuf); + return 2; + } + val = argv[i]; + } + key = keybuf; + { + int rc = set_option(&cli, key, val); + if (rc == -2) { fprintf(stderr, "%s: unknown option --%s\n", APP_NAME, key); return 2; } + if (rc != 0) { fprintf(stderr, "%s: bad value for --%s: %s\n", APP_NAME, key, val); return 2; } + } + if (ncli < (int)(sizeof ckey / sizeof *ckey)) { + ckey[ncli] = strdup(key); + cval[ncli] = val; + ncli++; + } + } + + if (snapshot_file) { + config_defaults(&cfg); + if (use_config) { + if (config_file) load_config_file(&cfg, config_file); + else { config_path(cfgbuf, sizeof cfgbuf); load_config_file(&cfg, cfgbuf); } + } + for (i = 0; i < ncli; i++) set_option(&cfg, ckey[i], cval[i]); + if (snap_w <= 0 || snap_h <= 0) { + Display *d = XOpenDisplay(NULL); + if (d) { + snap_w = DisplayWidth(d, DefaultScreen(d)); + snap_h = DisplayHeight(d, DefaultScreen(d)); + XCloseDisplay(d); + } else { snap_w = 1920; snap_h = 1080; } + } + return snapshot(&cfg, snapshot_file, snap_w, snap_h, snapshot_bg) == 0 ? 0 : 1; + } + + /* action dispatch ----------------------------------------------------- */ + lock_fd = lock_acquire(&other); + + if (action == ACT_STATUS) { + if (lock_fd < 0) { printf("running (pid %d)\n", (int)other); return 0; } + close(lock_fd); + printf("not running\n"); + return 1; + } + if (action == ACT_STOP) { + if (lock_fd < 0) { + if (stop_running(other) == 0) { printf("%s: stopped (pid %d)\n", APP_NAME, (int)other); return 0; } + fprintf(stderr, "%s: could not stop pid %d\n", APP_NAME, (int)other); + return 1; + } + close(lock_fd); + fprintf(stderr, "%s: not running\n", APP_NAME); + return 1; + } + if (lock_fd < 0) { + /* somebody else holds the lock */ + if (action == ACT_START) { + fprintf(stderr, "%s: already running (pid %d)\n", APP_NAME, (int)other); + return 1; + } + if (stop_running(other) != 0) { + fprintf(stderr, "%s: could not stop pid %d\n", APP_NAME, (int)other); + return 1; + } + if (action == ACT_TOGGLE) { printf("%s: stopped\n", APP_NAME); return 0; } + /* ACT_RESTART: take the lock now that the old one is gone */ + for (i = 0; i < 100 && (lock_fd = lock_acquire(&other)) < 0; i++) { + struct timespec ts = { 0, 10 * 1000 * 1000 }; + nanosleep(&ts, NULL); + } + if (lock_fd < 0) { fprintf(stderr, "%s: lock still held\n", APP_NAME); return 1; } + } + + /* config: defaults <- file <- command line ---------------------------- */ + config_defaults(&cfg); + if (use_config) { + if (config_file) load_config_file(&cfg, config_file); + else { config_path(cfgbuf, sizeof cfgbuf); load_config_file(&cfg, cfgbuf); } + } + for (i = 0; i < ncli; i++) set_option(&cfg, ckey[i], cval[i]); + + if (cfg.radius_min > cfg.radius_max) { + double t = cfg.radius_min; cfg.radius_min = cfg.radius_max; cfg.radius_max = t; + } + if (cfg.radius_min < 0.2) cfg.radius_min = 0.2; + if (cfg.fps < 1) cfg.fps = 1; + if (cfg.fps > 240) cfg.fps = 240; + if (cfg.glow < 1.0) cfg.glow = 1.0; + + /* bring it up --------------------------------------------------------- */ + memset(&app, 0, sizeof app); + app.cfg = cfg; + + app.dpy = XOpenDisplay(NULL); + if (!app.dpy) { + fprintf(stderr, "%s: cannot open display '%s'\n", APP_NAME, + getenv("DISPLAY") ? getenv("DISPLAY") : "(unset)"); + return 1; + } + app.screen = DefaultScreen(app.dpy); + app.root = RootWindow(app.dpy, app.screen); + app.w = DisplayWidth(app.dpy, app.screen); + app.h = DisplayHeight(app.dpy, app.screen); + + { + int fixes_event, fixes_error; + if (!XFixesQueryExtension(app.dpy, &fixes_event, &fixes_error)) { + fprintf(stderr, "%s: XFixes is required for click-through\n", APP_NAME); + return 1; + } + } + app.has_randr = XRRQueryExtension(app.dpy, &app.rr_event_base, &app.rr_error_base); + + if (window_create(&app) < 0) { + fprintf(stderr, "%s: cannot create the overlay window\n", APP_NAME); + return 1; + } + if (buffers_alloc(&app) < 0) { + fprintf(stderr, "%s: cannot allocate the frame buffer\n", APP_NAME); + return 1; + } + + srand((unsigned)(time(NULL) ^ getpid())); + particles_init(&app); + + fprintf(stdout, "%s: %dx%d, %d particles, %s, %d fps%s\n", + APP_NAME, app.w, app.h, app.n, + app.composited ? "ARGB via compositor" : "wallpaper copy (no compositor)", + app.cfg.fps, app.use_shm ? ", MIT-SHM" : ""); + fflush(stdout); + + if (!foreground && daemonize(app.cfg.log_path) < 0) { + fprintf(stderr, "%s: cannot detach: %s\n", APP_NAME, strerror(errno)); + return 1; + } + lock_write_pid(lock_fd); + + { + struct sigaction sa; + memset(&sa, 0, sizeof sa); + sa.sa_handler = on_signal; + sigaction(SIGINT, &sa, NULL); + sigaction(SIGTERM, &sa, NULL); + sigaction(SIGHUP, &sa, NULL); + signal(SIGPIPE, SIG_IGN); + } + + run(&app); + + buffers_free(&app); + if (app.gc) XFreeGC(app.dpy, app.gc); + XDestroyWindow(app.dpy, app.win); + XFreeColormap(app.dpy, app.cmap); + XCloseDisplay(app.dpy); + free(app.p); + free(app.cell_of); + free(app.cell_start); + free(app.cell_cursor); + free(app.order); + sprites_free(&app); + if (lock_fd >= 0) close(lock_fd); + return 0; +} diff --git a/st/build.sh b/st/build.sh new file mode 100755 index 0000000..0a04d18 --- /dev/null +++ b/st/build.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# st/build.sh - build st from st-flexipatch with the rice's patches and settings. +# +# st/build.sh [--install] [--src DIR] +# +# Clones st-flexipatch (pinned below) into ~/.local/src/st-flexipatch, or reuses +# --src, turns on the patches in st/patches.list, applies st/config.sed to +# upstream's config.def.h, enables the libraries those patches need in +# config.mk, and runs make. --install then runs `sudo make install`. +set -euo pipefail + +here="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" +repo="https://github.com/bakkeby/st-flexipatch" +commit="aa56259" # pinned: bump on purpose, after checking patches.list and config.sed still apply +src="${ST_SRC:-$HOME/.local/src/st-flexipatch}" +install=0 + +while (( $# )); do + case "$1" in + --install) install=1 ;; + --src) src="${2:?--src needs a directory}"; shift ;; + *) echo "usage: st/build.sh [--install] [--src DIR]" >&2; exit 2 ;; + esac + shift +done + +if [[ ! -d $src/.git ]]; then + mkdir -p "$(dirname "$src")" + git clone --quiet "$repo" "$src" +fi +if [[ $(git -C "$src" rev-parse --is-shallow-repository) == true ]]; then + git -C "$src" fetch --quiet --unshallow origin +fi +git -C "$src" checkout --quiet --force "$commit" + +cd "$src" + +# patches.h: upstream defaults with our list switched on. +enabled=$(sed 's/#.*//' "$here/patches.list" | awk 'NF { print $1 }') +cp patches.def.h patches.h +for p in $enabled; do + grep -q "^#define ${p}_PATCH " patches.h || { echo "st/build.sh: no patch called ${p}_PATCH" >&2; exit 1; } + sed -i "s/^#define ${p}_PATCH 0$/#define ${p}_PATCH 1/" patches.h +done + +# config.h: upstream defaults plus our values and shortcuts. +sed -f "$here/config.sed" config.def.h > config.h + +# config.mk: link the libraries the enabled patches need. +has() { grep -qx "$1" <<<"$enabled"; } +has ALPHA && sed -i -E 's/^#(XRENDER = )/\1/' config.mk +has LIGATURES && sed -i -E 's/^#(LIGATURES_(C|H|INC|LIBS) = )/\1/' config.mk +has SIXEL && sed -i -E 's/^#(SIXEL_(C|LIBS) = )/\1/' config.mk + +make clean >/dev/null +make -j"$(nproc)" + +if (( install )); then + sudo make install +fi +echo "st built in $src" diff --git a/st/config.sed b/st/config.sed new file mode 100644 index 0000000..ac446fe --- /dev/null +++ b/st/config.sed @@ -0,0 +1,24 @@ +# st/config.sed - the rice's changes to st-flexipatch's config.def.h. +# The font and colours here are only fallbacks: rice-theme sets both through +# Xresources, and st re-reads them on USR1. + +s|^static char \*font = .*|static char *font = "JetBrainsMono Nerd Font:pixelsize=14:antialias=true:autohint=true";| +s|^static int borderpx = .*|static int borderpx = 15;| +s|^float alpha = .*|float alpha = 0.75;| +s|^static unsigned int cursorshape = .*|static unsigned int cursorshape = 2;| + +# Alt-based zoom, scrolling and transparency, as in the previous st build. +/^static Shortcut shortcuts\[\] = {/a\ + { MODKEY, XK_comma, zoom, {.f = +1} },\ + { MODKEY, XK_period, zoom, {.f = -1} },\ + { MODKEY, XK_g, zoomreset, {.f = 0} },\ + { MODKEY, XK_Page_Up, kscrollup, {.i = -1}, S_PRI },\ + { MODKEY, XK_Page_Down, kscrolldown, {.i = -1}, S_PRI },\ + { MODKEY, XK_k, kscrollup, {.i = 1}, S_PRI },\ + { MODKEY, XK_j, kscrolldown, {.i = 1}, S_PRI },\ + { MODKEY, XK_Up, kscrollup, {.i = 1}, S_PRI },\ + { MODKEY, XK_Down, kscrolldown, {.i = 1}, S_PRI },\ + { MODKEY, XK_u, kscrollup, {.i = -1}, S_PRI },\ + { MODKEY, XK_d, kscrolldown, {.i = -1}, S_PRI },\ + { MODKEY, XK_a, changealpha, {.f = +0.05} },\ + { MODKEY, XK_s, changealpha, {.f = -0.05} }, diff --git a/st/config.siduck.h b/st/config.siduck.h new file mode 100644 index 0000000..a6e28bc --- /dev/null +++ b/st/config.siduck.h @@ -0,0 +1,575 @@ +/* See LICENSE file for copyright and license details. */ + +/* + * appearance + * + * font: see http://freedesktop.org/software/fontconfig/fontconfig-user.html + */ +static char *font = "JetBrainsMono Nerd Font :pixelsize=14:antialias=true:autohint=true"; +static char *font2[] = { "JetBrainsMono Nerd Font :pixelsize=14:antialias=true:autohint=true" }; +static int borderpx = 15; + +/* + * What program is execed by st depends of these precedence rules: + * 1: program passed with -e + * 2: utmp option + * 3: SHELL environment variable + * 4: value of shell in /etc/passwd + * 5: value of shell in config.h + */ +static char *shell = "/bin/sh"; +char *utmp = NULL; +char *stty_args = "stty raw pass8 nl -echo -iexten -cstopb 38400"; + +/* identification sequence returned in DA and DECID */ +char *vtiden = "\033[?6c"; + +/* Kerning / character bounding-box multipliers */ +static float cwscale = 1.0; +static float chscale = 1.0; + +/* + * word delimiter string + * + * More advanced example: L" `'\"()[]{}" + */ +wchar_t *worddelimiters = L" "; + +/* selection timeouts (in milliseconds) */ +static unsigned int doubleclicktimeout = 300; +static unsigned int tripleclicktimeout = 600; + +/* alt screens */ +int allowaltscreen = 1; + +/* + * draw latency range in ms - from new content/keypress/etc until drawing. + * within this range, st draws when content stops arriving (idle). mostly it's + * near minlatency, but it waits longer for slow updates to avoid partial draw. + * low minlatency will tear/flicker more, as it can "detect" idle too early. + */ +static double minlatency = 8; +static double maxlatency = 33; + +/* + * Synchronized-Update timeout in ms + * https://gitlab.com/gnachman/iterm2/-/wikis/synchronized-updates-spec + */ +static uint su_timeout = 200; + +/* + * blinking timeout (set to 0 to disable blinking) for the terminal blinking + * attribute. + */ +static unsigned int blinktimeout = 800; + +/* + * interval (in milliseconds) between each successive call to ximspot. This + * improves terminal performance while not reducing functionality to those + * whom need XIM support. + */ +int ximspot_update_interval = 1000; + +/* + * thickness of underline and bar cursors + */ +static unsigned int cursorthickness = 2; + +/* + * 1: render most of the lines/blocks characters without using the font for + * perfect alignment between cells (U2500 - U259F except dashes/diagonals). + * Bold affects lines thickness if boxdraw_bold is not 0. Italic is ignored. + * 0: disable (render all U25XX glyphs normally from the font). + */ +const int boxdraw = 1; +const int boxdraw_bold = 1; + +/* braille (U28XX): 1: render as adjacent "pixels", 0: use font */ +const int boxdraw_braille = 1; + +/* + * bell volume. It must be a value between -100 and 100. Use 0 for disabling + * it + */ +static int bellvolume = 0; + +/* default TERM value */ +char *termname = "st-256color"; + +/* + * spaces per tab + * + * When you are changing this value, don't forget to adapt the »it« value in + * the st.info and appropriately install the st.info in the environment where + * you use this st version. + * + * it#$tabspaces, + * + * Secondly make sure your kernel is not expanding tabs. When running `stty + * -a` »tab0« should appear. You can tell the terminal to not expand tabs by + * running following command: + * + * stty tabs + */ +unsigned int tabspaces = 8; + +/* bg opacity */ +float alpha = 0.72; + +/* Terminal colors (16 first used in escape sequence) */ +static const char *colorname[] = { + /* Sakura Line - sampled from bspwm/wallpaper/wallpaper.jpg */ + /* 8 normal colors */ + "#0f0d17", /* black */ + "#cf6a80", /* red */ + "#8fae74", /* green */ + "#d8a87c", /* yellow */ + "#7c9ede", /* blue */ + "#b483c0", /* magenta */ + "#8ab6c4", /* cyan */ + "#c9d1e2", /* white */ + + /* 8 bright colors */ + "#3a3350", /* black */ + "#e0798f", /* red */ + "#a8c68d", /* green */ + "#e9c79b", /* yellow */ + "#92b9ea", /* blue */ + "#d0a4d8", /* magenta */ + "#a3c0cb", /* cyan */ + "#e9eefb", /* white */ +}; + +/* + * Default colors (colorname index) + * foreground, background, cursor, reverse cursor + */ +unsigned int defaultfg = 15; // #e9eefb +unsigned int defaultbg = 0; // #0f0d17 +unsigned int defaultcs = 12; // cursor: #92b9ea (sky) +unsigned int defaultrcs = 0; + +/* + * https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h4-Functions-using-CSI-_-ordered-by-the-final-character-lparen-s-rparen:CSI-Ps-SP-q.1D81 + * Default style of cursor + * 0: Blinking block + * 1: Blinking block (default) + * 2: Steady block ("█") + * 3: Blinking underline + * 4: Steady underline ("_") + * 5: Blinking bar + * 6: Steady bar ("|") + * 7: Blinking st cursor + * 8: Steady st cursor + */ +static unsigned int cursorshape = 1; + +/* + * Default columns and rows numbers + */ + +static unsigned int cols = 80; +static unsigned int rows = 24; + +/* + * Default colour and shape of the mouse cursor + */ +static unsigned int mouseshape = XC_xterm; +static unsigned int mousefg = 7; +static unsigned int mousebg = 0; + +/* + * Color used to display font attributes when fontconfig selected a font which + * doesn't match the ones requested. + */ +static unsigned int defaultattr = 11; + +/* + * Xresources preferences to load at startup + */ +ResourcePref resources[] = { + { "font", STRING, &font }, + { "fontalt0", STRING, &font2[0] }, + { "color0", STRING, &colorname[0] }, + { "color1", STRING, &colorname[1] }, + { "color2", STRING, &colorname[2] }, + { "color3", STRING, &colorname[3] }, + { "color4", STRING, &colorname[4] }, + { "color5", STRING, &colorname[5] }, + { "color6", STRING, &colorname[6] }, + { "color7", STRING, &colorname[7] }, + { "color8", STRING, &colorname[8] }, + { "color9", STRING, &colorname[9] }, + { "color10", STRING, &colorname[10] }, + { "color11", STRING, &colorname[11] }, + { "color12", STRING, &colorname[12] }, + { "color13", STRING, &colorname[13] }, + { "color14", STRING, &colorname[14] }, + { "color15", STRING, &colorname[15] }, + { "background", STRING, &colorname[258] }, + { "foreground", STRING, &colorname[259] }, + { "cursorColor", STRING, &colorname[256] }, + { "termname", STRING, &termname }, + { "shell", STRING, &shell }, + { "blinktimeout", INTEGER, &blinktimeout }, + { "bellvolume", INTEGER, &bellvolume }, + { "tabspaces", INTEGER, &tabspaces }, + { "borderpx", INTEGER, &borderpx }, + { "cwscale", FLOAT, &cwscale }, + { "chscale", FLOAT, &chscale }, + { "alpha", FLOAT, &alpha }, + { "ximspot_update_interval", INTEGER, &ximspot_update_interval }, +}; + +/* + * Internal mouse shortcuts. + * Beware that overloading Button1 will disable the selection. + */ +const unsigned int mousescrollincrement = 3; +static MouseShortcut mshortcuts[] = { + /* button mask string */ + { Button4, XK_NO_MOD, "\031" }, + { Button5, XK_NO_MOD, "\005" }, +}; + +/* Internal keyboard shortcuts. */ +#define MODKEY Mod1Mask +#define TERMMOD (Mod4Mask|ShiftMask) + +MouseKey mkeys[] = { + /* button mask function argument */ + { Button4, XK_NO_MOD, kscrollup, {.i = mousescrollincrement} }, + { Button5, XK_NO_MOD, kscrolldown, {.i = mousescrollincrement} }, + { Button4, Mod4Mask, zoom, {.f = +1} }, + { Button5, Mod4Mask, zoom, {.f = -1} }, +}; + +static char *openurlcmd[] = { "/bin/sh", "-c", "st-urlhandler", "externalpipe", NULL }; + +static char *copyurlcmd[] = { "/bin/sh", "-c", + "tmp=$(sed 's/.*│//g' | tr -d '\n' | grep -aEo '(((http|https|gopher|gemini|ftp|ftps|git)://|www\\.)[a-zA-Z0-9.]*[:]?[a-zA-Z0-9./@$&%?$#=_-~]*)|((magnet:\\?xt=urn:btih:)[a-zA-Z0-9]*)' | uniq | sed 's/^www./http:\\/\\/www\\./g' ); IFS=; [ ! -z $tmp ] && echo $tmp | dmenu -i -p 'Copy which url?' -l 10 | tr -d '\n' | xclip -selection clipboard", + "externalpipe", NULL }; + +static char *copyoutput[] = { "/bin/sh", "-c", "st-copyout", "externalpipe", NULL }; + +static Shortcut shortcuts[] = { + /* mask keysym function argument */ + { XK_ANY_MOD, XK_Break, sendbreak, {.i = 0} }, + { ControlMask, XK_Print, toggleprinter, {.i = 0} }, + { ShiftMask, XK_Print, printscreen, {.i = 0} }, + { XK_ANY_MOD, XK_Print, printsel, {.i = 0} }, + { MODKEY, XK_comma, zoom, {.f = +1} }, + { MODKEY, XK_period, zoom, {.f = -1} }, + { MODKEY, XK_g, zoomreset, {.f = 0} }, + { ControlMask | ShiftMask, XK_C, clipcopy, {.i = 0} }, + { ShiftMask, XK_Insert, clippaste, {.i = 0} }, + { ControlMask | ShiftMask, XK_V, clippaste, {.i = 0} }, + { XK_ANY_MOD, Button2, selpaste, {.i = 0} }, + { MODKEY, XK_Num_Lock, numlock, {.i = 0} }, + { ControlMask | ShiftMask, XK_U, iso14755, {.i = 0} }, + { ShiftMask, XK_Page_Up, kscrollup, {.i = -1} }, + { ShiftMask, XK_Page_Down, kscrolldown, {.i = -1} }, + { MODKEY, XK_Page_Up, kscrollup, {.i = -1} }, + { MODKEY, XK_Page_Down, kscrolldown, {.i = -1} }, + { MODKEY, XK_k, kscrollup, {.i = 1} }, + { MODKEY, XK_j, kscrolldown, {.i = 1} }, + { MODKEY, XK_Up, kscrollup, {.i = 1} }, + { MODKEY, XK_Down, kscrolldown, {.i = 1} }, + { MODKEY, XK_u, kscrollup, {.i = -1} }, + { MODKEY, XK_d, kscrolldown, {.i = -1} }, + { MODKEY, XK_s, changealpha, {.f = -0.05} }, + { MODKEY, XK_a, changealpha, {.f = +0.05} }, + { MODKEY, XK_m, changealpha, {.f = +2.00} }, + { TERMMOD, XK_Up, zoom, {.f = +1} }, + { TERMMOD, XK_Down, zoom, {.f = -1} }, + { TERMMOD, XK_K, zoom, {.f = +1} }, + { TERMMOD, XK_J, zoom, {.f = -1} }, + { TERMMOD, XK_U, zoom, {.f = +2} }, + { TERMMOD, XK_D, zoom, {.f = -2} }, + { MODKEY, XK_l, externalpipe, {.v = openurlcmd } }, + { MODKEY, XK_y, externalpipe, {.v = copyurlcmd } }, + { MODKEY, XK_o, externalpipe, {.v = copyoutput } }, + { TERMMOD, XK_Return, newterm, {.i = 0} }, + +}; + +/* + * Special keys (change & recompile st.info accordingly) + * + * Mask value: + * * Use XK_ANY_MOD to match the key no matter modifiers state + * * Use XK_NO_MOD to match the key alone (no modifiers) + * appkey value: + * * 0: no value + * * > 0: keypad application mode enabled + * * = 2: term.numlock = 1 + * * < 0: keypad application mode disabled + * appcursor value: + * * 0: no value + * * > 0: cursor application mode enabled + * * < 0: cursor application mode disabled + * crlf value + * * 0: no value + * * > 0: crlf mode is enabled + * * < 0: crlf mode is disabled + * + * Be careful with the order of the definitions because st searches in + * this table sequentially, so any XK_ANY_MOD must be in the last + * position for a key. + */ + +/* + * If you want keys other than the X11 function keys (0xFD00 - 0xFFFF) + * to be mapped below, add them to this array. + */ +static KeySym mappedkeys[] = { -1 }; + +/* + * State bits to ignore when matching key or button events. By default, + * numlock (Mod2Mask) and keyboard layout (XK_SWITCH_MOD) are ignored. + */ +static uint ignoremod = Mod2Mask|XK_SWITCH_MOD; + +/* + * Override mouse-select while mask is active (when MODE_MOUSE is set). + * Note that if you want to use ShiftMask with selmasks, set this to an other + * modifier, set to 0 to not use it. + */ +static uint forceselmod = ShiftMask; + +/* + * This is the huge key array which defines all compatibility to the Linux + * world. Please decide about changes wisely. + */ +static Key key[] = { + /* keysym mask string appkey appcursor */ + { XK_KP_Home, ShiftMask, "\033[2J", 0, -1}, + { XK_KP_Home, ShiftMask, "\033[1;2H", 0, +1}, + { XK_KP_Home, XK_ANY_MOD, "\033[H", 0, -1}, + { XK_KP_Home, XK_ANY_MOD, "\033[1~", 0, +1}, + { XK_KP_Up, XK_ANY_MOD, "\033Ox", +1, 0}, + { XK_KP_Up, XK_ANY_MOD, "\033[A", 0, -1}, + { XK_KP_Up, XK_ANY_MOD, "\033OA", 0, +1}, + { XK_KP_Down, XK_ANY_MOD, "\033Or", +1, 0}, + { XK_KP_Down, XK_ANY_MOD, "\033[B", 0, -1}, + { XK_KP_Down, XK_ANY_MOD, "\033OB", 0, +1}, + { XK_KP_Left, XK_ANY_MOD, "\033Ot", +1, 0}, + { XK_KP_Left, XK_ANY_MOD, "\033[D", 0, -1}, + { XK_KP_Left, XK_ANY_MOD, "\033OD", 0, +1}, + { XK_KP_Right, XK_ANY_MOD, "\033Ov", +1, 0}, + { XK_KP_Right, XK_ANY_MOD, "\033[C", 0, -1}, + { XK_KP_Right, XK_ANY_MOD, "\033OC", 0, +1}, + { XK_KP_Prior, ShiftMask, "\033[5;2~", 0, 0}, + { XK_KP_Prior, XK_ANY_MOD, "\033[5~", 0, 0}, + { XK_KP_Begin, XK_ANY_MOD, "\033[E", 0, 0}, + { XK_KP_End, ControlMask, "\033[J", -1, 0}, + { XK_KP_End, ControlMask, "\033[1;5F", +1, 0}, + { XK_KP_End, ShiftMask, "\033[K", -1, 0}, + { XK_KP_End, ShiftMask, "\033[1;2F", +1, 0}, + { XK_KP_End, XK_ANY_MOD, "\033[4~", 0, 0}, + { XK_KP_Next, ShiftMask, "\033[6;2~", 0, 0}, + { XK_KP_Next, XK_ANY_MOD, "\033[6~", 0, 0}, + { XK_KP_Insert, ShiftMask, "\033[2;2~", +1, 0}, + { XK_KP_Insert, ShiftMask, "\033[4l", -1, 0}, + { XK_KP_Insert, ControlMask, "\033[L", -1, 0}, + { XK_KP_Insert, ControlMask, "\033[2;5~", +1, 0}, + { XK_KP_Insert, XK_ANY_MOD, "\033[4h", -1, 0}, + { XK_KP_Insert, XK_ANY_MOD, "\033[2~", +1, 0}, + { XK_KP_Delete, ControlMask, "\033[M", -1, 0}, + { XK_KP_Delete, ControlMask, "\033[3;5~", +1, 0}, + { XK_KP_Delete, ShiftMask, "\033[2K", -1, 0}, + { XK_KP_Delete, ShiftMask, "\033[3;2~", +1, 0}, + { XK_KP_Delete, XK_ANY_MOD, "\033[3~", -1, 0}, + { XK_KP_Delete, XK_ANY_MOD, "\033[3~", +1, 0}, + { XK_KP_Multiply, XK_ANY_MOD, "\033Oj", +2, 0}, + { XK_KP_Add, XK_ANY_MOD, "\033Ok", +2, 0}, + { XK_KP_Enter, XK_ANY_MOD, "\033OM", +2, 0}, + { XK_KP_Enter, XK_ANY_MOD, "\r", -1, 0}, + { XK_KP_Subtract, XK_ANY_MOD, "\033Om", +2, 0}, + { XK_KP_Decimal, XK_ANY_MOD, "\033On", +2, 0}, + { XK_KP_Divide, XK_ANY_MOD, "\033Oo", +2, 0}, + { XK_KP_0, XK_ANY_MOD, "\033Op", +2, 0}, + { XK_KP_1, XK_ANY_MOD, "\033Oq", +2, 0}, + { XK_KP_2, XK_ANY_MOD, "\033Or", +2, 0}, + { XK_KP_3, XK_ANY_MOD, "\033Os", +2, 0}, + { XK_KP_4, XK_ANY_MOD, "\033Ot", +2, 0}, + { XK_KP_5, XK_ANY_MOD, "\033Ou", +2, 0}, + { XK_KP_6, XK_ANY_MOD, "\033Ov", +2, 0}, + { XK_KP_7, XK_ANY_MOD, "\033Ow", +2, 0}, + { XK_KP_8, XK_ANY_MOD, "\033Ox", +2, 0}, + { XK_KP_9, XK_ANY_MOD, "\033Oy", +2, 0}, + { XK_Up, ShiftMask, "\033[1;2A", 0, 0}, + { XK_Up, Mod1Mask, "\033[1;3A", 0, 0}, + { XK_Up, ShiftMask|Mod1Mask,"\033[1;4A", 0, 0}, + { XK_Up, ControlMask, "\033[1;5A", 0, 0}, + { XK_Up, ShiftMask|ControlMask,"\033[1;6A", 0, 0}, + { XK_Up, ControlMask|Mod1Mask,"\033[1;7A", 0, 0}, + { XK_Up,ShiftMask|ControlMask|Mod1Mask,"\033[1;8A", 0, 0}, + { XK_Up, XK_ANY_MOD, "\033[A", 0, -1}, + { XK_Up, XK_ANY_MOD, "\033OA", 0, +1}, + { XK_Down, ShiftMask, "\033[1;2B", 0, 0}, + { XK_Down, Mod1Mask, "\033[1;3B", 0, 0}, + { XK_Down, ShiftMask|Mod1Mask,"\033[1;4B", 0, 0}, + { XK_Down, ControlMask, "\033[1;5B", 0, 0}, + { XK_Down, ShiftMask|ControlMask,"\033[1;6B", 0, 0}, + { XK_Down, ControlMask|Mod1Mask,"\033[1;7B", 0, 0}, + { XK_Down,ShiftMask|ControlMask|Mod1Mask,"\033[1;8B",0, 0}, + { XK_Down, XK_ANY_MOD, "\033[B", 0, -1}, + { XK_Down, XK_ANY_MOD, "\033OB", 0, +1}, + { XK_Left, ShiftMask, "\033[1;2D", 0, 0}, + { XK_Left, Mod1Mask, "\033[1;3D", 0, 0}, + { XK_Left, ShiftMask|Mod1Mask,"\033[1;4D", 0, 0}, + { XK_Left, ControlMask, "\033[1;5D", 0, 0}, + { XK_Left, ShiftMask|ControlMask,"\033[1;6D", 0, 0}, + { XK_Left, ControlMask|Mod1Mask,"\033[1;7D", 0, 0}, + { XK_Left,ShiftMask|ControlMask|Mod1Mask,"\033[1;8D",0, 0}, + { XK_Left, XK_ANY_MOD, "\033[D", 0, -1}, + { XK_Left, XK_ANY_MOD, "\033OD", 0, +1}, + { XK_Right, ShiftMask, "\033[1;2C", 0, 0}, + { XK_Right, Mod1Mask, "\033[1;3C", 0, 0}, + { XK_Right, ShiftMask|Mod1Mask,"\033[1;4C", 0, 0}, + { XK_Right, ControlMask, "\033[1;5C", 0, 0}, + { XK_Right, ShiftMask|ControlMask,"\033[1;6C", 0, 0}, + { XK_Right, ControlMask|Mod1Mask,"\033[1;7C", 0, 0}, + { XK_Right,ShiftMask|ControlMask|Mod1Mask,"\033[1;8C",0, 0}, + { XK_Right, XK_ANY_MOD, "\033[C", 0, -1}, + { XK_Right, XK_ANY_MOD, "\033OC", 0, +1}, + { XK_ISO_Left_Tab, ShiftMask, "\033[Z", 0, 0}, + { XK_Return, Mod1Mask, "\033\r", 0, 0}, + { XK_Return, XK_ANY_MOD, "\r", 0, 0}, + { XK_Insert, ShiftMask, "\033[4l", -1, 0}, + { XK_Insert, ShiftMask, "\033[2;2~", +1, 0}, + { XK_Insert, ControlMask, "\033[L", -1, 0}, + { XK_Insert, ControlMask, "\033[2;5~", +1, 0}, + { XK_Insert, XK_ANY_MOD, "\033[4h", -1, 0}, + { XK_Insert, XK_ANY_MOD, "\033[2~", +1, 0}, + { XK_Delete, ControlMask, "\033[M", -1, 0}, + { XK_Delete, ControlMask, "\033[3;5~", +1, 0}, + { XK_Delete, ShiftMask, "\033[2K", -1, 0}, + { XK_Delete, ShiftMask, "\033[3;2~", +1, 0}, + { XK_Delete, XK_ANY_MOD, "\033[3~", -1, 0}, + { XK_Delete, XK_ANY_MOD, "\033[3~", +1, 0}, + { XK_BackSpace, XK_NO_MOD, "\177", 0, 0}, + { XK_BackSpace, Mod1Mask, "\033\177", 0, 0}, + { XK_Home, ShiftMask, "\033[2J", 0, -1}, + { XK_Home, ShiftMask, "\033[1;2H", 0, +1}, + { XK_Home, XK_ANY_MOD, "\033[H", 0, -1}, + { XK_Home, XK_ANY_MOD, "\033[1~", 0, +1}, + { XK_End, ControlMask, "\033[J", -1, 0}, + { XK_End, ControlMask, "\033[1;5F", +1, 0}, + { XK_End, ShiftMask, "\033[K", -1, 0}, + { XK_End, ShiftMask, "\033[1;2F", +1, 0}, + { XK_End, XK_ANY_MOD, "\033[4~", 0, 0}, + { XK_Prior, ControlMask, "\033[5;5~", 0, 0}, + { XK_Prior, ShiftMask, "\033[5;2~", 0, 0}, + { XK_Prior, XK_ANY_MOD, "\033[5~", 0, 0}, + { XK_Next, ControlMask, "\033[6;5~", 0, 0}, + { XK_Next, ShiftMask, "\033[6;2~", 0, 0}, + { XK_Next, XK_ANY_MOD, "\033[6~", 0, 0}, + { XK_F1, XK_NO_MOD, "\033OP" , 0, 0}, + { XK_F1, /* F13 */ ShiftMask, "\033[1;2P", 0, 0}, + { XK_F1, /* F25 */ ControlMask, "\033[1;5P", 0, 0}, + { XK_F1, /* F37 */ Mod4Mask, "\033[1;6P", 0, 0}, + { XK_F1, /* F49 */ Mod1Mask, "\033[1;3P", 0, 0}, + { XK_F1, /* F61 */ Mod3Mask, "\033[1;4P", 0, 0}, + { XK_F2, XK_NO_MOD, "\033OQ" , 0, 0}, + { XK_F2, /* F14 */ ShiftMask, "\033[1;2Q", 0, 0}, + { XK_F2, /* F26 */ ControlMask, "\033[1;5Q", 0, 0}, + { XK_F2, /* F38 */ Mod4Mask, "\033[1;6Q", 0, 0}, + { XK_F2, /* F50 */ Mod1Mask, "\033[1;3Q", 0, 0}, + { XK_F2, /* F62 */ Mod3Mask, "\033[1;4Q", 0, 0}, + { XK_F3, XK_NO_MOD, "\033OR" , 0, 0}, + { XK_F3, /* F15 */ ShiftMask, "\033[1;2R", 0, 0}, + { XK_F3, /* F27 */ ControlMask, "\033[1;5R", 0, 0}, + { XK_F3, /* F39 */ Mod4Mask, "\033[1;6R", 0, 0}, + { XK_F3, /* F51 */ Mod1Mask, "\033[1;3R", 0, 0}, + { XK_F3, /* F63 */ Mod3Mask, "\033[1;4R", 0, 0}, + { XK_F4, XK_NO_MOD, "\033OS" , 0, 0}, + { XK_F4, /* F16 */ ShiftMask, "\033[1;2S", 0, 0}, + { XK_F4, /* F28 */ ControlMask, "\033[1;5S", 0, 0}, + { XK_F4, /* F40 */ Mod4Mask, "\033[1;6S", 0, 0}, + { XK_F4, /* F52 */ Mod1Mask, "\033[1;3S", 0, 0}, + { XK_F5, XK_NO_MOD, "\033[15~", 0, 0}, + { XK_F5, /* F17 */ ShiftMask, "\033[15;2~", 0, 0}, + { XK_F5, /* F29 */ ControlMask, "\033[15;5~", 0, 0}, + { XK_F5, /* F41 */ Mod4Mask, "\033[15;6~", 0, 0}, + { XK_F5, /* F53 */ Mod1Mask, "\033[15;3~", 0, 0}, + { XK_F6, XK_NO_MOD, "\033[17~", 0, 0}, + { XK_F6, /* F18 */ ShiftMask, "\033[17;2~", 0, 0}, + { XK_F6, /* F30 */ ControlMask, "\033[17;5~", 0, 0}, + { XK_F6, /* F42 */ Mod4Mask, "\033[17;6~", 0, 0}, + { XK_F6, /* F54 */ Mod1Mask, "\033[17;3~", 0, 0}, + { XK_F7, XK_NO_MOD, "\033[18~", 0, 0}, + { XK_F7, /* F19 */ ShiftMask, "\033[18;2~", 0, 0}, + { XK_F7, /* F31 */ ControlMask, "\033[18;5~", 0, 0}, + { XK_F7, /* F43 */ Mod4Mask, "\033[18;6~", 0, 0}, + { XK_F7, /* F55 */ Mod1Mask, "\033[18;3~", 0, 0}, + { XK_F8, XK_NO_MOD, "\033[19~", 0, 0}, + { XK_F8, /* F20 */ ShiftMask, "\033[19;2~", 0, 0}, + { XK_F8, /* F32 */ ControlMask, "\033[19;5~", 0, 0}, + { XK_F8, /* F44 */ Mod4Mask, "\033[19;6~", 0, 0}, + { XK_F8, /* F56 */ Mod1Mask, "\033[19;3~", 0, 0}, + { XK_F9, XK_NO_MOD, "\033[20~", 0, 0}, + { XK_F9, /* F21 */ ShiftMask, "\033[20;2~", 0, 0}, + { XK_F9, /* F33 */ ControlMask, "\033[20;5~", 0, 0}, + { XK_F9, /* F45 */ Mod4Mask, "\033[20;6~", 0, 0}, + { XK_F9, /* F57 */ Mod1Mask, "\033[20;3~", 0, 0}, + { XK_F10, XK_NO_MOD, "\033[21~", 0, 0}, + { XK_F10, /* F22 */ ShiftMask, "\033[21;2~", 0, 0}, + { XK_F10, /* F34 */ ControlMask, "\033[21;5~", 0, 0}, + { XK_F10, /* F46 */ Mod4Mask, "\033[21;6~", 0, 0}, + { XK_F10, /* F58 */ Mod1Mask, "\033[21;3~", 0, 0}, + { XK_F11, XK_NO_MOD, "\033[23~", 0, 0}, + { XK_F11, /* F23 */ ShiftMask, "\033[23;2~", 0, 0}, + { XK_F11, /* F35 */ ControlMask, "\033[23;5~", 0, 0}, + { XK_F11, /* F47 */ Mod4Mask, "\033[23;6~", 0, 0}, + { XK_F11, /* F59 */ Mod1Mask, "\033[23;3~", 0, 0}, + { XK_F12, XK_NO_MOD, "\033[24~", 0, 0}, + { XK_F12, /* F24 */ ShiftMask, "\033[24;2~", 0, 0}, + { XK_F12, /* F36 */ ControlMask, "\033[24;5~", 0, 0}, + { XK_F12, /* F48 */ Mod4Mask, "\033[24;6~", 0, 0}, + { XK_F12, /* F60 */ Mod1Mask, "\033[24;3~", 0, 0}, + { XK_F13, XK_NO_MOD, "\033[1;2P", 0, 0}, + { XK_F14, XK_NO_MOD, "\033[1;2Q", 0, 0}, + { XK_F15, XK_NO_MOD, "\033[1;2R", 0, 0}, + { XK_F16, XK_NO_MOD, "\033[1;2S", 0, 0}, + { XK_F17, XK_NO_MOD, "\033[15;2~", 0, 0}, + { XK_F18, XK_NO_MOD, "\033[17;2~", 0, 0}, + { XK_F19, XK_NO_MOD, "\033[18;2~", 0, 0}, + { XK_F20, XK_NO_MOD, "\033[19;2~", 0, 0}, + { XK_F21, XK_NO_MOD, "\033[20;2~", 0, 0}, + { XK_F22, XK_NO_MOD, "\033[21;2~", 0, 0}, + { XK_F23, XK_NO_MOD, "\033[23;2~", 0, 0}, + { XK_F24, XK_NO_MOD, "\033[24;2~", 0, 0}, + { XK_F25, XK_NO_MOD, "\033[1;5P", 0, 0}, + { XK_F26, XK_NO_MOD, "\033[1;5Q", 0, 0}, + { XK_F27, XK_NO_MOD, "\033[1;5R", 0, 0}, + { XK_F28, XK_NO_MOD, "\033[1;5S", 0, 0}, + { XK_F29, XK_NO_MOD, "\033[15;5~", 0, 0}, + { XK_F30, XK_NO_MOD, "\033[17;5~", 0, 0}, + { XK_F31, XK_NO_MOD, "\033[18;5~", 0, 0}, + { XK_F32, XK_NO_MOD, "\033[19;5~", 0, 0}, + { XK_F33, XK_NO_MOD, "\033[20;5~", 0, 0}, + { XK_F34, XK_NO_MOD, "\033[21;5~", 0, 0}, + { XK_F35, XK_NO_MOD, "\033[23;5~", 0, 0}, +}; + +/* + * Selection types' masks. + * Use the same masks as usual. + * Button1Mask is always unset, to make masks match between ButtonPress. + * ButtonRelease and MotionNotify. + * If no match is found, regular selection is used. + */ +static uint selmasks[] = { + [SEL_RECTANGULAR] = Mod1Mask, +}; + +/* + * Printable characters in ASCII, used to estimate the advance width + * of single wide characters. + */ +static char ascii_printable[] = +" !\"#$%&'()*+,-./0123456789:;<=>?" +"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" +"`abcdefghijklmnopqrstuvwxyz{|}~"; diff --git a/st/patches.list b/st/patches.list new file mode 100644 index 0000000..8d1fbc4 --- /dev/null +++ b/st/patches.list @@ -0,0 +1,18 @@ +# st-flexipatch patches the rice turns on, one per line, without "_PATCH". +# st/build.sh flips each to 1 in patches.h. See patches.def.h upstream for the full list. + +ALPHA # transparency (alt + a / alt + s) +BOXDRAW # gapless box-drawing and braille characters +CSI_22_23 # save/restore window title (nvim) +KEYBOARDSELECT # select text with the keyboard (ctrl + shift + escape) +LIGATURES # ligatures via harfbuzz +NEWTERM # new terminal in the same directory (ctrl + shift + return) +OPENURLONCLICK # ctrl + click opens a link +SCROLLBACK # shift/alt + page up/down, alt + j/k/u/d +SCROLLBACK_MOUSE # shift + wheel scrolls back +SCROLLBACK_MOUSE_ALTSCREEN # plain wheel scrolls back outside full-screen apps +SIXEL # images in the terminal (chafa, yazi, fzf previews) +UNDERCURL # curly underlines (nvim diagnostics) +WIDE_GLYPHS # Nerd Font icons drawn full width +XRESOURCES # font and colours from Xresources (rice-theme) +XRESOURCES_RELOAD # re-read them on USR1, so theme changes reach open terminals diff --git a/templates/Xresources.tpl b/templates/Xresources.tpl new file mode 100644 index 0000000..7de8748 --- /dev/null +++ b/templates/Xresources.tpl @@ -0,0 +1,34 @@ +! Rendered by rice-theme from colors.toml - edit templates/Xresources.tpl instead. +! st reads these through its xresources patch; reopen st (or send it USR1) to pick them up. + +*.foreground: {{ fg }} +*.background: {{ bg }} +*.cursorColor: {{ accent }} + +*.color0: {{ bg }} +*.color1: {{ red }} +*.color2: {{ green }} +*.color3: {{ yellow }} +*.color4: {{ blue }} +*.color5: {{ magenta }} +*.color6: {{ cyan }} +*.color7: {{ fg_alt }} +*.color8: {{ muted }} +*.color9: {{ bright_red }} +*.color10: {{ bright_green }} +*.color11: {{ bright_yellow }} +*.color12: {{ bright_blue }} +*.color13: {{ bright_magenta }} +*.color14: {{ bright_cyan }} +*.color15: {{ fg }} + +st.font: {{ font }}:pixelsize=14:antialias=true:autohint=true +st.alpha: {{ alpha }} +st.borderpx: 15 + +Xcursor.theme: {{ cursor_theme }} +Xcursor.size: {{ cursor_size }} +Xft.antialias: 1 +Xft.hinting: 1 +Xft.hintstyle: hintslight +Xft.rgba: rgb diff --git a/templates/bspwm-colors.sh.tpl b/templates/bspwm-colors.sh.tpl new file mode 100644 index 0000000..bb9c476 --- /dev/null +++ b/templates/bspwm-colors.sh.tpl @@ -0,0 +1,8 @@ +#!/bin/sh +# Rendered by rice-theme from colors.toml - edit templates/bspwm-colors.sh.tpl instead. +bspc config focused_border_color "{{ accent }}" +bspc config active_border_color "{{ border }}" +bspc config normal_border_color "{{ surface }}" +bspc config presel_feedback_color "{{ magenta }}" +bspc config border_width {{ window_border }} +bspc config window_gap {{ gap }} diff --git a/templates/dunst.conf.tpl b/templates/dunst.conf.tpl new file mode 100644 index 0000000..c51cd91 --- /dev/null +++ b/templates/dunst.conf.tpl @@ -0,0 +1,29 @@ +# Rendered by rice-theme from colors.toml - edit templates/dunst.conf.tpl instead. +# Loaded as a drop-in (~/.config/dunst/dunstrc.d/50-theme.conf), so it only +# carries colours; layout and behaviour live in dunstrc. + +[global] + font = "{{ font }} 10" + icon_theme = "{{ icon_theme }}, Adwaita, hicolor" + +[urgency_low] + background = "{{ bg }}{{ alpha_hex }}" + foreground = "{{ fg_alt }}" + frame_color = "{{ border }}" + highlight = "{{ dim }}" + +[urgency_normal] + background = "{{ bg }}{{ alpha_hex }}" + foreground = "{{ fg }}" + frame_color = "{{ border }}" + highlight = "{{ accent }}" + +[urgency_critical] + background = "{{ bg }}{{ alpha_hex }}" + foreground = "{{ fg }}" + frame_color = "{{ urgent }}" + highlight = "{{ urgent }}" + +[osd] + frame_color = "{{ accent }}" + highlight = "{{ accent }}" diff --git a/templates/eww-colors.scss.tpl b/templates/eww-colors.scss.tpl new file mode 100644 index 0000000..e9b0aa2 --- /dev/null +++ b/templates/eww-colors.scss.tpl @@ -0,0 +1,30 @@ +// Rendered by rice-theme from colors.toml - edit templates/eww-colors.scss.tpl instead. +$bg: {{ bg }}; +$bg-alt: {{ bg_alt }}; +$surface: {{ surface }}; +$overlay: {{ overlay }}; +$selection: {{ selection }}; +$border: {{ border }}; + +$fg: {{ fg }}; +$fg-alt: {{ fg_alt }}; +$dim: {{ dim }}; +$muted: {{ muted }}; + +$accent: {{ accent }}; +$accent-alt: {{ accent_alt }}; +$urgent: {{ urgent }}; + +$red: {{ red }}; +$green: {{ green }}; +$yellow: {{ yellow }}; +$blue: {{ blue }}; +$magenta: {{ magenta }}; +$cyan: {{ cyan }}; + +$font: "{{ font }}"; +$radius: {{ radius }}px; +$radius-small: {{ radius_small }}px; +$border-width: {{ border_width }}px; +$gap: {{ gap }}px; +$alpha: {{ alpha }}; diff --git a/templates/fzf.sh.tpl b/templates/fzf.sh.tpl new file mode 100644 index 0000000..5a68603 --- /dev/null +++ b/templates/fzf.sh.tpl @@ -0,0 +1,6 @@ +# Rendered by rice-theme from colors.toml - edit templates/fzf.sh.tpl instead. +# Sourced by ~/.bashrc so fzf, ff and Ctrl+R pickers match rofi. +export FZF_DEFAULT_OPTS="\ +--color=bg:-1,bg+:{{ selection }},fg:{{ fg_alt }},fg+:{{ fg }},hl:{{ accent }},hl+:{{ accent }} \ +--color=border:{{ border }},header:{{ accent }},info:{{ dim }},marker:{{ green }},pointer:{{ urgent }},prompt:{{ urgent }},spinner:{{ cyan }} \ +--prompt=' ' --pointer='▌' --marker='+' --border=rounded --layout=reverse --height=60% --info=inline-right" diff --git a/templates/gtk.css.tpl b/templates/gtk.css.tpl new file mode 100644 index 0000000..866eda1 --- /dev/null +++ b/templates/gtk.css.tpl @@ -0,0 +1,65 @@ +/* Rendered by rice-theme from colors.toml - edit templates/gtk.css.tpl instead. + Linked as ~/.config/gtk-3.0/gtk.css and ~/.config/gtk-4.0/gtk.css. */ + +/* GTK3 / Adwaita named colours */ +@define-color theme_bg_color {{ bg_alt }}; +@define-color theme_fg_color {{ fg }}; +@define-color theme_base_color {{ bg }}; +@define-color theme_text_color {{ fg }}; +@define-color theme_selected_bg_color {{ accent }}; +@define-color theme_selected_fg_color {{ bg }}; +@define-color theme_unfocused_bg_color {{ bg_alt }}; +@define-color theme_unfocused_fg_color {{ fg }}; +@define-color theme_unfocused_base_color {{ bg }}; +@define-color theme_unfocused_text_color {{ fg }}; +@define-color theme_unfocused_selected_bg_color {{ selection }}; +@define-color theme_unfocused_selected_fg_color {{ fg }}; +@define-color insensitive_bg_color {{ bg_alt }}; +@define-color insensitive_fg_color {{ dim }}; +@define-color insensitive_base_color {{ bg }}; +@define-color borders {{ border }}; +@define-color unfocused_borders {{ border }}; +@define-color wm_bg {{ surface }}; +@define-color wm_border {{ border }}; +@define-color wm_title {{ fg }}; +@define-color wm_unfocused_title {{ dim }}; +@define-color success_color {{ green }}; +@define-color warning_color {{ yellow }}; +@define-color error_color {{ red }}; +@define-color link_color {{ accent }}; +@define-color visited_link_color {{ magenta }}; +@define-color content_view_bg {{ bg }}; + +/* libadwaita / GTK4 named colours */ +@define-color accent_color {{ accent }}; +@define-color accent_bg_color {{ accent }}; +@define-color accent_fg_color {{ bg }}; +@define-color destructive_color {{ red }}; +@define-color window_bg_color {{ bg_alt }}; +@define-color window_fg_color {{ fg }}; +@define-color view_bg_color {{ bg }}; +@define-color view_fg_color {{ fg }}; +@define-color headerbar_bg_color {{ surface }}; +@define-color headerbar_fg_color {{ fg }}; +@define-color headerbar_border_color {{ border }}; +@define-color headerbar_backdrop_color {{ bg_alt }}; +@define-color headerbar_shade_color rgba(0, 0, 0, 0.36); +@define-color sidebar_bg_color {{ surface }}; +@define-color sidebar_fg_color {{ fg }}; +@define-color sidebar_backdrop_color {{ bg_alt }}; +@define-color sidebar_border_color {{ border }}; +@define-color sidebar_shade_color rgba(0, 0, 0, 0.36); +@define-color secondary_sidebar_bg_color {{ bg_alt }}; +@define-color secondary_sidebar_fg_color {{ fg }}; +@define-color card_bg_color {{ surface }}; +@define-color card_fg_color {{ fg }}; +@define-color card_shade_color rgba(0, 0, 0, 0.36); +@define-color dialog_bg_color {{ surface }}; +@define-color dialog_fg_color {{ fg }}; +@define-color popover_bg_color {{ overlay }}; +@define-color popover_fg_color {{ fg }}; +@define-color popover_shade_color rgba(0, 0, 0, 0.36); +@define-color thumbnail_bg_color {{ surface }}; +@define-color thumbnail_fg_color {{ fg }}; +@define-color shade_color rgba(0, 0, 0, 0.36); +@define-color scrollbar_outline_color {{ border }}; diff --git a/templates/imv-config.tpl b/templates/imv-config.tpl new file mode 100644 index 0000000..a34acd3 --- /dev/null +++ b/templates/imv-config.tpl @@ -0,0 +1,21 @@ +# Rendered by rice-theme from colors.toml - edit templates/imv-config.tpl instead. +# Linked as ~/.config/imv/config. +[options] +background = {{ bg_strip }} +overlay_font = {{ font }}:11 +overlay_text_color = {{ fg_strip }} +overlay_background_color = {{ bg_strip }} +overlay_background_alpha = cc + +[binds] +# Copy the picture to the clipboard +<Ctrl+c> = exec sh -c 'xclip -selection clipboard -t "$(file -b --mime-type "$1")" -i "$1"' _ "$imv_current_file" +# Make it the wallpaper +<Ctrl+w> = exec rice-wallpaper set "$imv_current_file" +# Rotate the file on disk by 90 degrees +<Ctrl+r> = exec mogrify -rotate 90 "$imv_current_file" +# Print it +<Ctrl+p> = exec lp "$imv_current_file" +# Move to the trash and quit / move to the trash and show the next one +<Ctrl+x> = exec gio trash -- "$imv_current_file"; quit +<Ctrl+Shift+X> = exec gio trash -- "$imv_current_file"; close diff --git a/templates/kitty.conf.tpl b/templates/kitty.conf.tpl new file mode 100644 index 0000000..33bb991 --- /dev/null +++ b/templates/kitty.conf.tpl @@ -0,0 +1,30 @@ +# Rendered by rice-theme from colors.toml - edit templates/kitty.conf.tpl instead. +font_family {{ font }} +bold_font auto +italic_font auto +bold_italic_font auto +background_opacity {{ alpha }} +background {{ bg }} +foreground {{ fg }} +cursor {{ accent }} +cursor_text_color {{ bg }} +selection_background {{ selection }} +selection_foreground {{ fg }} +url_color {{ accent }} + +color0 {{ bg }} +color1 {{ red }} +color2 {{ green }} +color3 {{ yellow }} +color4 {{ blue }} +color5 {{ magenta }} +color6 {{ cyan }} +color7 {{ fg_alt }} +color8 {{ muted }} +color9 {{ bright_red }} +color10 {{ bright_green }} +color11 {{ bright_yellow }} +color12 {{ bright_blue }} +color13 {{ bright_magenta }} +color14 {{ bright_cyan }} +color15 {{ fg }} diff --git a/templates/polybar-colors.ini.tpl b/templates/polybar-colors.ini.tpl new file mode 100644 index 0000000..65ba03d --- /dev/null +++ b/templates/polybar-colors.ini.tpl @@ -0,0 +1,21 @@ +; Rendered by rice-theme from colors.toml - edit templates/polybar-colors.ini.tpl instead. +[colors] +bg = {{ bg }} +bg-trans = #{{ alpha_hex }}{{ bg_strip }} +surface = {{ surface }} +border = #99{{ border_strip }} +text = {{ fg }} +dim = {{ dim }} +muted = {{ muted }} +accent = {{ accent }} +urgent = {{ urgent }} +red = {{ red }} +green = {{ green }} +yellow = {{ yellow }} +blue = {{ blue }} +magenta = {{ magenta }} +cyan = {{ cyan }} + +[fonts] +main = {{ font }}:style=Bold:pixelsize=10;2 +large = {{ font }}:style=Bold:pixelsize=12;2 diff --git a/templates/polybar-modules.ini.tpl b/templates/polybar-modules.ini.tpl new file mode 100644 index 0000000..5956247 --- /dev/null +++ b/templates/polybar-modules.ini.tpl @@ -0,0 +1,35 @@ +; Rendered by rice-theme from colors.toml - edit templates/polybar-modules.ini.tpl instead. +; Polybar only resolves ${colors.x} when it is the whole value, so modules that +; colour part of their own text live here with the theme's colours written in. + +[module/date] +type = internal/date +interval = 1 +date = %a %d +time = %H:%M +label = "%{A1:rice-panel calendar:}%{A3:rice-reminder new:}%{F{{ accent }}}%{F-} %time% %{F{{ dim }}}|%{F-} %date%%{A}%{A}" +format-padding = 1 + +[module/cpu] +type = custom/script +exec = rice-sysinfo --cpu --normal-color '{{ accent }}' --critical-color '{{ urgent }}' +click-left = rice-panel power +format = "%{F{{ accent }}}CPU%{F-} <label>" +format-padding = 1 +interval = 2 + +[module/ram] +type = custom/script +exec = rice-sysinfo --ram --normal-color '{{ yellow }}' --critical-color '{{ urgent }}' +click-left = rice-panel power +format = "%{F{{ yellow }}}RAM%{F-} <label>" +format-padding = 1 +interval = 2 + +[module/gpu] +type = custom/script +exec = rice-sysinfo --gpu --normal-color '{{ magenta }}' --critical-color '{{ urgent }}' +click-left = rice-panel power +format = "%{F{{ magenta }}}GPU%{F-} <label>" +format-padding = 1 +interval = 2 diff --git a/templates/rofi-colors.rasi.tpl b/templates/rofi-colors.rasi.tpl new file mode 100644 index 0000000..8dfaa27 --- /dev/null +++ b/templates/rofi-colors.rasi.tpl @@ -0,0 +1,18 @@ +/* Rendered by rice-theme from colors.toml - edit templates/rofi-colors.rasi.tpl instead. */ +* { + bg: {{ bg }}; + bg-trans: {{ bg }}{{ alpha_hex }}; + surface: {{ surface }}; + fg: {{ fg }}; + dim: {{ dim }}; + accent: {{ accent }}; + urgent: {{ urgent }}; + good: {{ green }}; + warn: {{ yellow }}; + border-col: {{ border }}; + select-bg: {{ accent }}2b; + select-fg: {{ fg }}; + font-main: "{{ font }} 11"; + font-bold: "{{ font }} Bold 12"; + font-entry: "{{ font }} 12"; +} diff --git a/templates/starship.toml.tpl b/templates/starship.toml.tpl new file mode 100644 index 0000000..bcbb2e9 --- /dev/null +++ b/templates/starship.toml.tpl @@ -0,0 +1,76 @@ +# Rendered by rice-theme from colors.toml - edit templates/starship.toml.tpl instead. +# One line, like bash's user@host prompt with colour and git: +# sarthak@arch ~/rice main !? ❯ +"$schema" = "https://starship.rs/config-schema.json" +add_newline = false +command_timeout = 300 +palette = "rice" + +format = "$username$hostname$directory$git_branch$git_state$git_status$jobs$cmd_duration$character" + +[palettes.rice] +fg = "{{ fg }}" +dim = "{{ dim }}" +accent = "{{ accent }}" +urgent = "{{ urgent }}" +green = "{{ green }}" +yellow = "{{ yellow }}" +magenta = "{{ magenta }}" + +[username] +show_always = true +format = "[$user]($style)" +style_user = "bold accent" +style_root = "bold urgent" + +[hostname] +ssh_only = false +trim_at = "." +format = "[@](dim)[$hostname]($style) " +style = "bold magenta" + +[directory] +style = "bold fg" +truncation_length = 3 +truncate_to_repo = false +truncation_symbol = "…/" +read_only = " 󰌾" +read_only_style = "urgent" +format = "[$path]($style)[$read_only]($read_only_style) " + +[git_branch] +symbol = " " +style = "dim" +format = "[$symbol$branch]($style) " + +[git_state] +style = "yellow" +format = "[$state( $progress_current/$progress_total)]($style) " + +[git_status] +style = "yellow" +format = "([$all_status$ahead_behind]($style) )" +conflicted = "=" +untracked = "?" +stashed = "" +modified = "!" +staged = "+" +renamed = "»" +deleted = "✘" +ahead = "⇡" +behind = "⇣" +diverged = "⇕" + +[jobs] +symbol = "✦" +style = "dim" +format = "[$symbol$number]($style) " + +[cmd_duration] +min_time = 2000 +format = "[$duration](dim) " + +[character] +success_symbol = "[❯](bold accent)" +error_symbol = "[❯](bold urgent)" +vimcmd_symbol = "[❮](bold green)" diff --git a/templates/xsecurelock.env.tpl b/templates/xsecurelock.env.tpl new file mode 100644 index 0000000..022157d --- /dev/null +++ b/templates/xsecurelock.env.tpl @@ -0,0 +1,15 @@ +# Rendered by rice-theme from colors.toml - edit templates/xsecurelock.env.tpl instead. +# Sourced by rice-lock before it starts xsecurelock. +export XSECURELOCK_FONT="{{ font }}:size=13" +export XSECURELOCK_BACKGROUND_COLOR="{{ bg }}" +export XSECURELOCK_AUTH_BACKGROUND_COLOR="{{ bg }}" +export XSECURELOCK_AUTH_FOREGROUND_COLOR="{{ fg }}" +export XSECURELOCK_AUTH_WARNING_COLOR="{{ urgent }}" +export XSECURELOCK_PASSWORD_PROMPT="asterisks" +export XSECURELOCK_SHOW_DATETIME=1 +export XSECURELOCK_DATETIME_FORMAT="%A %d %B %H:%M" +export XSECURELOCK_SHOW_HOSTNAME=0 +export XSECURELOCK_SHOW_USERNAME=0 +export XSECURELOCK_SAVER=saver_xscreensaver +export XSECURELOCK_BLANK_TIMEOUT=600 +export XSECURELOCK_BLANK_DPMS_STATE=off diff --git a/templates/xsettingsd.conf.tpl b/templates/xsettingsd.conf.tpl new file mode 100644 index 0000000..88795a9 --- /dev/null +++ b/templates/xsettingsd.conf.tpl @@ -0,0 +1,12 @@ +# Rendered by rice-theme from colors.toml - edit templates/xsettingsd.conf.tpl instead. +Net/ThemeName "{{ gtk_theme }}" +Net/IconThemeName "{{ icon_theme }}" +Gtk/CursorThemeName "{{ cursor_theme }}" +Gtk/CursorThemeSize {{ cursor_size }} +Gtk/FontName "Noto Sans 10" +Gtk/DecorationLayout "menu:" +Net/EnableEventSounds 0 +Xft/Antialias 1 +Xft/Hinting 1 +Xft/HintStyle "hintslight" +Xft/RGBA "rgb" diff --git a/themes/sakura-line/btop.theme b/themes/sakura-line/btop.theme new file mode 100644 index 0000000..3e4c89b --- /dev/null +++ b/themes/sakura-line/btop.theme @@ -0,0 +1,53 @@ +# btop - Sakura Line, sampled from bspwm/wallpaper/wallpaper.jpg +theme[main_bg]="" +theme[main_fg]="#e9eefb" +theme[title]="#e9eefb" +theme[hi_fg]="#92b9ea" +theme[selected_bg]="#2e2842" +theme[selected_fg]="#92b9ea" +theme[inactive_fg]="#7f7796" +theme[graph_text]="#c9d1e2" +theme[meter_bg]="#201b30" +theme[proc_misc]="#8ab6c4" + +theme[cpu_box]="#3d3752" +theme[mem_box]="#3d3752" +theme[net_box]="#3d3752" +theme[proc_box]="#3d3752" +theme[div_line]="#3d3752" + +theme[temp_start]="#8fae74" +theme[temp_mid]="#d8a87c" +theme[temp_end]="#cf6a80" + +theme[cpu_start]="#7c9ede" +theme[cpu_mid]="#92b9ea" +theme[cpu_end]="#d0a4d8" + +theme[free_start]="#4a5a6e" +theme[free_mid]="#7fa3ad" +theme[free_end]="#a3c0cb" + +theme[cached_start]="#6f8fc4" +theme[cached_mid]="#92b9ea" +theme[cached_end]="#aecdf5" + +theme[available_start]="#8a6a52" +theme[available_mid]="#d8a87c" +theme[available_end]="#e9c79b" + +theme[used_start]="#7a4256" +theme[used_mid]="#cf6a80" +theme[used_end]="#e0798f" + +theme[download_start]="#5f6f9e" +theme[download_mid]="#7c9ede" +theme[download_end]="#92b9ea" + +theme[upload_start]="#7a4a7e" +theme[upload_mid]="#b483c0" +theme[upload_end]="#d0a4d8" + +theme[process_start]="#8fae74" +theme[process_mid]="#a8c68d" +theme[process_end]="#e9c79b" diff --git a/themes/sakura-line/colors.toml b/themes/sakura-line/colors.toml new file mode 100644 index 0000000..9695a0e --- /dev/null +++ b/themes/sakura-line/colors.toml @@ -0,0 +1,59 @@ +# Sakura Line - sampled from wallpapers/sakura-line.jpg. +# Every surface (bspwm, polybar, rofi, dunst, eww, st, kitty, GTK, btop, +# xsecurelock, starship) is rendered from these keys by `rice-theme`. +# Colour values must be #rrggbb; templates add alpha where they need it. + +name = "Sakura Line" +mode = "dark" +wallpaper = "sakura-line.jpg" + +# --- surfaces, darkest to lightest --- +bg = "#0f0d17" +bg_alt = "#16121f" +surface = "#221c33" +overlay = "#2a2440" +selection = "#2e2842" +border = "#3d3752" + +# --- text --- +fg = "#e9eefb" +fg_alt = "#c9d1e2" +dim = "#7f7796" +muted = "#3a3350" + +# --- roles --- +accent = "#92b9ea" +accent_alt = "#5d7fb0" +urgent = "#e0798f" + +# --- named colours (ANSI 1-6) and their bright twins (9-14) --- +red = "#cf6a80" +green = "#8fae74" +yellow = "#d8a87c" +blue = "#7c9ede" +magenta = "#b483c0" +cyan = "#8ab6c4" + +bright_red = "#e0798f" +bright_green = "#a8c68d" +bright_yellow = "#e9c79b" +bright_blue = "#92b9ea" +bright_magenta = "#d0a4d8" +bright_cyan = "#a3c0cb" + +# --- translucency: bar, popups, menus, notifications and terminals all use it --- +alpha = "0.75" +alpha_hex = "bf" + +# --- shape and type, shared by every popup, menu and toast --- +font = "JetBrainsMono Nerd Font" +font_size = "11" +radius = "12" +radius_small = "8" +border_width = "1" +window_border = "2" +gap = "10" +icon_theme = "Papirus-Dark" +gtk_theme = "Adwaita-dark" +cursor_theme = "Bibata-Modern-Classic" +cursor_size = "20" diff --git a/wallpapers/sakura-line.jpg b/wallpapers/sakura-line.jpg new file mode 100644 index 0000000..72bdc62 Binary files /dev/null and b/wallpapers/sakura-line.jpg differ