Merge upstream main and resolve conflicts in nix-monitor
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env -S bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
API_BASE="${I18N_API_BASE:-https://i18n.noctalia.dev}"
|
||||
PROJECT_SLUG="community-plugins"
|
||||
|
||||
if [[ $# -ne 0 ]]; then
|
||||
echo "Usage: $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for command in curl jq; do
|
||||
if ! command -v "$command" >/dev/null 2>&1; then
|
||||
echo "Error: $command is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Project: $PROJECT_SLUG"
|
||||
echo "Output repository: $REPO_ROOT"
|
||||
read -r -p "Pull translations and overwrite returned local files? [y/N] " reply
|
||||
if [[ ! "$reply" =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RESPONSE_FILE="$(mktemp)"
|
||||
STAGING_DIR="$(mktemp -d)"
|
||||
trap 'rm -f "$RESPONSE_FILE"; rm -rf "$STAGING_DIR"' EXIT
|
||||
|
||||
HTTP_CODE="$(curl --silent --show-error \
|
||||
--output "$RESPONSE_FILE" \
|
||||
--write-out '%{http_code}' \
|
||||
"$API_BASE/api/projects/$PROJECT_SLUG/pull")"
|
||||
if [[ "$HTTP_CODE" != "200" ]]; then
|
||||
echo "Error: HTTP $HTTP_CODE" >&2
|
||||
jq . "$RESPONSE_FILE" 2>/dev/null || cat "$RESPONSE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! jq -e 'type == "object" and length > 0' "$RESPONSE_FILE" >/dev/null; then
|
||||
echo "Error: API response must be a non-empty locale object" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mapfile -t LOCALES < <(jq -r 'keys[]' "$RESPONSE_FILE")
|
||||
FILE_COUNT=0
|
||||
for locale in "${LOCALES[@]}"; do
|
||||
if [[ ! "$locale" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then
|
||||
echo "Error: API returned an invalid locale: $locale" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! jq -e --arg locale "$locale" '.[$locale] | type == "object" and length > 0' "$RESPONSE_FILE" >/dev/null; then
|
||||
echo "Error: Locale payload must be a non-empty object: $locale" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mapfile -t PLUGINS < <(jq -r --arg locale "$locale" '.[$locale] | keys[]' "$RESPONSE_FILE")
|
||||
for plugin in "${PLUGINS[@]}"; do
|
||||
if [[ ! "$plugin" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then
|
||||
echo "Error: API returned an invalid plugin name: $plugin" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$REPO_ROOT/$plugin/plugin.toml" ]]; then
|
||||
echo "Error: API returned unknown plugin: $plugin" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! jq -e --arg locale "$locale" --arg plugin "$plugin" '
|
||||
def valid_translation:
|
||||
if type == "object" then all(.[]; valid_translation)
|
||||
else type == "string"
|
||||
end;
|
||||
.[$locale][$plugin] | type == "object" and valid_translation
|
||||
' "$RESPONSE_FILE" >/dev/null; then
|
||||
echo "Error: Invalid translation payload for $plugin/$locale" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$STAGING_DIR/$plugin/translations"
|
||||
jq --arg locale "$locale" --arg plugin "$plugin" \
|
||||
'.[$locale][$plugin]' "$RESPONSE_FILE" \
|
||||
>"$STAGING_DIR/$plugin/translations/$locale.json"
|
||||
FILE_COUNT=$((FILE_COUNT + 1))
|
||||
done
|
||||
done
|
||||
|
||||
shopt -s nullglob
|
||||
MANIFESTS=("$REPO_ROOT"/*/plugin.toml)
|
||||
for manifest in "${MANIFESTS[@]}"; do
|
||||
plugin="$(basename -- "$(dirname -- "$manifest")")"
|
||||
if ! jq -e --arg plugin "$plugin" '.en[$plugin] | type == "object"' "$RESPONSE_FILE" >/dev/null; then
|
||||
echo "Error: English payload is missing plugin: $plugin" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
while IFS= read -r -d '' staged_file; do
|
||||
relative_path="${staged_file#"$STAGING_DIR/"}"
|
||||
output_file="$REPO_ROOT/$relative_path"
|
||||
output_dir="$(dirname -- "$output_file")"
|
||||
mkdir -p "$output_dir"
|
||||
temporary_file="$(mktemp "$output_dir/.i18n-pull.XXXXXX")"
|
||||
cp -- "$staged_file" "$temporary_file"
|
||||
mv -- "$temporary_file" "$output_file"
|
||||
echo "Saved: $relative_path"
|
||||
done < <(find "$STAGING_DIR" -type f -name '*.json' -print0 | sort -z)
|
||||
|
||||
echo "Successfully pulled $FILE_COUNT translation file(s)."
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env -S bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
API_BASE="${I18N_API_BASE:-https://i18n.noctalia.dev}"
|
||||
PROJECT_SLUG="community-plugins"
|
||||
OVERWRITE=false
|
||||
SINGLE_LANG=""
|
||||
|
||||
usage() {
|
||||
echo "Usage: COMMUNITY_PLUGINS_PUSH_SECRET=... $0 [--overwrite] [--lang <locale>]"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--overwrite)
|
||||
OVERWRITE=true
|
||||
shift
|
||||
;;
|
||||
--lang)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Error: --lang requires a locale" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
SINGLE_LANG="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
for command in curl jq; do
|
||||
if ! command -v "$command" >/dev/null 2>&1; then
|
||||
echo "Error: $command is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "${COMMUNITY_PLUGINS_PUSH_SECRET:-}" ]]; then
|
||||
echo "Error: COMMUNITY_PLUGINS_PUSH_SECRET is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$SINGLE_LANG" && ! "$SINGLE_LANG" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then
|
||||
echo "Error: Invalid locale: $SINGLE_LANG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
MANIFESTS=("$REPO_ROOT"/*/plugin.toml)
|
||||
if [[ ${#MANIFESTS[@]} -eq 0 ]]; then
|
||||
echo "Error: No plugin manifests found in $REPO_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
validate_translation_file() {
|
||||
local file="$1"
|
||||
if ! jq -e '
|
||||
def valid_translation:
|
||||
if type == "object" then all(.[]; valid_translation)
|
||||
else type == "string"
|
||||
end;
|
||||
type == "object" and valid_translation
|
||||
' "$file" >/dev/null; then
|
||||
echo "Error: Translation must be a JSON object containing only objects and strings: $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
COMBINED_JSON='{}'
|
||||
FILE_COUNT=0
|
||||
|
||||
for manifest in "${MANIFESTS[@]}"; do
|
||||
plugin_dir="$(dirname -- "$manifest")"
|
||||
plugin="$(basename -- "$plugin_dir")"
|
||||
if [[ ! "$plugin" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then
|
||||
echo "Error: Invalid plugin directory name: $plugin" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$SINGLE_LANG" ]]; then
|
||||
translation_files=("$plugin_dir/translations/$SINGLE_LANG.json")
|
||||
else
|
||||
english_file="$plugin_dir/translations/en.json"
|
||||
if [[ ! -f "$english_file" ]]; then
|
||||
echo "Error: Missing English translation: $english_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
translation_files=("$plugin_dir"/translations/*.json)
|
||||
fi
|
||||
|
||||
for file in "${translation_files[@]}"; do
|
||||
[[ -f "$file" ]] || continue
|
||||
locale="$(basename -- "$file" .json)"
|
||||
if [[ ! "$locale" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then
|
||||
echo "Error: Invalid locale filename: $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
validate_translation_file "$file"
|
||||
COMBINED_JSON="$(jq \
|
||||
--arg locale "$locale" \
|
||||
--arg plugin "$plugin" \
|
||||
--slurpfile content "$file" \
|
||||
'. + {($locale): ((.[$locale] // {}) + {($plugin): $content[0]})}' \
|
||||
<<<"$COMBINED_JSON")"
|
||||
echo "Loaded: $plugin/translations/$locale.json"
|
||||
FILE_COUNT=$((FILE_COUNT + 1))
|
||||
done
|
||||
done
|
||||
|
||||
if [[ $FILE_COUNT -eq 0 ]]; then
|
||||
if [[ -n "$SINGLE_LANG" ]]; then
|
||||
echo "Error: No plugin provides locale: $SINGLE_LANG" >&2
|
||||
else
|
||||
echo "Error: No translation files found" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LOCALE_COUNT="$(jq 'keys | length' <<<"$COMBINED_JSON")"
|
||||
echo "Project: $PROJECT_SLUG"
|
||||
echo "Found $FILE_COUNT file(s) across $LOCALE_COUNT locale(s)"
|
||||
read -r -p "Push these translations to $API_BASE? [y/N] " reply
|
||||
if [[ ! "$reply" =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PUSH_URL="$API_BASE/api/projects/$PROJECT_SLUG/push"
|
||||
if [[ "$OVERWRITE" == true ]]; then
|
||||
PUSH_URL="$PUSH_URL?overwrite=true"
|
||||
echo "Overwrite mode enabled"
|
||||
fi
|
||||
|
||||
RESPONSE_FILE="$(mktemp)"
|
||||
trap 'rm -f "$RESPONSE_FILE"' EXIT
|
||||
HTTP_CODE="$(curl --silent --show-error \
|
||||
--output "$RESPONSE_FILE" \
|
||||
--write-out '%{http_code}' \
|
||||
--request POST \
|
||||
--header "Authorization: Bearer $COMMUNITY_PLUGINS_PUSH_SECRET" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data-binary @- \
|
||||
"$PUSH_URL" <<<"$COMBINED_JSON")"
|
||||
|
||||
if [[ "$HTTP_CODE" != "200" ]]; then
|
||||
echo "Error: HTTP $HTTP_CODE" >&2
|
||||
jq . "$RESPONSE_FILE" 2>/dev/null || cat "$RESPONSE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Translations pushed successfully."
|
||||
jq . "$RESPONSE_FILE" 2>/dev/null || cat "$RESPONSE_FILE"
|
||||
@@ -126,6 +126,15 @@ error messages show the exact missing value, while maintainers review the useful
|
||||
Write `translations/en.json` only. Every `label_key` and `description_key` in your manifest must resolve to a key in
|
||||
it, and CI checks this. Do not add machine-translated locales; other languages are handled separately.
|
||||
|
||||
To test the latest translated locales from [Noctalia Translate](https://i18n.noctalia.dev) in a working checkout, run:
|
||||
|
||||
```sh
|
||||
./.tools/i18n-pull.sh
|
||||
```
|
||||
|
||||
The command asks for confirmation and overwrites the locale files returned by the translation service. It does not
|
||||
delete local locale files that are absent from the export. Review the resulting diff before committing anything.
|
||||
|
||||
### Tags
|
||||
|
||||
The `tags` in `plugin.toml` are used for catalog search. Tags must be lowercase and selected from this list:
|
||||
|
||||
@@ -65,7 +65,7 @@ tags = ["launcher"]
|
||||
[[plugin]]
|
||||
id = "gambled23/mangowm-keymode"
|
||||
name = "Mangowm Keymode"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
author = "gambled23"
|
||||
license = "MIT"
|
||||
icon = "keyboard"
|
||||
@@ -77,7 +77,7 @@ tags = ["bar", "mangowc"]
|
||||
[[plugin]]
|
||||
id = "oldirtty/color_picker"
|
||||
name = "Color Picker"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
author = "oldirtty"
|
||||
license = "MIT"
|
||||
icon = "palette"
|
||||
@@ -99,11 +99,11 @@ tags = ["hyprland", "sway", "desktop", "utility"]
|
||||
[[plugin]]
|
||||
id = "radimous/prismlauncher-instances"
|
||||
name = "PrismLauncher Instances"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
author = "radimous"
|
||||
license = "MIT"
|
||||
icon = "nut"
|
||||
description = "A launcher provider that adds PrismLauncher instances to the noctalia launcher."
|
||||
description = "A launcher provider that adds PrismLauncher and PrismLauncher fork instances to the noctalia launcher."
|
||||
deprecated = false
|
||||
plugin_api = 3
|
||||
tags = ["gaming", "launcher"]
|
||||
@@ -155,7 +155,7 @@ tags = ["hardware"]
|
||||
[[plugin]]
|
||||
id = "rxtsel/portctl"
|
||||
name = "Portctl"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
author = "Cristhian Melo"
|
||||
license = "MIT"
|
||||
icon = "plug"
|
||||
@@ -173,3 +173,69 @@ icon = "device-laptop"
|
||||
description = "Toggles battery conservation mode via the ideapad_acpi driver, on Lenovo IdeaPad and Legion laptops."
|
||||
plugin_api = 3
|
||||
tags = ["hardware", "shortcut", "system"]
|
||||
|
||||
[[plugin]]
|
||||
id = "goodroot/noctwhspr"
|
||||
name = "noctwhspr"
|
||||
version = "1.0.0"
|
||||
author = "goodroot"
|
||||
license = "MIT"
|
||||
icon = "microphone"
|
||||
description = "Noctalia companion for hyprwhspr: shows dictation state, click to record, right-click to restart."
|
||||
plugin_api = 3
|
||||
tags = ["audio", "bar", "recording", "utility"]
|
||||
|
||||
[[plugin]]
|
||||
id = "nightwatch75/file-search"
|
||||
name = "File Search"
|
||||
version = "0.0.10"
|
||||
author = "nightwatch75"
|
||||
license = "MIT"
|
||||
icon = "search"
|
||||
description = "Search files and folders as you type, fuzzy-matched with fzf; open results with the system MIME association."
|
||||
plugin_api = 3
|
||||
tags = ["bar", "launcher", "panel", "utility", "productivity"]
|
||||
|
||||
[[plugin]]
|
||||
id = "h465855hgg/lyrics"
|
||||
name = "Lyrics"
|
||||
version = "1.3.0"
|
||||
author = "h465855hgg"
|
||||
license = "MIT"
|
||||
icon = "music"
|
||||
description = "Synchronized lyrics with karaoke highlighting, animated transitions, and flexible lyric sources."
|
||||
plugin_api = 3
|
||||
tags = ["bar", "service", "music", "media", "animation"]
|
||||
|
||||
[[plugin]]
|
||||
id = "whyoolw/dropwall"
|
||||
name = "DropWall"
|
||||
version = "1.0.0"
|
||||
author = "whyoolw"
|
||||
license = "MIT"
|
||||
icon = "image"
|
||||
description = "Drag and drop an image onto the desktop to set it as wallpaper using Noctalia's wallpaper settings."
|
||||
plugin_api = 3
|
||||
tags = ["desktop", "wallpaper"]
|
||||
|
||||
[[plugin]]
|
||||
id = "cleboost/zed-provider"
|
||||
name = "Zed Provider"
|
||||
version = "1.0.0"
|
||||
author = "cleboost"
|
||||
license = "MIT"
|
||||
icon = "folder-open"
|
||||
description = "Open a recent Zed project from the launcher. Type /zed to list your projects."
|
||||
plugin_api = 3
|
||||
tags = ["launcher", "development", "productivity"]
|
||||
|
||||
[[plugin]]
|
||||
id = "pozzoo/hassio"
|
||||
name = "Home Assistant"
|
||||
version = "2.0.0"
|
||||
author = "Pozzoo"
|
||||
license = "MIT"
|
||||
icon = "smart-home"
|
||||
description = "Monitor and control Home Assistant entities from the bar. Displays entity states and provides quick toggles."
|
||||
plugin_api = 4
|
||||
tags = ["bar", "panel", "service", "shortcut", "network", "indicator"]
|
||||
|
||||
@@ -3,7 +3,7 @@ id = "oldirtty/color_picker"
|
||||
name = "Color Picker"
|
||||
description = "Pick a color from your screen with hyprpicker."
|
||||
license = "MIT"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
plugin_api = 3
|
||||
icon = "palette"
|
||||
dependencies = ["hyprpicker"]
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
-- CONSTANTS
|
||||
--==============================================
|
||||
local MAX_HISTORY = 6
|
||||
local STATE_DIR = "~/.local/state/noctalia/plugin-cache/community/color_picker"
|
||||
local HISTORY_FILE = STATE_DIR .. "/history.json"
|
||||
local SELECTED_FILE = STATE_DIR .. "/selected.json"
|
||||
local PERSISTENT_DIR = noctalia.pluginDataDir()
|
||||
local HISTORY_FILE = PERSISTENT_DIR .. "/history.json"
|
||||
local SELECTED_FILE = PERSISTENT_DIR .. "/selected.json"
|
||||
local tr_color_picker = noctalia.tr("color_picker")
|
||||
|
||||
--==============================================
|
||||
@@ -101,11 +101,9 @@ end
|
||||
--==============================================
|
||||
-- STATE MODEL
|
||||
--
|
||||
-- selectedColor / selectedOpacity: ephemeral, what the panel is currently
|
||||
-- showing. Set here on every successful pick; also updated by the panel
|
||||
-- itself for pure-UI interactions (history swatch click, field edit,
|
||||
-- opacity slider) that don't touch hyprpicker or disk.
|
||||
--
|
||||
-- selectedColor / selectedOpacity: persisted color, state is saved once
|
||||
-- per panel session when closed.
|
||||
|
||||
-- colorHistory: persisted list.
|
||||
-- Only mutated by pushCurrentColor(), called on immediate-commit picks
|
||||
-- (widget right-click) and on "commit" (panel close).
|
||||
@@ -139,37 +137,37 @@ local function pushCurrentColor(hex: string, opacity: number)
|
||||
end
|
||||
|
||||
local function loadHistoryFromDisk()
|
||||
local content = noctalia.readFile(HISTORY_FILE)
|
||||
if content == nil or content == "" then return end
|
||||
local raw = noctalia.readFile(HISTORY_FILE)
|
||||
if raw == nil or raw == "" then return end
|
||||
|
||||
local decoded = noctalia.json.decode(content)
|
||||
if type(decoded) ~= "table" then return end
|
||||
local decoded = noctalia.json.decode(raw)
|
||||
if type(decoded) ~= "table" then return end
|
||||
|
||||
noctalia.state.set("colorHistory", decoded)
|
||||
noctalia.state.set("colorHistory", decoded)
|
||||
end
|
||||
|
||||
local function saveHistoryToDisk()
|
||||
local history = noctalia.state.get("colorHistory") or {}
|
||||
local encoded = noctalia.json.encode(history)
|
||||
if encoded ~= nil then
|
||||
noctalia.writeFile(HISTORY_FILE, encoded)
|
||||
end
|
||||
local history = noctalia.state.get("colorHistory") or {}
|
||||
local encoded = noctalia.json.encode(history)
|
||||
if encoded ~= nil then
|
||||
noctalia.writeFile(HISTORY_FILE, encoded)
|
||||
end
|
||||
end
|
||||
|
||||
local function loadSelectedFromDisk()
|
||||
local content = noctalia.readFile(SELECTED_FILE)
|
||||
if content == nil or content == "" then return end
|
||||
|
||||
local decoded = noctalia.json.decode(content)
|
||||
local raw = noctalia.readFile(SELECTED_FILE)
|
||||
if raw == nil or raw == "" then return end
|
||||
|
||||
local decoded = noctalia.json.decode(raw)
|
||||
if type(decoded) ~= "table" or decoded.hex == nil then return end
|
||||
|
||||
|
||||
setSelected(decoded.hex, decoded.opacity or 1)
|
||||
end
|
||||
|
||||
local function saveSelectedToDisk()
|
||||
local hex = noctalia.state.get("selectedColor")
|
||||
if hex == nil then return end
|
||||
|
||||
|
||||
local opacity = tonumber(noctalia.state.get("selectedOpacity")) or 1
|
||||
local encoded = noctalia.json.encode({ hex = hex, opacity = opacity })
|
||||
if encoded ~= nil then
|
||||
@@ -291,7 +289,7 @@ end
|
||||
--==============================================
|
||||
|
||||
function load()
|
||||
noctalia.mkdirAll(STATE_DIR)
|
||||
noctalia.mkdirAll(PERSISTENT_DIR)
|
||||
loadHistoryFromDisk()
|
||||
loadSelectedFromDisk()
|
||||
end
|
||||
|
||||
@@ -1,31 +1,48 @@
|
||||
{
|
||||
"color_picker": "Color Picker",
|
||||
"no_color_picked": "No color picked yet.",
|
||||
"recent_colors": "Recent Colors",
|
||||
"opacity": "Opacity",
|
||||
"no_history": "No colors in history yet.",
|
||||
"color_copied": "Color {color} copied.",
|
||||
"color_picked": "Color {color} picked.",
|
||||
"hyprpicker_not_installed": "hyprpicker is not installed.",
|
||||
"color_picker": "Color Picker",
|
||||
"could_not_parse": "could not parse hyprpicker output.",
|
||||
"glyph": "Glyph",
|
||||
"hex": "HEX",
|
||||
"hyprpicker_not_installed": "hyprpicker is not installed.",
|
||||
"no_color_picked": "No color picked yet.",
|
||||
"no_history": "No colors in history yet.",
|
||||
"opacity": "Opacity",
|
||||
"recent_colors": "Recent Colors",
|
||||
"rgb": "RGB",
|
||||
|
||||
"settings.hyprpicker_format.label": "Default Format",
|
||||
"settings.hyprpicker_format.description": "Default color format to copy to clipboard (Hex or RGB).",
|
||||
"settings.swatch_radius.label": "Swatches Corner Roundness",
|
||||
"settings.swatch_radius.description": "Corner radius of the history swatches and current-color preview.",
|
||||
"settings.hyprpicker_lowercase.label": "Lowercase Hex",
|
||||
"settings.hyprpicker_lowercase.description": "Outputs the hexcode in lowercase.",
|
||||
"settings.hyprpicker_scale.label": "Zoom Scale",
|
||||
"settings.hyprpicker_scale.description": "Zoom lens magnification, from 1 to 10.",
|
||||
"settings.hyprpicker_radius.label": "Zoom Radius",
|
||||
"settings.hyprpicker_radius.description": "Zoom lens circle radius in pixels, from 1 to 1000.",
|
||||
"settings.hyprpicker_no_zoom.label": "Disable Zoom Lens",
|
||||
"settings.hyprpicker_no_zoom.description": "Turns off the magnifying zoom lens while picking.",
|
||||
"settings.hyprpicker_disable_preview.label": "Disable Live Preview",
|
||||
"settings.hyprpicker_disable_preview.description": "Turns off the live color preview while picking.",
|
||||
"settings.hyprpicker_cursor.label": "Show cursor",
|
||||
"settings.hyprpicker_cursor.description": "Includes the cursor in the frozen screen preview."
|
||||
}
|
||||
"settings": {
|
||||
"hyprpicker_cursor": {
|
||||
"description": "Includes the cursor in the frozen screen preview.",
|
||||
"label": "Show cursor"
|
||||
},
|
||||
"hyprpicker_disable_preview": {
|
||||
"description": "Turns off the live color preview while picking.",
|
||||
"label": "Disable Live Preview"
|
||||
},
|
||||
"hyprpicker_format": {
|
||||
"description": "Default color format to copy to clipboard (Hex or RGB).",
|
||||
"label": "Default Format"
|
||||
},
|
||||
"hyprpicker_lowercase": {
|
||||
"description": "Outputs the hexcode in lowercase.",
|
||||
"label": "Lowercase Hex"
|
||||
},
|
||||
"hyprpicker_no_zoom": {
|
||||
"description": "Turns off the magnifying zoom lens while picking.",
|
||||
"label": "Disable Zoom Lens"
|
||||
},
|
||||
"hyprpicker_radius": {
|
||||
"description": "Zoom lens circle radius in pixels, from 1 to 1000.",
|
||||
"label": "Zoom Radius"
|
||||
},
|
||||
"hyprpicker_scale": {
|
||||
"description": "Zoom lens magnification, from 1 to 10.",
|
||||
"label": "Zoom Scale"
|
||||
},
|
||||
"swatch_radius": {
|
||||
"description": "Corner radius of the history swatches and current-color preview.",
|
||||
"label": "Swatches Corner Roundness"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,48 @@
|
||||
{
|
||||
"color_picker": "Seletor de Cores",
|
||||
"no_color_picked": "Nenhuma cor escolhida ainda.",
|
||||
"recent_colors": "Cores Recentes",
|
||||
"opacity": "Opacidade",
|
||||
"no_history": "Ainda não há cores no histórico.",
|
||||
"color_copied": "Cor {color} copiada.",
|
||||
"color_picked": "Cor {color} selecionada.",
|
||||
"hyprpicker_not_installed": "hyprpicker não está instalado.",
|
||||
"color_picker": "Seletor de Cores",
|
||||
"could_not_parse": "não foi possível extrair a saída do hyprpicker.",
|
||||
"glyph": "Símbolo",
|
||||
"hex": "HEX",
|
||||
"hyprpicker_not_installed": "hyprpicker não está instalado.",
|
||||
"no_color_picked": "Nenhuma cor escolhida ainda.",
|
||||
"no_history": "Ainda não há cores no histórico.",
|
||||
"opacity": "Opacidade",
|
||||
"recent_colors": "Cores Recentes",
|
||||
"rgb": "RGB",
|
||||
|
||||
"settings.hyprpicker_format.label": "Formato Padrão",
|
||||
"settings.hyprpicker_format.description": "Formato de cor padrão para copiar para a área de transferência (Hex ou RGB).",
|
||||
"settings.swatch_radius.label": "Arredondamento de Cantos das Amostras",
|
||||
"settings.swatch_radius.description": "Raio dos cantos das amostras do histórico e da prévia da cor atual.",
|
||||
"settings.hyprpicker_lowercase.label": "Hexadecimal em Minúsculas",
|
||||
"settings.hyprpicker_lowercase.description": "Exibe o código hexadecimal em letras minúsculas.",
|
||||
"settings.hyprpicker_scale.label": "Escala do Zoom",
|
||||
"settings.hyprpicker_scale.description": "Ampliação da lupa, de 1 a 10.",
|
||||
"settings.hyprpicker_radius.label": "Raio do Zoom",
|
||||
"settings.hyprpicker_radius.description": "Raio do círculo da lupa em pixels, de 1 a 1000.",
|
||||
"settings.hyprpicker_no_zoom.label": "Desabilitar Lupa",
|
||||
"settings.hyprpicker_no_zoom.description": "Dpesativa a lupa ao selecionar a cor.",
|
||||
"settings.hyprpicker_disable_preview.label": "Desabilitar Prévia em Tempo Real",
|
||||
"settings.hyprpicker_disable_preview.description": "Desativa a prévia da cor em tempo real ao selecionar.",
|
||||
"settings.hyprpicker_cursor.label": "Mostrar cursor",
|
||||
"settings.hyprpicker_cursor.description": "Inclui o cursor na prévia congelada da tela."
|
||||
}
|
||||
"settings": {
|
||||
"hyprpicker_cursor": {
|
||||
"description": "Inclui o cursor na prévia congelada da tela.",
|
||||
"label": "Mostrar cursor"
|
||||
},
|
||||
"hyprpicker_disable_preview": {
|
||||
"description": "Desativa a prévia da cor em tempo real ao selecionar.",
|
||||
"label": "Desabilitar Prévia em Tempo Real"
|
||||
},
|
||||
"hyprpicker_format": {
|
||||
"description": "Formato de cor padrão para copiar para a área de transferência (Hex ou RGB).",
|
||||
"label": "Formato Padrão"
|
||||
},
|
||||
"hyprpicker_lowercase": {
|
||||
"description": "Exibe o código hexadecimal em letras minúsculas.",
|
||||
"label": "Hexadecimal em Minúsculas"
|
||||
},
|
||||
"hyprpicker_no_zoom": {
|
||||
"description": "Dpesativa a lupa ao selecionar a cor.",
|
||||
"label": "Desabilitar Lupa"
|
||||
},
|
||||
"hyprpicker_radius": {
|
||||
"description": "Raio do círculo da lupa em pixels, de 1 a 1000.",
|
||||
"label": "Raio do Zoom"
|
||||
},
|
||||
"hyprpicker_scale": {
|
||||
"description": "Ampliação da lupa, de 1 a 10.",
|
||||
"label": "Escala do Zoom"
|
||||
},
|
||||
"swatch_radius": {
|
||||
"description": "Raio dos cantos das amostras do histórico e da prévia da cor atual.",
|
||||
"label": "Arredondamento de Cantos das Amostras"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
{
|
||||
"notify.applied": "Wallpaper applied",
|
||||
"notify.failed": "Daily wallpaper failed",
|
||||
"settings.locale.description": "Bing market locale such as en-US, de-DE, or fr-FR. Empty uses en-US.",
|
||||
"settings.locale.label": "Locale",
|
||||
"settings.source.label": "Source",
|
||||
"settings.source.options.bing": "Bing",
|
||||
"settings.source.options.nasa": "NASA"
|
||||
"notify": {
|
||||
"applied": "Wallpaper applied",
|
||||
"failed": "Daily wallpaper failed"
|
||||
},
|
||||
"settings": {
|
||||
"locale": {
|
||||
"description": "Bing market locale such as en-US, de-DE, or fr-FR. Empty uses en-US.",
|
||||
"label": "Locale"
|
||||
},
|
||||
"source": {
|
||||
"label": "Source",
|
||||
"options": {
|
||||
"bing": "Bing",
|
||||
"nasa": "NASA"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 whyoolw
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,110 @@
|
||||
# DropWall
|
||||
|
||||
Drag a local image anywhere onto the desktop to set it as the wallpaper in
|
||||
Noctalia v5.
|
||||
|
||||
The image is applied through Noctalia's own wallpaper API, so the regular fill
|
||||
mode, fill color, transitions, and wallpaper state remain in effect.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `whyoolw/dropwall` |
|
||||
| Entries | Service: `service` (`service.luau`) |
|
||||
|
||||
DropWall is a headless service plugin. It does not add a bar widget or panel;
|
||||
enabling the plugin starts the desktop drop target service.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Enable `whyoolw/dropwall` from Noctalia's plugin store or with
|
||||
`noctalia msg plugins enable whyoolw/dropwall`.
|
||||
2. Drag a local image file onto the bare desktop.
|
||||
3. Drop it on any monitor to apply it as the wallpaper through Noctalia's
|
||||
wallpaper settings.
|
||||
4. Optional: open **Settings -> Plugins -> DropWall** to enable per-monitor
|
||||
drops, safe copying into the wallpaper directory, notifications, or the
|
||||
alternate `bottom` layer.
|
||||
|
||||
## Features
|
||||
|
||||
- Full-desktop drop targets on every connected monitor.
|
||||
- Optional per-monitor application based on the monitor receiving the drop.
|
||||
- Optional safe copy into Noctalia's wallpaper directory.
|
||||
- A subtle dashed highlight while a file is dragged over the desktop.
|
||||
- Automatic monitor hotplug handling and helper recovery.
|
||||
|
||||
## How it works
|
||||
|
||||
- A headless service opens one long-lived stream to
|
||||
`dropwall_supervisor.py`. The supervisor owns a GTK3 + gtk-layer-shell
|
||||
worker and restarts it after an unexpected exit without consuming extra
|
||||
Noctalia stream slots.
|
||||
- The worker keeps one fully transparent layer-shell surface per monitor,
|
||||
anchored to all edges. A runtime lock, parent-death signals, and pipe
|
||||
heartbeats prevent orphaned or duplicate workers.
|
||||
- Dragging a file over the desktop shows a subtle dashed drop highlight.
|
||||
- On drop, the worker accepts only a local regular file with a supported
|
||||
extension (`jpg`, `jpeg`, `png`, `webp`, `bmp`, or `gif`). It reports a
|
||||
percent-encoded path and the monitor's logical geometry to the service.
|
||||
- The service matches that geometry against `noctalia.outputs()` and applies
|
||||
the image with `noctalia.setWallpaper()`. Ambiguous per-monitor matches fail
|
||||
safely instead of changing every output.
|
||||
- When copying is enabled, the service resolves the current theme's wallpaper
|
||||
directory for that drop and starts `dropwall_copy.py`. The copier writes a
|
||||
private hidden temporary file, flushes it, then publishes the complete file
|
||||
atomically without replacing anything. `photo-1.jpg`, `photo-2.jpg`, and so
|
||||
on are used for name collisions.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `python3`
|
||||
- Python GObject bindings (`python-gobject` / `python3-gi`)
|
||||
- GTK 3 and the GTK Layer Shell typelib (`gtk3`, `gtk-layer-shell`)
|
||||
- A compositor with wlr-layer-shell support (niri, Hyprland, sway, …)
|
||||
|
||||
Package names vary between distributions. Install the packages that provide
|
||||
Python 3, PyGObject, GTK 3, and the GTK Layer Shell typelib on your system.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| Per-monitor drop | off | Set only on the monitor you dropped on; off = system behavior |
|
||||
| Copy into wallpaper directory | off | Create a non-overwriting copy in Noctalia's wallpaper directory before applying |
|
||||
| Notify on set | on | Notification when applied |
|
||||
| Drop surface layer | background | Use `bottom` if drops do not register; the service restarts automatically |
|
||||
|
||||
## Process, filesystem, and network behavior
|
||||
|
||||
DropWall keeps two local Python processes running: a small supervisor and its
|
||||
GTK worker. It writes a PID lock named `noctalia-dropwall.lock` in
|
||||
`$XDG_RUNTIME_DIR`. With **Copy into wallpaper directory** enabled, each drop
|
||||
starts one short-lived Python copier and writes a mode-0600 image to the
|
||||
directory returned by Noctalia. A completed copy appears atomically and
|
||||
existing files are never overwritten. The copier watches the exact GTK worker
|
||||
that accepted the drop and cleans up if the plugin is stopped or reloaded.
|
||||
Before a copy, the copier removes only
|
||||
owned `.dropwall-copy-*.tmp` files older than 24 hours that an unclean shutdown
|
||||
may have left in that directory. With copying disabled, Noctalia keeps
|
||||
referring to the original file, so moving or deleting that file can break the
|
||||
wallpaper.
|
||||
|
||||
DropWall makes no network requests and never downloads or executes remote
|
||||
code.
|
||||
|
||||
## Notes
|
||||
|
||||
- The drop surface accepts pointer input over the bare desktop (that's what
|
||||
makes Wayland DnD target it). Noctalia desktop widgets live on their own
|
||||
surfaces and are unaffected.
|
||||
- Files dragged from browsers as remote URLs (not `file://`) are ignored —
|
||||
save the image first.
|
||||
- If the highlight does not appear, switch **Drop surface layer** from
|
||||
`background` to `bottom`. If startup still fails, check Noctalia's log for
|
||||
messages prefixed with `dropwall helper:` and verify the dependencies above.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Atomically copy one dropped image without replacing an existing file."""
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
|
||||
TEMP_PREFIX = ".dropwall-copy-"
|
||||
TEMP_SUFFIX = ".tmp"
|
||||
STALE_SECONDS = 24 * 60 * 60
|
||||
ACTIVE_TEMP = None
|
||||
COPY_CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
# Exit immediately if Noctalia closes the process pipe.
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
||||
|
||||
|
||||
def arm_parent_death_signal():
|
||||
"""Ask Linux to terminate this copy if Noctalia disappears."""
|
||||
parent = os.getppid()
|
||||
try:
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
if libc.prctl(1, signal.SIGTERM, 0, 0, 0) != 0: # PR_SET_PDEATHSIG
|
||||
return
|
||||
if os.getppid() != parent:
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
except (AttributeError, OSError):
|
||||
return
|
||||
|
||||
|
||||
def encode_path(path):
|
||||
return urllib.parse.quote_from_bytes(os.fsencode(path), safe="")
|
||||
|
||||
|
||||
def remove_active_temp():
|
||||
global ACTIVE_TEMP
|
||||
if ACTIVE_TEMP:
|
||||
try:
|
||||
os.unlink(ACTIVE_TEMP)
|
||||
except OSError:
|
||||
pass
|
||||
ACTIVE_TEMP = None
|
||||
|
||||
|
||||
def terminate(signum, _frame):
|
||||
remove_active_temp()
|
||||
os._exit(128 + signum)
|
||||
|
||||
|
||||
class WorkerLease:
|
||||
"""A pidfd tied to the GTK worker that accepted this drop."""
|
||||
|
||||
def __init__(self, pid):
|
||||
if not hasattr(os, "pidfd_open"):
|
||||
raise OSError("this Linux/Python build does not support pidfd_open")
|
||||
self.fd = os.pidfd_open(pid, 0)
|
||||
self.poller = select.poll()
|
||||
self.poller.register(self.fd, select.POLLIN | select.POLLHUP | select.POLLERR)
|
||||
self.check()
|
||||
|
||||
def check(self):
|
||||
if self.poller.poll(0):
|
||||
raise BrokenPipeError("DropWall worker stopped during the copy")
|
||||
|
||||
def close(self):
|
||||
os.close(self.fd)
|
||||
|
||||
|
||||
def cleanup_stale_temps(directory):
|
||||
"""Remove only old, owned temporary files left by interrupted copies."""
|
||||
cutoff = time.time() - STALE_SECONDS
|
||||
try:
|
||||
names = os.listdir(directory)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
for name in names:
|
||||
if not (name.startswith(TEMP_PREFIX) and name.endswith(TEMP_SUFFIX)):
|
||||
continue
|
||||
path = os.path.join(directory, name)
|
||||
try:
|
||||
info = os.lstat(path)
|
||||
if info.st_uid == os.getuid() and stat.S_ISREG(info.st_mode) and info.st_mtime < cutoff:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def is_inside(path, directory):
|
||||
try:
|
||||
return os.path.commonpath((path, directory)) == directory
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def publish_unique(temp_path, source, directory, lease):
|
||||
filename = os.path.basename(source)
|
||||
stem, suffix = os.path.splitext(filename)
|
||||
stem = stem or "wallpaper"
|
||||
|
||||
for counter in range(10000):
|
||||
candidate_name = filename if counter == 0 else "%s-%d%s" % (stem, counter, suffix)
|
||||
candidate = os.path.join(directory, candidate_name)
|
||||
try:
|
||||
lease.check()
|
||||
# The hard link exposes the already-complete inode atomically and
|
||||
# fails if candidate exists. It can never replace user data.
|
||||
os.link(temp_path, candidate, follow_symlinks=False)
|
||||
return candidate
|
||||
except FileExistsError:
|
||||
continue
|
||||
|
||||
raise FileExistsError("could not allocate a unique destination filename")
|
||||
|
||||
|
||||
def copy_atomic(source, directory, lease):
|
||||
global ACTIVE_TEMP
|
||||
|
||||
real_directory = os.path.realpath(directory)
|
||||
if not os.path.isdir(real_directory):
|
||||
raise NotADirectoryError("wallpaper directory does not exist")
|
||||
|
||||
source_flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NONBLOCK", 0)
|
||||
source_fd = os.open(source, source_flags)
|
||||
with os.fdopen(source_fd, "rb") as source_file:
|
||||
source_info = os.fstat(source_file.fileno())
|
||||
if not stat.S_ISREG(source_info.st_mode):
|
||||
raise OSError("the dropped path is not a regular file")
|
||||
|
||||
# Resolve the descriptor we actually validated, not a pathname that
|
||||
# could have been swapped after open().
|
||||
real_source = os.path.realpath("/proc/self/fd/%d" % source_file.fileno())
|
||||
if is_inside(real_source, real_directory):
|
||||
return real_source
|
||||
|
||||
cleanup_stale_temps(real_directory)
|
||||
temp_fd, temp_path = tempfile.mkstemp(
|
||||
prefix=TEMP_PREFIX,
|
||||
suffix=TEMP_SUFFIX,
|
||||
dir=real_directory,
|
||||
)
|
||||
ACTIVE_TEMP = temp_path
|
||||
try:
|
||||
with os.fdopen(temp_fd, "wb") as temp_file:
|
||||
while True:
|
||||
lease.check()
|
||||
chunk = source_file.read(COPY_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
temp_file.write(chunk)
|
||||
temp_file.flush()
|
||||
os.fsync(temp_file.fileno())
|
||||
# Keep copies private even if the source was more permissive.
|
||||
os.chmod(temp_path, 0o600)
|
||||
return publish_unique(temp_path, source, real_directory, lease)
|
||||
finally:
|
||||
remove_active_temp()
|
||||
|
||||
|
||||
def main():
|
||||
arm_parent_death_signal()
|
||||
parser = argparse.ArgumentParser(description="Safely copy one DropWall image")
|
||||
parser.add_argument("--lease-pid", type=int, required=True)
|
||||
parser.add_argument("source")
|
||||
parser.add_argument("directory")
|
||||
args = parser.parse_args()
|
||||
|
||||
for signum in (signal.SIGINT, signal.SIGTERM):
|
||||
signal.signal(signum, terminate)
|
||||
|
||||
lease = None
|
||||
try:
|
||||
lease = WorkerLease(args.lease_pid)
|
||||
destination = copy_atomic(args.source, args.directory, lease)
|
||||
except Exception as error:
|
||||
print(str(error).replace("\n", " "), file=sys.stderr, flush=True)
|
||||
return 1
|
||||
finally:
|
||||
if lease is not None:
|
||||
lease.close()
|
||||
|
||||
print("COPIED\t%s" % encode_path(destination), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""DropWall helper: transparent layer-shell drop targets, one per monitor.
|
||||
|
||||
Owned by dropwall_supervisor.py, which keeps one worker attached to the single
|
||||
stream opened by the DropWall Noctalia service.
|
||||
|
||||
Protocol, one line per event on stdout:
|
||||
READY\t<n> n drop surfaces created
|
||||
ALIVE heartbeat for stream/orphan detection
|
||||
DROP\t<x>\t<y>\t<w>\t<h>\t<pid>\t<path> image dropped; path is percent-encoded
|
||||
INVALID\t<path> unsupported filename extension
|
||||
MISSING\t<path> dropped path is not a regular file
|
||||
ERR\t<message> non-fatal problem
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import fcntl
|
||||
import os
|
||||
import signal
|
||||
import stat
|
||||
import sys
|
||||
import threading
|
||||
import urllib.parse
|
||||
|
||||
# Python ignores SIGPIPE by default. Restoring the Unix behavior makes an
|
||||
# orphaned helper exit on its next heartbeat when Noctalia closes the stream.
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
||||
|
||||
try:
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("GtkLayerShell", "0.1")
|
||||
from gi.repository import Gdk, GLib, Gtk, GtkLayerShell # noqa: E402
|
||||
except (ImportError, ValueError) as error:
|
||||
message = str(error).replace("\n", " ")
|
||||
print("ERR\tGTK dependencies could not be loaded: %s" % message, flush=True)
|
||||
raise SystemExit(2)
|
||||
|
||||
CSS = b"""
|
||||
window { background-color: rgba(0, 0, 0, 0); }
|
||||
.dropzone {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
border: 3px dashed rgba(0, 0, 0, 0);
|
||||
border-radius: 18px;
|
||||
margin: 14px;
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
.dropzone.hover {
|
||||
background-color: rgba(128, 128, 128, 0.16);
|
||||
border-color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
"""
|
||||
|
||||
LAYERS = {
|
||||
"background": GtkLayerShell.Layer.BACKGROUND,
|
||||
"bottom": GtkLayerShell.Layer.BOTTOM,
|
||||
}
|
||||
|
||||
VALID_EXTENSIONS = {"jpg", "jpeg", "png", "webp", "bmp", "gif"}
|
||||
HEARTBEAT_SECONDS = 5
|
||||
EMIT_LOCK = threading.Lock()
|
||||
INSTANCE_LOCK = None
|
||||
|
||||
|
||||
def emit(line):
|
||||
with EMIT_LOCK:
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def arm_parent_death_signal():
|
||||
"""Ask Linux to terminate us if the process that spawned us disappears."""
|
||||
parent = os.getppid()
|
||||
try:
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
if libc.prctl(1, signal.SIGTERM, 0, 0, 0) != 0: # PR_SET_PDEATHSIG
|
||||
return
|
||||
# Close the small race where the parent dies immediately before prctl.
|
||||
if os.getppid() != parent:
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
except (AttributeError, OSError):
|
||||
# The heartbeat/SIGPIPE path remains the portable fallback.
|
||||
return
|
||||
|
||||
|
||||
def acquire_instance_lock():
|
||||
"""Keep at most one DropWall target alive in this user session."""
|
||||
global INSTANCE_LOCK
|
||||
|
||||
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
|
||||
if not runtime_dir:
|
||||
emit("ERR\tXDG_RUNTIME_DIR is not set")
|
||||
return False
|
||||
|
||||
lock_path = os.path.join(runtime_dir, "noctalia-dropwall.lock")
|
||||
flags = os.O_RDWR | os.O_CREAT
|
||||
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
|
||||
try:
|
||||
fd = os.open(lock_path, flags, 0o600)
|
||||
info = os.fstat(fd)
|
||||
if info.st_uid != os.getuid() or not stat.S_ISREG(info.st_mode):
|
||||
raise PermissionError("unsafe lock file")
|
||||
os.fchmod(fd, 0o600)
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
os.close(fd)
|
||||
emit("BUSY\tanother DropWall helper is already running")
|
||||
return False
|
||||
except OSError as error:
|
||||
if "fd" in locals():
|
||||
os.close(fd)
|
||||
emit("ERR\tcould not acquire the runtime lock: %s" % error)
|
||||
return False
|
||||
|
||||
INSTANCE_LOCK = os.fdopen(fd, "w", encoding="ascii")
|
||||
INSTANCE_LOCK.write("%d\n" % os.getpid())
|
||||
INSTANCE_LOCK.flush()
|
||||
return True
|
||||
|
||||
|
||||
def selection_to_path(data):
|
||||
"""Extract the first local file path from a drop's selection data."""
|
||||
uris = list(data.get_uris() or [])
|
||||
if not uris:
|
||||
text = data.get_text()
|
||||
if text:
|
||||
uris = [part for part in text.split("\n") if part.strip()]
|
||||
for uri in uris:
|
||||
uri = uri.strip()
|
||||
if uri.startswith("file://"):
|
||||
parsed = urllib.parse.urlsplit(uri)
|
||||
if parsed.netloc not in ("", "localhost"):
|
||||
continue
|
||||
path = os.fsdecode(urllib.parse.unquote_to_bytes(parsed.path))
|
||||
if uri.startswith("/"):
|
||||
path = uri
|
||||
elif not uri.startswith("file://"):
|
||||
continue
|
||||
if "\0" not in path and os.path.isabs(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def encode_path(path):
|
||||
"""Encode filesystem bytes as ASCII so filenames cannot forge events."""
|
||||
return urllib.parse.quote_from_bytes(os.fsencode(path), safe="")
|
||||
|
||||
|
||||
def process_drop(path, geometry):
|
||||
"""Validate a drop away from the GTK event loop, then report it."""
|
||||
encoded_path = encode_path(path)
|
||||
try:
|
||||
info = os.stat(path)
|
||||
except (OSError, ValueError):
|
||||
emit("MISSING\t%s" % encoded_path)
|
||||
return
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
emit("MISSING\t%s" % encoded_path)
|
||||
return
|
||||
|
||||
extension = os.path.splitext(path)[1].lower().lstrip(".")
|
||||
if extension not in VALID_EXTENSIONS:
|
||||
emit("INVALID\t%s" % encoded_path)
|
||||
return
|
||||
|
||||
encoded_path = encode_path(path)
|
||||
x, y, width, height = geometry
|
||||
emit("DROP\t%d\t%d\t%d\t%d\t%d\t%s" % (x, y, width, height, os.getpid(), encoded_path))
|
||||
|
||||
|
||||
class DropWindow(Gtk.Window):
|
||||
def __init__(self, monitor, layer):
|
||||
super().__init__(type=Gtk.WindowType.TOPLEVEL)
|
||||
self.monitor = monitor
|
||||
|
||||
visual = self.get_screen().get_rgba_visual()
|
||||
if visual is not None:
|
||||
self.set_visual(visual)
|
||||
|
||||
GtkLayerShell.init_for_window(self)
|
||||
GtkLayerShell.set_layer(self, layer)
|
||||
GtkLayerShell.set_monitor(self, monitor)
|
||||
GtkLayerShell.set_namespace(self, "dropwall")
|
||||
for edge in (
|
||||
GtkLayerShell.Edge.LEFT,
|
||||
GtkLayerShell.Edge.RIGHT,
|
||||
GtkLayerShell.Edge.TOP,
|
||||
GtkLayerShell.Edge.BOTTOM,
|
||||
):
|
||||
GtkLayerShell.set_anchor(self, edge, True)
|
||||
# Cover the full output, including space reserved by bars/docks.
|
||||
GtkLayerShell.set_exclusive_zone(self, -1)
|
||||
|
||||
self.zone = Gtk.Box()
|
||||
self.zone.get_style_context().add_class("dropzone")
|
||||
self.add(self.zone)
|
||||
|
||||
self.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY)
|
||||
self.drag_dest_add_uri_targets()
|
||||
self.drag_dest_add_text_targets()
|
||||
self.connect("drag-motion", self.on_drag_motion)
|
||||
self.connect("drag-leave", self.on_drag_leave)
|
||||
self.connect("drag-data-received", self.on_drag_data_received)
|
||||
|
||||
self.show_all()
|
||||
|
||||
def on_drag_motion(self, _widget, _context, _x, _y, _time):
|
||||
self.zone.get_style_context().add_class("hover")
|
||||
return False # let the default DestDefaults handler ack the drag
|
||||
|
||||
def on_drag_leave(self, _widget, _context, _time):
|
||||
self.zone.get_style_context().remove_class("hover")
|
||||
|
||||
def on_drag_data_received(self, _widget, _context, _x, _y, data, _info, _time):
|
||||
self.zone.get_style_context().remove_class("hover")
|
||||
path = selection_to_path(data)
|
||||
if not path:
|
||||
emit("ERR\tdrop carried no usable local file path")
|
||||
return
|
||||
geo = self.monitor.get_geometry()
|
||||
geometry = (geo.x, geo.y, geo.width, geo.height)
|
||||
threading.Thread(
|
||||
target=process_drop,
|
||||
args=(path, geometry),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self, layer):
|
||||
self.layer = layer
|
||||
self.windows = []
|
||||
self.rebuild_pending = False
|
||||
|
||||
provider = Gtk.CssProvider()
|
||||
provider.load_from_data(CSS)
|
||||
Gtk.StyleContext.add_provider_for_screen(
|
||||
Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||
)
|
||||
|
||||
display = Gdk.Display.get_default()
|
||||
display.connect("monitor-added", self.schedule_rebuild)
|
||||
display.connect("monitor-removed", self.schedule_rebuild)
|
||||
self.build_windows()
|
||||
GLib.timeout_add_seconds(HEARTBEAT_SECONDS, self.heartbeat)
|
||||
|
||||
def build_windows(self):
|
||||
for win in self.windows:
|
||||
win.destroy()
|
||||
self.windows = []
|
||||
display = Gdk.Display.get_default()
|
||||
for i in range(display.get_n_monitors()):
|
||||
monitor = display.get_monitor(i)
|
||||
if monitor is not None:
|
||||
self.windows.append(DropWindow(monitor, self.layer))
|
||||
emit("READY\t%d" % len(self.windows))
|
||||
|
||||
def heartbeat(self):
|
||||
emit("ALIVE")
|
||||
return True
|
||||
|
||||
def schedule_rebuild(self, *_args):
|
||||
# Debounce: hotplug fires added+removed bursts during mode changes.
|
||||
if self.rebuild_pending:
|
||||
return
|
||||
self.rebuild_pending = True
|
||||
|
||||
def do_rebuild():
|
||||
self.rebuild_pending = False
|
||||
self.build_windows()
|
||||
return False
|
||||
|
||||
GLib.timeout_add(500, do_rebuild)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="DropWall layer-shell drop helper")
|
||||
parser.add_argument("--layer", choices=sorted(LAYERS), default="background")
|
||||
args = parser.parse_args()
|
||||
|
||||
arm_parent_death_signal()
|
||||
if not acquire_instance_lock():
|
||||
return 3
|
||||
|
||||
try:
|
||||
if Gdk.Display.get_default() is None:
|
||||
emit("ERR\tcould not connect to the Wayland display")
|
||||
return 1
|
||||
if not GtkLayerShell.is_supported():
|
||||
emit("ERR\tlayer-shell is not supported by this compositor")
|
||||
return 1
|
||||
|
||||
App(LAYERS[args.layer])
|
||||
Gtk.main()
|
||||
return 0
|
||||
except Exception as error:
|
||||
emit("ERR\tGTK helper startup failed: %s" % str(error).replace("\n", " "))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Keep one DropWall GTK target attached to a single Noctalia stream."""
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
# Exit immediately when Noctalia closes runStream's stdout pipe.
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
||||
|
||||
|
||||
def arm_parent_death_signal():
|
||||
"""Ask Linux to terminate us if Noctalia disappears."""
|
||||
parent = os.getppid()
|
||||
try:
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
if libc.prctl(1, signal.SIGTERM, 0, 0, 0) != 0: # PR_SET_PDEATHSIG
|
||||
return
|
||||
if os.getppid() != parent:
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
except (AttributeError, OSError):
|
||||
return
|
||||
|
||||
|
||||
def emit(line):
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def worker_command(args):
|
||||
helper = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dropwall_helper.py")
|
||||
return [sys.executable, "-B", helper, "--layer", args.layer]
|
||||
|
||||
|
||||
def run_worker(command):
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
bufsize=1,
|
||||
)
|
||||
assert process.stdout is not None
|
||||
for line in process.stdout:
|
||||
emit(line.rstrip("\n"))
|
||||
return process.wait()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="DropWall helper supervisor")
|
||||
parser.add_argument("--layer", choices=("background", "bottom"), default="background")
|
||||
args = parser.parse_args()
|
||||
|
||||
arm_parent_death_signal()
|
||||
command = worker_command(args)
|
||||
lock_retry_seconds = 2
|
||||
|
||||
while True:
|
||||
try:
|
||||
exit_code = run_worker(command)
|
||||
except OSError as error:
|
||||
emit("ERR\tcould not start the GTK helper: %s" % str(error).replace("\n", " "))
|
||||
exit_code = 127
|
||||
|
||||
emit("RESTART\t%d" % exit_code)
|
||||
if exit_code == 3:
|
||||
# A collision normally means the previous runtime is still
|
||||
# shutting down. Back off if it is a genuinely persistent owner.
|
||||
time.sleep(lock_retry_seconds)
|
||||
lock_retry_seconds = min(lock_retry_seconds * 2, 60)
|
||||
else:
|
||||
lock_retry_seconds = 2
|
||||
time.sleep(30)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,61 @@
|
||||
# DropWall — drag & drop an image anywhere onto the desktop to set it as the
|
||||
# wallpaper.
|
||||
#
|
||||
# A headless [[service]] owns a Python supervisor and a GTK3 + gtk-layer-shell
|
||||
# worker that keeps one transparent layer-shell surface per monitor as a drop
|
||||
# target. When an image is dropped, the worker reports the file and monitor
|
||||
# geometry; the service resolves the output connector and applies it through
|
||||
# noctalia.setWallpaper(), just like the built-in wallpaper panel.
|
||||
#
|
||||
# External requirements: python3, python-gobject, gtk3, gtk-layer-shell.
|
||||
|
||||
id = "whyoolw/dropwall"
|
||||
name = "DropWall"
|
||||
version = "1.0.0"
|
||||
plugin_api = 3
|
||||
author = "whyoolw"
|
||||
license = "MIT"
|
||||
dependencies = ["python3", "python-gobject", "gtk3", "gtk-layer-shell"]
|
||||
tags = ["desktop", "wallpaper"]
|
||||
icon = "image"
|
||||
description = "Drag and drop an image onto the desktop to set it as wallpaper using Noctalia's wallpaper settings."
|
||||
|
||||
[[setting]]
|
||||
key = "per_monitor"
|
||||
type = "bool"
|
||||
label_key = "settings.per_monitor.label"
|
||||
description_key = "settings.per_monitor.description"
|
||||
default = false
|
||||
|
||||
[[setting]]
|
||||
key = "copy_to_wallpaper_dir"
|
||||
type = "bool"
|
||||
label_key = "settings.copy_to_wallpaper_dir.label"
|
||||
description_key = "settings.copy_to_wallpaper_dir.description"
|
||||
default = false
|
||||
|
||||
[[setting]]
|
||||
key = "notify_on_set"
|
||||
type = "bool"
|
||||
label_key = "settings.notify_on_set.label"
|
||||
description_key = "settings.notify_on_set.description"
|
||||
default = true
|
||||
|
||||
# Which layer-shell layer the invisible drop surface lives on. "background"
|
||||
# sits next to the wallpaper itself and is the least intrusive; switch to
|
||||
# "bottom" if drops don't register on your compositor because the wallpaper
|
||||
# surface swallows them. Changing it restarts the service automatically.
|
||||
[[setting]]
|
||||
key = "layer"
|
||||
type = "select"
|
||||
label_key = "settings.layer.label"
|
||||
description_key = "settings.layer.description"
|
||||
default = "background"
|
||||
options = [
|
||||
{ value = "background", label_key = "settings.layer.options.background" },
|
||||
{ value = "bottom", label_key = "settings.layer.options.bottom" },
|
||||
]
|
||||
|
||||
[[service]]
|
||||
id = "service"
|
||||
entry = "service.luau"
|
||||
@@ -0,0 +1,282 @@
|
||||
--!nonstrict
|
||||
-- DropWall service: owns one long-lived helper supervisor stream and applies
|
||||
-- dropped images as wallpapers.
|
||||
--
|
||||
-- The supervisor owns a GTK3 + gtk-layer-shell worker with one transparent
|
||||
-- surface per monitor. Paths are percent-encoded on its stdout protocol, then
|
||||
-- decoded here before going through noctalia.setWallpaper().
|
||||
|
||||
-- Same whitelist as the host wallpaper scanner (wallpaper.cpp).
|
||||
local VALID_EXT = { jpg = true, jpeg = true, png = true, webp = true, bmp = true, gif = true }
|
||||
local HELPER_ERROR_COOLDOWN = 300
|
||||
local helperErrorNotified = false
|
||||
local lastHelperErrorAt = 0
|
||||
local busyLogged = false
|
||||
local busyCount = 0
|
||||
local copyInProgress = false
|
||||
local pendingCopy = nil
|
||||
local helperStreamStarted = false
|
||||
|
||||
local function cfg(key)
|
||||
return noctalia.getConfig(key)
|
||||
end
|
||||
|
||||
local function basename(path)
|
||||
return path:match("([^/]+)$") or path
|
||||
end
|
||||
|
||||
local function shellQuote(s)
|
||||
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function now()
|
||||
return tonumber(noctalia.formatTime("%s")) or 0
|
||||
end
|
||||
|
||||
local function reportHelperError(message)
|
||||
noctalia.log("dropwall helper: " .. message)
|
||||
local stamp = now()
|
||||
if not helperErrorNotified or (stamp > 0 and stamp - lastHelperErrorAt >= HELPER_ERROR_COOLDOWN) then
|
||||
helperErrorNotified = true
|
||||
lastHelperErrorAt = stamp
|
||||
noctalia.notifyError(noctalia.tr("notify.helper_error_title"), message)
|
||||
end
|
||||
end
|
||||
|
||||
local function decodePath(value)
|
||||
if type(value) ~= "string" or value == "" then
|
||||
return nil
|
||||
end
|
||||
local decoded = noctalia.string.urlDecode(value)
|
||||
if type(decoded) ~= "string" or decoded == "" or decoded:find("%z") then
|
||||
return nil
|
||||
end
|
||||
return decoded
|
||||
end
|
||||
|
||||
-- Resolve the connector of the monitor the image was dropped on by matching
|
||||
-- the helper-reported logical geometry against the host's output list.
|
||||
local function findConnector(x, y, w, h)
|
||||
local outputs = noctalia.outputs()
|
||||
local exact = nil
|
||||
local exactCount = 0
|
||||
for i = 1, #outputs do
|
||||
local o = outputs[i]
|
||||
if o.x == x and o.y == y and o.width == w and o.height == h then
|
||||
exact = o.name
|
||||
exactCount = exactCount + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Mirrored outputs may share identical geometry. Refuse an ambiguous match
|
||||
-- instead of choosing one arbitrarily.
|
||||
if exactCount == 1 then
|
||||
return exact
|
||||
elseif exactCount > 1 then
|
||||
return nil
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
local function applyWallpaper(path, connector)
|
||||
if cfg("per_monitor") then
|
||||
if not connector then
|
||||
noctalia.notifyError(noctalia.tr("notify.output_error_title"), basename(path))
|
||||
return
|
||||
end
|
||||
noctalia.setWallpaper(connector, path)
|
||||
else
|
||||
-- The host applies its normal all-output wallpaper behavior.
|
||||
noctalia.setWallpaper(path)
|
||||
end
|
||||
if cfg("notify_on_set") then
|
||||
noctalia.notify(noctalia.tr("notify.set_title"), basename(path))
|
||||
end
|
||||
end
|
||||
|
||||
local function applyOriginalAfterCopyError(path, connector, message)
|
||||
noctalia.log("dropwall copy: " .. message)
|
||||
noctalia.notifyError(noctalia.tr("notify.copy_dir_title"), noctalia.tr("notify.copy_failed"))
|
||||
applyWallpaper(path, connector)
|
||||
end
|
||||
|
||||
local function copyAndApply(path, connector, leasePid)
|
||||
if copyInProgress then
|
||||
-- Bound copy concurrency to one process and retain only the latest queued
|
||||
-- drop. It will be copied as soon as the active one finishes.
|
||||
pendingCopy = { path = path, connector = connector, leasePid = leasePid }
|
||||
noctalia.log("dropwall copy: queued latest drop while a copy is active")
|
||||
return
|
||||
end
|
||||
|
||||
-- This is theme-mode dependent, so resolve it for every drop rather than
|
||||
-- pinning the directory when the service starts.
|
||||
local wallpaperDir = noctalia.wallpaperDirectory()
|
||||
if type(wallpaperDir) ~= "string" or wallpaperDir == "" then
|
||||
noctalia.notifyError(noctalia.tr("notify.copy_dir_title"), noctalia.tr("notify.copy_dir_missing"))
|
||||
applyWallpaper(path, connector)
|
||||
return
|
||||
end
|
||||
|
||||
local pluginDir = noctalia.pluginDir()
|
||||
if type(pluginDir) ~= "string" or pluginDir == "" then
|
||||
applyOriginalAfterCopyError(path, connector, "plugin directory is unavailable")
|
||||
return
|
||||
end
|
||||
local copier = pluginDir .. "/dropwall_copy.py"
|
||||
if not noctalia.fileExists(copier) then
|
||||
applyOriginalAfterCopyError(path, connector, "copy helper is missing")
|
||||
return
|
||||
end
|
||||
|
||||
local cmd = "exec python3 -B " .. shellQuote(copier)
|
||||
.. " --lease-pid " .. shellQuote(leasePid)
|
||||
.. " " .. shellQuote(path) .. " " .. shellQuote(wallpaperDir)
|
||||
copyInProgress = true
|
||||
local accepted = noctalia.runAsync(cmd, function(result)
|
||||
copyInProgress = false
|
||||
if type(result) ~= "table" or result.exitCode ~= 0 or result.timedOut or result.stdoutTruncated then
|
||||
local detail = type(result) == "table" and noctalia.string.trim(result.stderr or "") or "no result"
|
||||
applyOriginalAfterCopyError(path, connector, detail ~= "" and detail or "copy process failed")
|
||||
else
|
||||
local output = noctalia.string.trim(result.stdout or "")
|
||||
local encoded = output:match("^COPIED\t([^\t\r\n]+)$")
|
||||
local copied = decodePath(encoded)
|
||||
local copiedInfo = copied and noctalia.fileInfo(copied) or nil
|
||||
if not copied or type(copiedInfo) ~= "table" or copiedInfo.isDir then
|
||||
applyOriginalAfterCopyError(path, connector, "copy helper returned an invalid destination")
|
||||
else
|
||||
applyWallpaper(copied, connector)
|
||||
end
|
||||
end
|
||||
|
||||
local pending = pendingCopy
|
||||
pendingCopy = nil
|
||||
if pending then
|
||||
copyAndApply(pending.path, pending.connector, pending.leasePid)
|
||||
end
|
||||
end, 60000)
|
||||
|
||||
if not accepted then
|
||||
copyInProgress = false
|
||||
applyOriginalAfterCopyError(path, connector, "Noctalia rejected the copy process")
|
||||
end
|
||||
end
|
||||
|
||||
local function handleDrop(path, connector, leasePid)
|
||||
local ext = path:match("%.(%w+)$")
|
||||
if not ext or not VALID_EXT[ext:lower()] then
|
||||
noctalia.notifyError(noctalia.tr("notify.invalid_title"), basename(path))
|
||||
return
|
||||
end
|
||||
if not noctalia.fileExists(path) then
|
||||
noctalia.notifyError(noctalia.tr("notify.missing_title"), path)
|
||||
return
|
||||
end
|
||||
local info = noctalia.fileInfo(path)
|
||||
if type(info) ~= "table" or info.isDir then
|
||||
noctalia.notifyError(noctalia.tr("notify.missing_title"), path)
|
||||
return
|
||||
end
|
||||
|
||||
if cfg("per_monitor") and not connector then
|
||||
noctalia.notifyError(noctalia.tr("notify.output_error_title"), basename(path))
|
||||
return
|
||||
end
|
||||
|
||||
if cfg("copy_to_wallpaper_dir") then
|
||||
copyAndApply(path, connector, leasePid)
|
||||
else
|
||||
applyWallpaper(path, connector)
|
||||
end
|
||||
end
|
||||
|
||||
local function onHelperLine(line)
|
||||
if line:sub(1, 5) == "DROP\t" then
|
||||
local x, y, w, h, pid, encoded = line:match("^DROP\t(-?%d+)\t(-?%d+)\t(%d+)\t(%d+)\t(%d+)\t([^\t]+)$")
|
||||
local path = decodePath(encoded)
|
||||
local leasePid = tonumber(pid)
|
||||
if path and leasePid and leasePid > 1 then
|
||||
handleDrop(path, findConnector(tonumber(x), tonumber(y), tonumber(w), tonumber(h)), leasePid)
|
||||
else
|
||||
noctalia.log("dropwall: malformed DROP line: " .. line)
|
||||
end
|
||||
elseif line:sub(1, 8) == "INVALID\t" then
|
||||
local path = decodePath(line:sub(9))
|
||||
noctalia.notifyError(noctalia.tr("notify.invalid_title"), path and basename(path) or "")
|
||||
elseif line:sub(1, 8) == "MISSING\t" then
|
||||
local path = decodePath(line:sub(9))
|
||||
noctalia.notifyError(noctalia.tr("notify.missing_title"), path or "")
|
||||
elseif line:sub(1, 4) == "ERR\t" then
|
||||
reportHelperError(line:sub(5))
|
||||
elseif line:sub(1, 5) == "BUSY\t" then
|
||||
busyCount = busyCount + 1
|
||||
if not busyLogged then
|
||||
noctalia.log("dropwall helper: " .. line:sub(6))
|
||||
busyLogged = true
|
||||
end
|
||||
if busyCount == 3 then
|
||||
reportHelperError(noctalia.tr("notify.helper_busy"))
|
||||
end
|
||||
elseif line:sub(1, 6) == "READY\t" then
|
||||
local count = tonumber(line:sub(7)) or 0
|
||||
if count > 0 then
|
||||
helperErrorNotified = false
|
||||
busyLogged = false
|
||||
busyCount = 0
|
||||
else
|
||||
reportHelperError(noctalia.tr("notify.no_outputs"))
|
||||
end
|
||||
elseif line:sub(1, 8) == "RESTART\t" then
|
||||
local exitCode = line:sub(9)
|
||||
if exitCode ~= "3" then
|
||||
noctalia.log("dropwall: helper exited (code " .. exitCode .. "); supervisor will restart it")
|
||||
end
|
||||
elseif line ~= "ALIVE" and line ~= "" then
|
||||
noctalia.log("dropwall helper output: " .. line)
|
||||
end
|
||||
end
|
||||
|
||||
local function startHelper()
|
||||
if not noctalia.commandExists("python3") then
|
||||
reportHelperError(noctalia.tr("notify.python_missing"))
|
||||
return
|
||||
end
|
||||
|
||||
local layer = tostring(cfg("layer") or "background")
|
||||
if layer ~= "background" and layer ~= "bottom" then
|
||||
layer = "background"
|
||||
end
|
||||
|
||||
local pluginDir = noctalia.pluginDir()
|
||||
if type(pluginDir) ~= "string" or pluginDir == "" then
|
||||
reportHelperError(noctalia.tr("notify.plugin_dir_missing"))
|
||||
return
|
||||
end
|
||||
|
||||
local script = pluginDir .. "/dropwall_supervisor.py"
|
||||
if not noctalia.fileExists(script) then
|
||||
reportHelperError(noctalia.tr("notify.helper_file_missing"))
|
||||
return
|
||||
end
|
||||
local cmd = "exec python3 -B " .. shellQuote(script) .. " --layer " .. shellQuote(layer)
|
||||
cmd = cmd .. " 2>&1"
|
||||
if noctalia.runStream(cmd, onHelperLine) then
|
||||
helperStreamStarted = true
|
||||
else
|
||||
reportHelperError(noctalia.tr("notify.helper_start_failed"))
|
||||
end
|
||||
end
|
||||
|
||||
-- Boot.
|
||||
startHelper()
|
||||
|
||||
-- If python3 was missing or the initial stream launch was rejected, retry
|
||||
-- without ever opening more than the one long-lived supervisor stream.
|
||||
function update()
|
||||
noctalia.setUpdateInterval(60000)
|
||||
if not helperStreamStarted then
|
||||
startHelper()
|
||||
end
|
||||
end
|
||||
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"notify": {
|
||||
"copy_dir_missing": "No wallpaper directory is configured; the original file will be used.",
|
||||
"copy_dir_title": "Wallpaper was not copied",
|
||||
"copy_failed": "The safe copy failed; the original file will be used.",
|
||||
"helper_busy": "Another DropWall helper is still running. The plugin will keep retrying.",
|
||||
"helper_error_title": "DropWall helper error",
|
||||
"helper_file_missing": "The DropWall supervisor file is missing.",
|
||||
"helper_start_failed": "Noctalia could not open the helper stream.",
|
||||
"invalid_title": "Not an image (jpg, png, webp, bmp, gif)",
|
||||
"missing_title": "Dropped file not found",
|
||||
"no_outputs": "The helper could not create a drop target for any output.",
|
||||
"output_error_title": "Could not identify the target monitor",
|
||||
"plugin_dir_missing": "Noctalia did not provide the plugin directory.",
|
||||
"python_missing": "python3 is not available on PATH.",
|
||||
"set_title": "Wallpaper set"
|
||||
},
|
||||
"settings": {
|
||||
"copy_to_wallpaper_dir": {
|
||||
"description": "Copy the dropped image into the system wallpaper directory before applying. Existing files are never replaced; a numeric suffix is added when needed.",
|
||||
"label": "Copy into wallpaper directory"
|
||||
},
|
||||
"layer": {
|
||||
"description": "Layer-shell layer for the invisible drop surface. \"Background\" is the least intrusive; use \"Bottom\" if drops don't register.",
|
||||
"label": "Drop surface layer",
|
||||
"options": {
|
||||
"background": "Background",
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"notify_on_set": {
|
||||
"description": "Show a notification when a dropped image is applied.",
|
||||
"label": "Notify on set"
|
||||
},
|
||||
"per_monitor": {
|
||||
"description": "Set the wallpaper only on the monitor the image was dropped on. When off, apply it to every output.",
|
||||
"label": "Per-monitor drop"
|
||||
}
|
||||
},
|
||||
"title": "DropWall"
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
# File Search
|
||||
|
||||
A [noctalia](https://github.com/noctalia-dev/noctalia) v5 bar plugin: fuzzy
|
||||
search files and folders as you type, with [fzf](https://github.com/junegunn/fzf)
|
||||
as the matching subsystem. Click the bar glyph to open a search panel; picking
|
||||
a result opens it with the system MIME association (`xdg-open`) — directories
|
||||
open in your file manager.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `nightwatch75/file-search` |
|
||||
| Entries | Bar widget: `file-search`; panel: `panel`; launcher provider: `launcher` |
|
||||
| Launcher Prefix | `/fs` |
|
||||
|
||||
## Usage
|
||||
|
||||
Add the `file-search` widget from Noctalia's widget picker and click it to
|
||||
open the search panel. You can also open the panel directly or bind it in
|
||||
your compositor:
|
||||
|
||||
```sh
|
||||
noctalia msg panel-toggle nightwatch75/file-search:panel
|
||||
```
|
||||
|
||||
| Action | Effect |
|
||||
|--------------|-------------------------------------------------|
|
||||
| Left click | Open/close the search panel |
|
||||
| Right click | Open the search folder in the file manager |
|
||||
| Middle click | Copy the search folder path to the clipboard |
|
||||
|
||||
In the panel:
|
||||
|
||||
| Key | Action |
|
||||
|---------|-------------------------------------|
|
||||
| `Enter` | Open the top match |
|
||||
| `Esc` | Close the panel (noctalia default) |
|
||||
|
||||
In the noctalia launcher (keyboard-first flow, native navigation):
|
||||
|
||||
| Key | Action |
|
||||
|-------------|-------------------------------------------|
|
||||
| `/fs <text>`| Fuzzy search files and folders |
|
||||
| `↑` / `↓` | Move through the results |
|
||||
| `Enter` | Open the selected result (MIME/xdg-open) |
|
||||
|
||||
With an empty `/fs` query the list also offers *Rebuild search index*; the
|
||||
index is shared with the panel and built on demand when missing.
|
||||
|
||||
## Features
|
||||
|
||||
- Live results while you type: the search folder is walked once with `find`
|
||||
into a cache, then every keystroke is fuzzy-matched through
|
||||
`fzf --filter`, so typing stays responsive even on large trees
|
||||
- Configurable bar glyph, search folder (defaults to `~`), excluded folder
|
||||
names (`.git, node_modules, .cache, .venv` by default, matched anywhere in
|
||||
the tree), hidden entries on/off, max results
|
||||
- `Enter` opens the top match; every result row opens on click via the
|
||||
system MIME association — files in their default app, folders in the file
|
||||
manager
|
||||
- Launcher provider for a keyboard-first flow: type `/fs <text>` in the
|
||||
noctalia launcher and navigate the results with the native arrow keys +
|
||||
`Enter` (plugin panels cannot receive arrow keys in the current Luau API,
|
||||
so the launcher is the keyboard way to browse results)
|
||||
- Folder results are marked with a trailing `/` and a folder glyph
|
||||
- Index rebuilds automatically when the relevant settings change, and on
|
||||
demand via the panel's refresh button (external file changes are picked up
|
||||
on rebuild)
|
||||
- Panel placement (attached/floating), position and open-near-click are the
|
||||
standard per-panel settings noctalia exposes in Settings → Plugins
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `search_folder` | `folder` | *(empty)* | Root folder the search indexes. Empty = your home folder. |
|
||||
| `exclude_dirs` | `string` | `.git, node_modules, .cache, .venv` | Folder names skipped while indexing, separated by `,` or `;`, matched anywhere in the tree. |
|
||||
| `show_hidden` | `bool` | `false` | Index files and folders whose name starts with a dot. |
|
||||
| `max_results` | `int` | `50` | How many matches the panel lists at most (10–200). |
|
||||
| `glyph` (widget) | `glyph` | `search` | Icon shown on the bar. |
|
||||
|
||||
## Requirements
|
||||
|
||||
- noctalia ≥ 5.0.0
|
||||
- [`fzf`](https://github.com/junegunn/fzf) — the fuzzy matcher
|
||||
- `find` (GNU findutils) — walks the search folder into the index
|
||||
- `xdg-open` (xdg-utils) — opens results with the MIME association
|
||||
- `mktemp`, `mv`, `wc`, `head`, `rm` — GNU coreutils, standard on any Linux
|
||||
desktop
|
||||
|
||||
## Install
|
||||
|
||||
Install **File Search** from Noctalia's plugin store (*Settings → Plugins*),
|
||||
then add the widget to a bar from *Settings → Bar*. Plugin options live in
|
||||
*Settings → Plugins*.
|
||||
|
||||
For local development, add your working copy as a path source instead
|
||||
(`.luau` edits hot-reload):
|
||||
|
||||
```sh
|
||||
noctalia msg plugins source add dev path /path/to/plugins
|
||||
noctalia msg plugins enable nightwatch75/file-search
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The index lives in the plugin's private data directory
|
||||
(`noctalia.pluginDataDir()`, by default
|
||||
`~/.local/state/noctalia/plugins/data/nightwatch75/file-search/` — honors
|
||||
`NOCTALIA_STATE_HOME`/`XDG_STATE_HOME`): `index.list` is a plain list of
|
||||
paths relative to the search folder, and `index.meta` records which folder
|
||||
and exclusions built it, so both the panel and the launcher rebuild
|
||||
automatically after a settings change.
|
||||
- Both files are written to `mktemp`-created private files and renamed into
|
||||
place, so a rebuild never writes through a symlink planted at the cache
|
||||
path.
|
||||
- Names containing a newline are excluded from the index (they would break
|
||||
the one-record-per-line format), and every record is validated against
|
||||
the search root before being opened.
|
||||
- Excluded entries match by folder/file *name* (`find -name`), not by path;
|
||||
entries containing `/` are skipped and logged.
|
||||
- With hidden entries off, anything starting with a dot is pruned — both
|
||||
hidden folders (not descended into) and hidden files.
|
||||
- Unreadable subtrees are silently skipped (permission errors don't fail the
|
||||
index).
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
@@ -0,0 +1,70 @@
|
||||
--!nonstrict
|
||||
-- file-search — bar widget that toggles the fuzzy search panel.
|
||||
--
|
||||
-- The panel (panel.luau) publishes its open state on the shared
|
||||
-- "file_search_open" state key; the glyph lights up while it is open.
|
||||
--
|
||||
-- Click mapping:
|
||||
-- Left click — open/close the search panel
|
||||
-- Right click — open the search folder in the file manager
|
||||
-- Middle click — copy the search folder path to the clipboard
|
||||
|
||||
local PANEL_ID = "nightwatch75/file-search:panel"
|
||||
|
||||
local open = false
|
||||
|
||||
local function shellQuote(value)
|
||||
return "'" .. value:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function searchRoot()
|
||||
local dir = noctalia.getConfig("search_folder")
|
||||
if dir == nil or dir == "" then
|
||||
dir = noctalia.getenv("HOME") or "/tmp"
|
||||
else
|
||||
dir = noctalia.expandPath(dir)
|
||||
end
|
||||
dir = dir:gsub("/+$", "")
|
||||
if dir == "" then
|
||||
dir = "/"
|
||||
end
|
||||
return dir
|
||||
end
|
||||
|
||||
local function render()
|
||||
barWidget.setGlyph(noctalia.getConfig("glyph"))
|
||||
local root = searchRoot()
|
||||
if open then
|
||||
barWidget.setGlyphColor("primary")
|
||||
barWidget.setTooltip(noctalia.tr("tooltip_open", { path = root }))
|
||||
else
|
||||
barWidget.setGlyphColor("on_surface")
|
||||
barWidget.setTooltip(noctalia.tr("tooltip_closed", { path = root }))
|
||||
end
|
||||
end
|
||||
|
||||
noctalia.state.watch("file_search_open", function(value)
|
||||
open = value == true
|
||||
render()
|
||||
end)
|
||||
|
||||
-- Periodic re-render keeps the glyph and tooltip in sync with settings changes.
|
||||
function update()
|
||||
render()
|
||||
end
|
||||
|
||||
function onClick()
|
||||
noctalia.togglePanel(PANEL_ID)
|
||||
end
|
||||
|
||||
function onRightClick()
|
||||
noctalia.runAsync("xdg-open " .. shellQuote(searchRoot()) .. " >/dev/null 2>&1")
|
||||
end
|
||||
|
||||
function onMiddleClick()
|
||||
noctalia.copyToClipboard(searchRoot(), "text/plain")
|
||||
noctalia.notify(noctalia.tr("title"), noctalia.tr("copied_path"))
|
||||
end
|
||||
|
||||
noctalia.setUpdateInterval(1000)
|
||||
render()
|
||||
@@ -0,0 +1,286 @@
|
||||
--!nonstrict
|
||||
-- file-search — launcher provider: the same fuzzy search with the native
|
||||
-- keyboard flow of the noctalia launcher (type, arrows, Enter).
|
||||
--
|
||||
-- `/fs <text>` fuzzy-matches over the index file the panel builds; when the
|
||||
-- index is missing it is built here on demand. Activating a result opens it
|
||||
-- with the system MIME association (xdg-open) — directories open in the
|
||||
-- file manager. Late async results map back through the query echo of
|
||||
-- launcher.setResults, so fzf can answer out of band.
|
||||
|
||||
local MAX_RESULTS = 9
|
||||
|
||||
local searching = false
|
||||
local indexing = false
|
||||
local pendingQuery = nil -- latest query typed while a search/index ran
|
||||
|
||||
local runQuery
|
||||
|
||||
local function tr(key, args)
|
||||
return noctalia.tr(key, args)
|
||||
end
|
||||
|
||||
local function trim(value)
|
||||
return (value:gsub("^%s+", ""):gsub("%s+$", ""))
|
||||
end
|
||||
|
||||
local function shellQuote(value)
|
||||
return "'" .. value:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- Private per-plugin storage (XDG state); the host creates the directory on
|
||||
-- every call. nil (with a log line) when no state directory resolves.
|
||||
local function dataDir()
|
||||
local dir, err = noctalia.pluginDataDir()
|
||||
if dir == nil then
|
||||
noctalia.log("file-search: pluginDataDir failed: " .. tostring(err))
|
||||
return nil
|
||||
end
|
||||
return dir
|
||||
end
|
||||
|
||||
-- Shell header shared by every cache command. The .meta sidecar holds the
|
||||
-- settings fingerprint the cache was built with, so the panel and the
|
||||
-- launcher can both tell a stale index apart.
|
||||
local function cacheSh(dir)
|
||||
return "CACHE_DIR=" .. shellQuote(dir)
|
||||
.. '\nCACHE="$CACHE_DIR/index.list"\nMETA="$CACHE_DIR/index.meta"'
|
||||
end
|
||||
|
||||
local function searchRoot()
|
||||
local dir = noctalia.getConfig("search_folder")
|
||||
if dir == nil or dir == "" then
|
||||
dir = noctalia.getenv("HOME") or "/tmp"
|
||||
else
|
||||
dir = noctalia.expandPath(dir)
|
||||
end
|
||||
dir = dir:gsub("/+$", "")
|
||||
if dir == "" then
|
||||
dir = "/"
|
||||
end
|
||||
return dir
|
||||
end
|
||||
|
||||
|
||||
-- Same exclusion set as the panel: names from the setting plus hidden
|
||||
-- entries unless enabled.
|
||||
local function excludeNames()
|
||||
local raw = noctalia.getConfig("exclude_dirs")
|
||||
if type(raw) ~= "string" then
|
||||
raw = ""
|
||||
end
|
||||
local names = {}
|
||||
for entry in raw:gmatch("[^,;]+") do
|
||||
local name = trim(entry)
|
||||
if name ~= "" and not name:find("/") then
|
||||
table.insert(names, name)
|
||||
end
|
||||
end
|
||||
if noctalia.getConfig("show_hidden") ~= true then
|
||||
table.insert(names, ".*")
|
||||
end
|
||||
return names
|
||||
end
|
||||
|
||||
-- Must build the same string as the panel's indexKey(): the fingerprint in
|
||||
-- the .meta sidecar is how the two entries recognize each other's index.
|
||||
local function indexKey()
|
||||
return searchRoot() .. "\n" .. table.concat(excludeNames(), "\n")
|
||||
end
|
||||
|
||||
-- The cache on disk is current when its fingerprint matches the settings,
|
||||
-- whether the panel or the launcher built it.
|
||||
local function cacheFresh(dir)
|
||||
return noctalia.readFile(dir .. "/index.meta") == indexKey()
|
||||
end
|
||||
|
||||
-- One cache record, about to be joined to the search root. The cache is a
|
||||
-- plain user-editable file, so records are untrusted: reject anything that
|
||||
-- could resolve outside the root.
|
||||
local function safeRel(rel)
|
||||
if rel == "" or rel:sub(1, 1) == "/" or rel:find("\n", 1, true) then
|
||||
return false
|
||||
end
|
||||
for part in rel:gmatch("[^/]+") do
|
||||
if part == ".." then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function noopRow(titleKey)
|
||||
return { id = "noop", title = tr(titleKey), glyph = "info-circle" }
|
||||
end
|
||||
|
||||
local function buildIndex(query)
|
||||
if indexing then
|
||||
pendingQuery = query
|
||||
return
|
||||
end
|
||||
local dir = dataDir()
|
||||
if dir == nil then
|
||||
launcher.setResults(query, { noopRow("err_index") })
|
||||
return
|
||||
end
|
||||
indexing = true
|
||||
pendingQuery = query
|
||||
|
||||
local key = indexKey()
|
||||
-- Names containing a newline would each forge extra one-per-fragment
|
||||
-- records (a crafted name can smuggle '..' lines into the index), so
|
||||
-- they are pruned unconditionally, before the user's exclusions.
|
||||
local names = { "-name " .. shellQuote("*\n*") }
|
||||
for _, name in ipairs(excludeNames()) do
|
||||
table.insert(names, "-name " .. shellQuote(name))
|
||||
end
|
||||
local prune = "\\( " .. table.concat(names, " -o ") .. " \\) -prune -o "
|
||||
-- find's own exit status is ignored, so permission errors inside the
|
||||
-- tree don't fail the build. Cache and fingerprint are written to
|
||||
-- mktemp-created private files and renamed into place: rename replaces
|
||||
-- a planted symlink at the destination instead of following it. The
|
||||
-- host guarantees $CACHE_DIR exists (created by pluginDataDir above).
|
||||
local cmd = cacheSh(dir)
|
||||
.. '\nTMP=$(mktemp "$CACHE_DIR/index.list.XXXXXX") || exit 1\n'
|
||||
.. "find " .. shellQuote(searchRoot()) .. " -mindepth 1 " .. prune
|
||||
.. "-type d -printf '%P/\\n' -o -printf '%P\\n' > \"$TMP\" 2>/dev/null\n"
|
||||
.. 'mv -f "$TMP" "$CACHE" || exit 1\n'
|
||||
.. 'TMPM=$(mktemp "$CACHE_DIR/index.meta.XXXXXX") || exit 1\n'
|
||||
.. "printf '%s' " .. shellQuote(key) .. ' > "$TMPM"\n'
|
||||
.. 'mv -f "$TMPM" "$META" || exit 1'
|
||||
|
||||
launcher.setResults(query, { noopRow("launcher.indexing") })
|
||||
local ok = noctalia.runAsync(cmd, function(result)
|
||||
indexing = false
|
||||
local queued = pendingQuery
|
||||
pendingQuery = nil
|
||||
if result.exitCode == 0 and not result.timedOut then
|
||||
if indexKey() ~= key then
|
||||
-- Settings changed while the walk was running: the cache
|
||||
-- holds paths relative to the old root, and activation
|
||||
-- would join them to the new one. Rebuild instead of
|
||||
-- searching stale records (mirrors the panel).
|
||||
buildIndex(queued or query)
|
||||
else
|
||||
runQuery(queued or query)
|
||||
end
|
||||
else
|
||||
launcher.setResults(queued or query, { noopRow("err_index") })
|
||||
end
|
||||
end, 180000)
|
||||
if not ok then
|
||||
indexing = false
|
||||
launcher.setResults(query, { noopRow("err_spawn") })
|
||||
end
|
||||
end
|
||||
|
||||
runQuery = function(query)
|
||||
if indexing then
|
||||
pendingQuery = query
|
||||
return
|
||||
end
|
||||
if searching then
|
||||
pendingQuery = query
|
||||
return
|
||||
end
|
||||
local dir = dataDir()
|
||||
if dir == nil then
|
||||
launcher.setResults(query, { noopRow("err_index") })
|
||||
return
|
||||
end
|
||||
searching = true
|
||||
local text = trim(query)
|
||||
local cmd
|
||||
if text == "" then
|
||||
cmd = cacheSh(dir) .. '\nhead -n ' .. MAX_RESULTS .. ' "$CACHE" 2>/dev/null'
|
||||
else
|
||||
cmd = cacheSh(dir) .. "\nfzf --filter=" .. shellQuote(text)
|
||||
.. ' < "$CACHE" 2>/dev/null | head -n ' .. MAX_RESULTS
|
||||
end
|
||||
local ok = noctalia.runAsync(cmd, function(result)
|
||||
searching = false
|
||||
local rows = {}
|
||||
if not result.timedOut then
|
||||
for line in (result.stdout or ""):gmatch("[^\n]+") do
|
||||
local isDir = line:sub(-1) == "/"
|
||||
local rel = line:gsub("/+$", "")
|
||||
table.insert(rows, {
|
||||
id = "open:" .. line,
|
||||
title = rel:match("[^/]+$") or rel,
|
||||
subtitle = line,
|
||||
glyph = isDir and "folder" or "file",
|
||||
})
|
||||
end
|
||||
end
|
||||
if #rows == 0 and text ~= "" then
|
||||
table.insert(rows, noopRow("launcher.no_results"))
|
||||
end
|
||||
if text == "" then
|
||||
table.insert(rows, {
|
||||
id = "reindex",
|
||||
title = tr("launcher.reindex_title"),
|
||||
subtitle = searchRoot(),
|
||||
glyph = "refresh",
|
||||
})
|
||||
end
|
||||
launcher.setResults(query, rows)
|
||||
if pendingQuery ~= nil and pendingQuery ~= query then
|
||||
local nextQuery = pendingQuery
|
||||
pendingQuery = nil
|
||||
runQuery(nextQuery)
|
||||
end
|
||||
end, 15000)
|
||||
if not ok then
|
||||
searching = false
|
||||
end
|
||||
end
|
||||
|
||||
function onQuery(query)
|
||||
if not noctalia.commandExists("fzf") then
|
||||
launcher.setResults(query, { noopRow("err_no_fzf") })
|
||||
return
|
||||
end
|
||||
local dir = dataDir()
|
||||
if dir == nil then
|
||||
launcher.setResults(query, { noopRow("err_index") })
|
||||
return
|
||||
end
|
||||
-- A missing or stale index (no meta, or built with a different root or
|
||||
-- exclusion set) is rebuilt before searching: stale relative paths
|
||||
-- joined to a new root could resolve to unrelated files.
|
||||
if not cacheFresh(dir) then
|
||||
buildIndex(query)
|
||||
return
|
||||
end
|
||||
runQuery(query)
|
||||
end
|
||||
|
||||
function onActivate(id)
|
||||
if id == "reindex" then
|
||||
local dir = dataDir()
|
||||
if dir == nil then
|
||||
return
|
||||
end
|
||||
-- Drop cache and fingerprint so the next keystroke rebuilds against
|
||||
-- fresh disk state (a surviving .meta would read as a fresh index).
|
||||
noctalia.runAsync(cacheSh(dir) .. '\nrm -f "$CACHE" "$META"', function(_result)
|
||||
noctalia.notify(tr("title"), tr("launcher.reindex_done"))
|
||||
end)
|
||||
return
|
||||
end
|
||||
local rel = id:match("^open:(.+)$")
|
||||
if rel == nil then
|
||||
return
|
||||
end
|
||||
if not safeRel(rel) then
|
||||
noctalia.log("file-search: refusing unsafe index record: " .. rel)
|
||||
noctalia.notify(tr("title"), tr("err_bad_record"))
|
||||
return
|
||||
end
|
||||
local path = searchRoot()
|
||||
if path ~= "/" then
|
||||
path = path .. "/"
|
||||
end
|
||||
path = path .. rel:gsub("/+$", "")
|
||||
noctalia.runAsync("xdg-open " .. shellQuote(path) .. " >/dev/null 2>&1")
|
||||
end
|
||||
@@ -0,0 +1,387 @@
|
||||
--!nonstrict
|
||||
-- file-search — fuzzy search panel, fzf as the matching subsystem.
|
||||
--
|
||||
-- On open the search folder is walked once with `find` into a cache file
|
||||
-- (excluded directory names are pruned, hidden entries too unless enabled);
|
||||
-- after that every keystroke runs `fzf --filter=<query>` over the cache, so
|
||||
-- typing stays responsive even on large trees. Results update live; picking
|
||||
-- one opens it with the system MIME association (xdg-open) — directories
|
||||
-- open in the file manager. Enter opens the top match.
|
||||
--
|
||||
-- The index is rebuilt when the panel opens with changed settings, or on
|
||||
-- demand via the refresh button. The bar widget mirrors the panel's open
|
||||
-- state through the shared "file_search_open" state key.
|
||||
|
||||
local query = ""
|
||||
local results = {} -- relative paths; directories keep a trailing "/"
|
||||
local total = nil -- entries in the index, shown in the footer
|
||||
local indexing = false
|
||||
local searching = false
|
||||
local errMsg = nil
|
||||
local fzfMissing = false
|
||||
local inputRev = 0 -- bumped to reseed the query input on open
|
||||
local haveIndex = false -- the cache on disk matches the current settings
|
||||
|
||||
local render
|
||||
local runSearch
|
||||
local buildIndex
|
||||
|
||||
-- Per-row callbacks need distinct global names; the reconciler dispatches
|
||||
-- callbacks by name only. getfenv() is the script environment lua_getglobal
|
||||
-- reads from, so assigning into it defines the callback the host will find.
|
||||
local env = getfenv()
|
||||
local function rowCallback(prefix, index, fn)
|
||||
local name = prefix .. "_" .. index
|
||||
env[name] = fn
|
||||
return name
|
||||
end
|
||||
|
||||
local function tr(key, args)
|
||||
return noctalia.tr(key, args)
|
||||
end
|
||||
|
||||
local function trim(value)
|
||||
return (value:gsub("^%s+", ""):gsub("%s+$", ""))
|
||||
end
|
||||
|
||||
local function shellQuote(value)
|
||||
return "'" .. value:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- Private per-plugin storage (XDG state); the host creates the directory on
|
||||
-- every call. nil (with a log line) when no state directory resolves.
|
||||
local function dataDir()
|
||||
local dir, err = noctalia.pluginDataDir()
|
||||
if dir == nil then
|
||||
noctalia.log("file-search: pluginDataDir failed: " .. tostring(err))
|
||||
return nil
|
||||
end
|
||||
return dir
|
||||
end
|
||||
|
||||
-- Shell header shared by every cache command. The .meta sidecar holds the
|
||||
-- settings fingerprint the cache was built with, so the panel and the
|
||||
-- launcher can both tell a stale index apart.
|
||||
local function cacheSh(dir)
|
||||
return "CACHE_DIR=" .. shellQuote(dir)
|
||||
.. '\nCACHE="$CACHE_DIR/index.list"\nMETA="$CACHE_DIR/index.meta"'
|
||||
end
|
||||
|
||||
local function searchRoot()
|
||||
local dir = noctalia.getConfig("search_folder")
|
||||
if dir == nil or dir == "" then
|
||||
dir = noctalia.getenv("HOME") or "/tmp"
|
||||
else
|
||||
dir = noctalia.expandPath(dir)
|
||||
end
|
||||
dir = dir:gsub("/+$", "")
|
||||
if dir == "" then
|
||||
dir = "/"
|
||||
end
|
||||
return dir
|
||||
end
|
||||
|
||||
local function maxResults()
|
||||
return math.max(10, math.min(200, tonumber(noctalia.getConfig("max_results")) or 50))
|
||||
end
|
||||
|
||||
-- Excluded directory names from the setting, split on ',' or ';'. Matching
|
||||
-- is by basename (find -name), so entries containing '/' are skipped and
|
||||
-- logged. Hidden entries are folded in as an extra '.*' pattern.
|
||||
local function excludeNames()
|
||||
local raw = noctalia.getConfig("exclude_dirs")
|
||||
if type(raw) ~= "string" then
|
||||
raw = ""
|
||||
end
|
||||
local names = {}
|
||||
for entry in raw:gmatch("[^,;]+") do
|
||||
local name = trim(entry)
|
||||
if name:find("/") then
|
||||
noctalia.log("file-search: ignoring exclude entry with '/': '" .. name .. "'")
|
||||
elseif name ~= "" then
|
||||
table.insert(names, name)
|
||||
end
|
||||
end
|
||||
if noctalia.getConfig("show_hidden") ~= true then
|
||||
table.insert(names, ".*")
|
||||
end
|
||||
return names
|
||||
end
|
||||
|
||||
local function indexKey()
|
||||
return searchRoot() .. "\n" .. table.concat(excludeNames(), "\n")
|
||||
end
|
||||
|
||||
-- The cache on disk is current when its fingerprint matches the settings,
|
||||
-- whether the panel or the launcher built it.
|
||||
local function cacheFresh(dir)
|
||||
return noctalia.readFile(dir .. "/index.meta") == indexKey()
|
||||
end
|
||||
|
||||
-- One cache record, about to be joined to the search root. The cache is a
|
||||
-- plain user-editable file, so records are untrusted: reject anything that
|
||||
-- could resolve outside the root.
|
||||
local function safeRel(rel)
|
||||
if rel == "" or rel:sub(1, 1) == "/" or rel:find("\n", 1, true) then
|
||||
return false
|
||||
end
|
||||
for part in rel:gmatch("[^/]+") do
|
||||
if part == ".." then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
buildIndex = function()
|
||||
if fzfMissing or indexing then
|
||||
return
|
||||
end
|
||||
local dir = dataDir()
|
||||
if dir == nil then
|
||||
errMsg = tr("err_index")
|
||||
render()
|
||||
return
|
||||
end
|
||||
indexing = true
|
||||
errMsg = nil
|
||||
local key = indexKey()
|
||||
|
||||
-- Names containing a newline would each forge extra one-per-fragment
|
||||
-- records (a crafted name can smuggle '..' lines into the index), so
|
||||
-- they are pruned unconditionally, before the user's exclusions.
|
||||
local names = { "-name " .. shellQuote("*\n*") }
|
||||
for _, name in ipairs(excludeNames()) do
|
||||
table.insert(names, "-name " .. shellQuote(name))
|
||||
end
|
||||
local prune = "\\( " .. table.concat(names, " -o ") .. " \\) -prune -o "
|
||||
-- %P prints paths relative to the root; the trailing '/' marks
|
||||
-- directories so rows get the right glyph. find's own exit status is
|
||||
-- ignored, so permission errors inside the tree don't fail the build.
|
||||
-- Cache and fingerprint are written to mktemp-created private files and
|
||||
-- renamed into place: rename replaces a planted symlink at the
|
||||
-- destination instead of following it. The host guarantees $CACHE_DIR
|
||||
-- exists (created by pluginDataDir above).
|
||||
local cmd = cacheSh(dir)
|
||||
.. '\nTMP=$(mktemp "$CACHE_DIR/index.list.XXXXXX") || exit 1\n'
|
||||
.. "find " .. shellQuote(searchRoot()) .. " -mindepth 1 " .. prune
|
||||
.. "-type d -printf '%P/\\n' -o -printf '%P\\n' > \"$TMP\" 2>/dev/null\n"
|
||||
.. 'mv -f "$TMP" "$CACHE" || exit 1\n'
|
||||
.. 'TMPM=$(mktemp "$CACHE_DIR/index.meta.XXXXXX") || exit 1\n'
|
||||
.. "printf '%s' " .. shellQuote(key) .. ' > "$TMPM"\n'
|
||||
.. 'mv -f "$TMPM" "$META" || exit 1\n'
|
||||
.. 'wc -l < "$CACHE"'
|
||||
|
||||
render()
|
||||
local ok = noctalia.runAsync(cmd, function(result)
|
||||
indexing = false
|
||||
if result.exitCode == 0 and not result.timedOut then
|
||||
haveIndex = true
|
||||
total = tonumber(trim(result.stdout or "")) or 0
|
||||
if indexKey() ~= key then
|
||||
buildIndex() -- settings changed while the walk was running
|
||||
else
|
||||
runSearch()
|
||||
end
|
||||
else
|
||||
haveIndex = false
|
||||
errMsg = tr("err_index")
|
||||
end
|
||||
render()
|
||||
end, 180000)
|
||||
if not ok then
|
||||
indexing = false
|
||||
errMsg = tr("err_spawn")
|
||||
render()
|
||||
end
|
||||
end
|
||||
|
||||
runSearch = function()
|
||||
if fzfMissing or indexing or searching or not haveIndex then
|
||||
return
|
||||
end
|
||||
local dir = dataDir()
|
||||
if dir == nil then
|
||||
return
|
||||
end
|
||||
searching = true
|
||||
local q = query
|
||||
local limit = maxResults()
|
||||
local cmd
|
||||
if trim(q) == "" then
|
||||
cmd = cacheSh(dir) .. '\nhead -n ' .. limit .. ' "$CACHE" 2>/dev/null'
|
||||
else
|
||||
-- head closes the pipe early; the pipeline status stays head's (0).
|
||||
cmd = cacheSh(dir) .. "\nfzf --filter=" .. shellQuote(q)
|
||||
.. ' < "$CACHE" 2>/dev/null | head -n ' .. limit
|
||||
end
|
||||
local ok = noctalia.runAsync(cmd, function(result)
|
||||
searching = false
|
||||
if not result.timedOut then
|
||||
results = {}
|
||||
for line in (result.stdout or ""):gmatch("[^\n]+") do
|
||||
table.insert(results, line)
|
||||
end
|
||||
end
|
||||
-- The query moved on while this search ran: chase it. The stale
|
||||
-- rows rendered below are overwritten as soon as it completes.
|
||||
if query ~= q then
|
||||
runSearch()
|
||||
end
|
||||
render()
|
||||
end, 15000)
|
||||
if not ok then
|
||||
searching = false
|
||||
end
|
||||
end
|
||||
|
||||
local function openEntry(rel)
|
||||
if not safeRel(rel) then
|
||||
noctalia.log("file-search: refusing unsafe index record: " .. rel)
|
||||
noctalia.notify(tr("title"), tr("err_bad_record"))
|
||||
return
|
||||
end
|
||||
local path = searchRoot()
|
||||
if path ~= "/" then
|
||||
path = path .. "/"
|
||||
end
|
||||
path = path .. rel:gsub("/+$", "")
|
||||
noctalia.runAsync("xdg-open " .. shellQuote(path) .. " >/dev/null 2>&1")
|
||||
panel.close()
|
||||
end
|
||||
|
||||
local function resultRow(rel, index)
|
||||
local isDir = rel:sub(-1) == "/"
|
||||
return ui.button({
|
||||
key = "hit-" .. index,
|
||||
glyph = isDir and "folder" or "file",
|
||||
text = rel,
|
||||
variant = "ghost",
|
||||
contentAlign = "start",
|
||||
onClick = rowCallback("openHit", index, function()
|
||||
openEntry(rel)
|
||||
end),
|
||||
})
|
||||
end
|
||||
|
||||
local function statusFooter()
|
||||
if indexing then
|
||||
return ui.label({ text = tr("status_indexing", { path = searchRoot() }), fontSize = 11, color = "secondary" })
|
||||
end
|
||||
local shown = #results
|
||||
return ui.label({
|
||||
text = tr("counts", { shown = shown, total = total or 0 }),
|
||||
fontSize = 11,
|
||||
color = "on_surface_variant",
|
||||
})
|
||||
end
|
||||
|
||||
render = function()
|
||||
local children = {
|
||||
ui.row({ gap = 6, align = "center" }, {
|
||||
ui.label({
|
||||
text = tr("title"),
|
||||
fontSize = 16,
|
||||
fontWeight = "bold",
|
||||
color = "on_surface",
|
||||
flexGrow = 1,
|
||||
}),
|
||||
ui.button({ glyph = "refresh", variant = "ghost", tooltip = tr("tip_refresh"), onClick = "onRefreshIndex" }),
|
||||
ui.button({ glyph = "close", variant = "ghost", tooltip = tr("tip_close"), onClick = "onClosePanel" }),
|
||||
}),
|
||||
ui.input({
|
||||
key = "query-" .. inputRev,
|
||||
value = query,
|
||||
focus = true,
|
||||
placeholder = tr("search_placeholder"),
|
||||
onChange = "onQueryChanged",
|
||||
onSubmit = "onOpenFirst",
|
||||
}),
|
||||
}
|
||||
|
||||
if fzfMissing then
|
||||
table.insert(children, ui.label({ text = tr("err_no_fzf"), color = "error" }))
|
||||
elseif errMsg ~= nil then
|
||||
table.insert(children, ui.label({ text = errMsg, color = "error" }))
|
||||
else
|
||||
if #results == 0 and not indexing and trim(query) ~= "" then
|
||||
table.insert(children, ui.label({
|
||||
text = tr("no_results", { query = query }),
|
||||
color = "on_surface_variant",
|
||||
flexGrow = 1,
|
||||
}))
|
||||
else
|
||||
local rows = {}
|
||||
for index, rel in ipairs(results) do
|
||||
table.insert(rows, resultRow(rel, index))
|
||||
end
|
||||
table.insert(children, ui.scroll({ flexGrow = 1, gap = 2 }, rows))
|
||||
end
|
||||
table.insert(children, statusFooter())
|
||||
end
|
||||
|
||||
panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, children))
|
||||
end
|
||||
|
||||
function onOpen(_context)
|
||||
fzfMissing = not noctalia.commandExists("fzf")
|
||||
query = ""
|
||||
results = {}
|
||||
inputRev += 1
|
||||
noctalia.state.set("file_search_open", true)
|
||||
if not fzfMissing then
|
||||
local dir = dataDir()
|
||||
if dir == nil then
|
||||
errMsg = tr("err_index")
|
||||
else
|
||||
haveIndex = cacheFresh(dir)
|
||||
if not haveIndex then
|
||||
buildIndex()
|
||||
else
|
||||
runSearch()
|
||||
end
|
||||
end
|
||||
end
|
||||
render()
|
||||
end
|
||||
|
||||
function onClose()
|
||||
noctalia.state.set("file_search_open", false)
|
||||
end
|
||||
|
||||
function onConfigChanged()
|
||||
if fzfMissing or indexing then
|
||||
return
|
||||
end
|
||||
local dir = dataDir()
|
||||
if dir == nil then
|
||||
return
|
||||
end
|
||||
haveIndex = cacheFresh(dir)
|
||||
if not haveIndex then
|
||||
buildIndex()
|
||||
else
|
||||
runSearch()
|
||||
end
|
||||
end
|
||||
|
||||
function onQueryChanged(value)
|
||||
query = value
|
||||
runSearch()
|
||||
end
|
||||
|
||||
function onOpenFirst(value)
|
||||
query = value
|
||||
if results[1] ~= nil then
|
||||
openEntry(results[1])
|
||||
end
|
||||
end
|
||||
|
||||
function onRefreshIndex()
|
||||
haveIndex = false
|
||||
buildIndex()
|
||||
end
|
||||
|
||||
function onClosePanel()
|
||||
panel.close()
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
# File Search — fuzzy file & folder search from the bar, powered by fzf.
|
||||
# The bar glyph toggles a search panel: the search folder is indexed with
|
||||
# `find` (honoring the excluded directories), every keystroke is matched
|
||||
# through `fzf --filter`, and picking a result opens it with the system
|
||||
# MIME association (xdg-open).
|
||||
|
||||
id = "nightwatch75/file-search"
|
||||
name = "File Search"
|
||||
version = "0.0.10"
|
||||
plugin_api = 3
|
||||
author = "nightwatch75"
|
||||
license = "MIT"
|
||||
# The exact commands the plugin spawns (fzf aside, findutils + coreutils +
|
||||
# xdg-utils on any Linux desktop).
|
||||
dependencies = ["fzf", "find", "mktemp", "mv", "wc", "head", "rm", "xdg-open"]
|
||||
tags = ["bar", "launcher", "panel", "utility", "productivity"]
|
||||
icon = "search"
|
||||
description = "Search files and folders as you type, fuzzy-matched with fzf; open results with the system MIME association."
|
||||
|
||||
# Plugin-level settings: shared by the bar widget (tooltip, folder actions)
|
||||
# and the panel (index + search).
|
||||
|
||||
[[setting]]
|
||||
key = "search_folder"
|
||||
type = "folder"
|
||||
label_key = "settings.search_folder.label"
|
||||
description_key = "settings.search_folder.description"
|
||||
|
||||
[[setting]]
|
||||
key = "exclude_dirs"
|
||||
type = "string"
|
||||
label_key = "settings.exclude_dirs.label"
|
||||
description_key = "settings.exclude_dirs.description"
|
||||
default = ".git, node_modules, .cache, .venv"
|
||||
|
||||
[[setting]]
|
||||
key = "show_hidden"
|
||||
type = "bool"
|
||||
label_key = "settings.show_hidden.label"
|
||||
description_key = "settings.show_hidden.description"
|
||||
default = false
|
||||
|
||||
[[setting]]
|
||||
key = "max_results"
|
||||
type = "int"
|
||||
label_key = "settings.max_results.label"
|
||||
description_key = "settings.max_results.description"
|
||||
default = 50
|
||||
min = 10
|
||||
max = 200
|
||||
|
||||
# Keyboard-first flow: the noctalia launcher provides native arrows + Enter
|
||||
# navigation, which plugin panels cannot receive from the current Luau API.
|
||||
[[launcher_provider]]
|
||||
id = "launcher"
|
||||
entry = "launcher.luau"
|
||||
prefix = "fs"
|
||||
glyph = "search"
|
||||
include_in_global_search = false
|
||||
|
||||
[[panel]]
|
||||
id = "panel"
|
||||
entry = "panel.luau"
|
||||
width = 520
|
||||
height = 480
|
||||
placement = "attached"
|
||||
open_near_click = true
|
||||
|
||||
[[widget]]
|
||||
id = "file-search"
|
||||
entry = "file-search.luau"
|
||||
|
||||
[[widget.setting]]
|
||||
key = "glyph"
|
||||
type = "glyph"
|
||||
label_key = "settings.glyph.label"
|
||||
description_key = "settings.glyph.description"
|
||||
default = "search"
|
||||
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"copied_path": "Search folder path copied to clipboard",
|
||||
"counts": "{shown} shown · {total} indexed",
|
||||
"err_bad_record": "Ignored an invalid index record — rebuild the index",
|
||||
"err_index": "Failed to index the search folder",
|
||||
"err_no_fzf": "fzf not found — install fzf to use this plugin",
|
||||
"err_spawn": "Could not run the search command",
|
||||
"launcher": {
|
||||
"indexing": "Indexing files…",
|
||||
"no_results": "No matches",
|
||||
"reindex_done": "Search index dropped — it rebuilds on the next search",
|
||||
"reindex_title": "Rebuild search index"
|
||||
},
|
||||
"no_results": "No matches for \"{query}\"",
|
||||
"search_placeholder": "Type to search files and folders…",
|
||||
"settings": {
|
||||
"exclude_dirs": {
|
||||
"description": "Folder names skipped while indexing, separated by ',' or ';' (matched anywhere in the tree), e.g. '.git, node_modules, .cache'.",
|
||||
"label": "Excluded folders"
|
||||
},
|
||||
"glyph": {
|
||||
"description": "Icon shown on the bar.",
|
||||
"label": "Glyph"
|
||||
},
|
||||
"max_results": {
|
||||
"description": "How many matches the panel lists at most.",
|
||||
"label": "Max results"
|
||||
},
|
||||
"search_folder": {
|
||||
"description": "Root folder the search indexes. Defaults to your home folder when empty.",
|
||||
"label": "Search folder"
|
||||
},
|
||||
"show_hidden": {
|
||||
"description": "Index files and folders whose name starts with a dot.",
|
||||
"label": "Include hidden entries"
|
||||
}
|
||||
},
|
||||
"status_indexing": "Indexing {path}…",
|
||||
"tip_close": "Close",
|
||||
"tip_refresh": "Rebuild the file index",
|
||||
"title": "File Search",
|
||||
"tooltip_closed": "File search — {path}\nClick to search · right-click: folder · middle-click: copy path",
|
||||
"tooltip_open": "File search open — {path}\nClick to close"
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# Home Assistant
|
||||
|
||||
Monitor and control your Home Assistant entities from the Noctalia bar and control center. Useful for quickly toggling lights, checking sensor states, and opening a full entity manager panel for richer controls.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `pozzoo/hassio` |
|
||||
| Entries | Widget: `status`; Service: `connection`; Shortcuts: `ha_toggle_1`, `ha_toggle_2`, `ha_toggle_3`, `ha_toggle_4`, `ha_panel`; Panel: `entity_manager` |
|
||||
| Launcher Prefix | — |
|
||||
|
||||
## Requirements
|
||||
|
||||
- A running Home Assistant instance with API access enabled
|
||||
- A Long-lived Access Token (Profile → Security → Long-lived access tokens)
|
||||
- `xdg-open` available on `PATH` (used to open the Home Assistant URL in your browser)
|
||||
|
||||
## Usage
|
||||
|
||||
1. Configure the plugin: open **Settings → Plugins → Home Assistant** and set:
|
||||
|
||||
| Setting | Description |
|
||||
| --- | --- |
|
||||
| Home Assistant URL | The URL to your HA instance, for example `http://homeassistant.local:8123` |
|
||||
| Long-Lived Access Token | Token created in HA under Profile → Security |
|
||||
| Quick Toggle 1–4 — Entity ID | (Optional) Entity IDs to assign to each quick-toggle tile (e.g. `light.living_room`) |
|
||||
|
||||
2. Add the bar widget: add the **status** widget from the widget picker to show connection state and (optionally) the number of monitored entities. Right-click the widget to open your Home Assistant URL in the default browser.
|
||||
|
||||
3. Add control-center tiles: go to **Settings → Control Center** and add any combination of:
|
||||
- **Home Assistant** (×4) — Quick-toggle tiles. Each corresponds to one of the four entity slots configured in plugin settings.
|
||||
- **Home Assistant** (panel opener) — Opens the entity manager panel.
|
||||
|
||||
4. Open the entity manager panel: use the panel opener tile or the panel IPC command to open the full browser and pin entities for monitoring.
|
||||
|
||||
```sh
|
||||
noctalia msg panel-toggle pozzoo/hassio:entity_manager
|
||||
```
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `ha_url` | `string` | `""` | Home Assistant base URL used for API requests (include protocol and port if needed). |
|
||||
| `ha_token` | `string` | `""` | Long-lived access token for Home Assistant. Keep this secret. |
|
||||
| `shortcut_entity_1` | `string` | `""` | Entity ID used by Quick Toggle 1 (e.g. `light.kitchen`). |
|
||||
| `shortcut_entity_2` | `string` | `""` | Entity ID used by Quick Toggle 2. |
|
||||
| `shortcut_entity_3` | `string` | `""` | Entity ID used by Quick Toggle 3. |
|
||||
| `shortcut_entity_4` | `string` | `""` | Entity ID used by Quick Toggle 4. |
|
||||
| `show_entity_count` | `bool` | `false` | Show the number of monitored entities next to the connection status in the `status` bar widget. |
|
||||
|
||||
## IPC
|
||||
|
||||
- Open the panel:
|
||||
|
||||
```sh
|
||||
noctalia msg panel-toggle pozzoo/hassio:entity_manager
|
||||
```
|
||||
|
||||
- Force a refresh of the connection and entity states:
|
||||
|
||||
```sh
|
||||
noctalia msg plugin pozzoo/hassio:status focused refresh
|
||||
```
|
||||
|
||||
Use `all` in place of `focused` to target every bar instance. This triggers the same refresh as sending the `refresh` command internally, and shows a notification when it starts.
|
||||
|
||||
Notes: the plugin forwards Home Assistant state updates internally and uses Noctalia's state routing to update widgets and shortcuts.
|
||||
|
||||
## Notes
|
||||
|
||||
- The plugin maintains a live SSE connection to Home Assistant to receive real-time state changes. Noctalia's native HTTP streaming API is used for this connection; all other requests (fetching states, toggling entities, browsing) go through Noctalia's native HTTP API.
|
||||
- The pinned entity list is saved to `managed_entities.json` in the plugin's persistent data directory (`noctalia.pluginDataDir()`), so it survives plugin updates.
|
||||
- To authenticate the SSE connection without exposing the access token on the command line, the plugin uses Noctalia's native streaming request headers.
|
||||
- The plugin issues HTTP requests to your HA instance; do not install untrusted plugins if you do not want them to access your network or tokens.
|
||||
- If authentication fails, generate a new long-lived access token in Home Assistant and paste it into plugin settings.
|
||||
@@ -0,0 +1,634 @@
|
||||
--!nonstrict
|
||||
|
||||
local haUrl = noctalia.getConfig("ha_url")
|
||||
local haToken = noctalia.getConfig("ha_token")
|
||||
|
||||
local entityStates = {}
|
||||
local status = "unconfigured"
|
||||
local expandedEntityId = nil
|
||||
local entitySlots = {}
|
||||
local sliderDraft = {}
|
||||
local sliderDirty = {}
|
||||
|
||||
local pendingToggle = {}
|
||||
local pendingSince = {} -- entity_id -> os.clock() when toggle was requested
|
||||
|
||||
local PENDING_TIMEOUT_SECONDS = 8
|
||||
|
||||
local view = "list" -- "list" | "browser"
|
||||
local allEntities = {}
|
||||
local browserSlots = {}
|
||||
local browserLoading = false
|
||||
local searchText = ""
|
||||
local monitoredEntities = {} -- current monitored list (owned here, saved to file)
|
||||
local panelOpenCount = 0
|
||||
|
||||
local function tr(key, subst)
|
||||
return noctalia.tr("panel." .. key, subst)
|
||||
end
|
||||
|
||||
local function getStatusText()
|
||||
if status == "connected" then
|
||||
return noctalia.tr("widget.status_connected")
|
||||
elseif status == "connecting" then
|
||||
return noctalia.tr("widget.status_connecting")
|
||||
elseif status == "disconnected" then
|
||||
return noctalia.tr("widget.status_disconnected")
|
||||
elseif status == "auth_failed" then
|
||||
return noctalia.tr("widget.status_auth_failed")
|
||||
else
|
||||
return noctalia.tr("widget.status_unconfigured")
|
||||
end
|
||||
end
|
||||
|
||||
local function getCleanToken()
|
||||
if type(haToken) ~= "string" then return "" end
|
||||
return haToken:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
end
|
||||
|
||||
local function haPost(path, body)
|
||||
if not haUrl or haUrl == "" then return end
|
||||
local cleanToken = getCleanToken()
|
||||
if cleanToken == "" then return end
|
||||
local encodedBody = noctalia.json.encode(body)
|
||||
|
||||
noctalia.http({
|
||||
url = haUrl .. path,
|
||||
method = "POST",
|
||||
headers = {
|
||||
"Authorization: Bearer " .. cleanToken,
|
||||
"Content-Type: application/json",
|
||||
},
|
||||
body = encodedBody,
|
||||
}, function(response: HttpResponse)end)
|
||||
end
|
||||
|
||||
local function callService(domain, service, entityId)
|
||||
haPost("/api/services/" .. domain .. "/" .. service, { entity_id = entityId })
|
||||
end
|
||||
|
||||
local MANAGED_ENTITIES_FILE = "managed_entities.json"
|
||||
|
||||
local function managedEntitiesPath()
|
||||
local dataDir, dirErr = noctalia.pluginDataDir()
|
||||
if not dataDir then
|
||||
noctalia.log("Cannot resolve plugin data dir: " .. (dirErr or "unknown"))
|
||||
return nil
|
||||
end
|
||||
return dataDir .. "/" .. MANAGED_ENTITIES_FILE
|
||||
end
|
||||
|
||||
local function loadMonitoredEntities()
|
||||
local path = managedEntitiesPath()
|
||||
local data = path and noctalia.readFile(path)
|
||||
if data and data ~= "" then
|
||||
local parsed = noctalia.json.decode(data)
|
||||
if type(parsed) == "table" then
|
||||
monitoredEntities = parsed
|
||||
return
|
||||
end
|
||||
end
|
||||
monitoredEntities = {}
|
||||
end
|
||||
|
||||
local function saveMonitoredEntities()
|
||||
local path = managedEntitiesPath()
|
||||
if not path then
|
||||
noctalia.notifyError(noctalia.tr("shortcut.title"), tr("save_failed_no_data_dir"))
|
||||
return
|
||||
end
|
||||
|
||||
local encodedEntities = noctalia.json.encode(monitoredEntities)
|
||||
local ok, writeErr = noctalia.writeFile(path, encodedEntities)
|
||||
if not ok then
|
||||
noctalia.log("Failed to save managed entities: " .. (writeErr or "unknown"))
|
||||
noctalia.notifyError(noctalia.tr("shortcut.title"), tr("save_failed"))
|
||||
return
|
||||
end
|
||||
noctalia.state.set("entities_override", monitoredEntities)
|
||||
noctalia.state.set("command", "reload_entities")
|
||||
end
|
||||
|
||||
local function isPinned(entityId)
|
||||
for _, eid in ipairs(monitoredEntities) do
|
||||
if eid == entityId then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function toggleBrowserPin(entityId)
|
||||
if not entityId then return end
|
||||
local newList, found = {}, false
|
||||
for _, eid in ipairs(monitoredEntities) do
|
||||
if eid == entityId then found = true
|
||||
else table.insert(newList, eid) end
|
||||
end
|
||||
if not found then table.insert(newList, entityId) end
|
||||
monitoredEntities = newList
|
||||
saveMonitoredEntities()
|
||||
render()
|
||||
end
|
||||
|
||||
local function fetchAllEntities()
|
||||
if browserLoading then return end
|
||||
local cleanToken = getCleanToken()
|
||||
if not haUrl or haUrl == "" or cleanToken == "" then
|
||||
allEntities = {}
|
||||
browserLoading = false
|
||||
render()
|
||||
return
|
||||
end
|
||||
|
||||
browserLoading = true
|
||||
render()
|
||||
|
||||
noctalia.http({
|
||||
url = haUrl .. "/api/states",
|
||||
headers = {
|
||||
"Authorization: Bearer " .. cleanToken,
|
||||
}
|
||||
}, function(response: HttpResponse)
|
||||
browserLoading = false
|
||||
if response and response.body and response.body ~= "" then
|
||||
local states = noctalia.json.decode(response.body)
|
||||
if type(states) == "table" then
|
||||
allEntities = {}
|
||||
for _, s in ipairs(states) do
|
||||
local attrs = s.attributes or {}
|
||||
table.insert(allEntities, {
|
||||
entity_id = s.entity_id,
|
||||
friendly_name = attrs.friendly_name or s.entity_id,
|
||||
domain = s.entity_id:match("^([^.]+)"),
|
||||
})
|
||||
end
|
||||
table.sort(allEntities, function(a, b) return a.entity_id < b.entity_id end)
|
||||
end
|
||||
end
|
||||
render()
|
||||
end)
|
||||
end
|
||||
|
||||
local function initDraft(entityId)
|
||||
if sliderDraft[entityId] then return end
|
||||
local e = entityStates[entityId]
|
||||
local ct = e and e.color_temp or -1
|
||||
local ctKelvin
|
||||
if ct > 500 then ctKelvin = math.floor(ct + 0.5)
|
||||
elseif ct > 0 then ctKelvin = math.floor(1000000 / ct + 0.5)
|
||||
else ctKelvin = 4000 end
|
||||
sliderDraft[entityId] = {
|
||||
brightness = e and (e.brightness > 0 and e.brightness or 255) or 255,
|
||||
color_temp = ctKelvin,
|
||||
hue = e and (e.hue or 0) or 0,
|
||||
}
|
||||
end
|
||||
|
||||
local function markDirty(key)
|
||||
if not expandedEntityId then return end
|
||||
if not sliderDirty[expandedEntityId] then sliderDirty[expandedEntityId] = {} end
|
||||
sliderDirty[expandedEntityId][key] = true
|
||||
end
|
||||
|
||||
local function clearPending(entityId)
|
||||
pendingToggle[entityId] = nil
|
||||
pendingSince[entityId] = nil
|
||||
end
|
||||
|
||||
local function isPendingTimedOut(entityId, now)
|
||||
local startedAt = pendingSince[entityId]
|
||||
if not startedAt then return false end
|
||||
return (now or os.clock()) - startedAt >= PENDING_TIMEOUT_SECONDS
|
||||
end
|
||||
|
||||
local function reconcilePending(now)
|
||||
local changed = false
|
||||
for entityId, expected in pairs(pendingToggle) do
|
||||
local real = entityStates[entityId]
|
||||
if not real or real.state == expected or isPendingTimedOut(entityId, now) then
|
||||
clearPending(entityId)
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
return changed
|
||||
end
|
||||
|
||||
-- _actN/_expN are per-slot callback targets referenced by index from the rendered rows below.
|
||||
-- In list view they act on the entity in that row's slot; in browser view _actN toggles its pin.
|
||||
local function actSlot(i)
|
||||
if view == "browser" then
|
||||
toggleBrowserPin(browserSlots[i])
|
||||
return
|
||||
end
|
||||
local eid = entitySlots[i]
|
||||
if not eid then return end
|
||||
local entity = entityStates[eid]
|
||||
if not entity then return end
|
||||
local d = entity.domain
|
||||
if d == "script" then callService("script", "turn_on", eid)
|
||||
elseif d == "automation" then callService("automation", "trigger", eid)
|
||||
else
|
||||
local newState = entity.state == "on" and "off" or "on"
|
||||
pendingToggle[eid] = newState
|
||||
pendingSince[eid] = os.clock()
|
||||
callService(d, "toggle", eid)
|
||||
render()
|
||||
end
|
||||
end
|
||||
|
||||
local function expSlot(i)
|
||||
local eid = entitySlots[i]
|
||||
if not eid then return end
|
||||
if expandedEntityId == eid then
|
||||
expandedEntityId = nil
|
||||
else
|
||||
sliderDraft[eid] = nil
|
||||
sliderDirty[eid] = nil
|
||||
expandedEntityId = eid
|
||||
end
|
||||
render()
|
||||
end
|
||||
|
||||
function _act1() actSlot(1) end; function _exp1() expSlot(1) end
|
||||
function _act2() actSlot(2) end; function _exp2() expSlot(2) end
|
||||
function _act3() actSlot(3) end; function _exp3() expSlot(3) end
|
||||
function _act4() actSlot(4) end; function _exp4() expSlot(4) end
|
||||
function _act5() actSlot(5) end; function _exp5() expSlot(5) end
|
||||
function _act6() actSlot(6) end; function _exp6() expSlot(6) end
|
||||
function _act7() actSlot(7) end; function _exp7() expSlot(7) end
|
||||
function _act8() actSlot(8) end; function _exp8() expSlot(8) end
|
||||
function _act9() actSlot(9) end; function _exp9() expSlot(9) end
|
||||
function _act10() actSlot(10) end; function _exp10() expSlot(10) end
|
||||
function _act11() actSlot(11) end; function _exp11() expSlot(11) end
|
||||
function _act12() actSlot(12) end; function _exp12() expSlot(12) end
|
||||
function _act13() actSlot(13) end; function _exp13() expSlot(13) end
|
||||
function _act14() actSlot(14) end; function _exp14() expSlot(14) end
|
||||
function _act15() actSlot(15) end; function _exp15() expSlot(15) end
|
||||
function _act16() actSlot(16) end; function _exp16() expSlot(16) end
|
||||
function _act17() actSlot(17) end; function _exp17() expSlot(17) end
|
||||
function _act18() actSlot(18) end; function _exp18() expSlot(18) end
|
||||
function _act19() actSlot(19) end; function _exp19() expSlot(19) end
|
||||
function _act20() actSlot(20) end; function _exp20() expSlot(20) end
|
||||
function _act21() actSlot(21) end; function _exp21() expSlot(21) end
|
||||
function _act22() actSlot(22) end; function _exp22() expSlot(22) end
|
||||
function _act23() actSlot(23) end; function _exp23() expSlot(23) end
|
||||
function _act24() actSlot(24) end; function _exp24() expSlot(24) end
|
||||
function _act25() actSlot(25) end; function _exp25() expSlot(25) end
|
||||
function _act26() actSlot(26) end; function _exp26() expSlot(26) end
|
||||
function _act27() actSlot(27) end; function _exp27() expSlot(27) end
|
||||
function _act28() actSlot(28) end; function _exp28() expSlot(28) end
|
||||
function _act29() actSlot(29) end; function _exp29() expSlot(29) end
|
||||
function _act30() actSlot(30) end; function _exp30() expSlot(30) end
|
||||
|
||||
function onBrightness(value)
|
||||
if not expandedEntityId then return end
|
||||
initDraft(expandedEntityId)
|
||||
sliderDraft[expandedEntityId].brightness = math.floor((tonumber(value) or 255) + 0.5)
|
||||
markDirty("brightness")
|
||||
render()
|
||||
end
|
||||
|
||||
function onColorTemp(value)
|
||||
if not expandedEntityId then return end
|
||||
initDraft(expandedEntityId)
|
||||
sliderDraft[expandedEntityId].color_temp = math.floor((tonumber(value) or 4000) + 0.5)
|
||||
markDirty("color_temp")
|
||||
render()
|
||||
end
|
||||
|
||||
function onHue(value)
|
||||
if not expandedEntityId then return end
|
||||
initDraft(expandedEntityId)
|
||||
sliderDraft[expandedEntityId].hue = math.floor((tonumber(value) or 0) + 0.5)
|
||||
markDirty("hue")
|
||||
render()
|
||||
end
|
||||
|
||||
function onApplyLight()
|
||||
if not expandedEntityId then return end
|
||||
local draft = sliderDraft[expandedEntityId]
|
||||
local dirty = sliderDirty[expandedEntityId] or {}
|
||||
local entity = entityStates[expandedEntityId]
|
||||
if not draft or not entity then return end
|
||||
|
||||
local data = { entity_id = expandedEntityId }
|
||||
if dirty.hue and entity.supports_rgb then
|
||||
data.hs_color = { draft.hue, 100 }
|
||||
if dirty.brightness and entity.supports_brightness then
|
||||
data.brightness = draft.brightness
|
||||
end
|
||||
else
|
||||
if dirty.brightness and entity.supports_brightness then
|
||||
data.brightness = draft.brightness
|
||||
end
|
||||
if dirty.color_temp and entity.supports_color_temp then
|
||||
data.color_temp_kelvin = draft.color_temp
|
||||
end
|
||||
end
|
||||
|
||||
if dirty.brightness or dirty.color_temp or dirty.hue then
|
||||
haPost("/api/services/light/turn_on", data)
|
||||
sliderDirty[expandedEntityId] = {}
|
||||
end
|
||||
end
|
||||
|
||||
function onNoop() end
|
||||
|
||||
function onOpenBrowser()
|
||||
view = "browser"
|
||||
searchText = ""
|
||||
fetchAllEntities()
|
||||
end
|
||||
|
||||
function onBackToList()
|
||||
view = "list"
|
||||
allEntities = {}
|
||||
searchText = ""
|
||||
render()
|
||||
end
|
||||
|
||||
function onSearchChange(value)
|
||||
searchText = value or ""
|
||||
render()
|
||||
end
|
||||
|
||||
local function isControllable(domain)
|
||||
return domain == "light" or domain == "switch" or domain == "input_boolean"
|
||||
or domain == "fan" or domain == "cover" or domain == "lock"
|
||||
end
|
||||
|
||||
local function isSensor(domain)
|
||||
return domain == "sensor" or domain == "binary_sensor" or domain == "weather" or domain == "number"
|
||||
end
|
||||
|
||||
local function isAutomation(domain)
|
||||
return domain == "automation" or domain == "script"
|
||||
end
|
||||
|
||||
local function domainGlyph(domain)
|
||||
local g = {
|
||||
light = "bulb", switch = "toggle-right", input_boolean = "toggle-right",
|
||||
sensor = "chart-line", binary_sensor = "activity", climate = "temperature",
|
||||
cover = "door", fan = "wind", lock = "lock", media_player = "device-speaker",
|
||||
weather = "cloud", automation = "robot", script = "player-play",
|
||||
}
|
||||
return g[domain] or "smart-home"
|
||||
end
|
||||
|
||||
local function stateGlyph(domain, isOn)
|
||||
if domain == "light" then return isOn and "bulb" or "bulb-off"
|
||||
elseif domain == "switch" or domain == "input_boolean" then return isOn and "toggle-right" or "toggle-left"
|
||||
elseif domain == "fan" then return isOn and "wind" or "wind-off"
|
||||
elseif domain == "lock" then return isOn and "lock" or "lock-open"
|
||||
elseif domain == "cover" then return isOn and "door-open" or "door"
|
||||
end
|
||||
return domainGlyph(domain)
|
||||
end
|
||||
|
||||
local function entityStateLabel(entity)
|
||||
if isSensor(entity.domain) then
|
||||
return entity.state .. (entity.unit ~= "" and " " .. entity.unit or "")
|
||||
end
|
||||
if entity.domain == "light" and entity.state == "on" and entity.brightness >= 0 then
|
||||
local pct = math.floor(entity.brightness / 255 * 100 + 0.5)
|
||||
return tr("state_on_brightness", { percent = pct })
|
||||
end
|
||||
return entity.state
|
||||
end
|
||||
|
||||
local function buildExpandedSection(entity)
|
||||
local eid = entity.entity_id
|
||||
initDraft(eid)
|
||||
local draft = sliderDraft[eid]
|
||||
local rows = {}
|
||||
|
||||
if entity.supports_brightness then
|
||||
local bv = draft.brightness
|
||||
table.insert(rows, ui.row({ align = "center", gap = 8 }, {
|
||||
ui.label({ text = tr("brightness"), color = "on_surface_variant" }),
|
||||
ui.slider({ min = 1, max = 255, step = 1, value = bv, onChange = "onBrightness", flexGrow = 1 }),
|
||||
ui.label({ text = math.floor(bv / 255 * 100 + 0.5) .. "%" }),
|
||||
}))
|
||||
end
|
||||
|
||||
if entity.supports_color_temp then
|
||||
local ctk = draft.color_temp
|
||||
table.insert(rows, ui.row({ align = "center", gap = 8 }, {
|
||||
ui.label({ text = tr("color_temp"), color = "on_surface_variant" }),
|
||||
ui.slider({ min = 2000, max = 6500, step = 50, value = ctk, onChange = "onColorTemp", flexGrow = 1 }),
|
||||
ui.label({ text = math.floor(ctk) .. "K" }),
|
||||
}))
|
||||
end
|
||||
|
||||
if entity.supports_rgb then
|
||||
local hv = draft.hue
|
||||
table.insert(rows, ui.row({ align = "center", gap = 8 }, {
|
||||
ui.label({ text = tr("hue"), color = "on_surface_variant" }),
|
||||
ui.slider({ min = 0, max = 360, step = 1, value = hv, onChange = "onHue", flexGrow = 1 }),
|
||||
ui.label({ text = math.floor(hv) .. "°" }),
|
||||
}))
|
||||
end
|
||||
|
||||
if #rows == 0 then return nil end
|
||||
|
||||
table.insert(rows, ui.row({ justify = "end" }, {
|
||||
ui.button({ glyph = "check", onClick = "onApplyLight" }),
|
||||
}))
|
||||
|
||||
return ui.column({ gap = 8 }, rows)
|
||||
end
|
||||
|
||||
local function buildEntityCard(entity, i)
|
||||
local pending = pendingToggle[entity.entity_id]
|
||||
local isOn = (pending or entity.state) == "on"
|
||||
local domain = entity.domain
|
||||
local canExpand = domain == "light"
|
||||
and (entity.supports_brightness or entity.supports_color_temp or entity.supports_rgb)
|
||||
local isExpanded = expandedEntityId == entity.entity_id
|
||||
|
||||
local expandBtn
|
||||
if canExpand then
|
||||
expandBtn = ui.button({
|
||||
glyph = isExpanded and "chevron-up" or "chevron-down",
|
||||
onClick = "_exp" .. i,
|
||||
})
|
||||
end
|
||||
|
||||
local isPending = pending ~= nil
|
||||
local iconClick = isPending and "onNoop"
|
||||
or (isControllable(domain) or isAutomation(domain)) and "_act" .. i or "onNoop"
|
||||
local iconGlyph = isPending and "loader"
|
||||
or isAutomation(domain) and "player-play" or stateGlyph(domain, isOn)
|
||||
|
||||
local rowChildren = {
|
||||
ui.button({ glyph = iconGlyph, onClick = iconClick }),
|
||||
ui.column({ flexGrow = 1, gap = 2 }, {
|
||||
ui.label({ text = entity.friendly_name or entity.entity_id, fontWeight = "medium" }),
|
||||
ui.label({ text = entityStateLabel(entity), color = "on_surface_variant", fontSize = 12 }),
|
||||
}),
|
||||
}
|
||||
if expandBtn then table.insert(rowChildren, expandBtn) end
|
||||
|
||||
local cardChildren = { ui.row({ align = "center", gap = 8 }, rowChildren) }
|
||||
|
||||
if isExpanded then
|
||||
local expanded = buildExpandedSection(entity)
|
||||
if expanded then table.insert(cardChildren, expanded) end
|
||||
end
|
||||
|
||||
return ui.column({ gap = 8 }, cardChildren)
|
||||
end
|
||||
|
||||
-- Capped to 30: matches the number of _actN/_expN slot callbacks defined above.
|
||||
local MAX_SLOTS = 30
|
||||
|
||||
local function getOrderedIds()
|
||||
local ids, seen = {}, {}
|
||||
for _, eid in ipairs(monitoredEntities) do
|
||||
if entityStates[eid] then
|
||||
table.insert(ids, eid)
|
||||
seen[eid] = true
|
||||
if #ids >= MAX_SLOTS then return ids end
|
||||
end
|
||||
end
|
||||
for eid in pairs(entityStates) do
|
||||
if not seen[eid] then
|
||||
table.insert(ids, eid)
|
||||
if #ids >= MAX_SLOTS then return ids end
|
||||
end
|
||||
end
|
||||
return ids
|
||||
end
|
||||
|
||||
local function buildListBody(ids)
|
||||
if not haUrl or haUrl == "" or getCleanToken() == "" then
|
||||
return ui.label({ text = tr("not_configured"), color = "on_surface_variant" })
|
||||
end
|
||||
if status == "auth_failed" then
|
||||
return ui.label({ text = tr("auth_failed"), color = "error" })
|
||||
end
|
||||
if status == "connecting" then
|
||||
return ui.label({ text = tr("connecting"), color = "on_surface_variant" })
|
||||
end
|
||||
if status == "disconnected" then
|
||||
return ui.label({ text = tr("disconnected_reconnecting"), color = "error" })
|
||||
end
|
||||
if #ids == 0 then
|
||||
return ui.label({
|
||||
text = status == "connected"
|
||||
and tr("no_entities_monitored")
|
||||
or tr("status_fallback", { status = status }),
|
||||
color = "on_surface_variant",
|
||||
})
|
||||
end
|
||||
local cards = {}
|
||||
for i, eid in ipairs(ids) do
|
||||
table.insert(cards, buildEntityCard(entityStates[eid], i))
|
||||
end
|
||||
return ui.scroll({ flexGrow = 1, gap = 8 }, cards)
|
||||
end
|
||||
|
||||
local function buildBrowserBody()
|
||||
if browserLoading then
|
||||
return ui.label({ text = tr("loading_entities"), color = "on_surface_variant" })
|
||||
end
|
||||
if #allEntities == 0 then
|
||||
return ui.label({ text = tr("no_entities_found"), color = "on_surface_variant" })
|
||||
end
|
||||
|
||||
local q = searchText:lower()
|
||||
local filtered = {}
|
||||
for _, e in ipairs(allEntities) do
|
||||
if q == ""
|
||||
or e.entity_id:lower():find(q, 1, true)
|
||||
or e.friendly_name:lower():find(q, 1, true)
|
||||
then
|
||||
table.insert(filtered, e)
|
||||
if #filtered >= 30 then break end
|
||||
end
|
||||
end
|
||||
|
||||
if #filtered == 0 then
|
||||
return ui.label({ text = tr("no_entities_match"), color = "on_surface_variant" })
|
||||
end
|
||||
|
||||
browserSlots = {}
|
||||
local rows = {}
|
||||
for i, e in ipairs(filtered) do
|
||||
browserSlots[i] = e.entity_id
|
||||
local pinned = isPinned(e.entity_id)
|
||||
table.insert(rows, ui.row({ align = "center", gap = 8 }, {
|
||||
ui.column({ flexGrow = 1, gap = 2 }, {
|
||||
ui.label({ text = e.friendly_name, fontWeight = "medium" }),
|
||||
ui.label({ text = e.entity_id, color = "on_surface_variant", fontSize = 12 }),
|
||||
}),
|
||||
ui.button({ glyph = pinned and "pin-filled" or "pin", onClick = "_act" .. i }),
|
||||
}))
|
||||
end
|
||||
|
||||
return ui.scroll({ flexGrow = 1, gap = 4 }, rows)
|
||||
end
|
||||
|
||||
function render()
|
||||
reconcilePending(os.clock())
|
||||
|
||||
if view == "browser" then
|
||||
panel.render(ui.column({ flexGrow = 1, gap = 16 }, {
|
||||
ui.row({ align = "center", gap = 8 }, {
|
||||
ui.button({ glyph = "arrow-left", onClick = "onBackToList" }),
|
||||
ui.label({ text = tr("manage_entities_title"), fontSize = 16, fontWeight = "bold", color = "primary", flexGrow = 1 }),
|
||||
ui.button({ glyph = "close", onClick = "onCloseClicked" }),
|
||||
}),
|
||||
ui.input({ key = "entity_search", placeholder = tr("search_placeholder"), onChange = "onSearchChange" }),
|
||||
buildBrowserBody(),
|
||||
}))
|
||||
else
|
||||
local ids = getOrderedIds()
|
||||
entitySlots = {}
|
||||
for i, eid in ipairs(ids) do entitySlots[i] = eid end
|
||||
|
||||
panel.render(ui.column({ flexGrow = 1, gap = 16 }, {
|
||||
ui.row({ align = "center", gap = 8 }, {
|
||||
ui.label({ text = noctalia.tr("shortcut.title"), fontSize = 16, fontWeight = "bold", color = "primary", flexGrow = 1 }),
|
||||
ui.label({ text = getStatusText(), color = status == "connected" and "primary" or "on_surface_variant" }),
|
||||
ui.button({ glyph = "list-search", onClick = "onOpenBrowser" }),
|
||||
ui.button({ glyph = "close", onClick = "onCloseClicked" }),
|
||||
}),
|
||||
buildListBody(ids),
|
||||
}))
|
||||
end
|
||||
end
|
||||
|
||||
function onOpen(_context)
|
||||
status = noctalia.state.get("connection_status") or "unconfigured"
|
||||
entityStates = noctalia.state.get("entities") or {}
|
||||
loadMonitoredEntities()
|
||||
panelOpenCount = panelOpenCount + 1
|
||||
panel.setWantsSecondTicks(true)
|
||||
noctalia.state.set("panel_open_signal", panelOpenCount)
|
||||
render()
|
||||
end
|
||||
|
||||
function onClose()
|
||||
panel.setWantsSecondTicks(false)
|
||||
end
|
||||
|
||||
function update()
|
||||
if reconcilePending(os.clock()) then
|
||||
render()
|
||||
end
|
||||
end
|
||||
|
||||
function onCloseClicked()
|
||||
panel.close()
|
||||
end
|
||||
|
||||
noctalia.state.watch("entities", function(value)
|
||||
entityStates = value or {}
|
||||
reconcilePending(os.clock())
|
||||
render()
|
||||
end)
|
||||
|
||||
noctalia.state.watch("connection_status", function(value)
|
||||
status = value or "unconfigured"
|
||||
render()
|
||||
end)
|
||||
@@ -0,0 +1,95 @@
|
||||
id = "pozzoo/hassio"
|
||||
name = "Home Assistant"
|
||||
version = "2.0.0"
|
||||
plugin_api = 4
|
||||
author = "Pozzoo"
|
||||
license = "MIT"
|
||||
tags = ["bar", "panel", "service", "shortcut", "network", "indicator"]
|
||||
icon = "smart-home"
|
||||
dependencies = ["xdg-open"]
|
||||
description = "Monitor and control Home Assistant entities from the bar. Displays entity states and provides quick toggles."
|
||||
|
||||
[[setting]]
|
||||
key = "ha_url"
|
||||
type = "string"
|
||||
label_key = "settings.url.label"
|
||||
description_key = "settings.url.description"
|
||||
default = ""
|
||||
|
||||
[[setting]]
|
||||
key = "ha_token"
|
||||
type = "string"
|
||||
label_key = "settings.token.label"
|
||||
description_key = "settings.token.description"
|
||||
default = ""
|
||||
|
||||
[[setting]]
|
||||
key = "shortcut_entity_1"
|
||||
type = "string"
|
||||
label_key = "settings.shortcut_entity_1.label"
|
||||
description_key = "settings.shortcut_entity.description"
|
||||
default = ""
|
||||
|
||||
[[setting]]
|
||||
key = "shortcut_entity_2"
|
||||
type = "string"
|
||||
label_key = "settings.shortcut_entity_2.label"
|
||||
description_key = "settings.shortcut_entity.description"
|
||||
default = ""
|
||||
|
||||
[[setting]]
|
||||
key = "shortcut_entity_3"
|
||||
type = "string"
|
||||
label_key = "settings.shortcut_entity_3.label"
|
||||
description_key = "settings.shortcut_entity.description"
|
||||
default = ""
|
||||
|
||||
[[setting]]
|
||||
key = "shortcut_entity_4"
|
||||
type = "string"
|
||||
label_key = "settings.shortcut_entity_4.label"
|
||||
description_key = "settings.shortcut_entity.description"
|
||||
default = ""
|
||||
|
||||
[[widget]]
|
||||
id = "status"
|
||||
entry = "widget.luau"
|
||||
|
||||
[[widget.setting]]
|
||||
key = "show_entity_count"
|
||||
type = "bool"
|
||||
label_key = "settings.show_entity_count.label"
|
||||
default = false
|
||||
|
||||
# Native streaming background service maintaining a live connection to HA
|
||||
[[service]]
|
||||
id = "connection"
|
||||
entry = "service_sse.luau"
|
||||
|
||||
[[shortcut]]
|
||||
id = "ha_toggle_1"
|
||||
entry = "shortcut.luau"
|
||||
|
||||
[[shortcut]]
|
||||
id = "ha_toggle_2"
|
||||
entry = "shortcut_2.luau"
|
||||
|
||||
[[shortcut]]
|
||||
id = "ha_toggle_3"
|
||||
entry = "shortcut_3.luau"
|
||||
|
||||
[[shortcut]]
|
||||
id = "ha_toggle_4"
|
||||
entry = "shortcut_4.luau"
|
||||
|
||||
[[shortcut]]
|
||||
id = "ha_panel"
|
||||
entry = "shortcut_panel.luau"
|
||||
|
||||
[[panel]]
|
||||
id = "entity_manager"
|
||||
entry = "panel.luau"
|
||||
width = 450
|
||||
height = 560
|
||||
placement = "floating"
|
||||
position = "center"
|
||||
@@ -0,0 +1,428 @@
|
||||
--!nonstrict
|
||||
|
||||
local haUrl = noctalia.getConfig("ha_url")
|
||||
local haToken = noctalia.getConfig("ha_token")
|
||||
|
||||
local MANAGED_ENTITIES_FILE = "managed_entities.json"
|
||||
|
||||
local function loadManagedEntities()
|
||||
local dataDir, dirErr = noctalia.pluginDataDir()
|
||||
if not dataDir then
|
||||
noctalia.log("Cannot resolve plugin data dir: " .. (dirErr or "unknown"))
|
||||
return {}
|
||||
end
|
||||
|
||||
local data = noctalia.readFile(dataDir .. "/" .. MANAGED_ENTITIES_FILE)
|
||||
if data and data ~= "" then
|
||||
local parsed = noctalia.json.decode(data)
|
||||
if type(parsed) == "table" then return parsed end
|
||||
end
|
||||
return {}
|
||||
end
|
||||
|
||||
local entities = loadManagedEntities()
|
||||
|
||||
local status = "unconfigured"
|
||||
local entityStates = {}
|
||||
local sseActive = false
|
||||
local streamHandle = nil
|
||||
|
||||
local MAX_RECONNECT_RETRIES = 10
|
||||
local CONNECTED_POLL_INTERVAL_MS = 30000
|
||||
local RECONNECT_POLL_INTERVAL_MS = 1000
|
||||
local retryCount = 0
|
||||
local maxRetriesNotified = false
|
||||
local suspended = false
|
||||
local connectionRequestInFlight = false
|
||||
|
||||
local function getCleanToken()
|
||||
if type(haToken) ~= "string" then return "" end
|
||||
return noctalia.string.trim(haToken)
|
||||
end
|
||||
|
||||
local function syncUpdateInterval()
|
||||
if status == "connected" and sseActive then
|
||||
noctalia.setUpdateInterval(CONNECTED_POLL_INTERVAL_MS)
|
||||
else
|
||||
noctalia.setUpdateInterval(RECONNECT_POLL_INTERVAL_MS)
|
||||
end
|
||||
end
|
||||
|
||||
local function isConfigured()
|
||||
return haUrl and haUrl ~= "" and getCleanToken() ~= ""
|
||||
end
|
||||
|
||||
local function haRequest(endpoint, method, body, callback)
|
||||
if not isConfigured() then return false end
|
||||
|
||||
local url = haUrl .. endpoint
|
||||
local cleanToken = getCleanToken()
|
||||
|
||||
local jsonBody
|
||||
if body then
|
||||
jsonBody = noctalia.json.encode(body)
|
||||
end
|
||||
|
||||
local launched = noctalia.http({
|
||||
url = url,
|
||||
method = method,
|
||||
body = jsonBody,
|
||||
headers = {
|
||||
"Authorization: Bearer " .. cleanToken,
|
||||
"Content-Type: application/json",
|
||||
}
|
||||
}, function(response: HttpResponse)
|
||||
if not response then
|
||||
callback({ ok = false, status = 0, body = "No response" })
|
||||
return
|
||||
end
|
||||
|
||||
callback({
|
||||
ok = response.status >= 200 and response.status < 300,
|
||||
status = response.status,
|
||||
body = response.body
|
||||
})
|
||||
end)
|
||||
|
||||
return launched
|
||||
|
||||
end
|
||||
|
||||
local function supportsColorMode(modes, targets)
|
||||
if type(modes) ~= "table" then return false end
|
||||
for _, mode in ipairs(modes) do
|
||||
for _, target in ipairs(targets) do
|
||||
if mode == target then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function processEntity(state)
|
||||
if not state then return nil end
|
||||
|
||||
local attrs = state.attributes or {}
|
||||
local modes = attrs.supported_color_modes or {}
|
||||
|
||||
-- stored as mireds for the panel slider
|
||||
local colorTemp = -1
|
||||
if attrs.color_temp_kelvin and attrs.color_temp_kelvin > 0 then
|
||||
colorTemp = math.floor(1000000 / attrs.color_temp_kelvin + 0.5)
|
||||
elseif attrs.color_temp and attrs.color_temp > 0 then
|
||||
colorTemp = attrs.color_temp
|
||||
end
|
||||
|
||||
local currentColor = ""
|
||||
if type(attrs.rgb_color) == "table" and #attrs.rgb_color == 3 then
|
||||
currentColor = attrs.rgb_color[1] .. "," .. attrs.rgb_color[2] .. "," .. attrs.rgb_color[3]
|
||||
end
|
||||
|
||||
return {
|
||||
entity_id = state.entity_id,
|
||||
state = state.state,
|
||||
friendly_name = attrs.friendly_name or state.entity_id,
|
||||
domain = state.entity_id:match("^([^.]+)"),
|
||||
unit = attrs.unit_of_measurement or "",
|
||||
brightness = attrs.brightness or -1,
|
||||
color_temp = colorTemp,
|
||||
hue = (type(attrs.hs_color) == "table") and (attrs.hs_color[1] or 0) or 0,
|
||||
current_color = currentColor,
|
||||
supports_brightness = supportsColorMode(modes, {"brightness","color_temp","hs","xy","rgb","rgbw","rgbww"}),
|
||||
supports_color_temp = supportsColorMode(modes, {"color_temp"}),
|
||||
supports_rgb = supportsColorMode(modes, {"hs","xy","rgb","rgbw","rgbww"}),
|
||||
}
|
||||
end
|
||||
|
||||
local function updateEntity(entityId, newState)
|
||||
if #entities > 0 then
|
||||
local found = false
|
||||
for _, id in ipairs(entities) do
|
||||
if id == entityId then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not found then return end
|
||||
end
|
||||
|
||||
local processed = processEntity(newState)
|
||||
if processed then
|
||||
entityStates[entityId] = processed
|
||||
noctalia.state.set("entities", entityStates)
|
||||
|
||||
local count = 0
|
||||
for _ in pairs(entityStates) do
|
||||
count = count + 1
|
||||
end
|
||||
noctalia.state.set("entity_count", count)
|
||||
end
|
||||
end
|
||||
|
||||
-- HA embeds event_type inside the data JSON rather than a separate SSE "event:" field
|
||||
local currentEvent = {
|
||||
data = "",
|
||||
event = "",
|
||||
}
|
||||
|
||||
local function tryDispatch()
|
||||
if not currentEvent.data then return end
|
||||
local parsed = noctalia.json.decode(currentEvent.data)
|
||||
if not parsed then return end
|
||||
local evType = currentEvent.event or parsed.event_type
|
||||
if evType ~= "state_changed" then return end
|
||||
local inner = parsed.data or {}
|
||||
if inner.entity_id and inner.new_state then
|
||||
updateEntity(inner.entity_id, inner.new_state)
|
||||
end
|
||||
end
|
||||
|
||||
local function processSSELine(line)
|
||||
if line == "" then
|
||||
tryDispatch()
|
||||
currentEvent = {}
|
||||
elseif line:match("^event:") then
|
||||
if currentEvent.data then tryDispatch() end
|
||||
currentEvent = { event = line:match("^event:%s*(.+)") or "" }
|
||||
elseif line:match("^data:") then
|
||||
local data = line:match("^data:%s*(.+)") or ""
|
||||
local parsed = noctalia.json.decode(data)
|
||||
if parsed then
|
||||
local evType = currentEvent.event or parsed.event_type
|
||||
if evType == "state_changed" then
|
||||
local inner = parsed.data or {}
|
||||
if inner.entity_id and inner.new_state then
|
||||
updateEntity(inner.entity_id, inner.new_state)
|
||||
currentEvent = {}
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
if currentEvent.data and currentEvent.data ~= "" then
|
||||
currentEvent.data = currentEvent.data .. "\n" .. data
|
||||
else
|
||||
currentEvent.data = data
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function fetchInitialStates()
|
||||
if not isConfigured() or suspended or connectionRequestInFlight then return end
|
||||
connectionRequestInFlight = true
|
||||
local launched = haRequest("/api/states", "GET", nil, function(res)
|
||||
connectionRequestInFlight = false
|
||||
|
||||
if not res.body or res.body == "" then
|
||||
noctalia.log("Empty response body from /api/states")
|
||||
status = "disconnected"
|
||||
noctalia.state.set("connection_status", status)
|
||||
syncUpdateInterval()
|
||||
return
|
||||
end
|
||||
|
||||
if not res.ok or res.status == 401 or res.body:match("^401") or res.body:match("Unauthorized") then
|
||||
status = "auth_failed"
|
||||
noctalia.log("Authentication failed - check your token")
|
||||
noctalia.notifyError(noctalia.tr("notifications.auth_failed"))
|
||||
noctalia.state.set("connection_status", status)
|
||||
syncUpdateInterval()
|
||||
return
|
||||
end
|
||||
|
||||
if not res.ok or (res.status and res.status >= 400) then
|
||||
status = "disconnected"
|
||||
noctalia.log("HTTP request failed: status " .. (res.status or "unknown"))
|
||||
noctalia.state.set("connection_status", status)
|
||||
syncUpdateInterval()
|
||||
return
|
||||
end
|
||||
|
||||
local allStates, err = noctalia.json.decode(res.body)
|
||||
if not allStates then
|
||||
noctalia.log("Failed to parse states: " .. (err or "unknown error"))
|
||||
status = "disconnected"
|
||||
noctalia.state.set("connection_status", status)
|
||||
syncUpdateInterval()
|
||||
return
|
||||
end
|
||||
|
||||
status = "connected"
|
||||
noctalia.state.set("connection_status", status)
|
||||
retryCount = 0
|
||||
maxRetriesNotified = false
|
||||
suspended = false
|
||||
|
||||
entityStates = {}
|
||||
local count = 0
|
||||
|
||||
for _, state in ipairs(allStates) do
|
||||
if #entities == 0 then break end
|
||||
for _, entityId in ipairs(entities) do
|
||||
if state.entity_id == entityId then
|
||||
entityStates[entityId] = processEntity(state)
|
||||
count = count + 1
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
noctalia.state.set("entities", entityStates)
|
||||
noctalia.state.set("entity_count", count)
|
||||
|
||||
if status == "connected" then
|
||||
if not startSSEStream() then
|
||||
status = "disconnected"
|
||||
noctalia.state.set("connection_status", status)
|
||||
syncUpdateInterval()
|
||||
else
|
||||
syncUpdateInterval()
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
if not launched then
|
||||
connectionRequestInFlight = false
|
||||
status = "disconnected"
|
||||
noctalia.state.set("connection_status", status)
|
||||
syncUpdateInterval()
|
||||
end
|
||||
end
|
||||
|
||||
function startSSEStream()
|
||||
if suspended then return false end
|
||||
if sseActive then return true end
|
||||
|
||||
local cleanToken = getCleanToken()
|
||||
if cleanToken == "" then
|
||||
return false
|
||||
end
|
||||
|
||||
local url = haUrl .. "/api/stream?restrict=state_changed"
|
||||
|
||||
noctalia.log("Starting SSE stream")
|
||||
currentEvent = { data = "", event = "" }
|
||||
|
||||
local handle
|
||||
handle = noctalia.httpStream({
|
||||
url = url,
|
||||
headers = {
|
||||
"Accept: text/event-stream",
|
||||
"Authorization: Bearer " .. cleanToken,
|
||||
},
|
||||
}, function(line)
|
||||
processSSELine(line)
|
||||
end, function(result)
|
||||
if streamHandle ~= handle then return end
|
||||
|
||||
streamHandle = nil
|
||||
sseActive = false
|
||||
currentEvent = {}
|
||||
|
||||
if suspended then return end
|
||||
|
||||
if result and result.ok and result.status == 401 then
|
||||
status = "auth_failed"
|
||||
noctalia.log("Authentication failed - check your token")
|
||||
noctalia.notifyError(noctalia.tr("notifications.auth_failed"))
|
||||
noctalia.state.set("connection_status", status)
|
||||
syncUpdateInterval()
|
||||
return
|
||||
end
|
||||
|
||||
status = "disconnected"
|
||||
noctalia.log("SSE stream closed")
|
||||
noctalia.state.set("connection_status", status)
|
||||
syncUpdateInterval()
|
||||
end)
|
||||
|
||||
if not handle then
|
||||
noctalia.log("Failed to start SSE stream")
|
||||
return false
|
||||
end
|
||||
|
||||
streamHandle = handle
|
||||
sseActive = true
|
||||
syncUpdateInterval()
|
||||
return true
|
||||
end
|
||||
|
||||
noctalia.state.watch("command", function(cmd)
|
||||
if cmd == "refresh" then
|
||||
-- Just refetch a fresh snapshot over HTTP. Do NOT touch sseActive here:
|
||||
-- the running stream (if any) is untouched by this, and forcing the
|
||||
-- flag false would bypass startSSEStream()'s duplicate guard and spawn
|
||||
-- a second stream on top of one that's still alive.
|
||||
fetchInitialStates()
|
||||
elseif cmd == "reload_entities" then
|
||||
local override = noctalia.state.get("entities_override")
|
||||
if type(override) == "table" then
|
||||
entities = override
|
||||
else
|
||||
entities = loadManagedEntities()
|
||||
end
|
||||
-- Entity filtering happens client-side in updateEntity(), so the
|
||||
-- existing stream keeps working for the new entity list as-is.
|
||||
-- The initial snapshot needs refetching, though, to account for any
|
||||
-- entities that were removed from the filter. See the note above for why
|
||||
-- sseActive must not be forced here.
|
||||
fetchInitialStates()
|
||||
end
|
||||
end)
|
||||
|
||||
noctalia.state.watch("panel_open_signal", function(value)
|
||||
if value then
|
||||
retryCount = 0
|
||||
maxRetriesNotified = false
|
||||
suspended = false
|
||||
end
|
||||
end)
|
||||
|
||||
function update()
|
||||
syncUpdateInterval()
|
||||
|
||||
if not isConfigured() then
|
||||
status = "unconfigured"
|
||||
noctalia.state.set("connection_status", status)
|
||||
sseActive = false
|
||||
return
|
||||
end
|
||||
|
||||
if suspended then
|
||||
return
|
||||
end
|
||||
|
||||
if status == "disconnected" then
|
||||
retryCount = retryCount + 1
|
||||
if retryCount >= MAX_RECONNECT_RETRIES then
|
||||
if not maxRetriesNotified then
|
||||
maxRetriesNotified = true
|
||||
noctalia.notifyError(noctalia.tr("notifications.reconnect_failed", { count = MAX_RECONNECT_RETRIES }))
|
||||
end
|
||||
suspended = true
|
||||
status = "disconnected"
|
||||
noctalia.state.set("connection_status", status)
|
||||
return
|
||||
end
|
||||
|
||||
status = "connecting"
|
||||
noctalia.state.set("connection_status", status)
|
||||
fetchInitialStates()
|
||||
end
|
||||
end
|
||||
|
||||
function onExit()
|
||||
if streamHandle then
|
||||
streamHandle.stop()
|
||||
streamHandle = nil
|
||||
end
|
||||
sseActive = false
|
||||
end
|
||||
|
||||
noctalia.state.set("connection_status", status)
|
||||
noctalia.state.set("entity_count", 0)
|
||||
noctalia.state.set("entities", {})
|
||||
|
||||
if isConfigured() then
|
||||
status = "connecting"
|
||||
noctalia.state.set("connection_status", status)
|
||||
fetchInitialStates()
|
||||
end
|
||||
@@ -0,0 +1,144 @@
|
||||
--!nonstrict
|
||||
|
||||
local haUrl = noctalia.getConfig("ha_url")
|
||||
local haToken = noctalia.getConfig("ha_token")
|
||||
local configuredEntityId = noctalia.getConfig("shortcut_entity_1") or ""
|
||||
local SLOT_LABEL = noctalia.tr("shortcut.label_1")
|
||||
|
||||
local entityStates = {}
|
||||
local pendingToggle = {}
|
||||
local pendingSince = {}
|
||||
local pendingTimerToken = {}
|
||||
local pendingTimerSeq = 0
|
||||
|
||||
local PENDING_TIMEOUT_SECONDS = 8
|
||||
local render
|
||||
|
||||
local function getCleanToken()
|
||||
if type(haToken) ~= "string" then return "" end
|
||||
return haToken:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
end
|
||||
|
||||
local function stateGlyph(domain, isOn)
|
||||
if domain == "light" then return isOn and "bulb" or "bulb-off"
|
||||
elseif domain == "switch" or domain == "input_boolean" then return isOn and "toggle-right" or "toggle-left"
|
||||
elseif domain == "fan" then return isOn and "wind" or "wind-off"
|
||||
elseif domain == "lock" then return isOn and "lock" or "lock-open"
|
||||
elseif domain == "cover" then return isOn and "door-open" or "door"
|
||||
end
|
||||
return "smart-home"
|
||||
end
|
||||
|
||||
local function getEntity()
|
||||
if configuredEntityId == "" then return nil end
|
||||
return entityStates[configuredEntityId]
|
||||
end
|
||||
|
||||
local function clearPending(entityId)
|
||||
pendingToggle[entityId] = nil
|
||||
pendingSince[entityId] = nil
|
||||
pendingTimerToken[entityId] = nil
|
||||
end
|
||||
|
||||
local function isPendingTimedOut(entityId, now)
|
||||
local startedAt = pendingSince[entityId]
|
||||
if not startedAt then return false end
|
||||
return (now or os.clock()) - startedAt >= PENDING_TIMEOUT_SECONDS
|
||||
end
|
||||
|
||||
local function reconcilePending(now)
|
||||
for entityId, expected in pairs(pendingToggle) do
|
||||
local real = entityStates[entityId]
|
||||
if not real or real.state == expected or isPendingTimedOut(entityId, now) then
|
||||
clearPending(entityId)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function schedulePendingTimeout(entityId)
|
||||
pendingTimerSeq = pendingTimerSeq + 1
|
||||
local token = pendingTimerSeq
|
||||
pendingSince[entityId] = os.clock()
|
||||
pendingTimerToken[entityId] = token
|
||||
|
||||
noctalia.runAsync("sleep " .. tostring(PENDING_TIMEOUT_SECONDS), function(_result)
|
||||
if pendingTimerToken[entityId] ~= token then return end
|
||||
if pendingToggle[entityId] and isPendingTimedOut(entityId) then
|
||||
clearPending(entityId)
|
||||
render()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
render = function()
|
||||
reconcilePending(os.clock())
|
||||
|
||||
local entity = getEntity()
|
||||
|
||||
if not entity then
|
||||
shortcut.setLabel(configuredEntityId ~= "" and configuredEntityId or SLOT_LABEL)
|
||||
shortcut.setIcon("smart-home")
|
||||
shortcut.setActive(false)
|
||||
shortcut.setEnabled(false)
|
||||
return
|
||||
end
|
||||
|
||||
local eid = entity.entity_id
|
||||
local pending = pendingToggle[eid]
|
||||
|
||||
if pending then
|
||||
shortcut.setLabel(entity.friendly_name or eid)
|
||||
shortcut.setIcon("loader")
|
||||
shortcut.setActive(pending == "on")
|
||||
shortcut.setEnabled(false)
|
||||
return
|
||||
end
|
||||
|
||||
local isOn = entity.state == "on"
|
||||
shortcut.setLabel(entity.friendly_name or eid)
|
||||
shortcut.setIcon(stateGlyph(entity.domain, true), stateGlyph(entity.domain, false))
|
||||
shortcut.setActive(isOn)
|
||||
shortcut.setEnabled(true)
|
||||
end
|
||||
|
||||
noctalia.state.watch("entities", function(value)
|
||||
entityStates = value or {}
|
||||
reconcilePending(os.clock())
|
||||
render()
|
||||
end)
|
||||
|
||||
noctalia.state.watch("connection_status", function(_value)
|
||||
render()
|
||||
end)
|
||||
|
||||
function onClick()
|
||||
reconcilePending(os.clock())
|
||||
|
||||
local entity = getEntity()
|
||||
local cleanToken = getCleanToken()
|
||||
if not entity or not haUrl or haUrl == "" or cleanToken == "" then return end
|
||||
|
||||
local eid = entity.entity_id
|
||||
if pendingToggle[eid] then return end
|
||||
|
||||
local newState = entity.state == "on" and "off" or "on"
|
||||
pendingToggle[eid] = newState
|
||||
schedulePendingTimeout(eid)
|
||||
|
||||
local encoded = noctalia.json.encode({ entity_id = eid })
|
||||
|
||||
noctalia.http({
|
||||
url = haUrl .. "/api/services/" .. entity.domain .. "/toggle",
|
||||
method = "POST",
|
||||
headers = {
|
||||
"Authorization: Bearer " .. cleanToken,
|
||||
"Content-Type: application/json",
|
||||
},
|
||||
body = encoded,
|
||||
}, function(response)end)
|
||||
|
||||
render()
|
||||
end
|
||||
|
||||
entityStates = noctalia.state.get("entities") or {}
|
||||
render()
|
||||
@@ -0,0 +1,144 @@
|
||||
--!nonstrict
|
||||
|
||||
local haUrl = noctalia.getConfig("ha_url")
|
||||
local haToken = noctalia.getConfig("ha_token")
|
||||
local configuredEntityId = noctalia.getConfig("shortcut_entity_2") or ""
|
||||
local SLOT_LABEL = noctalia.tr("shortcut.label_2")
|
||||
|
||||
local entityStates = {}
|
||||
local pendingToggle = {}
|
||||
local pendingSince = {}
|
||||
local pendingTimerToken = {}
|
||||
local pendingTimerSeq = 0
|
||||
|
||||
local PENDING_TIMEOUT_SECONDS = 8
|
||||
local render
|
||||
|
||||
local function getCleanToken()
|
||||
if type(haToken) ~= "string" then return "" end
|
||||
return haToken:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
end
|
||||
|
||||
local function stateGlyph(domain, isOn)
|
||||
if domain == "light" then return isOn and "bulb" or "bulb-off"
|
||||
elseif domain == "switch" or domain == "input_boolean" then return isOn and "toggle-right" or "toggle-left"
|
||||
elseif domain == "fan" then return isOn and "wind" or "wind-off"
|
||||
elseif domain == "lock" then return isOn and "lock" or "lock-open"
|
||||
elseif domain == "cover" then return isOn and "door-open" or "door"
|
||||
end
|
||||
return "smart-home"
|
||||
end
|
||||
|
||||
local function getEntity()
|
||||
if configuredEntityId == "" then return nil end
|
||||
return entityStates[configuredEntityId]
|
||||
end
|
||||
|
||||
local function clearPending(entityId)
|
||||
pendingToggle[entityId] = nil
|
||||
pendingSince[entityId] = nil
|
||||
pendingTimerToken[entityId] = nil
|
||||
end
|
||||
|
||||
local function isPendingTimedOut(entityId, now)
|
||||
local startedAt = pendingSince[entityId]
|
||||
if not startedAt then return false end
|
||||
return (now or os.clock()) - startedAt >= PENDING_TIMEOUT_SECONDS
|
||||
end
|
||||
|
||||
local function reconcilePending(now)
|
||||
for entityId, expected in pairs(pendingToggle) do
|
||||
local real = entityStates[entityId]
|
||||
if not real or real.state == expected or isPendingTimedOut(entityId, now) then
|
||||
clearPending(entityId)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function schedulePendingTimeout(entityId)
|
||||
pendingTimerSeq = pendingTimerSeq + 1
|
||||
local token = pendingTimerSeq
|
||||
pendingSince[entityId] = os.clock()
|
||||
pendingTimerToken[entityId] = token
|
||||
|
||||
noctalia.runAsync("sleep " .. tostring(PENDING_TIMEOUT_SECONDS), function(_result)
|
||||
if pendingTimerToken[entityId] ~= token then return end
|
||||
if pendingToggle[entityId] and isPendingTimedOut(entityId) then
|
||||
clearPending(entityId)
|
||||
render()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
render = function()
|
||||
reconcilePending(os.clock())
|
||||
|
||||
local entity = getEntity()
|
||||
|
||||
if not entity then
|
||||
shortcut.setLabel(configuredEntityId ~= "" and configuredEntityId or SLOT_LABEL)
|
||||
shortcut.setIcon("smart-home")
|
||||
shortcut.setActive(false)
|
||||
shortcut.setEnabled(false)
|
||||
return
|
||||
end
|
||||
|
||||
local eid = entity.entity_id
|
||||
local pending = pendingToggle[eid]
|
||||
|
||||
if pending then
|
||||
shortcut.setLabel(entity.friendly_name or eid)
|
||||
shortcut.setIcon("loader")
|
||||
shortcut.setActive(pending == "on")
|
||||
shortcut.setEnabled(false)
|
||||
return
|
||||
end
|
||||
|
||||
local isOn = entity.state == "on"
|
||||
shortcut.setLabel(entity.friendly_name or eid)
|
||||
shortcut.setIcon(stateGlyph(entity.domain, true), stateGlyph(entity.domain, false))
|
||||
shortcut.setActive(isOn)
|
||||
shortcut.setEnabled(true)
|
||||
end
|
||||
|
||||
noctalia.state.watch("entities", function(value)
|
||||
entityStates = value or {}
|
||||
reconcilePending(os.clock())
|
||||
render()
|
||||
end)
|
||||
|
||||
noctalia.state.watch("connection_status", function(_value)
|
||||
render()
|
||||
end)
|
||||
|
||||
function onClick()
|
||||
reconcilePending(os.clock())
|
||||
|
||||
local entity = getEntity()
|
||||
local cleanToken = getCleanToken()
|
||||
if not entity or not haUrl or haUrl == "" or cleanToken == "" then return end
|
||||
|
||||
local eid = entity.entity_id
|
||||
if pendingToggle[eid] then return end
|
||||
|
||||
local newState = entity.state == "on" and "off" or "on"
|
||||
pendingToggle[eid] = newState
|
||||
schedulePendingTimeout(eid)
|
||||
|
||||
local encoded = noctalia.json.encode({ entity_id = eid })
|
||||
|
||||
noctalia.http({
|
||||
url = haUrl .. "/api/services/" .. entity.domain .. "/toggle",
|
||||
method = "POST",
|
||||
headers = {
|
||||
"Authorization: Bearer " .. cleanToken,
|
||||
"Content-Type: application/json",
|
||||
},
|
||||
body = encoded,
|
||||
}, function(response)end)
|
||||
|
||||
render()
|
||||
end
|
||||
|
||||
entityStates = noctalia.state.get("entities") or {}
|
||||
render()
|
||||
@@ -0,0 +1,144 @@
|
||||
--!nonstrict
|
||||
|
||||
local haUrl = noctalia.getConfig("ha_url")
|
||||
local haToken = noctalia.getConfig("ha_token")
|
||||
local configuredEntityId = noctalia.getConfig("shortcut_entity_3") or ""
|
||||
local SLOT_LABEL = noctalia.tr("shortcut.label_3")
|
||||
|
||||
local entityStates = {}
|
||||
local pendingToggle = {}
|
||||
local pendingSince = {}
|
||||
local pendingTimerToken = {}
|
||||
local pendingTimerSeq = 0
|
||||
|
||||
local PENDING_TIMEOUT_SECONDS = 8
|
||||
local render
|
||||
|
||||
local function getCleanToken()
|
||||
if type(haToken) ~= "string" then return "" end
|
||||
return haToken:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
end
|
||||
|
||||
local function stateGlyph(domain, isOn)
|
||||
if domain == "light" then return isOn and "bulb" or "bulb-off"
|
||||
elseif domain == "switch" or domain == "input_boolean" then return isOn and "toggle-right" or "toggle-left"
|
||||
elseif domain == "fan" then return isOn and "wind" or "wind-off"
|
||||
elseif domain == "lock" then return isOn and "lock" or "lock-open"
|
||||
elseif domain == "cover" then return isOn and "door-open" or "door"
|
||||
end
|
||||
return "smart-home"
|
||||
end
|
||||
|
||||
local function getEntity()
|
||||
if configuredEntityId == "" then return nil end
|
||||
return entityStates[configuredEntityId]
|
||||
end
|
||||
|
||||
local function clearPending(entityId)
|
||||
pendingToggle[entityId] = nil
|
||||
pendingSince[entityId] = nil
|
||||
pendingTimerToken[entityId] = nil
|
||||
end
|
||||
|
||||
local function isPendingTimedOut(entityId, now)
|
||||
local startedAt = pendingSince[entityId]
|
||||
if not startedAt then return false end
|
||||
return (now or os.clock()) - startedAt >= PENDING_TIMEOUT_SECONDS
|
||||
end
|
||||
|
||||
local function reconcilePending(now)
|
||||
for entityId, expected in pairs(pendingToggle) do
|
||||
local real = entityStates[entityId]
|
||||
if not real or real.state == expected or isPendingTimedOut(entityId, now) then
|
||||
clearPending(entityId)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function schedulePendingTimeout(entityId)
|
||||
pendingTimerSeq = pendingTimerSeq + 1
|
||||
local token = pendingTimerSeq
|
||||
pendingSince[entityId] = os.clock()
|
||||
pendingTimerToken[entityId] = token
|
||||
|
||||
noctalia.runAsync("sleep " .. tostring(PENDING_TIMEOUT_SECONDS), function(_result)
|
||||
if pendingTimerToken[entityId] ~= token then return end
|
||||
if pendingToggle[entityId] and isPendingTimedOut(entityId) then
|
||||
clearPending(entityId)
|
||||
render()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
render = function()
|
||||
reconcilePending(os.clock())
|
||||
|
||||
local entity = getEntity()
|
||||
|
||||
if not entity then
|
||||
shortcut.setLabel(configuredEntityId ~= "" and configuredEntityId or SLOT_LABEL)
|
||||
shortcut.setIcon("smart-home")
|
||||
shortcut.setActive(false)
|
||||
shortcut.setEnabled(false)
|
||||
return
|
||||
end
|
||||
|
||||
local eid = entity.entity_id
|
||||
local pending = pendingToggle[eid]
|
||||
|
||||
if pending then
|
||||
shortcut.setLabel(entity.friendly_name or eid)
|
||||
shortcut.setIcon("loader")
|
||||
shortcut.setActive(pending == "on")
|
||||
shortcut.setEnabled(false)
|
||||
return
|
||||
end
|
||||
|
||||
local isOn = entity.state == "on"
|
||||
shortcut.setLabel(entity.friendly_name or eid)
|
||||
shortcut.setIcon(stateGlyph(entity.domain, true), stateGlyph(entity.domain, false))
|
||||
shortcut.setActive(isOn)
|
||||
shortcut.setEnabled(true)
|
||||
end
|
||||
|
||||
noctalia.state.watch("entities", function(value)
|
||||
entityStates = value or {}
|
||||
reconcilePending(os.clock())
|
||||
render()
|
||||
end)
|
||||
|
||||
noctalia.state.watch("connection_status", function(_value)
|
||||
render()
|
||||
end)
|
||||
|
||||
function onClick()
|
||||
reconcilePending(os.clock())
|
||||
|
||||
local entity = getEntity()
|
||||
local cleanToken = getCleanToken()
|
||||
if not entity or not haUrl or haUrl == "" or cleanToken == "" then return end
|
||||
|
||||
local eid = entity.entity_id
|
||||
if pendingToggle[eid] then return end
|
||||
|
||||
local newState = entity.state == "on" and "off" or "on"
|
||||
pendingToggle[eid] = newState
|
||||
schedulePendingTimeout(eid)
|
||||
|
||||
local encoded = noctalia.json.encode({ entity_id = eid })
|
||||
|
||||
noctalia.http({
|
||||
url = haUrl .. "/api/services/" .. entity.domain .. "/toggle",
|
||||
method = "POST",
|
||||
headers = {
|
||||
"Authorization: Bearer " .. cleanToken,
|
||||
"Content-Type: application/json",
|
||||
},
|
||||
body = encoded,
|
||||
}, function(response)end)
|
||||
|
||||
render()
|
||||
end
|
||||
|
||||
entityStates = noctalia.state.get("entities") or {}
|
||||
render()
|
||||
@@ -0,0 +1,144 @@
|
||||
--!nonstrict
|
||||
|
||||
local haUrl = noctalia.getConfig("ha_url")
|
||||
local haToken = noctalia.getConfig("ha_token")
|
||||
local configuredEntityId = noctalia.getConfig("shortcut_entity_4") or ""
|
||||
local SLOT_LABEL = noctalia.tr("shortcut.label_4")
|
||||
|
||||
local entityStates = {}
|
||||
local pendingToggle = {}
|
||||
local pendingSince = {}
|
||||
local pendingTimerToken = {}
|
||||
local pendingTimerSeq = 0
|
||||
|
||||
local PENDING_TIMEOUT_SECONDS = 8
|
||||
local render
|
||||
|
||||
local function getCleanToken()
|
||||
if type(haToken) ~= "string" then return "" end
|
||||
return haToken:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
end
|
||||
|
||||
local function stateGlyph(domain, isOn)
|
||||
if domain == "light" then return isOn and "bulb" or "bulb-off"
|
||||
elseif domain == "switch" or domain == "input_boolean" then return isOn and "toggle-right" or "toggle-left"
|
||||
elseif domain == "fan" then return isOn and "wind" or "wind-off"
|
||||
elseif domain == "lock" then return isOn and "lock" or "lock-open"
|
||||
elseif domain == "cover" then return isOn and "door-open" or "door"
|
||||
end
|
||||
return "smart-home"
|
||||
end
|
||||
|
||||
local function getEntity()
|
||||
if configuredEntityId == "" then return nil end
|
||||
return entityStates[configuredEntityId]
|
||||
end
|
||||
|
||||
local function clearPending(entityId)
|
||||
pendingToggle[entityId] = nil
|
||||
pendingSince[entityId] = nil
|
||||
pendingTimerToken[entityId] = nil
|
||||
end
|
||||
|
||||
local function isPendingTimedOut(entityId, now)
|
||||
local startedAt = pendingSince[entityId]
|
||||
if not startedAt then return false end
|
||||
return (now or os.clock()) - startedAt >= PENDING_TIMEOUT_SECONDS
|
||||
end
|
||||
|
||||
local function reconcilePending(now)
|
||||
for entityId, expected in pairs(pendingToggle) do
|
||||
local real = entityStates[entityId]
|
||||
if not real or real.state == expected or isPendingTimedOut(entityId, now) then
|
||||
clearPending(entityId)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function schedulePendingTimeout(entityId)
|
||||
pendingTimerSeq = pendingTimerSeq + 1
|
||||
local token = pendingTimerSeq
|
||||
pendingSince[entityId] = os.clock()
|
||||
pendingTimerToken[entityId] = token
|
||||
|
||||
noctalia.runAsync("sleep " .. tostring(PENDING_TIMEOUT_SECONDS), function(_result)
|
||||
if pendingTimerToken[entityId] ~= token then return end
|
||||
if pendingToggle[entityId] and isPendingTimedOut(entityId) then
|
||||
clearPending(entityId)
|
||||
render()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
render = function()
|
||||
reconcilePending(os.clock())
|
||||
|
||||
local entity = getEntity()
|
||||
|
||||
if not entity then
|
||||
shortcut.setLabel(configuredEntityId ~= "" and configuredEntityId or SLOT_LABEL)
|
||||
shortcut.setIcon("smart-home")
|
||||
shortcut.setActive(false)
|
||||
shortcut.setEnabled(false)
|
||||
return
|
||||
end
|
||||
|
||||
local eid = entity.entity_id
|
||||
local pending = pendingToggle[eid]
|
||||
|
||||
if pending then
|
||||
shortcut.setLabel(entity.friendly_name or eid)
|
||||
shortcut.setIcon("loader")
|
||||
shortcut.setActive(pending == "on")
|
||||
shortcut.setEnabled(false)
|
||||
return
|
||||
end
|
||||
|
||||
local isOn = entity.state == "on"
|
||||
shortcut.setLabel(entity.friendly_name or eid)
|
||||
shortcut.setIcon(stateGlyph(entity.domain, true), stateGlyph(entity.domain, false))
|
||||
shortcut.setActive(isOn)
|
||||
shortcut.setEnabled(true)
|
||||
end
|
||||
|
||||
noctalia.state.watch("entities", function(value)
|
||||
entityStates = value or {}
|
||||
reconcilePending(os.clock())
|
||||
render()
|
||||
end)
|
||||
|
||||
noctalia.state.watch("connection_status", function(_value)
|
||||
render()
|
||||
end)
|
||||
|
||||
function onClick()
|
||||
reconcilePending(os.clock())
|
||||
|
||||
local entity = getEntity()
|
||||
local cleanToken = getCleanToken()
|
||||
if not entity or not haUrl or haUrl == "" or cleanToken == "" then return end
|
||||
|
||||
local eid = entity.entity_id
|
||||
if pendingToggle[eid] then return end
|
||||
|
||||
local newState = entity.state == "on" and "off" or "on"
|
||||
pendingToggle[eid] = newState
|
||||
schedulePendingTimeout(eid)
|
||||
|
||||
local encoded = noctalia.json.encode({ entity_id = eid })
|
||||
|
||||
noctalia.http({
|
||||
url = haUrl .. "/api/services/" .. entity.domain .. "/toggle",
|
||||
method = "POST",
|
||||
headers = {
|
||||
"Authorization: Bearer " .. cleanToken,
|
||||
"Content-Type: application/json",
|
||||
},
|
||||
body = encoded,
|
||||
}, function(response)end)
|
||||
|
||||
render()
|
||||
end
|
||||
|
||||
entityStates = noctalia.state.get("entities") or {}
|
||||
render()
|
||||
@@ -0,0 +1,10 @@
|
||||
--!nonstrict
|
||||
|
||||
shortcut.setLabel(noctalia.tr("shortcut.panel_label"))
|
||||
shortcut.setIcon("layout-list")
|
||||
shortcut.setActive(false)
|
||||
shortcut.setEnabled(true)
|
||||
|
||||
function onClick()
|
||||
noctalia.togglePanel("pozzoo/hassio:entity_manager")
|
||||
end
|
||||
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"notifications": {
|
||||
"auth_failed": "Authentication failed. Check your access token.",
|
||||
"reconnect_failed": "Unable to reconnect to Home Assistant after {count} attempts."
|
||||
},
|
||||
"panel": {
|
||||
"auth_failed": "Authentication failed. Check your access token.",
|
||||
"brightness": "Brightness",
|
||||
"color_temp": "Color Temperature",
|
||||
"connecting": "Connecting…",
|
||||
"disconnected_reconnecting": "Disconnected. Reconnecting…",
|
||||
"hue": "Hue",
|
||||
"loading_entities": "Loading entities…",
|
||||
"manage_entities_title": "Manage Entities",
|
||||
"no_entities_found": "No entities found",
|
||||
"no_entities_match": "No entities match your search",
|
||||
"no_entities_monitored": "No entities monitored yet. Use the browser to add some.",
|
||||
"not_configured": "Not configured. Set your Home Assistant URL and token in settings.",
|
||||
"save_failed": "Failed to save monitored entities",
|
||||
"save_failed_no_data_dir": "Could not save: plugin data directory unavailable",
|
||||
"search_placeholder": "Search entities…",
|
||||
"state_on_brightness": "On · {percent}%",
|
||||
"status_fallback": "Status: {status}"
|
||||
},
|
||||
"settings": {
|
||||
"shortcut_entity": {
|
||||
"description": "Home Assistant entity ID to show and toggle on this tile (e.g. light.living_room)."
|
||||
},
|
||||
"shortcut_entity_1": {
|
||||
"label": "Quick Toggle 1 - Entity ID"
|
||||
},
|
||||
"shortcut_entity_2": {
|
||||
"label": "Quick Toggle 2 - Entity ID"
|
||||
},
|
||||
"shortcut_entity_3": {
|
||||
"label": "Quick Toggle 3 - Entity ID"
|
||||
},
|
||||
"shortcut_entity_4": {
|
||||
"label": "Quick Toggle 4 - Entity ID"
|
||||
},
|
||||
"show_entity_count": {
|
||||
"label": "Show entity count in widget"
|
||||
},
|
||||
"token": {
|
||||
"description": "Create a token in Home Assistant under Profile → Security → Long-lived access tokens",
|
||||
"label": "Long-Lived Access Token"
|
||||
},
|
||||
"url": {
|
||||
"description": "The URL to your Home Assistant instance (e.g., http://homeassistant.local:8123)",
|
||||
"label": "Home Assistant URL"
|
||||
}
|
||||
},
|
||||
"shortcut": {
|
||||
"label_1": "Quick Toggle 1",
|
||||
"label_2": "Quick Toggle 2",
|
||||
"label_3": "Quick Toggle 3",
|
||||
"label_4": "Quick Toggle 4",
|
||||
"panel_label": "Entity Manager",
|
||||
"title": "Home Assistant"
|
||||
},
|
||||
"widget": {
|
||||
"refreshing_connection": "Refreshing connection…",
|
||||
"status_auth_failed": "Auth Failed",
|
||||
"status_connected": "Connected",
|
||||
"status_connecting": "Connecting",
|
||||
"status_disconnected": "Disconnected",
|
||||
"status_unconfigured": "Not Configured",
|
||||
"tooltip": "Home Assistant: {status}",
|
||||
"tooltip_entities": "Home Assistant: {status} ({count} entities)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"notifications": {
|
||||
"auth_failed": "Falha na autenticação. Verifique seu token de acesso.",
|
||||
"reconnect_failed": "Não foi possível reconectar ao Home Assistant após {count} tentativas."
|
||||
},
|
||||
"panel": {
|
||||
"auth_failed": "Falha na autenticação. Verifique seu token de acesso.",
|
||||
"brightness": "Brilho",
|
||||
"color_temp": "Temperatura de Cor",
|
||||
"connecting": "Conectando…",
|
||||
"disconnected_reconnecting": "Desconectado. Reconectando…",
|
||||
"hue": "Matiz",
|
||||
"loading_entities": "Carregando entidades…",
|
||||
"manage_entities_title": "Gerenciar Entidades",
|
||||
"no_entities_found": "Nenhuma entidade encontrada",
|
||||
"no_entities_match": "Nenhuma entidade corresponde à sua busca",
|
||||
"no_entities_monitored": "Nenhuma entidade monitorada ainda. Use o navegador para adicionar.",
|
||||
"not_configured": "Não configurado. Defina a URL e o token do Home Assistant nas configurações.",
|
||||
"save_failed": "Falha ao salvar as entidades monitoradas",
|
||||
"save_failed_no_data_dir": "Não foi possível salvar: diretório de dados do plugin indisponível",
|
||||
"search_placeholder": "Buscar entidades…",
|
||||
"state_on_brightness": "Ligado · {percent}%",
|
||||
"status_fallback": "Status: {status}"
|
||||
},
|
||||
"settings": {
|
||||
"shortcut_entity": {
|
||||
"description": "ID da entidade do Home Assistant para mostrar e alternar neste espaço (e.g. light.living_room)."
|
||||
},
|
||||
"shortcut_entity_1": {
|
||||
"label": "Botão Rápido 1 - ID da Entidade"
|
||||
},
|
||||
"shortcut_entity_2": {
|
||||
"label": "Botão Rápido 2 - ID da Entidade"
|
||||
},
|
||||
"shortcut_entity_3": {
|
||||
"label": "Botão Rápido 3 - ID da Entidade"
|
||||
},
|
||||
"shortcut_entity_4": {
|
||||
"label": "Botão Rápido 4 - ID da Entidade"
|
||||
},
|
||||
"show_entity_count": {
|
||||
"label": "Mostrar o Número de Entidades"
|
||||
},
|
||||
"token": {
|
||||
"description": "Crie um token no Home Assistant em Profile → Security → Long-lived access tokens",
|
||||
"label": "Token de Acesso Longo"
|
||||
},
|
||||
"url": {
|
||||
"description": "A URL para sua instância do Home Assistant (e.g., http://homeassistant.local:8123)",
|
||||
"label": "URL do Home Assistant"
|
||||
}
|
||||
},
|
||||
"shortcut": {
|
||||
"label_1": "Botão Rápido 1",
|
||||
"label_2": "Botão Rápido 2",
|
||||
"label_3": "Botão Rápido 3",
|
||||
"label_4": "Botão Rápido 4",
|
||||
"panel_label": "Gerenciador de Entidades",
|
||||
"title": "Home Assistant"
|
||||
},
|
||||
"widget": {
|
||||
"refreshing_connection": "Atualizando conexão…",
|
||||
"status_auth_failed": "Falha de Autenticação",
|
||||
"status_connected": "Conectado",
|
||||
"status_connecting": "Conectando",
|
||||
"status_disconnected": "Desconectado",
|
||||
"status_unconfigured": "Não Configurado",
|
||||
"tooltip": "Home Assistant: {status}",
|
||||
"tooltip_entities": "Home Assistant: {status} ({count} entidades)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
--!nonstrict
|
||||
|
||||
local haUrl = noctalia.getConfig("ha_url")
|
||||
local showEntityCount = noctalia.getConfig("show_entity_count")
|
||||
|
||||
local status = "unconfigured"
|
||||
local entityCount = 0
|
||||
|
||||
local render
|
||||
|
||||
local function tr(key, subst)
|
||||
return noctalia.tr("widget." .. key, subst)
|
||||
end
|
||||
|
||||
noctalia.state.watch("connection_status", function(value)
|
||||
status = value or "unconfigured"
|
||||
if render then render() end
|
||||
end)
|
||||
|
||||
noctalia.state.watch("entity_count", function(value)
|
||||
entityCount = value or 0
|
||||
if render then render() end
|
||||
end)
|
||||
|
||||
local function getStatusText()
|
||||
if status == "connected" then
|
||||
return tr("status_connected")
|
||||
elseif status == "connecting" then
|
||||
return tr("status_connecting")
|
||||
elseif status == "disconnected" then
|
||||
return tr("status_disconnected")
|
||||
elseif status == "auth_failed" then
|
||||
return tr("status_auth_failed")
|
||||
else
|
||||
return tr("status_unconfigured")
|
||||
end
|
||||
end
|
||||
|
||||
local function getStatusColor()
|
||||
if status == "connected" then
|
||||
return "primary"
|
||||
elseif status == "connecting" then
|
||||
return "on_error"
|
||||
elseif status == "disconnected" or status == "auth_failed" then
|
||||
return "error"
|
||||
else
|
||||
return "on_surface_variant"
|
||||
end
|
||||
end
|
||||
|
||||
render = function()
|
||||
barWidget.setGlyph("smart-home")
|
||||
barWidget.setGlyphColor(getStatusColor())
|
||||
|
||||
if showEntityCount and entityCount > 0 then
|
||||
barWidget.setText(tostring(entityCount))
|
||||
barWidget.setTooltip(tr("tooltip_entities", {
|
||||
status = getStatusText(),
|
||||
count = entityCount
|
||||
}))
|
||||
else
|
||||
barWidget.setTooltip(tr("tooltip", {
|
||||
status = getStatusText()
|
||||
}))
|
||||
end
|
||||
end
|
||||
|
||||
function update()
|
||||
noctalia.setUpdateInterval(5000)
|
||||
|
||||
if status == "unconfigured" then
|
||||
local serviceStatus = noctalia.state.get("connection_status")
|
||||
if serviceStatus then
|
||||
status = serviceStatus
|
||||
end
|
||||
end
|
||||
|
||||
render()
|
||||
end
|
||||
|
||||
function onClick()
|
||||
noctalia.togglePanel("pozzoo/hassio:entity_manager")
|
||||
end
|
||||
|
||||
function onRightClick()
|
||||
if haUrl and haUrl ~= "" then
|
||||
noctalia.runAsync("xdg-open " .. ("'" .. haUrl:gsub("'", "'\\''") .. "'"))
|
||||
end
|
||||
end
|
||||
|
||||
function onIpc(event, payload)
|
||||
if event == "refresh" then
|
||||
noctalia.state.set("command", "refresh")
|
||||
noctalia.notify(noctalia.tr("shortcut.title"), tr("refreshing_connection"))
|
||||
end
|
||||
end
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"title": "Conservation Mode",
|
||||
"shortcut": {
|
||||
"on": "Conservation On",
|
||||
"off": "Conservation Off",
|
||||
"na": "Conservation N/A"
|
||||
},
|
||||
"notify": {
|
||||
"enabled": "Enabled - charging capped for battery health.",
|
||||
"disabled": "Disabled - charging to 100%.",
|
||||
"setup_needed": "One-time setup needed - check the terminal window that just opened."
|
||||
},
|
||||
"error": {
|
||||
"device_not_found": "Could not find the ideapad_acpi conservation_mode sysfs file on this machine.",
|
||||
"read_failed": "Could not read the current conservation_mode state.",
|
||||
"write_failed": "Failed to write conservation_mode ({error}) and could not locate the plugin directory to run setup.",
|
||||
"write_failed_manual": "Failed to write conservation_mode ({error}). Run scripts/setup-permissions.sh with sudo manually, then log out and back in."
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
"disabled": "Disabled - charging to 100%.",
|
||||
"enabled": "Enabled - charging capped for battery health.",
|
||||
"setup_needed": "One-time setup needed - check the terminal window that just opened."
|
||||
},
|
||||
"shortcut": {
|
||||
"na": "Conservation N/A",
|
||||
"off": "Conservation Off",
|
||||
"on": "Conservation On"
|
||||
},
|
||||
"title": "Conservation Mode"
|
||||
}
|
||||
|
||||
@@ -1,29 +1,45 @@
|
||||
{
|
||||
"settings.iio.label": "IIO Tool",
|
||||
"settings.iio.description": "Executable used to control orientation",
|
||||
"settings.iio.options.sway":"iio-sway",
|
||||
"settings.iio.options.hyprland":"iio-hyprland",
|
||||
|
||||
"settings.transform.label": "Transform Order",
|
||||
"settings.transform.description":"Rotation values for Up, Left, Down, Right.",
|
||||
|
||||
"settings.wm.label": "Window Manager",
|
||||
"settings.wm.description": "Choose the window manager backend.",
|
||||
"settings.wm.options.sway":"Sway",
|
||||
"settings.wm.options.hyprland":"Hyprland",
|
||||
|
||||
|
||||
"settings.glyph.locked.label": "Locked Glyph",
|
||||
"settings.glyph.locked.description": "Icon displayed when the device is locked.",
|
||||
|
||||
"settings.glyph.unlocked.label": "Unlocked Glyph",
|
||||
"settings.glyph.unlocked.description": "Icon displayed when the device is unlocked.",
|
||||
|
||||
|
||||
"panel.title": "iio-lock",
|
||||
"panel.lock": "Toggle Lock",
|
||||
|
||||
"widget.locked": "Locked",
|
||||
"widget.unlocked": "Unlocked",
|
||||
"toggle.description": "Toggle orientation lock"
|
||||
"panel": {
|
||||
"lock": "Toggle Lock",
|
||||
"title": "iio-lock"
|
||||
},
|
||||
"settings": {
|
||||
"glyph": {
|
||||
"locked": {
|
||||
"description": "Icon displayed when the device is locked.",
|
||||
"label": "Locked Glyph"
|
||||
},
|
||||
"unlocked": {
|
||||
"description": "Icon displayed when the device is unlocked.",
|
||||
"label": "Unlocked Glyph"
|
||||
}
|
||||
},
|
||||
"iio": {
|
||||
"description": "Executable used to control orientation",
|
||||
"label": "IIO Tool",
|
||||
"options": {
|
||||
"hyprland": "iio-hyprland",
|
||||
"sway": "iio-sway"
|
||||
}
|
||||
},
|
||||
"transform": {
|
||||
"description": "Rotation values for Up, Left, Down, Right.",
|
||||
"label": "Transform Order"
|
||||
},
|
||||
"wm": {
|
||||
"description": "Choose the window manager backend.",
|
||||
"label": "Window Manager",
|
||||
"options": {
|
||||
"hyprland": "Hyprland",
|
||||
"sway": "Sway"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toggle": {
|
||||
"description": "Toggle orientation lock"
|
||||
},
|
||||
"widget": {
|
||||
"locked": "Locked",
|
||||
"unlocked": "Unlocked"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Lyrics
|
||||
|
||||
Lyrics adds a synchronized status-bar lyric display with album artwork, karaoke
|
||||
highlighting, animated line changes, and configurable online or local sources.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `h465855hgg/lyrics` |
|
||||
| Entries | Bar widget: `lyrics`; service: `service` |
|
||||
|
||||
## Requirements
|
||||
|
||||
Install `playerctl`, `python3`, and `cp` on `PATH`. The active media
|
||||
player must expose MPRIS metadata for automatic track and playback detection.
|
||||
|
||||
Noctalia installs the plugin files; it does not install system packages for you.
|
||||
To check or install the runtime packages automatically, run:
|
||||
|
||||
```sh
|
||||
sh scripts/setup-deps.sh --check
|
||||
sh scripts/setup-deps.sh
|
||||
```
|
||||
|
||||
Use `--yes` for unattended installs. The script supports `apt`, `dnf`,
|
||||
`pacman`, `zypper`, `apk`, and `xbps-install`.
|
||||
|
||||
## Usage
|
||||
|
||||
Enable `h465855hgg/lyrics`, then add the `lyrics` bar widget in Noctalia's bar
|
||||
settings. The background `service` detects the active MPRIS player, resolves
|
||||
lyrics, downloads or caches album artwork, and publishes playback state to the
|
||||
widget.
|
||||
|
||||
When several players are available, the service prefers a playing player, then
|
||||
a paused player, and keeps the current player when priorities are equal. The
|
||||
optional allowlist and blocklist match player names or instances and support
|
||||
`*` wildcards.
|
||||
|
||||
Left-click the widget to switch between lyrics and track information. Right-click
|
||||
to pause or resume the active player. Paused content is dimmed and all lyric,
|
||||
transition, and marquee animation stops until playback resumes.
|
||||
|
||||
When synchronized lyrics are unavailable, the widget displays
|
||||
`track title + artist`. Long lines pause at each end while scrolling. Intro and
|
||||
instrumental gaps can show a configurable cue such as `•••••`.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Status-bar widget:
|
||||
|
||||

|
||||
|
||||
Plugin settings:
|
||||
|
||||

|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `player_allowlist` | `string_list` | empty | Only uses matching MPRIS player names or instances; supports `*` wildcards. |
|
||||
| `player_blocklist` | `string_list` | empty | Ignores matching MPRIS player names or instances; takes priority over the allowlist. |
|
||||
| `lyrics_source` | `select` | `auto` | Selects automatic fallback, LRCLIB, public NetEase, MPRIS text, custom HTTP, or external IPC. |
|
||||
| `custom_url` | `string` | empty | HTTP URL template with `{title}`, `{artist}`, `{album}`, and `{duration}` placeholders. |
|
||||
| `custom_json_field` | `string` | `syncedLyrics` | Dotted field path containing an LRC string or timed-lines array in a JSON response. |
|
||||
| `cue_text` | `string` | `•••••` | Characters highlighted through long intro or instrumental gaps. |
|
||||
| `cue_font_mode` | `select` | `follow` | Follows Noctalia's interface font or uses a custom installed font for intro/interlude characters. |
|
||||
| `cue_font_family` | `string` | `sans-serif` | Installed font family used for intro/interlude characters in custom-font mode. |
|
||||
| `scroll_mode` | `select` | `auto` | Enables automatic marquee, forced marquee, or static truncation. |
|
||||
| `marquee_speed` | `int` | `30` | Approximate long-line scroll speed in logical pixels per second. |
|
||||
| `max_lines` | `int` | `1` | Number of lines shown on a vertical bar, from 1 to 3. |
|
||||
| `gradient` | `bool` | `true` | Enables progressive per-character highlighting. |
|
||||
| `animation` | `select` | `karaoke` | Chooses karaoke, cascade, wave, fade-only, or no line transition. |
|
||||
| `max_chars` | `int` | `15` | Number of visible Unicode characters before marquee scrolling starts. |
|
||||
| `char_width` | `int` | `9` | Estimated logical-pixel character width used for scroll timing and minimum layout width. |
|
||||
| `glyph` | `glyph` | `music` | Fallback icon shown when album artwork is unavailable. |
|
||||
| `show_artist` | `bool` | `true` | Includes the artist in track-information mode. |
|
||||
| `hide_when_paused` | `bool` | `false` | Hides the widget instead of dimming it while paused. |
|
||||
| `show_cover` | `bool` | `true` | Shows circular album artwork beside the lyrics. |
|
||||
| `active_color` | `color` | `on_surface` | Colors the current and already-sung lyric characters. |
|
||||
| `inactive_color` | `color` | `on_surface_variant` | Colors upcoming lyrics, paused playback, and secondary lines. |
|
||||
|
||||
## IPC
|
||||
|
||||
External players can set `lyrics_source` to `external` and address the singleton
|
||||
service with:
|
||||
|
||||
```sh
|
||||
noctalia msg plugin h465855hgg/lyrics:service all <event> '<payload>'
|
||||
```
|
||||
|
||||
Supported events:
|
||||
|
||||
- `push-lrc`: accepts synchronized or plain LRC text.
|
||||
- `push-json`: accepts JSON with a `lines` timed array or a `lyrics` LRC string.
|
||||
- `push-state`: also accepts `track`, `position`, `playing`, and `cover` fields.
|
||||
- `clear`: clears the currently published lyrics.
|
||||
|
||||
Line timestamps and character timestamps are milliseconds. MPRIS track duration
|
||||
and playback position are microseconds:
|
||||
|
||||
```json
|
||||
{"lines":[{"time":1200,"duration":1800,"text":"Hello","chars":[1200,1500,1800,2100,2400]}]}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
Automatic mode requests LRCLIB first, then the public NetEase Music API. Custom
|
||||
HTTP mode contacts only the configured endpoint. The plugin never reads browser
|
||||
cookies or player credentials.
|
||||
|
||||
The service runs `playerctl` to select, read, and control MPRIS playback, `python3` for the
|
||||
LRCLIB helper and dynamic-lyric parser, and `cp` to preserve temporary local cover
|
||||
files. Public NetEase requests use Noctalia's HTTP API. Query scratch files and
|
||||
downloaded cover images are written inside the plugin runtime directory. Remote
|
||||
code is never downloaded or executed.
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
# Decode NetEase KRC ("klyric") dynamic lyrics into per-character timings.
|
||||
# Reads the klyric field (base64 of "krc1" + zlib stream) from argv[1],
|
||||
# writes JSON: {"type":"krc","lines":[{"time":ms,"text":"...","chars":[ms,...]}]}
|
||||
import sys, json, base64, zlib, re
|
||||
|
||||
def find_zlib(buf):
|
||||
for i in range(len(buf) - 1):
|
||||
if buf[i] == 0x78 and buf[i + 1] in (0x01, 0x9c, 0xda):
|
||||
try:
|
||||
return zlib.decompress(buf[i:])
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
def decode_krc(raw):
|
||||
if isinstance(raw, str) and re.search(r"^\[\d+,\d+\]", raw, re.MULTILINE):
|
||||
return raw
|
||||
data = None
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
data = base64.b64decode(raw)
|
||||
except Exception:
|
||||
data = raw.encode("latin-1")
|
||||
else:
|
||||
data = raw
|
||||
if data[:4] == b"krc1":
|
||||
data = data[4:]
|
||||
dec = find_zlib(data)
|
||||
if dec is None:
|
||||
return None
|
||||
try:
|
||||
return dec.decode("utf-8")
|
||||
except Exception:
|
||||
return dec.decode("utf-8", "ignore")
|
||||
|
||||
LINE_RE = re.compile(r"^\[(\d+),(\d+)\](.*)$")
|
||||
PREFIX_SYL_RE = re.compile(r"(?:<|\()(\d+),(\d+)(?:,\d+)?(?:>|\))([^<(]*)")
|
||||
SUFFIX_SYL_RE = re.compile(r"(.*?)<(\d+),(\d+)(?:,\d+)?>")
|
||||
|
||||
def parse_krc(text):
|
||||
out = []
|
||||
for line in text.splitlines():
|
||||
m = LINE_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
start = int(m.group(1))
|
||||
dur = int(m.group(2))
|
||||
body = m.group(3)
|
||||
# NetEase has shipped both `(offset,duration,0)word` and
|
||||
# `word<offset,duration>` variants of its word-synced format.
|
||||
prefixed = PREFIX_SYL_RE.findall(body) if re.match(r"^[<(]\d+,", body) else []
|
||||
if prefixed:
|
||||
syl = [(word, int(offset), int(duration))
|
||||
for offset, duration, word in prefixed]
|
||||
else:
|
||||
syl = [(word, int(offset), int(duration))
|
||||
for word, offset, duration in SUFFIX_SYL_RE.findall(body)]
|
||||
if not syl and body.strip() != "":
|
||||
syl = [(body, 0, dur)]
|
||||
chars = []
|
||||
full = ""
|
||||
for (word, so, sd) in syl:
|
||||
n = len(word)
|
||||
if n == 0:
|
||||
continue
|
||||
for j in range(n):
|
||||
chars.append(start + so + (j * sd) // n)
|
||||
full += word[j]
|
||||
if full.strip() != "":
|
||||
out.append({"time": start, "duration": dur, "text": full, "chars": chars})
|
||||
return out
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({"type": "none"}))
|
||||
return
|
||||
try:
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
except Exception:
|
||||
print(json.dumps({"type": "none"}))
|
||||
return
|
||||
text = decode_krc(raw)
|
||||
if text is None:
|
||||
print(json.dumps({"type": "none"}))
|
||||
return
|
||||
print(json.dumps({"type": "krc", "lines": parse_krc(text)}, ensure_ascii=False))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys, os, json, urllib.request, urllib.parse
|
||||
|
||||
LRCLIB = "https://lrclib.net/api/search"
|
||||
|
||||
|
||||
def norm(s):
|
||||
return "".join(ch for ch in (s or "") if ch.isalnum() or "\u4e00" <= ch <= "\u9fff").lower()
|
||||
|
||||
|
||||
def http_get(url):
|
||||
req = urllib.request.Request(url, headers={
|
||||
"User-Agent": "lyrics-plugin/1.0",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return r.read().decode("utf-8", "ignore")
|
||||
|
||||
|
||||
def main():
|
||||
raw = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
title, artist, album = "test", "", ""
|
||||
if raw and os.path.isfile(raw):
|
||||
try:
|
||||
with open(raw, encoding="utf-8") as f:
|
||||
lines = [l.strip() for l in f.read().splitlines()]
|
||||
title = lines[0] if lines else "test"
|
||||
artist = lines[1] if len(lines) > 1 else ""
|
||||
album = lines[2] if len(lines) > 2 else ""
|
||||
except Exception as e:
|
||||
out = {"type": "none", "lines": [], "lrc": "", "diag": [f"query_file_read_err={e!r}"]}
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return
|
||||
else:
|
||||
title = raw or "test"
|
||||
|
||||
out = {"type": "none", "lines": [], "lrc": "", "diag": []}
|
||||
try:
|
||||
q = urllib.parse.urlencode({"track_name": title, "artist_name": artist or title})
|
||||
s = json.loads(http_get(LRCLIB + "?" + q))
|
||||
if not s:
|
||||
out["diag"].append("lrclib: no results")
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return
|
||||
|
||||
nart = norm(artist)
|
||||
nalb = norm(album)
|
||||
ntitle = norm(title)
|
||||
best = None
|
||||
for c in s:
|
||||
cn = norm(c.get("trackName", ""))
|
||||
ca = norm(c.get("artistName", ""))
|
||||
if ntitle and cn != ntitle and ntitle not in cn and cn not in ntitle:
|
||||
continue
|
||||
if nart and ca and (nart in ca or ca in nart):
|
||||
best = c
|
||||
break
|
||||
if nalb and norm(c.get("albumName", "")) == nalb:
|
||||
best = c
|
||||
break
|
||||
if best is None:
|
||||
best = c
|
||||
if best is None:
|
||||
best = s[0]
|
||||
|
||||
lrc = best.get("syncedLyrics") or best.get("plainLyrics") or ""
|
||||
if not lrc:
|
||||
out["diag"].append("lrclib: empty lyrics")
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return
|
||||
out["lrc"] = lrc
|
||||
out["type"] = "lrc"
|
||||
out["diag"].append(f"lrclib: {best.get('trackName')} / {best.get('artistName')} synced={bool(best.get('syncedLyrics'))}")
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
out["diag"].append(f"lrclib ERR: {e!r}")
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,472 @@
|
||||
--!nonstrict
|
||||
-- Synchronized lyrics bar widget with karaoke highlighting and long-line scrolling.
|
||||
|
||||
noctalia.setUpdateInterval(33)
|
||||
|
||||
local glyph = noctalia.getConfig("glyph")
|
||||
local showArtist = noctalia.getConfig("show_artist")
|
||||
local hideWhenPaused = noctalia.getConfig("hide_when_paused")
|
||||
local showCover = noctalia.getConfig("show_cover")
|
||||
if showCover == nil then showCover = true end
|
||||
local activeColor = noctalia.getConfig("active_color") or "on_surface"
|
||||
local inactiveColor = noctalia.getConfig("inactive_color") or "on_surface_variant"
|
||||
|
||||
-- These are plugin settings, not per-widget settings.
|
||||
local scrollMode = noctalia.getConfig("scroll_mode") or "auto"
|
||||
local marqueeSpeed = tonumber(noctalia.getConfig("marquee_speed")) or 30
|
||||
local maxLines = tonumber(noctalia.getConfig("max_lines")) or 1
|
||||
local maxChars = tonumber(noctalia.getConfig("max_chars")) or 15
|
||||
local charWidth = tonumber(noctalia.getConfig("char_width")) or 9
|
||||
local gradientOn = noctalia.getConfig("gradient")
|
||||
if gradientOn == nil then gradientOn = true end
|
||||
local animation = noctalia.getConfig("animation") or "karaoke"
|
||||
local cueText = noctalia.getConfig("cue_text") or "•••••"
|
||||
if cueText == "" then cueText = "•••••" end
|
||||
local cueFontMode = noctalia.getConfig("cue_font_mode") or "follow"
|
||||
local cueFontFamily = noctalia.getConfig("cue_font_family") or "sans-serif"
|
||||
if cueFontFamily == "" then cueFontFamily = "sans-serif" end
|
||||
|
||||
local track = nil
|
||||
local lyrics = nil
|
||||
local cover = nil
|
||||
local playing = false
|
||||
local showMode = "auto"
|
||||
local rendered = false
|
||||
local playerInstance = ""
|
||||
|
||||
local baselinePosition = 0
|
||||
local baselineClock = os.clock()
|
||||
local lastClock = 0
|
||||
local displayKey = ""
|
||||
local displayedLine = nil
|
||||
local outgoingLine = nil
|
||||
local transitionElapsed = 0
|
||||
local marqueeElapsed = 0
|
||||
local smoothProgress = 0
|
||||
|
||||
local FADE_OUT_MS = 130
|
||||
local FADE_IN_MS = 260
|
||||
local MARQUEE_HOLD = 1.1
|
||||
local BLEND_MS = 350
|
||||
local INTERLUDE_GAP_MS = 7000
|
||||
local INTRO_MIN_MS = 6000
|
||||
local INTERLUDE_MIN_MS = 8000
|
||||
|
||||
local function shellQuote(value)
|
||||
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function clamp(value, low, high)
|
||||
return math.min(high, math.max(low, value))
|
||||
end
|
||||
|
||||
local function easeOutCubic(t)
|
||||
local u = 1 - clamp(t, 0, 1)
|
||||
return 1 - u * u * u
|
||||
end
|
||||
|
||||
local function toChars(text)
|
||||
local chars = {}
|
||||
local i = 1
|
||||
while i <= #text do
|
||||
local byte = text:byte(i)
|
||||
local length = 1
|
||||
if byte >= 0xF0 then length = 4
|
||||
elseif byte >= 0xE0 then length = 3
|
||||
elseif byte >= 0xC0 then length = 2 end
|
||||
chars[#chars + 1] = text:sub(i, i + length - 1)
|
||||
i = i + length
|
||||
end
|
||||
return chars
|
||||
end
|
||||
|
||||
local function getProgressMs()
|
||||
local elapsed = playing and (os.clock() - baselineClock) * 1000 or 0
|
||||
return baselinePosition / 1000 + elapsed
|
||||
end
|
||||
|
||||
local function getLineInfo()
|
||||
if not lyrics or #lyrics == 0 or not track then return nil end
|
||||
|
||||
local position = getProgressMs()
|
||||
local synced = lyrics[1].time >= 0
|
||||
if not synced then
|
||||
return { key = "plain", index = 1, line = lyrics[1], progress = 0, synced = false }
|
||||
end
|
||||
|
||||
local firstTime = lyrics[1].time
|
||||
if firstTime >= INTRO_MIN_MS and position < firstTime then
|
||||
return {
|
||||
key = "intro",
|
||||
index = 0,
|
||||
line = { text = cueText, cue = true },
|
||||
progress = clamp(position / firstTime, 0, 1),
|
||||
synced = true,
|
||||
position = position,
|
||||
cue = true,
|
||||
}
|
||||
end
|
||||
|
||||
local index = 1
|
||||
for i = #lyrics, 1, -1 do
|
||||
if lyrics[i].time <= position then
|
||||
index = i
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
local line = lyrics[index]
|
||||
local startTime = line.time
|
||||
local durationMs = track.duration and track.duration > 0 and track.duration / 1000 or nil
|
||||
local nextLine = lyrics[index + 1]
|
||||
local nextTime = nextLine and nextLine.time
|
||||
or durationMs
|
||||
or (startTime + 4000)
|
||||
local lineEnd = nextTime
|
||||
|
||||
if line.duration and line.duration > 0 then
|
||||
lineEnd = math.min(nextTime, startTime + line.duration)
|
||||
elseif nextTime - startTime >= INTERLUDE_GAP_MS then
|
||||
-- LRC only marks line starts. Do not stretch a lyric across a long instrumental gap.
|
||||
local estimatedDuration = clamp(#toChars(line.text) * 320, 3200, 6000)
|
||||
lineEnd = math.min(nextTime, startTime + estimatedDuration)
|
||||
end
|
||||
|
||||
-- Only show an interlude between two real lyric lines. The final lyric stays
|
||||
-- visible through the outro instead of turning the whole song ending into dots.
|
||||
if nextLine and nextTime - lineEnd >= INTERLUDE_MIN_MS and position >= lineEnd then
|
||||
return {
|
||||
key = "interlude-" .. tostring(index),
|
||||
index = index,
|
||||
line = { text = cueText, cue = true },
|
||||
progress = clamp((position - lineEnd) / (nextTime - lineEnd), 0, 1),
|
||||
synced = true,
|
||||
position = position,
|
||||
cue = true,
|
||||
}
|
||||
end
|
||||
|
||||
local progress = lineEnd > startTime and clamp((position - startTime) / (lineEnd - startTime), 0, 1) or 1
|
||||
|
||||
return {
|
||||
key = "line-" .. tostring(index),
|
||||
index = index,
|
||||
line = line,
|
||||
progress = progress,
|
||||
synced = true,
|
||||
position = position,
|
||||
}
|
||||
end
|
||||
|
||||
local function getTrackLabel()
|
||||
if not track then return "--" end
|
||||
if not showArtist then return track.title end
|
||||
if track.artist and track.artist ~= "" then
|
||||
return track.artist .. " - " .. track.title
|
||||
end
|
||||
return track.title
|
||||
end
|
||||
|
||||
local function getFallbackLabel()
|
||||
if not track then return "--" end
|
||||
if track.artist and track.artist ~= "" then
|
||||
return track.title .. " + " .. track.artist
|
||||
end
|
||||
return track.title
|
||||
end
|
||||
|
||||
local function shouldMarquee(text)
|
||||
return scrollMode ~= "static" and #toChars(text) > maxChars
|
||||
end
|
||||
|
||||
local function getMarqueeOffset(length)
|
||||
local distance = math.max(0, length - maxChars)
|
||||
if distance == 0 then return 0 end
|
||||
|
||||
-- The setting is pixels per second; convert it to character cells per second.
|
||||
local charsPerSecond = math.max(0.5, marqueeSpeed / math.max(1, charWidth))
|
||||
local travelTime = distance / charsPerSecond
|
||||
local cycle = MARQUEE_HOLD * 2 + travelTime * 2
|
||||
local phase = marqueeElapsed % cycle
|
||||
|
||||
if phase < MARQUEE_HOLD then return 0 end
|
||||
phase = phase - MARQUEE_HOLD
|
||||
if phase < travelTime then return distance * (phase / travelTime) end
|
||||
phase = phase - travelTime
|
||||
if phase < MARQUEE_HOLD then return distance end
|
||||
phase = phase - MARQUEE_HOLD
|
||||
return distance * (1 - phase / travelTime)
|
||||
end
|
||||
|
||||
local function karaokeColor(charTime, charProgress, position, useGradient)
|
||||
if not playing then return inactiveColor end
|
||||
local active = activeColor
|
||||
local inactive = inactiveColor
|
||||
if charTime and position then
|
||||
if not useGradient then
|
||||
return position >= charTime and active or inactive
|
||||
end
|
||||
if position >= charTime then return active end
|
||||
local distance = charTime - position
|
||||
if distance >= BLEND_MS then return inactive end
|
||||
local alpha = 1 - distance / BLEND_MS
|
||||
if alpha <= 0.5 then return inactive end
|
||||
return active .. "/" .. string.format("%.2f", math.max(0.72, alpha))
|
||||
end
|
||||
|
||||
if not useGradient then
|
||||
return smoothProgress >= charProgress and active or inactive
|
||||
end
|
||||
local distance = charProgress - smoothProgress
|
||||
if distance <= 0 then return active end
|
||||
if distance >= 0.16 then return inactive end
|
||||
local alpha = 1 - distance / 0.16
|
||||
if alpha <= 0.5 then return inactive end
|
||||
return active .. "/" .. string.format("%.2f", math.max(0.72, alpha))
|
||||
end
|
||||
|
||||
local function buildLineRow(line, opts)
|
||||
opts = opts or {}
|
||||
local text = type(line) == "table" and line.text or line
|
||||
local times = type(line) == "table" and line.chars or nil
|
||||
local isCue = opts.cue or (type(line) == "table" and line.cue == true)
|
||||
local chars = toChars(text or "")
|
||||
if #chars == 0 then chars = { " " } end
|
||||
|
||||
local exactOffset = opts.marquee and getMarqueeOffset(#chars) or 0
|
||||
local offset = math.floor(exactOffset)
|
||||
local fraction = exactOffset - offset
|
||||
local first = offset + 1
|
||||
local last = math.min(#chars, first + maxChars)
|
||||
local labels = {}
|
||||
|
||||
for sourceIndex = first, last do
|
||||
local active = playing and activeColor or inactiveColor
|
||||
local color = opts.dim and inactiveColor or active
|
||||
if not opts.solid and not opts.dim then
|
||||
local progress = #chars > 1 and (sourceIndex - 1) / (#chars - 1) or 0
|
||||
color = karaokeColor(times and times[sourceIndex], progress, opts.position, opts.useGradient)
|
||||
end
|
||||
|
||||
-- Soft viewport edges make cell-by-cell marquee movement less abrupt.
|
||||
local alpha = 1
|
||||
local edgeAlpha = noctalia.isDarkMode() and 0.5 or 0.72
|
||||
if opts.marquee and offset > 0 and sourceIndex == first then alpha = edgeAlpha end
|
||||
if opts.marquee and last < #chars and sourceIndex == last then alpha = edgeAlpha end
|
||||
if opts.cascade then
|
||||
local charProgress = (sourceIndex - first) / math.max(1, last - first)
|
||||
local cascadeProgress = clamp((transitionElapsed - FADE_OUT_MS) / 420, 0, 1)
|
||||
alpha = alpha * clamp((cascadeProgress - charProgress * 0.45) * 2.4, 0.08, 1)
|
||||
end
|
||||
if opts.wave then
|
||||
local waveProgress = clamp((transitionElapsed - FADE_OUT_MS) / 650, 0, 1)
|
||||
local wave = 0.55 + 0.45 * math.sin(waveProgress * math.pi * 2 - sourceIndex * 0.78)
|
||||
alpha = alpha * (wave * (1 - waveProgress) + waveProgress)
|
||||
end
|
||||
|
||||
-- Bar labels elide glyphs when constrained below their natural width, so
|
||||
-- animate the viewport edges with opacity without clipping CJK characters.
|
||||
if opts.marquee and fraction > 0 then
|
||||
if sourceIndex == first then
|
||||
alpha = alpha * (1 - fraction)
|
||||
elseif sourceIndex == first + maxChars then
|
||||
alpha = alpha * fraction
|
||||
end
|
||||
elseif sourceIndex == first + maxChars then
|
||||
alpha = 0
|
||||
end
|
||||
|
||||
labels[#labels + 1] = ui.label({
|
||||
key = "char-" .. tostring(sourceIndex),
|
||||
text = chars[sourceIndex],
|
||||
color = color,
|
||||
opacity = alpha,
|
||||
maxLines = 1,
|
||||
fontFamily = isCue and cueFontMode == "custom" and cueFontFamily or nil,
|
||||
})
|
||||
end
|
||||
|
||||
if opts.marquee then
|
||||
while #labels < maxChars + 1 do
|
||||
labels[#labels + 1] = ui.label({ text = " ", opacity = 0, maxLines = 1 })
|
||||
end
|
||||
end
|
||||
|
||||
return ui.row({
|
||||
key = opts.key or "line",
|
||||
gap = 0,
|
||||
align = "center",
|
||||
minWidth = maxChars * charWidth,
|
||||
opacity = opts.opacity or 1,
|
||||
}, labels)
|
||||
end
|
||||
|
||||
local function currentTransitionLine(info)
|
||||
if animation == "none" or transitionElapsed >= FADE_OUT_MS then
|
||||
local opacity = animation == "none" and 1
|
||||
or easeOutCubic((transitionElapsed - FADE_OUT_MS) / FADE_IN_MS)
|
||||
return info and info.line or nil, opacity
|
||||
end
|
||||
return outgoingLine, 1 - easeOutCubic(transitionElapsed / FADE_OUT_MS)
|
||||
end
|
||||
|
||||
local function render()
|
||||
if hideWhenPaused and not playing then
|
||||
barWidget.setVisible(false)
|
||||
return
|
||||
end
|
||||
barWidget.setVisible(true)
|
||||
|
||||
local vertical = barWidget.isVertical()
|
||||
local useGradient = (animation == "karaoke" or animation == "cascade" or animation == "wave") and gradientOn
|
||||
local position = getProgressMs()
|
||||
local prefix = {}
|
||||
local coverPath = showCover and cover and cover ~= "" and noctalia.fileExists(cover) and cover or nil
|
||||
if coverPath then
|
||||
prefix[#prefix + 1] = ui.image({ path = coverPath, width = 18, height = 18, radius = 9, fit = "cover" })
|
||||
else
|
||||
prefix[#prefix + 1] = ui.glyph({
|
||||
name = glyph,
|
||||
size = 14,
|
||||
color = playing and activeColor or inactiveColor,
|
||||
})
|
||||
end
|
||||
|
||||
local lineNodes = {}
|
||||
if showMode == "track" then
|
||||
local label = getTrackLabel()
|
||||
lineNodes[1] = buildLineRow(label, {
|
||||
key = "track",
|
||||
marquee = shouldMarquee(label),
|
||||
solid = true,
|
||||
})
|
||||
else
|
||||
local info = getLineInfo()
|
||||
if info then
|
||||
local shownLine, opacity = currentTransitionLine(info)
|
||||
if shownLine then
|
||||
lineNodes[#lineNodes + 1] = buildLineRow(shownLine, {
|
||||
key = "current-" .. tostring(info.index),
|
||||
marquee = shownLine == info.line and shouldMarquee(shownLine.text),
|
||||
position = position,
|
||||
useGradient = shownLine == info.line and info.synced and useGradient,
|
||||
solid = not info.synced or (animation ~= "karaoke" and animation ~= "cascade" and animation ~= "wave"),
|
||||
cascade = shownLine == info.line and animation == "cascade",
|
||||
wave = shownLine == info.line and animation == "wave",
|
||||
opacity = opacity,
|
||||
cue = shownLine == info.line and info.cue == true,
|
||||
})
|
||||
end
|
||||
|
||||
if vertical and maxLines > 1 and shownLine == info.line then
|
||||
for i = 1, maxLines - 1 do
|
||||
local nextLine = lyrics[info.index + i]
|
||||
if not nextLine then break end
|
||||
lineNodes[#lineNodes + 1] = buildLineRow(nextLine, {
|
||||
key = "next-" .. tostring(info.index + i),
|
||||
dim = true,
|
||||
opacity = math.max(0.25, 0.58 - (i - 1) * 0.16),
|
||||
})
|
||||
end
|
||||
end
|
||||
else
|
||||
local label = getFallbackLabel()
|
||||
lineNodes[1] = buildLineRow(label, {
|
||||
key = "fallback",
|
||||
marquee = shouldMarquee(label),
|
||||
solid = true,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
if vertical then
|
||||
barWidget.render(ui.column({ gap = 3, align = "start", opacity = playing and 1 or 0.58 }, {
|
||||
ui.row({ gap = 6, align = "center" }, prefix),
|
||||
ui.column({ gap = 2, align = "start" }, lineNodes),
|
||||
}))
|
||||
else
|
||||
local children = {}
|
||||
for _, node in ipairs(prefix) do children[#children + 1] = node end
|
||||
children[#children + 1] = lineNodes[1]
|
||||
barWidget.render(ui.row({ gap = 6, align = "center", opacity = playing and 1 or 0.58 }, children))
|
||||
end
|
||||
|
||||
barWidget.setTooltip(getTrackLabel())
|
||||
rendered = true
|
||||
end
|
||||
|
||||
function onClick()
|
||||
showMode = showMode == "auto" and "track" or "auto"
|
||||
marqueeElapsed = 0
|
||||
if rendered then render() end
|
||||
end
|
||||
|
||||
function onRightClick()
|
||||
if playerInstance == "" then return end
|
||||
noctalia.runAsync("playerctl --player " .. shellQuote(playerInstance) .. " play-pause", function(result)
|
||||
if result.exitCode == 0 then
|
||||
local wasPlaying = playing
|
||||
if wasPlaying then baselinePosition = getProgressMs() * 1000 end
|
||||
playing = not wasPlaying
|
||||
noctalia.state.set("playing", playing)
|
||||
baselineClock = os.clock()
|
||||
render()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function update()
|
||||
local now = os.clock()
|
||||
local delta = lastClock > 0 and now - lastClock or 0
|
||||
lastClock = now
|
||||
|
||||
track = noctalia.state.get("track")
|
||||
lyrics = noctalia.state.get("lyrics")
|
||||
cover = noctalia.state.get("cover")
|
||||
playing = noctalia.state.get("playing") == true
|
||||
playerInstance = noctalia.state.get("player_instance") or ""
|
||||
|
||||
local info = getLineInfo()
|
||||
local nextKey = info and info.key or "fallback"
|
||||
if nextKey ~= displayKey then
|
||||
outgoingLine = displayedLine
|
||||
displayKey = nextKey
|
||||
transitionElapsed = animation == "none" and FADE_OUT_MS + FADE_IN_MS or 0
|
||||
marqueeElapsed = 0
|
||||
smoothProgress = info and info.progress or 0
|
||||
else
|
||||
local target = info and info.progress or 0
|
||||
smoothProgress = smoothProgress + (target - smoothProgress) * math.min(1, delta * 8)
|
||||
end
|
||||
displayedLine = info and info.line or nil
|
||||
|
||||
if playing then
|
||||
transitionElapsed = transitionElapsed + delta * 1000
|
||||
local text = showMode == "track" and getTrackLabel() or (info and info.line.text or "")
|
||||
if shouldMarquee(text) then marqueeElapsed = marqueeElapsed + delta end
|
||||
end
|
||||
|
||||
render()
|
||||
end
|
||||
|
||||
noctalia.state.watch("position", function(value)
|
||||
baselinePosition = tonumber(value) or 0
|
||||
baselineClock = os.clock()
|
||||
end)
|
||||
|
||||
noctalia.state.watch("lyrics", function(value)
|
||||
lyrics = value
|
||||
displayKey = ""
|
||||
displayedLine = nil
|
||||
outgoingLine = nil
|
||||
marqueeElapsed = 0
|
||||
end)
|
||||
|
||||
noctalia.state.watch("playing", function(value)
|
||||
-- Preserve the interpolated position when pausing between service polls.
|
||||
if playing and value ~= true then
|
||||
baselinePosition = getProgressMs() * 1000
|
||||
end
|
||||
playing = value == true
|
||||
baselineClock = os.clock()
|
||||
end)
|
||||
@@ -0,0 +1,593 @@
|
||||
--!nonstrict
|
||||
-- Lyrics — headless service.
|
||||
-- Polls MPRIS metadata via playerctl, fetches lyrics from NetEase Cloud Music
|
||||
-- via /api endpoints, publishes state. No polling delay.
|
||||
|
||||
noctalia.setUpdateInterval(250)
|
||||
|
||||
local cache = {}
|
||||
local coverCache = {}
|
||||
local lastTrackKey = ""
|
||||
local inFlight = nil
|
||||
local coverInFlight = nil
|
||||
local pluginDir = noctalia.pluginDir() or "/tmp"
|
||||
local cacheDir = pluginDir .. "/.cache"
|
||||
noctalia.mkdirAll(cacheDir)
|
||||
local krcTmp = cacheDir .. "/krc.tmp"
|
||||
local lyricsSource = noctalia.getConfig("lyrics_source") or "auto"
|
||||
local customUrl = noctalia.getConfig("custom_url") or ""
|
||||
local customJsonField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
|
||||
local currentTrack = nil
|
||||
local currentEmbeddedLyrics = ""
|
||||
local currentPlayerInstance = ""
|
||||
|
||||
local function normalizePatterns(value)
|
||||
if type(value) ~= "table" then return {} end
|
||||
local patterns = {}
|
||||
for _, pattern in ipairs(value) do
|
||||
pattern = tostring(pattern):lower():gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if pattern ~= "" then patterns[#patterns + 1] = pattern end
|
||||
end
|
||||
table.sort(patterns)
|
||||
return patterns
|
||||
end
|
||||
|
||||
local playerAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist"))
|
||||
local playerBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist"))
|
||||
|
||||
local function trackKey(track)
|
||||
return (track.playerInstance or "") .. "|" .. track.title .. "|" .. track.artist .. "|" .. track.album
|
||||
end
|
||||
|
||||
local function patternMatches(value, pattern)
|
||||
local luaPattern = "^" .. pattern:gsub("([%%%^%$%(%)%.%[%]%+%-%?])", "%%%1"):gsub("%*", ".*") .. "$"
|
||||
return value:lower():match(luaPattern) ~= nil
|
||||
end
|
||||
|
||||
local function matchesAny(player, patterns)
|
||||
for _, pattern in ipairs(patterns) do
|
||||
if patternMatches(player.name, pattern) or patternMatches(player.instance, pattern) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function playerAllowed(player)
|
||||
if #playerAllowlist > 0 and not matchesAny(player, playerAllowlist) then return false end
|
||||
return not matchesAny(player, playerBlocklist)
|
||||
end
|
||||
|
||||
local function selectPlayer(players)
|
||||
local best = nil
|
||||
local bestRank = -1
|
||||
for _, player in ipairs(players) do
|
||||
if playerAllowed(player) then
|
||||
local rank = player.status == "playing" and 2 or (player.status == "paused" and 1 or 0)
|
||||
if rank > bestRank or (rank == bestRank and player.instance == currentPlayerInstance) then
|
||||
best = player
|
||||
bestRank = rank
|
||||
end
|
||||
end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
local function clearPlayerState()
|
||||
currentPlayerInstance = ""
|
||||
currentTrack = nil
|
||||
currentEmbeddedLyrics = ""
|
||||
lastTrackKey = ""
|
||||
inFlight = nil
|
||||
coverInFlight = nil
|
||||
noctalia.state.set("player_instance", nil)
|
||||
noctalia.state.set("player_name", nil)
|
||||
noctalia.state.set("track", nil)
|
||||
noctalia.state.set("lyrics", nil)
|
||||
noctalia.state.set("cover", nil)
|
||||
noctalia.state.set("playing", false)
|
||||
end
|
||||
|
||||
local function coverPathFor(track)
|
||||
local safe = (track.artist .. "_" .. track.album .. "_" .. track.title):gsub("[^%w]+", "_")
|
||||
return cacheDir .. "/cover_" .. safe .. ".jpg"
|
||||
end
|
||||
|
||||
local function shellQuote(value)
|
||||
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function parseLRC(lrcText)
|
||||
local lines = {}
|
||||
for line in lrcText:gmatch("[^\n]+") do
|
||||
local mins, secs = line:match("%[(%d+):(%d+%.?%d*)%]")
|
||||
if mins and secs then
|
||||
local ms = math.floor(tonumber(mins) * 60000 + tonumber(secs) * 1000)
|
||||
local text = line:gsub("%[%d+:%d+%.?%d*%]", ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if text ~= "" then
|
||||
local isMeta = ms < 5000 and (text:find(":") or text:find(":"))
|
||||
if not isMeta then
|
||||
lines[#lines + 1] = { time = ms, text = text }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if #lines == 0 then return nil end
|
||||
return lines
|
||||
end
|
||||
|
||||
local function parsePlain(text)
|
||||
local lines = {}
|
||||
local seenLyric = false
|
||||
for line in text:gmatch("[^\n]+") do
|
||||
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if trimmed ~= "" then
|
||||
if seenLyric then
|
||||
lines[#lines + 1] = { time = -1, text = trimmed }
|
||||
elseif trimmed:find(":") or trimmed:find(":") then
|
||||
-- skip metadata header line
|
||||
else
|
||||
seenLyric = true
|
||||
lines[#lines + 1] = { time = -1, text = trimmed }
|
||||
end
|
||||
end
|
||||
end
|
||||
if #lines == 0 then return nil end
|
||||
return lines
|
||||
end
|
||||
|
||||
local function evictCache()
|
||||
local keys = {}
|
||||
for k, _ in pairs(cache) do keys[#keys + 1] = k end
|
||||
if #keys > 30 then
|
||||
table.sort(keys)
|
||||
for i = 1, #keys - 30 do cache[keys[i]] = nil end
|
||||
end
|
||||
end
|
||||
|
||||
local function fetchLyricsNetEase(track, embeddedLyrics)
|
||||
local tk = trackKey(track)
|
||||
if inFlight == tk then return end
|
||||
inFlight = tk
|
||||
|
||||
local function tryFetch(query, fallback)
|
||||
local searchUrl = "https://music.163.com/api/search/get?type=1&s=" .. noctalia.string.urlEncode(query) .. "&limit=5"
|
||||
|
||||
noctalia.http({ url = searchUrl, headers = { "Referer: https://music.163.com" } }, function(r1)
|
||||
if inFlight ~= tk then return end
|
||||
if tk ~= lastTrackKey then inFlight = nil; return end
|
||||
|
||||
if not r1.ok or r1.status < 200 or r1.status >= 300 or not r1.body or #r1.body == 0 then
|
||||
if fallback then fallback()
|
||||
else inFlight = nil; noctalia.state.set("lyrics", nil) end
|
||||
return
|
||||
end
|
||||
|
||||
local data = noctalia.json.decode(r1.body)
|
||||
if not data or not data.result or not data.result.songs or #data.result.songs == 0 then
|
||||
if fallback then fallback()
|
||||
else inFlight = nil; noctalia.state.set("lyrics", nil) end
|
||||
return
|
||||
end
|
||||
|
||||
local function songArtist(s)
|
||||
if s.artists and #s.artists > 0 then
|
||||
return (s.artists[1].name or ""):lower()
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
local bestMatch = nil
|
||||
local trackArtist = track.artist:lower()
|
||||
for _, s in ipairs(data.result.songs) do
|
||||
if songArtist(s):find(trackArtist, 1, true) then
|
||||
bestMatch = s
|
||||
break
|
||||
end
|
||||
end
|
||||
if not bestMatch then
|
||||
bestMatch = data.result.songs[1]
|
||||
end
|
||||
|
||||
local songId = tostring(bestMatch.id or "")
|
||||
if not songId:match("^%d+$") then
|
||||
if fallback then fallback()
|
||||
else inFlight = nil; noctalia.state.set("lyrics", nil) end
|
||||
return
|
||||
end
|
||||
local lyricUrl = "https://music.163.com/api/song/lyric?id=" .. noctalia.string.urlEncode(songId) .. "&lv=1&kv=1&tv=-1"
|
||||
|
||||
noctalia.http({ url = lyricUrl, headers = { "Referer: https://music.163.com" } }, function(r2)
|
||||
if inFlight ~= tk then return end
|
||||
if tk ~= lastTrackKey then inFlight = nil; return end
|
||||
|
||||
local lyrics = nil
|
||||
if r2.ok and r2.status >= 200 and r2.status < 300 and r2.body and #r2.body > 0 then
|
||||
local ldata = noctalia.json.decode(r2.body)
|
||||
local klyricStr = ""
|
||||
if ldata and ldata.klyric then
|
||||
local k = ldata.klyric
|
||||
if type(k) == "string" then
|
||||
klyricStr = k
|
||||
elseif type(k) == "table" then
|
||||
klyricStr = (k.lyric and type(k.lyric) == "string") and k.lyric or ""
|
||||
end
|
||||
end
|
||||
if klyricStr ~= "" then
|
||||
local ok = pcall(noctalia.writeFile, krcTmp, klyricStr)
|
||||
if not ok then klyricStr = "" end
|
||||
end
|
||||
if klyricStr ~= "" then
|
||||
local py = 'python3 "' .. noctalia.pluginDir() .. '/krc_decode.py" "' .. krcTmp .. '"'
|
||||
noctalia.runAsync(py, function(r3)
|
||||
if inFlight ~= tk then return end
|
||||
if tk ~= lastTrackKey then inFlight = nil; return end
|
||||
local ok, parsed = pcall(noctalia.json.decode, r3.stdout or "")
|
||||
if ok and parsed and parsed.type == "krc" and parsed.lines then
|
||||
cache[tk] = parsed.lines
|
||||
evictCache()
|
||||
inFlight = nil
|
||||
noctalia.state.set("lyrics", parsed.lines)
|
||||
return
|
||||
end
|
||||
local lrc = (ldata.lrc and ldata.lrc.lyric) or ""
|
||||
local lyr = parseLRC(lrc)
|
||||
if not lyr then lyr = parsePlain(lrc) end
|
||||
if lyr then
|
||||
cache[tk] = lyr
|
||||
evictCache()
|
||||
inFlight = nil
|
||||
noctalia.state.set("lyrics", lyr)
|
||||
elseif fallback then
|
||||
fallback()
|
||||
else
|
||||
inFlight = nil
|
||||
noctalia.state.set("lyrics", nil)
|
||||
end
|
||||
end)
|
||||
return
|
||||
end
|
||||
if ldata and ldata.lrc and ldata.lrc.lyric and ldata.lrc.lyric ~= "" then
|
||||
lyrics = parseLRC(ldata.lrc.lyric)
|
||||
if not lyrics then
|
||||
lyrics = parsePlain(ldata.lrc.lyric)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if lyrics then
|
||||
cache[tk] = lyrics
|
||||
evictCache()
|
||||
inFlight = nil
|
||||
noctalia.state.set("lyrics", lyrics)
|
||||
elseif fallback then
|
||||
fallback()
|
||||
else
|
||||
inFlight = nil
|
||||
noctalia.state.set("lyrics", nil)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
local query = track.title .. "\n" .. track.artist .. "\n" .. (track.album or "")
|
||||
local qTmp = cacheDir .. "/query.tmp"
|
||||
noctalia.writeFile(qTmp, query)
|
||||
local dir = noctalia.pluginDir() or "/tmp"
|
||||
|
||||
local function applyParsed(parsed)
|
||||
if parsed and parsed.type == "krc" and parsed.lines then
|
||||
cache[tk] = parsed.lines
|
||||
evictCache()
|
||||
noctalia.state.set("lyrics", parsed.lines)
|
||||
return true
|
||||
end
|
||||
if parsed and parsed.type == "lrc" and parsed.lrc and parsed.lrc ~= "" then
|
||||
local lyr = parseLRC(parsed.lrc)
|
||||
if not lyr then lyr = parsePlain(parsed.lrc) end
|
||||
if lyr then
|
||||
cache[tk] = lyr
|
||||
evictCache()
|
||||
noctalia.state.set("lyrics", lyr)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function runPy(script, cb)
|
||||
local py = 'python3 "' .. dir .. "/" .. script .. '" "' .. qTmp .. '"'
|
||||
noctalia.runAsync(py, function(r)
|
||||
if inFlight ~= tk then return end
|
||||
if tk ~= lastTrackKey then inFlight = nil; return end
|
||||
local ok, parsed = pcall(noctalia.json.decode, r.stdout or "")
|
||||
cb(ok and parsed or nil)
|
||||
end)
|
||||
end
|
||||
|
||||
local function applyText(text)
|
||||
if not text or text == "" then return false end
|
||||
local parsed = parseLRC(text)
|
||||
if not parsed then parsed = parsePlain(text) end
|
||||
if not parsed then return false end
|
||||
cache[tk] = parsed
|
||||
evictCache()
|
||||
noctalia.state.set("lyrics", parsed)
|
||||
return true
|
||||
end
|
||||
|
||||
local function fetchCustom()
|
||||
if customUrl == "" then
|
||||
inFlight = nil
|
||||
noctalia.state.set("lyrics", nil)
|
||||
return
|
||||
end
|
||||
|
||||
local replacements = {
|
||||
title = track.title,
|
||||
artist = track.artist,
|
||||
album = track.album,
|
||||
duration = tostring(math.floor((track.duration or 0) / 1000000)),
|
||||
}
|
||||
local url = customUrl:gsub("{([%w_]+)}", function(key)
|
||||
return noctalia.string.urlEncode(replacements[key] or "")
|
||||
end)
|
||||
|
||||
noctalia.http({ url = url, headers = { "Accept: application/json, text/plain" } }, function(response)
|
||||
if tk ~= lastTrackKey then inFlight = nil; return end
|
||||
local text = response.ok and response.body or ""
|
||||
if text ~= "" and customJsonField ~= "" then
|
||||
local decoded = noctalia.json.decode(text)
|
||||
for part in customJsonField:gmatch("[^.]+") do
|
||||
decoded = type(decoded) == "table" and decoded[part] or nil
|
||||
end
|
||||
if type(decoded) == "table" then
|
||||
cache[tk] = decoded
|
||||
evictCache()
|
||||
noctalia.state.set("lyrics", decoded)
|
||||
inFlight = nil
|
||||
return
|
||||
end
|
||||
text = type(decoded) == "string" and decoded or ""
|
||||
end
|
||||
applyText(text)
|
||||
inFlight = nil
|
||||
end)
|
||||
end
|
||||
|
||||
if lyricsSource == "external" then
|
||||
inFlight = nil
|
||||
return
|
||||
elseif lyricsSource == "mpris" then
|
||||
applyText(embeddedLyrics)
|
||||
inFlight = nil
|
||||
return
|
||||
elseif lyricsSource == "custom" then
|
||||
fetchCustom()
|
||||
return
|
||||
elseif lyricsSource == "netease" then
|
||||
tryFetch(track.title .. " " .. track.artist, function()
|
||||
tryFetch(track.title, nil)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- Auto: LRCLIB, then the public NetEase API.
|
||||
runPy("lrclib_lyric.py", function(parsed)
|
||||
if applyParsed(parsed) then inFlight = nil; return end
|
||||
if lyricsSource == "lrclib" then
|
||||
inFlight = nil
|
||||
noctalia.state.set("lyrics", nil)
|
||||
return
|
||||
end
|
||||
tryFetch(track.title .. " " .. track.artist, function()
|
||||
tryFetch(track.title, nil)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
local function fetchCover(track, artUrl)
|
||||
local tk = trackKey(track)
|
||||
if coverInFlight == tk then return end
|
||||
coverInFlight = tk
|
||||
|
||||
local dest = coverPathFor(track)
|
||||
|
||||
local apply = function(path)
|
||||
if coverInFlight ~= tk then return end
|
||||
coverInFlight = nil
|
||||
if tk ~= lastTrackKey then return end
|
||||
coverCache[tk] = path
|
||||
noctalia.state.set("cover", path)
|
||||
end
|
||||
|
||||
if not artUrl or artUrl == "" then
|
||||
noctalia.state.set("cover", nil)
|
||||
coverInFlight = nil
|
||||
return
|
||||
end
|
||||
|
||||
if artUrl:sub(1, 7) == "file://" then
|
||||
local source = noctalia.string.urlDecode(artUrl:sub(8))
|
||||
if not noctalia.fileExists(source) then
|
||||
apply(nil)
|
||||
return
|
||||
end
|
||||
noctalia.runAsync("cp -- " .. shellQuote(source) .. " " .. shellQuote(dest), function(result)
|
||||
if result.exitCode == 0 then apply(dest) else apply(source) end
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
noctalia.download(artUrl, dest, function(ok)
|
||||
if ok then apply(dest) else apply(nil) end
|
||||
end)
|
||||
end
|
||||
|
||||
local function poll()
|
||||
local cmd = [[playerctl --all-players metadata --format $'{{playerInstance}}\x1f{{playerName}}\x1f{{lc(status)}}\x1f{{title}}\x1f{{artist}}\x1f{{album}}\x1f{{position}}\x1f{{mpris:length}}\x1f{{mpris:artUrl}}\x1f{{xesam:asText}}\x1e' 2>/dev/null]]
|
||||
|
||||
noctalia.runAsync(cmd, function(r)
|
||||
if r.exitCode ~= 0 or not r.stdout or r.stdout == "" then
|
||||
clearPlayerState()
|
||||
return
|
||||
end
|
||||
|
||||
local players = {}
|
||||
local fieldSeparator = string.char(31)
|
||||
local recordSeparator = string.char(30)
|
||||
for record in (r.stdout .. recordSeparator):gmatch("(.-)" .. recordSeparator) do
|
||||
if record ~= "" then
|
||||
local parts = {}
|
||||
for field in (record .. fieldSeparator):gmatch("(.-)" .. fieldSeparator) do
|
||||
parts[#parts + 1] = field:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
end
|
||||
local player = {
|
||||
instance = parts[1] or "",
|
||||
name = parts[2] or "",
|
||||
status = parts[3] or "stopped",
|
||||
title = parts[4] or "",
|
||||
artist = parts[5] or "",
|
||||
album = parts[6] or "",
|
||||
position = tonumber(parts[7]) or 0,
|
||||
duration = tonumber(parts[8]) or 0,
|
||||
artUrl = parts[9] or "",
|
||||
embeddedLyrics = parts[10] or "",
|
||||
}
|
||||
if player.instance ~= "" or player.name ~= "" then players[#players + 1] = player end
|
||||
end
|
||||
end
|
||||
|
||||
local selected = selectPlayer(players)
|
||||
if not selected then
|
||||
clearPlayerState()
|
||||
return
|
||||
end
|
||||
|
||||
currentPlayerInstance = selected.instance
|
||||
noctalia.state.set("player_instance", selected.instance)
|
||||
noctalia.state.set("player_name", selected.name)
|
||||
|
||||
if selected.title == "" and selected.artist == "" then
|
||||
noctalia.state.set("track", nil)
|
||||
noctalia.state.set("lyrics", nil)
|
||||
noctalia.state.set("playing", selected.status == "playing")
|
||||
return
|
||||
end
|
||||
|
||||
local playing = selected.status == "playing"
|
||||
local t = {
|
||||
title = selected.title,
|
||||
artist = selected.artist,
|
||||
album = selected.album,
|
||||
status = selected.status,
|
||||
position = selected.position,
|
||||
duration = selected.duration,
|
||||
playerInstance = selected.instance,
|
||||
}
|
||||
local tk = trackKey(t)
|
||||
currentTrack = t
|
||||
currentEmbeddedLyrics = selected.embeddedLyrics
|
||||
|
||||
noctalia.state.set("position", t.position)
|
||||
|
||||
if tk ~= lastTrackKey then
|
||||
lastTrackKey = tk
|
||||
noctalia.state.set("track", t)
|
||||
noctalia.state.set("playing", playing)
|
||||
|
||||
local cached = cache[tk]
|
||||
if cached then
|
||||
noctalia.state.set("lyrics", cached)
|
||||
else
|
||||
noctalia.state.set("lyrics", nil)
|
||||
fetchLyricsNetEase(t, selected.embeddedLyrics)
|
||||
end
|
||||
|
||||
local cachedCover = coverCache[tk]
|
||||
if cachedCover then
|
||||
noctalia.state.set("cover", cachedCover)
|
||||
else
|
||||
noctalia.state.set("cover", nil)
|
||||
fetchCover(t, selected.artUrl)
|
||||
end
|
||||
else
|
||||
noctalia.state.set("track", t)
|
||||
noctalia.state.set("playing", playing)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local pollTick = 0
|
||||
|
||||
function update()
|
||||
if lyricsSource == "external" then return end
|
||||
pollTick = pollTick + 1
|
||||
if pollTick % 2 == 0 then
|
||||
poll()
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
function onConfigChanged()
|
||||
local nextSource = noctalia.getConfig("lyrics_source") or "auto"
|
||||
local nextUrl = noctalia.getConfig("custom_url") or ""
|
||||
local nextField = noctalia.getConfig("custom_json_field") or "syncedLyrics"
|
||||
local nextAllowlist = normalizePatterns(noctalia.getConfig("player_allowlist"))
|
||||
local nextBlocklist = normalizePatterns(noctalia.getConfig("player_blocklist"))
|
||||
local sourceChanged = nextSource ~= lyricsSource or nextUrl ~= customUrl or nextField ~= customJsonField
|
||||
local playersChanged = table.concat(nextAllowlist, "\n") ~= table.concat(playerAllowlist, "\n")
|
||||
or table.concat(nextBlocklist, "\n") ~= table.concat(playerBlocklist, "\n")
|
||||
lyricsSource = nextSource
|
||||
customUrl = nextUrl
|
||||
customJsonField = nextField
|
||||
playerAllowlist = nextAllowlist
|
||||
playerBlocklist = nextBlocklist
|
||||
if playersChanged then
|
||||
currentPlayerInstance = ""
|
||||
inFlight = nil
|
||||
poll()
|
||||
end
|
||||
if sourceChanged and currentTrack and not playersChanged then
|
||||
inFlight = nil
|
||||
cache = {}
|
||||
noctalia.state.set("lyrics", nil)
|
||||
fetchLyricsNetEase(currentTrack, currentEmbeddedLyrics)
|
||||
end
|
||||
end
|
||||
|
||||
local function applyPushedLyrics(payload)
|
||||
local decoded = noctalia.json.decode(payload or "")
|
||||
if type(decoded) == "table" then
|
||||
if decoded.track then
|
||||
currentTrack = decoded.track
|
||||
lastTrackKey = trackKey({
|
||||
playerInstance = decoded.track.playerInstance or "",
|
||||
title = decoded.track.title or "",
|
||||
artist = decoded.track.artist or "",
|
||||
album = decoded.track.album or "",
|
||||
})
|
||||
noctalia.state.set("track", decoded.track)
|
||||
end
|
||||
if decoded.lines then noctalia.state.set("lyrics", decoded.lines) end
|
||||
if decoded.lyrics then
|
||||
local parsed = parseLRC(decoded.lyrics) or parsePlain(decoded.lyrics)
|
||||
noctalia.state.set("lyrics", parsed)
|
||||
end
|
||||
if decoded.position ~= nil then noctalia.state.set("position", decoded.position) end
|
||||
if decoded.playing ~= nil then noctalia.state.set("playing", decoded.playing == true) end
|
||||
if decoded.cover ~= nil then noctalia.state.set("cover", decoded.cover) end
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function onIpc(event, payload)
|
||||
if event == "push-lrc" then
|
||||
noctalia.state.set("lyrics", parseLRC(payload or "") or parsePlain(payload or ""))
|
||||
elseif event == "push-json" or event == "push-state" then
|
||||
applyPushedLyrics(payload)
|
||||
elseif event == "clear" then
|
||||
noctalia.state.set("lyrics", nil)
|
||||
end
|
||||
end
|
||||
|
||||
if lyricsSource ~= "external" then poll() end
|
||||
@@ -0,0 +1,201 @@
|
||||
id = "h465855hgg/lyrics"
|
||||
name = "Lyrics"
|
||||
version = "1.3.0"
|
||||
plugin_api = 3
|
||||
author = "h465855hgg"
|
||||
license = "MIT"
|
||||
dependencies = ["playerctl", "python3", "cp"]
|
||||
tags = ["bar", "service", "music", "media", "animation"]
|
||||
icon = "music"
|
||||
description = "Synchronized lyrics with karaoke highlighting, animated transitions, and flexible lyric sources."
|
||||
|
||||
[[setting]]
|
||||
key = "player_allowlist"
|
||||
type = "string_list"
|
||||
label_key = "settings.player_allowlist.label"
|
||||
description_key = "settings.player_allowlist.description"
|
||||
default = []
|
||||
advanced = true
|
||||
|
||||
[[setting]]
|
||||
key = "player_blocklist"
|
||||
type = "string_list"
|
||||
label_key = "settings.player_blocklist.label"
|
||||
description_key = "settings.player_blocklist.description"
|
||||
default = []
|
||||
advanced = true
|
||||
|
||||
[[setting]]
|
||||
key = "lyrics_source"
|
||||
type = "select"
|
||||
label_key = "settings.lyrics_source.label"
|
||||
description_key = "settings.lyrics_source.description"
|
||||
default = "auto"
|
||||
options = [
|
||||
{ value = "auto", label_key = "settings.lyrics_source.options.auto" },
|
||||
{ value = "lrclib", label_key = "settings.lyrics_source.options.lrclib" },
|
||||
{ value = "netease", label_key = "settings.lyrics_source.options.netease" },
|
||||
{ value = "mpris", label_key = "settings.lyrics_source.options.mpris" },
|
||||
{ value = "custom", label_key = "settings.lyrics_source.options.custom" },
|
||||
{ value = "external", label_key = "settings.lyrics_source.options.external" },
|
||||
]
|
||||
|
||||
[[setting]]
|
||||
key = "custom_url"
|
||||
type = "string"
|
||||
label_key = "settings.custom_url.label"
|
||||
description_key = "settings.custom_url.description"
|
||||
default = ""
|
||||
visible_when = { key = "lyrics_source", values = ["custom"] }
|
||||
|
||||
[[setting]]
|
||||
key = "custom_json_field"
|
||||
type = "string"
|
||||
label_key = "settings.custom_json_field.label"
|
||||
description_key = "settings.custom_json_field.description"
|
||||
default = "syncedLyrics"
|
||||
visible_when = { key = "lyrics_source", values = ["custom"] }
|
||||
|
||||
[[setting]]
|
||||
key = "cue_text"
|
||||
type = "string"
|
||||
label_key = "settings.cue_text.label"
|
||||
description_key = "settings.cue_text.description"
|
||||
default = "•••••"
|
||||
|
||||
[[setting]]
|
||||
key = "cue_font_mode"
|
||||
type = "select"
|
||||
label_key = "settings.cue_font_mode.label"
|
||||
description_key = "settings.cue_font_mode.description"
|
||||
default = "follow"
|
||||
options = [
|
||||
{ value = "follow", label_key = "settings.cue_font_mode.options.follow" },
|
||||
{ value = "custom", label_key = "settings.cue_font_mode.options.custom" },
|
||||
]
|
||||
|
||||
[[setting]]
|
||||
key = "cue_font_family"
|
||||
type = "string"
|
||||
label_key = "settings.cue_font_family.label"
|
||||
description_key = "settings.cue_font_family.description"
|
||||
default = "sans-serif"
|
||||
visible_when = { key = "cue_font_mode", values = ["custom"] }
|
||||
|
||||
[[setting]]
|
||||
key = "scroll_mode"
|
||||
type = "select"
|
||||
label_key = "settings.scroll_mode.label"
|
||||
default = "auto"
|
||||
description_key = "settings.scroll_mode.description"
|
||||
options = [
|
||||
{ value = "auto", label_key = "settings.scroll_mode.options.auto" },
|
||||
{ value = "marquee", label_key = "settings.scroll_mode.options.marquee" },
|
||||
{ value = "static", label_key = "settings.scroll_mode.options.static" },
|
||||
]
|
||||
|
||||
[[setting]]
|
||||
key = "marquee_speed"
|
||||
type = "int"
|
||||
label_key = "settings.marquee_speed.label"
|
||||
default = 30
|
||||
min = 10
|
||||
max = 120
|
||||
description_key = "settings.marquee_speed.description"
|
||||
|
||||
[[setting]]
|
||||
key = "max_lines"
|
||||
type = "int"
|
||||
label_key = "settings.max_lines.label"
|
||||
default = 1
|
||||
min = 1
|
||||
max = 3
|
||||
description_key = "settings.max_lines.description"
|
||||
|
||||
[[setting]]
|
||||
key = "gradient"
|
||||
type = "bool"
|
||||
label_key = "settings.gradient.label"
|
||||
default = true
|
||||
description_key = "settings.gradient.description"
|
||||
|
||||
[[setting]]
|
||||
key = "animation"
|
||||
type = "select"
|
||||
label_key = "settings.animation.label"
|
||||
default = "karaoke"
|
||||
description_key = "settings.animation.description"
|
||||
options = [
|
||||
{ value = "karaoke", label_key = "settings.animation.options.karaoke" },
|
||||
{ value = "cascade", label_key = "settings.animation.options.cascade" },
|
||||
{ value = "wave", label_key = "settings.animation.options.wave" },
|
||||
{ value = "fade", label_key = "settings.animation.options.fade" },
|
||||
{ value = "none", label_key = "settings.animation.options.none" },
|
||||
]
|
||||
|
||||
[[setting]]
|
||||
key = "max_chars"
|
||||
type = "int"
|
||||
label_key = "settings.max_chars.label"
|
||||
default = 15
|
||||
min = 8
|
||||
max = 80
|
||||
description_key = "settings.max_chars.description"
|
||||
|
||||
[[setting]]
|
||||
key = "char_width"
|
||||
type = "int"
|
||||
label_key = "settings.char_width.label"
|
||||
default = 9
|
||||
min = 4
|
||||
max = 24
|
||||
description_key = "settings.char_width.description"
|
||||
|
||||
# Headless background service: polls MPRIS metadata, fetches lyrics, publishes state.
|
||||
[[service]]
|
||||
id = "service"
|
||||
entry = "lyrics_service.luau"
|
||||
|
||||
# Bar widget: thin presentation that mirrors the published lyrics state.
|
||||
[[widget]]
|
||||
id = "lyrics"
|
||||
entry = "lyrics.luau"
|
||||
|
||||
[[widget.setting]]
|
||||
key = "glyph"
|
||||
type = "glyph"
|
||||
label_key = "settings.glyph.label"
|
||||
default = "music"
|
||||
|
||||
[[widget.setting]]
|
||||
key = "show_artist"
|
||||
type = "bool"
|
||||
label_key = "settings.show_artist.label"
|
||||
default = true
|
||||
|
||||
[[widget.setting]]
|
||||
key = "hide_when_paused"
|
||||
type = "bool"
|
||||
label_key = "settings.hide_when_paused.label"
|
||||
default = false
|
||||
|
||||
[[widget.setting]]
|
||||
key = "show_cover"
|
||||
type = "bool"
|
||||
label_key = "settings.show_cover.label"
|
||||
description_key = "settings.show_cover.description"
|
||||
default = true
|
||||
|
||||
[[widget.setting]]
|
||||
key = "active_color"
|
||||
type = "color"
|
||||
label_key = "settings.active_color.label"
|
||||
description_key = "settings.active_color.description"
|
||||
default = "on_surface"
|
||||
|
||||
[[widget.setting]]
|
||||
key = "inactive_color"
|
||||
type = "color"
|
||||
label_key = "settings.inactive_color.label"
|
||||
description_key = "settings.inactive_color.description"
|
||||
default = "on_surface_variant"
|
||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/setup-deps.sh [--check] [--yes]
|
||||
|
||||
Install or check runtime dependencies for the Noctalia Lyrics plugin.
|
||||
|
||||
Options:
|
||||
--check Only report missing commands; do not install anything.
|
||||
--yes Skip the confirmation prompt before installing packages.
|
||||
--help Show this help text.
|
||||
EOF
|
||||
}
|
||||
|
||||
CHECK_ONLY=0
|
||||
ASSUME_YES=0
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--check)
|
||||
CHECK_ONLY=1
|
||||
;;
|
||||
-y|--yes)
|
||||
ASSUME_YES=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
need_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || MISSING_COMMANDS="$MISSING_COMMANDS $1"
|
||||
}
|
||||
|
||||
MISSING_COMMANDS=""
|
||||
need_command playerctl
|
||||
need_command python3
|
||||
need_command cp
|
||||
|
||||
if [ -z "$MISSING_COMMANDS" ]; then
|
||||
echo "All runtime commands are installed: playerctl python3 cp"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Missing runtime command(s):$MISSING_COMMANDS"
|
||||
|
||||
if [ "$CHECK_ONLY" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
SUDO=""
|
||||
elif command -v sudo >/dev/null 2>&1; then
|
||||
SUDO="sudo"
|
||||
else
|
||||
echo "sudo is required to install packages as a non-root user." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_pm() {
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
echo apt
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
echo dnf
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
echo pacman
|
||||
elif command -v zypper >/dev/null 2>&1; then
|
||||
echo zypper
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
echo apk
|
||||
elif command -v xbps-install >/dev/null 2>&1; then
|
||||
echo xbps
|
||||
else
|
||||
echo unknown
|
||||
fi
|
||||
}
|
||||
|
||||
PM="$(detect_pm)"
|
||||
|
||||
case "$PM" in
|
||||
apt)
|
||||
INSTALL_CMD="$SUDO apt-get update && $SUDO apt-get install -y playerctl python3 coreutils"
|
||||
;;
|
||||
dnf)
|
||||
INSTALL_CMD="$SUDO dnf install -y playerctl python3 coreutils"
|
||||
;;
|
||||
pacman)
|
||||
INSTALL_CMD="$SUDO pacman -S --needed playerctl python coreutils"
|
||||
;;
|
||||
zypper)
|
||||
INSTALL_CMD="$SUDO zypper install -y playerctl python3 coreutils"
|
||||
;;
|
||||
apk)
|
||||
INSTALL_CMD="$SUDO apk add playerctl python3 coreutils"
|
||||
;;
|
||||
xbps)
|
||||
INSTALL_CMD="$SUDO xbps-install -Sy playerctl python3 coreutils"
|
||||
;;
|
||||
*)
|
||||
cat >&2 <<'EOF'
|
||||
Could not detect a supported package manager.
|
||||
Install these packages manually with your distribution package manager:
|
||||
playerctl python3 coreutils
|
||||
EOF
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Detected package manager: $PM"
|
||||
echo "Install command: $INSTALL_CMD"
|
||||
|
||||
if [ "$ASSUME_YES" -ne 1 ]; then
|
||||
printf "Proceed with installation? [y/N] "
|
||||
read -r answer
|
||||
case "$answer" in
|
||||
y|Y|yes|YES)
|
||||
;;
|
||||
*)
|
||||
echo "Cancelled."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
sh -c "$INSTALL_CMD"
|
||||
|
||||
MISSING_COMMANDS=""
|
||||
need_command playerctl
|
||||
need_command python3
|
||||
need_command cp
|
||||
|
||||
if [ -n "$MISSING_COMMANDS" ]; then
|
||||
echo "Still missing after installation:$MISSING_COMMANDS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Dependencies installed successfully."
|
||||
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"instrumental": "Instrumental",
|
||||
"intro": "Intro",
|
||||
"no_lyrics": "♪ No lyrics available",
|
||||
"settings": {
|
||||
"active_color": {
|
||||
"description": "Color used for the current and already-sung lyric characters. Defaults to the interface text color.",
|
||||
"label": "Active lyric color"
|
||||
},
|
||||
"animation": {
|
||||
"description": "How lyrics animate on line change.",
|
||||
"label": "Animation",
|
||||
"options": {
|
||||
"cascade": "Character cascade",
|
||||
"fade": "Fade only",
|
||||
"karaoke": "Karaoke gradient + fade",
|
||||
"none": "No animation",
|
||||
"wave": "Character wave reveal"
|
||||
}
|
||||
},
|
||||
"char_width": {
|
||||
"description": "Approximate pixel width per character, used to fix the widget width and clip scrolling.",
|
||||
"label": "Character width (px)"
|
||||
},
|
||||
"cue_font_family": {
|
||||
"description": "Installed font family used only for intro and interlude characters in custom-font mode.",
|
||||
"label": "Intro/interlude font"
|
||||
},
|
||||
"cue_font_mode": {
|
||||
"description": "Choose whether intro and interlude characters follow Noctalia's interface font or use another installed font.",
|
||||
"label": "Intro/interlude font mode",
|
||||
"options": {
|
||||
"custom": "Custom font",
|
||||
"follow": "Follow interface font"
|
||||
}
|
||||
},
|
||||
"cue_text": {
|
||||
"description": "Text or characters highlighted during intros and interludes.",
|
||||
"label": "Intro/interlude characters"
|
||||
},
|
||||
"custom_json_field": {
|
||||
"description": "Lyrics field in a JSON response, with dotted paths such as data.lyric. Leave empty for plain LRC.",
|
||||
"label": "JSON lyrics field"
|
||||
},
|
||||
"custom_url": {
|
||||
"description": "Supports {title}, {artist}, {album}, and {duration} placeholders. Text responses may contain LRC directly.",
|
||||
"label": "Custom endpoint URL"
|
||||
},
|
||||
"glyph": {
|
||||
"label": "Glyph"
|
||||
},
|
||||
"gradient": {
|
||||
"description": "Light up each character as the song progresses (karaoke style).",
|
||||
"label": "Per-character gradient"
|
||||
},
|
||||
"hide_when_paused": {
|
||||
"label": "Hide when paused"
|
||||
},
|
||||
"inactive_color": {
|
||||
"description": "Color used for upcoming lyrics, paused playback, and secondary lines.",
|
||||
"label": "Inactive lyric color"
|
||||
},
|
||||
"lyrics_source": {
|
||||
"description": "Choose a preset source, local-player lyrics, a custom endpoint, or external push protocol.",
|
||||
"label": "Lyrics source",
|
||||
"options": {
|
||||
"auto": "Automatic fallback",
|
||||
"custom": "Custom HTTP endpoint",
|
||||
"external": "External IPC push",
|
||||
"lrclib": "LRCLIB",
|
||||
"mpris": "Local player (MPRIS)",
|
||||
"netease": "NetEase Music (public API)"
|
||||
}
|
||||
},
|
||||
"marquee_speed": {
|
||||
"description": "Pixels per second for long-line scrolling.",
|
||||
"label": "Marquee speed"
|
||||
},
|
||||
"max_chars": {
|
||||
"description": "Visible width before a long lyric line scrolls (marquee).",
|
||||
"label": "Max characters"
|
||||
},
|
||||
"max_lines": {
|
||||
"description": "Maximum lyric lines to show at once.",
|
||||
"label": "Max lines"
|
||||
},
|
||||
"player_allowlist": {
|
||||
"description": "Only use matching MPRIS player names or instances. Supports * wildcards; leave empty to allow all players.",
|
||||
"label": "Allowed players"
|
||||
},
|
||||
"player_blocklist": {
|
||||
"description": "Ignore matching MPRIS player names or instances. Supports * wildcards and takes priority over the allowlist.",
|
||||
"label": "Blocked players"
|
||||
},
|
||||
"scroll_mode": {
|
||||
"description": "How lyrics scroll in the bar.",
|
||||
"label": "Scroll mode",
|
||||
"options": {
|
||||
"auto": "Auto (synced)",
|
||||
"marquee": "Marquee",
|
||||
"static": "Static"
|
||||
}
|
||||
},
|
||||
"show_artist": {
|
||||
"label": "Show artist"
|
||||
},
|
||||
"show_cover": {
|
||||
"description": "Display the song's album art next to the lyrics.",
|
||||
"label": "Show album cover"
|
||||
}
|
||||
},
|
||||
"title": "Lyrics"
|
||||
}
|
||||
@@ -26,3 +26,4 @@ MangoWC changes keymode; click it to show the current keymode in a notification.
|
||||
| --- | --- | --- | --- |
|
||||
| `show_text` | `bool` | `true` | Shows the current keymode beside the widget glyph. |
|
||||
| `notify_change` | `bool` | `true` | Sends a notification when the keymode changes. |
|
||||
| `hide_on_default` | `bool` | `false` | Hides the widget when default keymode is active. |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
local showText = noctalia.getConfig("show_text")
|
||||
local notifyChange = noctalia.getConfig("notify_change")
|
||||
local hideOnDefault = noctalia.getConfig("hide_on_default")
|
||||
|
||||
local currentKeymode = "default"
|
||||
|
||||
@@ -17,11 +18,16 @@ end)
|
||||
|
||||
function update()
|
||||
noctalia.setUpdateInterval(1000)
|
||||
barWidget.setGlyph("keyboard")
|
||||
if showText then
|
||||
barWidget.setText(currentKeymode)
|
||||
if hideOnDefault and currentKeymode == "default" then
|
||||
barWidget.setVisible(false)
|
||||
else
|
||||
barWidget.setText("")
|
||||
barWidget.setVisible(true)
|
||||
barWidget.setGlyph("keyboard")
|
||||
if showText then
|
||||
barWidget.setText(currentKeymode)
|
||||
else
|
||||
barWidget.setText("")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
id = "gambled23/mangowm-keymode"
|
||||
name = "Mangowm Keymode"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
plugin_api = 3
|
||||
author = "gambled23"
|
||||
license = "MIT"
|
||||
@@ -14,6 +14,12 @@ tags = ["bar", "mangowc"]
|
||||
id = "mangowm-keymode"
|
||||
entry = "keymode.luau"
|
||||
|
||||
[[widget.setting]]
|
||||
key = "hide_on_default"
|
||||
type = "bool"
|
||||
label_key = "settings.hide_on_default.label"
|
||||
default = false
|
||||
|
||||
[[widget.setting]]
|
||||
key = "show_text"
|
||||
type = "bool"
|
||||
|
||||
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"settings.show_text.label": "Show text on bar",
|
||||
"settings.notify_change.label": "Notify change"
|
||||
"settings": {
|
||||
"hide_on_default": {
|
||||
"label": "Hide on default keymode"
|
||||
},
|
||||
"notify_change": {
|
||||
"label": "Notify change"
|
||||
},
|
||||
"show_text": {
|
||||
"label": "Show text on bar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +1,116 @@
|
||||
{
|
||||
"title": "Mini Docker",
|
||||
"widget": {
|
||||
"running": "Running containers: {count}",
|
||||
"unavailable": "Docker is not available",
|
||||
"refresh_requested": "Docker refresh requested"
|
||||
},
|
||||
"settings": {
|
||||
"refresh_interval": {
|
||||
"label": "Refresh interval",
|
||||
"description": "How often Mini Docker refreshes Docker state, in seconds."
|
||||
},
|
||||
"default_network": {
|
||||
"label": "Default network",
|
||||
"description": "Network selected when running an image."
|
||||
},
|
||||
"show_count": {
|
||||
"label": "Show running count",
|
||||
"description": "Show the number of running containers beside the Docker icon."
|
||||
},
|
||||
"glyph_color": {
|
||||
"label": "Icon color",
|
||||
"description": "Theme color used by the Docker icon."
|
||||
},
|
||||
"status_mode": {
|
||||
"label": "Status indicator",
|
||||
"description": "Choose when the status dot is visible.",
|
||||
"options": {
|
||||
"always": "Always",
|
||||
"running_only": "Only while containers are running",
|
||||
"hidden": "Hidden"
|
||||
}
|
||||
},
|
||||
"active_color": {
|
||||
"label": "Active indicator color",
|
||||
"description": "Color used when at least one container is running."
|
||||
},
|
||||
"inactive_color": {
|
||||
"label": "Inactive indicator color",
|
||||
"description": "Color used when no containers are running."
|
||||
}
|
||||
"actions": {
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"refresh": "Refresh",
|
||||
"remove": "Remove",
|
||||
"restart": "Restart",
|
||||
"run": "Run",
|
||||
"select": "Select",
|
||||
"start": "Start",
|
||||
"stop": "Stop"
|
||||
},
|
||||
"colors": {
|
||||
"default": "Default",
|
||||
"error": "Error",
|
||||
"muted": "Muted",
|
||||
"primary": "Primary",
|
||||
"secondary": "Secondary",
|
||||
"tertiary": "Tertiary",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"muted": "Muted"
|
||||
"tertiary": "Tertiary"
|
||||
},
|
||||
"details": {
|
||||
"created": "Created: {value}",
|
||||
"driver": "Driver: {value}",
|
||||
"id": "ID: {value}",
|
||||
"image": "Image: {value}",
|
||||
"mountpoint": "Mountpoint: {value}",
|
||||
"ports": "Ports: {value}",
|
||||
"scope": "Scope: {value}",
|
||||
"size": "Size: {value}",
|
||||
"status": "Status: {value}"
|
||||
},
|
||||
"panel": {
|
||||
"busy": "Docker command in progress…",
|
||||
"default_network": "Built-in network",
|
||||
"image_in_use": "Used by a container",
|
||||
"image_unused": "Not used by a container",
|
||||
"loading": "Loading Docker state…",
|
||||
"no_containers": "No containers found.",
|
||||
"no_images": "No images found.",
|
||||
"no_networks": "No networks found.",
|
||||
"no_volumes": "No volumes found.",
|
||||
"select_hint": "Select an item to see available actions.",
|
||||
"showing_limit": "Showing the first {count} items.",
|
||||
"updated": "Last updated: {time}"
|
||||
},
|
||||
"result": {
|
||||
"command_busy": "Another Docker command is still running.",
|
||||
"docker_missing": "The Docker CLI is not installed.",
|
||||
"docker_unreachable": "Could not reach the Docker daemon.",
|
||||
"failed": "Docker command failed: {error}",
|
||||
"success": "Docker command completed."
|
||||
},
|
||||
"run_form": {
|
||||
"container_name": "Container name (optional)",
|
||||
"container_name_placeholder": "my-container",
|
||||
"environment": "Environment variables",
|
||||
"environment_help": "Enter one KEY=value pair per line.",
|
||||
"environment_placeholder": "KEY=value\nANOTHER_KEY=value",
|
||||
"invalid_environment": "Invalid environment-variable line: {line}",
|
||||
"invalid_name": "Container names may contain letters, numbers, dots, underscores, and hyphens.",
|
||||
"invalid_port": "Port must be a number from 1 to 65535.",
|
||||
"network": "Network",
|
||||
"port": "Host and container port",
|
||||
"port_placeholder": "8080",
|
||||
"publish_port": "Publish a port",
|
||||
"title": "Run {image}"
|
||||
},
|
||||
"settings": {
|
||||
"active_color": {
|
||||
"description": "Color used when at least one container is running.",
|
||||
"label": "Active indicator color"
|
||||
},
|
||||
"default_network": {
|
||||
"description": "Network selected when running an image.",
|
||||
"label": "Default network"
|
||||
},
|
||||
"glyph_color": {
|
||||
"description": "Theme color used by the Docker icon.",
|
||||
"label": "Icon color"
|
||||
},
|
||||
"inactive_color": {
|
||||
"description": "Color used when no containers are running.",
|
||||
"label": "Inactive indicator color"
|
||||
},
|
||||
"refresh_interval": {
|
||||
"description": "How often Mini Docker refreshes Docker state, in seconds.",
|
||||
"label": "Refresh interval"
|
||||
},
|
||||
"show_count": {
|
||||
"description": "Show the number of running containers beside the Docker icon.",
|
||||
"label": "Show running count"
|
||||
},
|
||||
"status_mode": {
|
||||
"description": "Choose when the status dot is visible.",
|
||||
"label": "Status indicator",
|
||||
"options": {
|
||||
"always": "Always",
|
||||
"hidden": "Hidden",
|
||||
"running_only": "Only while containers are running"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"containers": "Containers",
|
||||
"images": "Images",
|
||||
"volumes": "Volumes",
|
||||
"networks": "Networks"
|
||||
"networks": "Networks",
|
||||
"volumes": "Volumes"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
"select": "Select",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"restart": "Restart",
|
||||
"remove": "Remove",
|
||||
"run": "Run",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close"
|
||||
},
|
||||
"panel": {
|
||||
"loading": "Loading Docker state…",
|
||||
"busy": "Docker command in progress…",
|
||||
"updated": "Last updated: {time}",
|
||||
"select_hint": "Select an item to see available actions.",
|
||||
"no_containers": "No containers found.",
|
||||
"no_images": "No images found.",
|
||||
"no_volumes": "No volumes found.",
|
||||
"no_networks": "No networks found.",
|
||||
"showing_limit": "Showing the first {count} items.",
|
||||
"default_network": "Built-in network",
|
||||
"image_in_use": "Used by a container",
|
||||
"image_unused": "Not used by a container"
|
||||
},
|
||||
"details": {
|
||||
"image": "Image: {value}",
|
||||
"status": "Status: {value}",
|
||||
"ports": "Ports: {value}",
|
||||
"id": "ID: {value}",
|
||||
"created": "Created: {value}",
|
||||
"size": "Size: {value}",
|
||||
"driver": "Driver: {value}",
|
||||
"mountpoint": "Mountpoint: {value}",
|
||||
"scope": "Scope: {value}"
|
||||
},
|
||||
"run_form": {
|
||||
"title": "Run {image}",
|
||||
"container_name": "Container name (optional)",
|
||||
"container_name_placeholder": "my-container",
|
||||
"network": "Network",
|
||||
"publish_port": "Publish a port",
|
||||
"port": "Host and container port",
|
||||
"port_placeholder": "8080",
|
||||
"environment": "Environment variables",
|
||||
"environment_placeholder": "KEY=value\nANOTHER_KEY=value",
|
||||
"environment_help": "Enter one KEY=value pair per line.",
|
||||
"invalid_name": "Container names may contain letters, numbers, dots, underscores, and hyphens.",
|
||||
"invalid_port": "Port must be a number from 1 to 65535.",
|
||||
"invalid_environment": "Invalid environment-variable line: {line}"
|
||||
},
|
||||
"result": {
|
||||
"success": "Docker command completed.",
|
||||
"failed": "Docker command failed: {error}",
|
||||
"docker_missing": "The Docker CLI is not installed.",
|
||||
"docker_unreachable": "Could not reach the Docker daemon.",
|
||||
"command_busy": "Another Docker command is still running."
|
||||
"title": "Mini Docker",
|
||||
"widget": {
|
||||
"refresh_requested": "Docker refresh requested",
|
||||
"running": "Running containers: {count}",
|
||||
"unavailable": "Docker is not available"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +1,156 @@
|
||||
{
|
||||
"bar.checking": "Checking...",
|
||||
"bar.update_available": "Update available!",
|
||||
"bar.up_to_date": "Up to date",
|
||||
"bar.unknown": "Unknown",
|
||||
"notification.checking_update": "Checking NixOS update...",
|
||||
"notification.update_is_available": "NixOS update is available!",
|
||||
"notification.update_check_completed": "NixOS update check completed!",
|
||||
"notification.update_command_is_empty": "Update command is empty!",
|
||||
"notification.optimize_command_is_empty": "Optimize command is empty!",
|
||||
"notification.clean_command_is_empty": "Clean command is empty!",
|
||||
"panel.title": "Nix Monitor",
|
||||
"panel.cancel": "Cancel",
|
||||
"panel.update": "Update",
|
||||
"panel.check": "Check",
|
||||
"panel.optimize": "Optimize",
|
||||
"panel.clean": "Clean",
|
||||
"panel.local": "Local",
|
||||
"panel.remote": "Remote",
|
||||
"panel.last_checked": "Last checked",
|
||||
"panel.current": "Current",
|
||||
"panel.store_size": "Store size",
|
||||
"panel.closure_size": "Closure size",
|
||||
"terminal.press_enter_to_exit": "Press enter to exit...",
|
||||
"setting.widget.show_text.label": "Show text",
|
||||
"setting.widget.colorize_text.label": "Colorize text",
|
||||
"setting.widget.show_glyph.label": "Show glyph",
|
||||
"setting.widget.colorize_glyph.label": "Colorize glyph",
|
||||
"setting.widget.up_to_date_glyph.label": "Up to date glyph",
|
||||
"setting.widget.up_to_date_color.label": "Up to date color",
|
||||
"setting.widget.checking_glyph.label": "Checking glyph",
|
||||
"setting.widget.checking_color.label": "Checking color",
|
||||
"setting.widget.update_available_glyph.label": "Update available glyph",
|
||||
"setting.widget.update_available_color.label": "Update available color",
|
||||
"setting.widget.unknown_glyph.label": "Unknown status glyph",
|
||||
"setting.widget.unknown_color.label": "Unknown status color",
|
||||
"setting.widget.panel_card_background_color.label": "Panel card background color",
|
||||
"setting.widget.panel_card_background_opacity.label": "Panel card background opacity",
|
||||
"setting.update_check_interval.label": "Update check interval",
|
||||
"setting.update_check_interval.description": "How often to check for updates (in minutes)",
|
||||
"setting.update_check_duration_threshold.label": "Update check duration threshold",
|
||||
"setting.update_check_duration_threshold.description": "Cancel update check if it took too long (in minutes)",
|
||||
"setting.system_stats_check_interval.label": "System stats check interval",
|
||||
"setting.system_stats_check_interval.description": "How often to check for system stats (in minutes)",
|
||||
"setting.system_stats_check_duration_threshold.label": "System stats check duration threshold",
|
||||
"setting.system_stats_check_duration_threshold.description": "Cancel system stats check if it took too long (in minutes)",
|
||||
"setting.show_update_check_notification.label": "Show update check notification",
|
||||
"setting.show_update_check_notification.description": "Whether to show a notification whenever update check is in progress and completed",
|
||||
"setting.show_update_available_notification.label": "Show update available notification",
|
||||
"setting.show_update_available_notification.description": "Whether to show a notification whenever an update is available",
|
||||
"setting.branch.label": "Branch",
|
||||
"setting.branch.description": "Which nixpkgs branch to use",
|
||||
"setting.update_command.label": "Update command",
|
||||
"setting.update_command.description": "Command to run when clicking Update button",
|
||||
"setting.optimize_command.label": "Optimize command",
|
||||
"setting.optimize_command.description": "Command to run when clicking Optimize button",
|
||||
"setting.clean_command.label": "Clean command",
|
||||
"setting.clean_command.description": "Command to run when clicking Clean button",
|
||||
"setting.hide_update_button.label": "Hide update button",
|
||||
"setting.hide_update_button.description": "Whether to hide the update button on the panel",
|
||||
"setting.hide_optimize_button.label": "Hide optimize button",
|
||||
"setting.hide_optimize_button.description": "Whether to hide the optimize button on the panel",
|
||||
"setting.hide_clean_button.label": "Hide clean button",
|
||||
"setting.hide_clean_button.description": "Whether to hide the clean button on the panel",
|
||||
"setting.close_on_enter.label": "Close on press Enter",
|
||||
"setting.close_on_enter.description": "Add 'Press enter to exit' on the terminal window",
|
||||
"setting.option.branch.master": "Master",
|
||||
"setting.option.branch.nixos_unstable": "NixOS Unstable",
|
||||
"setting.option.branch.nixos_unstable_small": "NixOS Unstable Small",
|
||||
"setting.option.branch.nixos_26_05": "NixOS 26.05",
|
||||
"setting.option.branch.nixos_25_11": "NixOS 25.11",
|
||||
"setting.option.branch.nixos_25_05": "NixOS 25.05",
|
||||
"setting.option.branch.nixos_24_11": "NixOS 24.11",
|
||||
"setting.option.branch.nixos_24_05": "NixOS 24.05",
|
||||
"setting.option.branch.nixos_23_11": "NixOS 23.11",
|
||||
"setting.option.branch.nixos_23_05": "NixOS 23.05",
|
||||
"setting.option.branch.nixos_26_05_small": "NixOS 26.05 Small",
|
||||
"setting.option.branch.nixos_25_11_small": "NixOS 25.11 Small",
|
||||
"setting.option.branch.nixos_25_05_small": "NixOS 25.05 Small",
|
||||
"setting.option.branch.nixos_24_11_small": "NixOS 24.11 Small",
|
||||
"setting.option.branch.nixos_24_05_small": "NixOS 24.05 Small",
|
||||
"setting.option.branch.nixos_23_11_small": "NixOS 23.11 Small",
|
||||
"setting.option.branch.nixos_23_05_small": "NixOS 23.05 Small"
|
||||
"bar": {
|
||||
"checking": "Checking...",
|
||||
"unknown": "Unknown",
|
||||
"up_to_date": "Up to date",
|
||||
"update_available": "Update available!"
|
||||
},
|
||||
"notification": {
|
||||
"checking_update": "Checking NixOS update...",
|
||||
"clean_command_is_empty": "Clean command is empty!",
|
||||
"optimize_command_is_empty": "Optimize command is empty!",
|
||||
"update_check_completed": "NixOS update check completed!",
|
||||
"update_command_is_empty": "Update command is empty!",
|
||||
"update_is_available": "NixOS update is available!"
|
||||
},
|
||||
"panel": {
|
||||
"cancel": "Cancel",
|
||||
"check": "Check",
|
||||
"optimize": "Optimize",
|
||||
"clean": "Clean",
|
||||
"closure_size": "Closure size",
|
||||
"current": "Current",
|
||||
"last_checked": "Last checked",
|
||||
"local": "Local",
|
||||
"remote": "Remote",
|
||||
"store_size": "Store size",
|
||||
"title": "Nix Monitor",
|
||||
"update": "Update"
|
||||
},
|
||||
"setting": {
|
||||
"branch": {
|
||||
"description": "Which nixpkgs branch to use",
|
||||
"label": "Branch"
|
||||
},
|
||||
"update_command": {
|
||||
"description": "Command to run when clicking Update button",
|
||||
"label": "Update command"
|
||||
},
|
||||
"optimize_command": {
|
||||
"label": "Optimize command",
|
||||
"description": "Command to run when clicking Optimize button"
|
||||
},
|
||||
"clean_command": {
|
||||
"description": "Command to run when clicking Clean button",
|
||||
"label": "Clean command"
|
||||
},
|
||||
"close_on_enter": {
|
||||
"description": "Add 'Press enter to exit' on the terminal window",
|
||||
"label": "Close on press Enter"
|
||||
},
|
||||
"hide_update_button": {
|
||||
"description": "Whether to hide the update button on the panel",
|
||||
"label": "Hide update button"
|
||||
},
|
||||
"hide_optimize_button": {
|
||||
"label": "Hide optimize button",
|
||||
"description": "Whether to hide the optimize button on the panel"
|
||||
},
|
||||
"hide_clean_button": {
|
||||
"description": "Whether to hide the clean button on the panel",
|
||||
"label": "Hide clean button"
|
||||
},
|
||||
"option": {
|
||||
"branch": {
|
||||
"master": "Master",
|
||||
"nixos_23_05": "NixOS 23.05",
|
||||
"nixos_23_05_small": "NixOS 23.05 Small",
|
||||
"nixos_23_11": "NixOS 23.11",
|
||||
"nixos_23_11_small": "NixOS 23.11 Small",
|
||||
"nixos_24_05": "NixOS 24.05",
|
||||
"nixos_24_05_small": "NixOS 24.05 Small",
|
||||
"nixos_24_11": "NixOS 24.11",
|
||||
"nixos_24_11_small": "NixOS 24.11 Small",
|
||||
"nixos_25_05": "NixOS 25.05",
|
||||
"nixos_25_05_small": "NixOS 25.05 Small",
|
||||
"nixos_25_11": "NixOS 25.11",
|
||||
"nixos_25_11_small": "NixOS 25.11 Small",
|
||||
"nixos_26_05": "NixOS 26.05",
|
||||
"nixos_26_05_small": "NixOS 26.05 Small",
|
||||
"nixos_unstable": "NixOS Unstable",
|
||||
"nixos_unstable_small": "NixOS Unstable Small"
|
||||
}
|
||||
},
|
||||
"show_update_available_notification": {
|
||||
"description": "Whether to show a notification whenever an update is available",
|
||||
"label": "Show update available notification"
|
||||
},
|
||||
"show_update_check_notification": {
|
||||
"description": "Whether to show a notification whenever update check is in progress and completed",
|
||||
"label": "Show update check notification"
|
||||
},
|
||||
"system_stats_check_duration_threshold": {
|
||||
"description": "Cancel system stats check if it took too long (in minutes)",
|
||||
"label": "System stats check duration threshold"
|
||||
},
|
||||
"system_stats_check_interval": {
|
||||
"description": "How often to check for system stats (in minutes)",
|
||||
"label": "System stats check interval"
|
||||
},
|
||||
"update_check_duration_threshold": {
|
||||
"description": "Cancel update check if it took too long (in minutes)",
|
||||
"label": "Update check duration threshold"
|
||||
},
|
||||
"update_check_interval": {
|
||||
"description": "How often to check for updates (in minutes)",
|
||||
"label": "Update check interval"
|
||||
},
|
||||
"widget": {
|
||||
"checking_color": {
|
||||
"label": "Checking color"
|
||||
},
|
||||
"checking_glyph": {
|
||||
"label": "Checking glyph"
|
||||
},
|
||||
"colorize_glyph": {
|
||||
"label": "Colorize glyph"
|
||||
},
|
||||
"colorize_text": {
|
||||
"label": "Colorize text"
|
||||
},
|
||||
"panel_card_background_color": {
|
||||
"label": "Panel card background color"
|
||||
},
|
||||
"panel_card_background_opacity": {
|
||||
"label": "Panel card background opacity"
|
||||
},
|
||||
"show_glyph": {
|
||||
"label": "Show glyph"
|
||||
},
|
||||
"show_text": {
|
||||
"label": "Show text"
|
||||
},
|
||||
"unknown_color": {
|
||||
"label": "Unknown status color"
|
||||
},
|
||||
"unknown_glyph": {
|
||||
"label": "Unknown status glyph"
|
||||
},
|
||||
"up_to_date_color": {
|
||||
"label": "Up to date color"
|
||||
},
|
||||
"up_to_date_glyph": {
|
||||
"label": "Up to date glyph"
|
||||
},
|
||||
"update_available_color": {
|
||||
"label": "Update available color"
|
||||
},
|
||||
"update_available_glyph": {
|
||||
"label": "Update available glyph"
|
||||
}
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"press_enter_to_exit": "Press enter to exit..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# noctwhspr
|
||||
|
||||
Noctalia companion for [hyprwhspr](https://github.com/goodroot/hyprwhspr) —
|
||||
native speech-to-text for Linux. hyprwhspr is fast, accurate, private,
|
||||
system-wide dictation: press a hotkey, talk, and your words land in whatever
|
||||
you were typing — transcribed by local in-memory models (Whisper, Parakeet)
|
||||
that never leave your machine, or by top cloud APIs if you point it there.
|
||||
Its visuals even theme themselves to match your Noctalia theme.
|
||||
|
||||
This plugin puts hyprwhspr on your bar: the widget shows the dictation state
|
||||
at a glance, left-click starts or stops a recording, and right-click restarts
|
||||
the hyprwhspr service if it ever gets stuck.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `goodroot/noctwhspr` |
|
||||
| Entries | Bar widget: `status` |
|
||||
|
||||
## Requirements
|
||||
|
||||
Install [`hyprwhspr`](https://github.com/goodroot/hyprwhspr) and set it up so
|
||||
its `hyprwhspr.service` systemd user service exists. The widget drives
|
||||
hyprwhspr's own tray script (`hyprwhspr-tray.sh`), which it looks up under the
|
||||
install root — `/usr/lib/hyprwhspr` for the Arch/AUR package. Nothing else is
|
||||
needed; the widget has no network access of its own.
|
||||
|
||||
## Usage
|
||||
|
||||
Enable the plugin in Settings → Plugins, then add the `status` widget to your
|
||||
bar in Settings → Bar.
|
||||
|
||||
The glyph tracks the dictation state reported by hyprwhspr:
|
||||
|
||||
| Glyph | State |
|
||||
| --- | --- |
|
||||
| Filled record dot (error color) | Recording — dictation is capturing audio |
|
||||
| Microphone (primary color) | Ready — service is up, waiting for a hotkey or click |
|
||||
| Zzz | Model unloaded — first recording will load it |
|
||||
| Microphone off | Service stopped |
|
||||
| Warning triangle | Error — the tooltip explains what is wrong |
|
||||
|
||||
Interactions:
|
||||
|
||||
- **Left-click** — toggle recording (same as the hyprwhspr hotkey). If the
|
||||
service is stopped, this starts it first.
|
||||
- **Right-click** — restart the `hyprwhspr.service` systemd user service.
|
||||
- **Hover** — the tooltip shows the detailed status line, including error
|
||||
reasons when something is wrong.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `root` | `string` | `/usr/lib/hyprwhspr` | Directory hyprwhspr is installed under. Leave at the default unless hyprwhspr lives at a non-standard prefix; `HYPRWHSPR_ROOT` in Noctalia's environment is also honored. |
|
||||
|
||||
## Notes
|
||||
|
||||
- The widget is a thin shim over hyprwhspr's tray script — the same script
|
||||
that backs its Waybar module — so state detection stays in one place. It
|
||||
polls `<root>/config/hyprland/hyprwhspr-tray.sh status` every 2 seconds and
|
||||
renders the returned JSON.
|
||||
- Spawned processes: the tray script on every poll and click; the script in
|
||||
turn queries `systemctl --user` and hyprwhspr's runtime state files.
|
||||
Right-click runs `systemctl --user restart hyprwhspr.service` through the
|
||||
script. No network calls and no filesystem writes are made by the plugin
|
||||
itself.
|
||||
- If the widget shows "hyprwhspr not found", hyprwhspr is not installed at
|
||||
the configured root — point the `root` setting at your install prefix.
|
||||
- Works on any compositor Noctalia runs on; the `hyprland` in the script path
|
||||
is historical, hyprwhspr itself supports Hyprland, Niri and friends.
|
||||
@@ -0,0 +1,21 @@
|
||||
id = "goodroot/noctwhspr"
|
||||
name = "noctwhspr"
|
||||
version = "1.0.0"
|
||||
plugin_api = 3
|
||||
author = "goodroot"
|
||||
license = "MIT"
|
||||
icon = "microphone"
|
||||
description = "Noctalia companion for hyprwhspr: shows dictation state, click to record, right-click to restart."
|
||||
dependencies = ["hyprwhspr"]
|
||||
tags = ["audio", "bar", "recording", "utility"]
|
||||
|
||||
[[widget]]
|
||||
id = "status"
|
||||
entry = "widget.luau"
|
||||
|
||||
[[widget.setting]]
|
||||
key = "root"
|
||||
type = "string"
|
||||
label_key = "settings.root.label"
|
||||
description_key = "settings.root.description"
|
||||
default = "/usr/lib/hyprwhspr"
|
||||
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"settings": {
|
||||
"root": {
|
||||
"description": "Directory hyprwhspr is installed under. Leave at the default unless hyprwhspr lives at a non-standard prefix.",
|
||||
"label": "hyprwhspr install root"
|
||||
}
|
||||
},
|
||||
"title": "noctwhspr"
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
--!nonstrict
|
||||
-- hyprwhspr bar widget for Noctalia.
|
||||
--
|
||||
-- Thin shim over the existing tray script (single source of truth for state
|
||||
-- detection, shared with the Waybar module): polls `hyprwhspr-tray.sh status`
|
||||
-- and renders its JSON (class + tooltip) as a glyph. Clicks invoke the same
|
||||
-- script actions the Waybar module binds.
|
||||
|
||||
local TRAY_REL = "/config/hyprland/hyprwhspr-tray.sh"
|
||||
|
||||
-- state class (from the tray script's JSON) -> glyph + palette color
|
||||
local STATES = {
|
||||
recording = { glyph = "player-record-filled", color = "error" },
|
||||
ready = { glyph = "microphone", color = "primary" },
|
||||
unloaded = { glyph = "zzz", color = "outline" },
|
||||
stopped = { glyph = "microphone-off", color = "outline" },
|
||||
error = { glyph = "alert-triangle", color = "error" },
|
||||
}
|
||||
|
||||
local function applyState(state, tooltip)
|
||||
barWidget.setGlyph(state.glyph)
|
||||
barWidget.setGlyphColor(state.color)
|
||||
if tooltip ~= nil and tooltip ~= "" then
|
||||
barWidget.setTooltip(tooltip)
|
||||
end
|
||||
end
|
||||
|
||||
local function applyError(tooltip)
|
||||
applyState(STATES.error, tooltip)
|
||||
end
|
||||
|
||||
-- Resolve the tray script lazily and cache the hit. Noctalia's process is
|
||||
-- spawned by the compositor, so HYPRWHSPR_ROOT is usually absent from its
|
||||
-- environment — the widget "root" setting is the override for non-standard
|
||||
-- install prefixes.
|
||||
local trayCached = nil
|
||||
local function trayPath()
|
||||
if trayCached ~= nil and noctalia.fileExists(trayCached) then
|
||||
return trayCached
|
||||
end
|
||||
trayCached = nil
|
||||
local candidates = {}
|
||||
local cfg = noctalia.getConfig("root")
|
||||
if type(cfg) == "string" and cfg ~= "" then
|
||||
table.insert(candidates, cfg)
|
||||
end
|
||||
local env = noctalia.getenv("HYPRWHSPR_ROOT")
|
||||
if env ~= nil and env ~= "" then
|
||||
table.insert(candidates, env)
|
||||
end
|
||||
table.insert(candidates, "/usr/lib/hyprwhspr")
|
||||
for _, root in ipairs(candidates) do
|
||||
local path = root .. TRAY_REL
|
||||
if noctalia.fileExists(path) then
|
||||
trayCached = path
|
||||
return path
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function trayCommand(action)
|
||||
local path = trayPath()
|
||||
if path == nil then
|
||||
return nil
|
||||
end
|
||||
-- Single-quote the path: install roots may contain spaces.
|
||||
return "'" .. path .. "' " .. action
|
||||
end
|
||||
|
||||
local function render(json)
|
||||
if type(json) ~= "string" or json == "" then
|
||||
applyError("hyprwhspr: tray script returned no status")
|
||||
return
|
||||
end
|
||||
local class = json:match('"class":"([^"]*)"') or "error"
|
||||
local tooltip = json:match('"tooltip":"([^"]*)"') or ""
|
||||
|
||||
-- Strip the Waybar cache-buster line, unescape \n
|
||||
tooltip = tooltip:gsub("\\n_ts:%d+", "")
|
||||
tooltip = tooltip:gsub("\\n", "\n")
|
||||
|
||||
applyState(STATES[class] or STATES.error, tooltip)
|
||||
end
|
||||
|
||||
local function refresh()
|
||||
local cmd = trayCommand("status")
|
||||
if cmd == nil then
|
||||
applyError("hyprwhspr not found\nInstall hyprwhspr or set this widget's root setting")
|
||||
return
|
||||
end
|
||||
noctalia.runAsync(cmd, function(result)
|
||||
if type(result) ~= "table" then
|
||||
applyError("hyprwhspr: status query failed")
|
||||
return
|
||||
end
|
||||
render(result.stdout)
|
||||
end)
|
||||
end
|
||||
|
||||
function update()
|
||||
-- The tray script re-derives state via many subprocesses per call; 2s is
|
||||
-- responsive enough (the recording overlay gives immediate feedback).
|
||||
noctalia.setUpdateInterval(2000)
|
||||
refresh()
|
||||
end
|
||||
|
||||
function onClick()
|
||||
local cmd = trayCommand("record")
|
||||
if cmd ~= nil then
|
||||
noctalia.runAsync(cmd, function() refresh() end)
|
||||
end
|
||||
end
|
||||
|
||||
function onRightClick()
|
||||
local cmd = trayCommand("restart")
|
||||
if cmd ~= nil then
|
||||
noctalia.runAsync(cmd, function() refresh() end)
|
||||
end
|
||||
end
|
||||
|
||||
-- Honest placeholder until the first poll lands.
|
||||
barWidget.setGlyph("microphone")
|
||||
barWidget.setGlyphColor("outline")
|
||||
barWidget.setTooltip("hyprwhspr: waiting for status…")
|
||||
@@ -277,7 +277,7 @@ local function catSection(catName, ports)
|
||||
fill = "surface_container/0.4",
|
||||
}, {
|
||||
ui.glyph({ name = meta.icon, size = 12, color = meta.color }),
|
||||
ui.label({ text = noctalia.tr("categories." .. catName), fontSize = 11, fontWeight = "bold", color = meta.color }),
|
||||
ui.label({ text = noctalia.tr("categories." .. catName:lower()), fontSize = 11, fontWeight = "bold", color = meta.color }),
|
||||
ui.label({ text = tostring(#ports), fontSize = 10, color = meta.color .. "/0.6" }),
|
||||
}))
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
id = "rxtsel/portctl"
|
||||
name = "Portctl"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
plugin_api = 3
|
||||
author = "Cristhian Melo"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
{
|
||||
"title": "Portctl",
|
||||
"categories": {
|
||||
"cloud": "Cloud",
|
||||
"containers": "Containers",
|
||||
"databases": "Databases",
|
||||
"development": "Development",
|
||||
"other": "Other",
|
||||
"servers": "Servers"
|
||||
},
|
||||
"panel": {
|
||||
"search_placeholder": "Search port, process, PID…",
|
||||
"tcp": "TCP",
|
||||
"udp": "UDP",
|
||||
"no_ports": "No listening ports",
|
||||
"no_results": "No results for \"{query}\"",
|
||||
"killing": "Killing {name}…",
|
||||
"kill_confirm": "Kill {name}?",
|
||||
"cancel": "Cancel",
|
||||
"kill": "Kill",
|
||||
"pid_copied": "Copied PID {pid}"
|
||||
},
|
||||
"categories": {
|
||||
"Development": "Development",
|
||||
"Databases": "Databases",
|
||||
"Containers": "Containers",
|
||||
"Servers": "Servers",
|
||||
"Cloud": "Cloud",
|
||||
"Other": "Other"
|
||||
"kill_confirm": "Kill {name}?",
|
||||
"killing": "Killing {name}…",
|
||||
"no_ports": "No listening ports",
|
||||
"no_results": "No results for \"{query}\"",
|
||||
"pid_copied": "Copied PID {pid}",
|
||||
"search_placeholder": "Search port, process, PID…",
|
||||
"tcp": "TCP",
|
||||
"udp": "UDP"
|
||||
},
|
||||
"service": {
|
||||
"terminated": "Process {pid} terminated",
|
||||
"kill_failed": "Failed to kill PID {pid}",
|
||||
"ss_not_found": "ss (iproute2) not found — install the iproute2 package"
|
||||
"ss_not_found": "ss (iproute2) not found — install the iproute2 package",
|
||||
"terminated": "Process {pid} terminated"
|
||||
},
|
||||
"settings": {
|
||||
"refresh_interval": {
|
||||
"label": "Refresh interval",
|
||||
"description": "Seconds between port scans"
|
||||
},
|
||||
"ignore_list": {
|
||||
"label": "Ignore processes",
|
||||
"description": "Comma-separated process name substrings to hide (e.g. discord,chrome,steam)"
|
||||
},
|
||||
"ignore_ports": {
|
||||
"label": "Ignore ports",
|
||||
"description": "Comma-separated port numbers to hide (e.g. 37700,6463)"
|
||||
"glyph": {
|
||||
"description": "Icon shown in the bar.",
|
||||
"label": "Glyph"
|
||||
},
|
||||
"hide_system_ports": {
|
||||
"label": "Hide system ports",
|
||||
"description": "Hide ports below 1024 (privileged/root ports)"
|
||||
"description": "Hide ports below 1024 (privileged/root ports)",
|
||||
"label": "Hide system ports"
|
||||
},
|
||||
"hide_unknown_ports": {
|
||||
"label": "Hide unknown ports",
|
||||
"description": "Hide ports whose process info is not accessible (e.g. rootlessport, root-owned processes without sudo)"
|
||||
"description": "Hide ports whose process info is not accessible (e.g. rootlessport, root-owned processes without sudo)",
|
||||
"label": "Hide unknown ports"
|
||||
},
|
||||
"glyph": {
|
||||
"label": "Glyph",
|
||||
"description": "Icon shown in the bar."
|
||||
"ignore_list": {
|
||||
"description": "Comma-separated process name substrings to hide (e.g. discord,chrome,steam)",
|
||||
"label": "Ignore processes"
|
||||
},
|
||||
"ignore_ports": {
|
||||
"description": "Comma-separated port numbers to hide (e.g. 37700,6463)",
|
||||
"label": "Ignore ports"
|
||||
},
|
||||
"refresh_interval": {
|
||||
"description": "Seconds between port scans",
|
||||
"label": "Refresh interval"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Portctl"
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
id = "radimous/prismlauncher-instances"
|
||||
name = "PrismLauncher Instances"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
plugin_api = 3
|
||||
author = "radimous"
|
||||
license = "MIT"
|
||||
dependencies = ["prismlauncher"]
|
||||
icon = "nut"
|
||||
deprecated = false
|
||||
description = "A launcher provider that adds PrismLauncher instances to the noctalia launcher."
|
||||
description = "A launcher provider that adds PrismLauncher and PrismLauncher fork instances to the noctalia launcher."
|
||||
tags = ["gaming", "launcher"]
|
||||
|
||||
[[launcher_provider]]
|
||||
@@ -24,3 +24,10 @@ type = "string"
|
||||
label_key = "settings.prismlauncher_path.label"
|
||||
description_key = "settings.prismlauncher_path.description"
|
||||
default = "~/.local/share/PrismLauncher"
|
||||
|
||||
[[setting]]
|
||||
key = "launcher_exec_command"
|
||||
type = "string"
|
||||
label_key = "settings.launcher_exec_command.label"
|
||||
description_key = "settings.launcher_exec_command.description"
|
||||
default = "prismlauncher"
|
||||
@@ -5,10 +5,15 @@ local function getPrismPath()
|
||||
return noctalia.expandPath(prismPath)
|
||||
end
|
||||
|
||||
local function getLauncherCommand()
|
||||
local launcherCommand = noctalia.getConfig("launcher_exec_command")
|
||||
return noctalia.expandPath(launcherCommand)
|
||||
end
|
||||
|
||||
local function getInstancesDir()
|
||||
local prismPath = getPrismPath()
|
||||
|
||||
local configuredInstanceDir = getCfgValue(prismPath .. "/prismlauncher.cfg", "InstanceDir")
|
||||
local configuredInstanceDir = getCfgValue(prismPath .. "/*.cfg", "InstanceDir")
|
||||
|
||||
local instances
|
||||
if configuredInstanceDir == nil then -- default path
|
||||
@@ -91,8 +96,10 @@ local function shellQuote(s)
|
||||
end
|
||||
|
||||
function onActivate(id)
|
||||
local launcherCommand = getLauncherCommand()
|
||||
|
||||
if id == "" then return end
|
||||
noctalia.runAsync(string.format("prismlauncher --launch %s &>/dev/null", shellQuote(id)))
|
||||
noctalia.runAsync(string.format(launcherCommand .. " --launch %s &>/dev/null", shellQuote(id)))
|
||||
end
|
||||
|
||||
-- doesn't handle icons that were changed to different default icon, rest is fine
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
{
|
||||
"settings": {
|
||||
"prismlauncher_path": {
|
||||
"label": "PrismLauncher path",
|
||||
"description": "Path to PrismLauncher"
|
||||
}
|
||||
},
|
||||
"no_instances": {
|
||||
"title": "No instances found",
|
||||
"subtitle": "Filter: "
|
||||
"subtitle": "Filter: ",
|
||||
"title": "No instances found"
|
||||
},
|
||||
"settings": {
|
||||
"launcher_exec_command": {
|
||||
"description": "Start Prismlauncher or fork of choice",
|
||||
"label": "Prism Executable"
|
||||
},
|
||||
"prismlauncher_path": {
|
||||
"description": "Path to PrismLauncher",
|
||||
"label": "PrismLauncher path"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"copied-password": "Copied password to clipboard",
|
||||
"failed-list-vaults": "Failed to list vaults. Are you logged into pass-cli?",
|
||||
"item-not-found": "Item not found",
|
||||
"loading-items": "Loading Items…",
|
||||
"loading-vaults": "Loading Vaults…"
|
||||
"copied-password": "Copied password to clipboard",
|
||||
"failed-list-vaults": "Failed to list vaults. Are you logged into pass-cli?",
|
||||
"item-not-found": "Item not found",
|
||||
"loading-items": "Loading Items…",
|
||||
"loading-vaults": "Loading Vaults…"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
{
|
||||
"title": "ShareDND",
|
||||
"settings.only_active.label": "Only active streams",
|
||||
"settings.only_active.description": "Count only screencasts that are actively streaming. When off, any open screencast session (even one paused or showing nothing) keeps Do Not Disturb enabled.",
|
||||
"settings.always_off_after.label": "Always disable DND after sharing",
|
||||
"settings.always_off_after.description": "Turn Do Not Disturb off when sharing ends even if it was already enabled before sharing started. When off, DND is only disabled if this plugin enabled it.",
|
||||
"notify.no_niri_title": "ShareDND: niri not found",
|
||||
"notify.no_niri_body": "This looks like a niri session (NIRI_SOCKET is set), but the niri binary is not in PATH, so screen sharing cannot be detected."
|
||||
"notify": {
|
||||
"no_niri_body": "This looks like a niri session (NIRI_SOCKET is set), but the niri binary is not in PATH, so screen sharing cannot be detected.",
|
||||
"no_niri_title": "ShareDND: niri not found"
|
||||
},
|
||||
"settings": {
|
||||
"always_off_after": {
|
||||
"description": "Turn Do Not Disturb off when sharing ends even if it was already enabled before sharing started. When off, DND is only disabled if this plugin enabled it.",
|
||||
"label": "Always disable DND after sharing"
|
||||
},
|
||||
"only_active": {
|
||||
"description": "Count only screencasts that are actively streaming. When off, any open screencast session (even one paused or showing nothing) keeps Do Not Disturb enabled.",
|
||||
"label": "Only active streams"
|
||||
}
|
||||
},
|
||||
"title": "ShareDND"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"title": "Shelly",
|
||||
"notify": {
|
||||
"new_updates": {
|
||||
"title": "New Updates Available",
|
||||
"description": "There are {count} new updates available for your system."
|
||||
}
|
||||
},
|
||||
"bar": {
|
||||
"click_handler": {
|
||||
"error": {
|
||||
"missing_cli": "Shelly CLI is not installed. Please install it to use this feature.",
|
||||
"missing_gui": "Shelly GUI is not installed. Please install it to use this feature."
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"error": "?",
|
||||
"updates": {
|
||||
@@ -16,53 +15,54 @@
|
||||
},
|
||||
"tooltip": {
|
||||
"aur_title": "AUR",
|
||||
"flatpak_title": "Flatpak",
|
||||
"packages_title": "Packages",
|
||||
"no_updates": "System is up to date",
|
||||
"error": {
|
||||
"decode_json": "Failed to decode output from `shelly check-updates --json`"
|
||||
}
|
||||
},
|
||||
"click_handler": {
|
||||
"error": {
|
||||
"missing_gui": "Shelly GUI is not installed. Please install it to use this feature.",
|
||||
"missing_cli": "Shelly CLI is not installed. Please install it to use this feature."
|
||||
}
|
||||
},
|
||||
"flatpak_title": "Flatpak",
|
||||
"no_updates": "System is up to date",
|
||||
"packages_title": "Packages"
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
"new_updates": {
|
||||
"description": "There are {count} new updates available for your system.",
|
||||
"title": "New Updates Available"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"interval": {
|
||||
"label": "Refresh Seconds",
|
||||
"description": "How often the service checks for updates in the background."
|
||||
},
|
||||
"notify": {
|
||||
"label": "Notify",
|
||||
"description": "Whether to show a notification when new updates are available."
|
||||
},
|
||||
"click_action": {
|
||||
"label": "Click Action",
|
||||
"description": "What happens when you click on the bar.",
|
||||
"label": "Click Action",
|
||||
"options": {
|
||||
"none": "Nothing",
|
||||
"open_gui": "Open Shelly GUI",
|
||||
"open_updater": "Start Update in Terminal"
|
||||
}
|
||||
},
|
||||
"hide_when_up_to_date": {
|
||||
"label": "Hide When Up-to-Date",
|
||||
"description": "Whether to hide the bar when there are no updates available."
|
||||
},
|
||||
"color": {
|
||||
"label": "Color",
|
||||
"description": "The color of the bar text."
|
||||
"description": "The color of the bar text.",
|
||||
"label": "Color"
|
||||
},
|
||||
"glyph": {
|
||||
"label": "Glyph",
|
||||
"description": "The glyph shown before the label."
|
||||
"description": "The glyph shown before the label.",
|
||||
"label": "Glyph"
|
||||
},
|
||||
"glyph_color": {
|
||||
"label": "Glyph Color",
|
||||
"description": "The color of the glyph."
|
||||
"description": "The color of the glyph.",
|
||||
"label": "Glyph Color"
|
||||
},
|
||||
"hide_when_up_to_date": {
|
||||
"description": "Whether to hide the bar when there are no updates available.",
|
||||
"label": "Hide When Up-to-Date"
|
||||
},
|
||||
"interval": {
|
||||
"description": "How often the service checks for updates in the background.",
|
||||
"label": "Refresh Seconds"
|
||||
},
|
||||
"notify": {
|
||||
"description": "Whether to show a notification when new updates are available.",
|
||||
"label": "Notify"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Shelly"
|
||||
}
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
{
|
||||
"title": "To Do",
|
||||
"clear_done_confirm": "Delete all done entries?",
|
||||
"clear_done_no": "Cancel",
|
||||
"clear_done_yes": "Delete",
|
||||
"empty": "No tasks yet — add one with +",
|
||||
"placeholder": "New task…",
|
||||
"tooltip": "To Do — {count} to do",
|
||||
"save_failed": "Failed to save the task list",
|
||||
"prio_important": "Important",
|
||||
"prio_low": "Low",
|
||||
"prio_medium": "Medium",
|
||||
"prio_important": "Important",
|
||||
"sort_priority": "Priority",
|
||||
"save_failed": "Failed to save the task list",
|
||||
"settings": {
|
||||
"glyph": {
|
||||
"description": "The glyph shown for the To Do widget on the bar.",
|
||||
"label": "Bar glyph"
|
||||
},
|
||||
"todo_folder": {
|
||||
"description": "Folder holding the task list (todo.json). Defaults to ~/Documents/Todo.",
|
||||
"label": "To Do folder"
|
||||
}
|
||||
},
|
||||
"sort_manual": "Manual",
|
||||
"clear_done_confirm": "Delete all done entries?",
|
||||
"clear_done_yes": "Delete",
|
||||
"clear_done_no": "Cancel",
|
||||
"tip_sort": "Switch ordering mode",
|
||||
"tip_clear_done": "Delete all done tasks",
|
||||
"sort_priority": "Priority",
|
||||
"tip_add": "Add a task",
|
||||
"tip_clear_done": "Delete all done tasks",
|
||||
"tip_close": "Close",
|
||||
"tip_commit": "Save (Enter)",
|
||||
"tip_delete": "Delete task",
|
||||
"tip_done": "Mark as done",
|
||||
"tip_edit": "Edit",
|
||||
"tip_grip": "Reorder — click to pick this row up",
|
||||
"tip_grip_drop": "Click another grip to drop here, or this one to cancel",
|
||||
"tip_commit": "Save (Enter)",
|
||||
"tip_done": "Mark as done",
|
||||
"tip_sort": "Switch ordering mode",
|
||||
"tip_undone": "Mark as to do",
|
||||
"tip_edit": "Edit",
|
||||
"tip_delete": "Delete task",
|
||||
"settings.todo_folder.label": "To Do folder",
|
||||
"settings.todo_folder.description": "Folder holding the task list (todo.json). Defaults to ~/Documents/Todo.",
|
||||
"settings.glyph.label": "Bar glyph",
|
||||
"settings.glyph.description": "The glyph shown for the To Do widget on the bar."
|
||||
"title": "To Do",
|
||||
"tooltip": "To Do — {count} to do"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"title": "ASUS UM5606 Fan State",
|
||||
"settings": {
|
||||
"poll_interval": {
|
||||
"description": "Poll interval in ms",
|
||||
"label": "Poll interval"
|
||||
},
|
||||
"show_label": {
|
||||
"label": "Show label"
|
||||
},
|
||||
"poll_interval": {
|
||||
"label": "Poll interval",
|
||||
"description": "Poll interval in ms"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "ASUS UM5606 Fan State"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"settings": {
|
||||
"show_centibeats": {
|
||||
"label": "Display subbeats",
|
||||
"description": "Toggle subbeat display (.xx)"
|
||||
},
|
||||
"beat_display": {
|
||||
"label": "Display .beats",
|
||||
"description": "Toggle internet time notation (.beats)"
|
||||
},
|
||||
"time_format_toggle": {
|
||||
"label": "Toggle 24HR time",
|
||||
"description": "Display 24 hour time"
|
||||
}
|
||||
"settings": {
|
||||
"beat_display": {
|
||||
"description": "Toggle internet time notation (.beats)",
|
||||
"label": "Display .beats"
|
||||
},
|
||||
"show_centibeats": {
|
||||
"description": "Toggle subbeat display (.xx)",
|
||||
"label": "Display subbeats"
|
||||
},
|
||||
"time_format_toggle": {
|
||||
"description": "Display 24 hour time",
|
||||
"label": "Toggle 24HR time"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Zed Provider
|
||||
|
||||

|
||||
|
||||
Zed Provider integrates recent Zed workspaces with the Noctalia launcher so you
|
||||
can reopen a project quickly without leaving the shell.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `cleboost/zed-provider` |
|
||||
| Entry | Launcher provider: `provider` |
|
||||
| Launcher Prefix | `/zed` |
|
||||
|
||||
## Requirements
|
||||
|
||||
Install [Zed](https://zed.dev) and ensure `zed` is available on `PATH` as
|
||||
`zeditor`. The `sqlite3` command must also be available to read Zed's workspace
|
||||
database. `nohup` command is required to launch Zed.
|
||||
|
||||
## Usage
|
||||
|
||||
Open the Noctalia launcher and type `/zed` to list recent local Zed workspaces.
|
||||
Continue typing to filter projects by name, then select one to open it with
|
||||
`zeditor`.
|
||||
|
||||
## Settings
|
||||
|
||||
- `db_path` — path to Zed's `db.sqlite` workspace database.
|
||||
- `max_results` — maximum number of projects shown in the launcher.
|
||||
|
||||
## Notes
|
||||
|
||||
Projects are read from Zed's workspace database, typically at
|
||||
`~/.local/share/zed/db/0-stable/db.sqlite`. Remote workspaces are excluded.
|
||||
The list is cached for the launcher session and refreshed when you clear the
|
||||
query.
|
||||
@@ -0,0 +1,34 @@
|
||||
id = "cleboost/zed-provider"
|
||||
name = "Zed Provider"
|
||||
version = "1.0.0"
|
||||
plugin_api = 3
|
||||
author = "cleboost"
|
||||
license = "MIT"
|
||||
icon = "folder-open"
|
||||
description = "Open a recent Zed project from the launcher. Type /zed to list your projects."
|
||||
tags = ["launcher", "development", "productivity"]
|
||||
dependencies = ["sqlite3", "zed", "nohup"]
|
||||
|
||||
[[setting]]
|
||||
key = "db_path"
|
||||
type = "file"
|
||||
label_key = "settings.db_path.label"
|
||||
description_key = "settings.db_path.description"
|
||||
default = "~/.local/share/zed/db/0-stable/db.sqlite"
|
||||
|
||||
[[setting]]
|
||||
key = "max_results"
|
||||
type = "int"
|
||||
label_key = "settings.max_results.label"
|
||||
description_key = "settings.max_results.description"
|
||||
default = 20
|
||||
min = 1
|
||||
max = 100
|
||||
|
||||
[[launcher_provider]]
|
||||
id = "provider"
|
||||
entry = "zed_provider.luau"
|
||||
prefix = "zed"
|
||||
glyph = "bolt"
|
||||
include_in_global_search = false
|
||||
debounce_ms = 0
|
||||
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"database-empty": "Zed database is empty or not found",
|
||||
"filter-empty": "Filter: \"{filter}\"",
|
||||
"loading": "Loading…",
|
||||
"loading-subtitle": "Reading Zed projects",
|
||||
"no-projects-found": "No projects found",
|
||||
"settings": {
|
||||
"db_path": {
|
||||
"description": "Path to Zed's db.sqlite file.",
|
||||
"label": "Zed database path"
|
||||
},
|
||||
"max_results": {
|
||||
"description": "Maximum number of projects to display.",
|
||||
"label": "Maximum results"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"database-empty": "La base Zed est vide ou introuvable",
|
||||
"filter-empty": "Filtre : « {filter} »",
|
||||
"loading": "Chargement…",
|
||||
"loading-subtitle": "Lecture des projets Zed",
|
||||
"no-projects-found": "Aucun projet trouvé",
|
||||
"settings": {
|
||||
"db_path": {
|
||||
"description": "Chemin vers le fichier db.sqlite de Zed.",
|
||||
"label": "Chemin de la base Zed"
|
||||
},
|
||||
"max_results": {
|
||||
"description": "Nombre maximum de projets à afficher.",
|
||||
"label": "Nombre maximum de résultats"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
--!nonstrict
|
||||
|
||||
local cachedProjects = nil
|
||||
|
||||
local function getDbPath()
|
||||
local configured = noctalia.getConfig("db_path")
|
||||
if type(configured) == "string" and configured ~= "" then
|
||||
return noctalia.expandPath(configured)
|
||||
end
|
||||
return noctalia.expandPath("~/.local/share/zed/db/0-stable/db.sqlite")
|
||||
end
|
||||
|
||||
local function getMaxResults()
|
||||
local n = noctalia.getConfig("max_results")
|
||||
return (type(n) == "number" and n > 0) and math.floor(n) or 20
|
||||
end
|
||||
|
||||
local function trim(s)
|
||||
return s:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
local function projectName(path)
|
||||
return path:match("([^/]+)$") or path
|
||||
end
|
||||
|
||||
local function shellQuote(s)
|
||||
return "'" .. s:gsub("'", "'\"'\"'") .. "'"
|
||||
end
|
||||
|
||||
local function loadProjects(onDone)
|
||||
local db = getDbPath()
|
||||
local limit = getMaxResults()
|
||||
local sql = string.format(
|
||||
"SELECT DISTINCT paths FROM workspaces WHERE paths IS NOT NULL AND paths != '' AND remote_connection_id IS NULL ORDER BY timestamp DESC LIMIT %d;",
|
||||
limit
|
||||
)
|
||||
local cmd = string.format("sqlite3 -separator '\\n' %s %s 2>/dev/null", shellQuote(db), shellQuote(sql))
|
||||
|
||||
noctalia.runAsync(cmd, function(result)
|
||||
local projects = {}
|
||||
if result.exitCode == 0 and type(result.stdout) == "string" then
|
||||
for line in result.stdout:gmatch("[^\n]+") do
|
||||
local path = trim(line)
|
||||
if path ~= "" then
|
||||
table.insert(projects, path)
|
||||
end
|
||||
end
|
||||
end
|
||||
cachedProjects = projects
|
||||
onDone(projects)
|
||||
end)
|
||||
end
|
||||
|
||||
local function makeRows(projects, filter)
|
||||
local home = noctalia.getenv("HOME") or ""
|
||||
local rows = {}
|
||||
for _, path in projects do
|
||||
local name = projectName(path)
|
||||
local score = filter == "" and 0 or noctalia.fuzzyScore(filter, name)
|
||||
if score == nil then
|
||||
score = noctalia.fuzzyScore(filter, path)
|
||||
end
|
||||
if filter == "" or score ~= nil then
|
||||
local display = (home ~= "" and path:sub(1, #home) == home) and "~" .. path:sub(#home + 1) or path
|
||||
table.insert(rows, {
|
||||
id = path,
|
||||
title = name,
|
||||
subtitle = display,
|
||||
icon = "zed",
|
||||
score = filter == "" and nil or score,
|
||||
})
|
||||
end
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function showResults(query, projects, filter)
|
||||
local rows = makeRows(projects, filter)
|
||||
if #rows == 0 then
|
||||
launcher.setResults(query, {
|
||||
{
|
||||
id = "",
|
||||
title = noctalia.tr("no-projects-found"),
|
||||
subtitle = filter ~= "" and noctalia.tr("filter-empty", { filter = filter }) or noctalia.tr("database-empty"),
|
||||
glyph = "folder-x",
|
||||
},
|
||||
})
|
||||
else
|
||||
launcher.setResults(query, rows)
|
||||
end
|
||||
end
|
||||
|
||||
function onQuery(query)
|
||||
local filter = trim(query)
|
||||
|
||||
if filter == "" then
|
||||
cachedProjects = nil
|
||||
end
|
||||
|
||||
if cachedProjects then
|
||||
showResults(query, cachedProjects, filter)
|
||||
return
|
||||
end
|
||||
|
||||
launcher.setResults(query, {
|
||||
{
|
||||
id = "",
|
||||
title = noctalia.tr("loading"),
|
||||
subtitle = noctalia.tr("loading-subtitle"),
|
||||
glyph = "loader",
|
||||
},
|
||||
})
|
||||
|
||||
loadProjects(function(projects)
|
||||
showResults(query, projects, filter)
|
||||
end)
|
||||
end
|
||||
|
||||
function onActivate(id)
|
||||
if id == "" then
|
||||
return
|
||||
end
|
||||
noctalia.runAsync(string.format("nohup zeditor %s &>/dev/null &", shellQuote(id)))
|
||||
end
|
||||