Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f30bf8a3bd | |||
| 11d8e9542a | |||
| 751432fbb1 | |||
| fd50b7deb9 | |||
| ba75f3c7f7 | |||
| 03fbcb735a | |||
| 04a89118bc | |||
| f9007e3225 | |||
| b14290fffe | |||
| b93b27e43a | |||
| a91c8cf8f2 | |||
| b220b21509 | |||
| a52cc3a427 | |||
| 67004150dc | |||
| ba9c351589 | |||
| a63724d4f1 | |||
| 00947d2410 | |||
| b5ce26f244 | |||
| 793a16b2fc | |||
| 1e2f869bf3 | |||
| 7e7c62ec42 | |||
| e6d69e1e45 | |||
| 514e284b16 | |||
| efa96077a5 | |||
| 734906d50e |
@@ -0,0 +1,3 @@
|
|||||||
|
# Used by the Noctalia Pi Status Bridge adapter and the Hyprland launcher.
|
||||||
|
PI_STATUS_BRIDGE_CLIENT=/home/alex/.npm-global/bin/pi-status-bridge-client
|
||||||
|
PI_STATUS_UI_BINARY=/home/alex/.local/bin/pi-status-ui
|
||||||
@@ -29,3 +29,16 @@ end
|
|||||||
# bun
|
# bun
|
||||||
set --export BUN_INSTALL "$HOME/.bun"
|
set --export BUN_INSTALL "$HOME/.bun"
|
||||||
set --export PATH $BUN_INSTALL/bin $PATH
|
set --export PATH $BUN_INSTALL/bin $PATH
|
||||||
|
|
||||||
|
# opencode
|
||||||
|
fish_add_path /home/alex/.opencode/bin
|
||||||
|
|
||||||
|
# Yazi wrapper that keeps the shell in its selected directory.
|
||||||
|
function y
|
||||||
|
set tmp (mktemp -t "yazi-cwd.XXXXXX")
|
||||||
|
command yazi $argv --cwd-file="$tmp"
|
||||||
|
if read -z cwd <"$tmp"; and [ "$cwd" != "$PWD" ]; and test -d "$cwd"
|
||||||
|
builtin cd -- "$cwd"
|
||||||
|
end
|
||||||
|
command rm -f -- "$tmp"
|
||||||
|
end
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Non-locking Wayland screen blanker. It covers every monitor in black and
|
||||||
|
# dismisses itself on any key press or mouse click; it never changes DPMS.
|
||||||
|
listener {
|
||||||
|
timeout = 300
|
||||||
|
on-timeout = /home/alex/.local/bin/wayshell-blanker
|
||||||
|
}
|
||||||
+41
-12
@@ -10,16 +10,21 @@ for _, monitor in ipairs(machine.monitors or {}) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
local main_mod = "SUPER"
|
local main_mod = "SUPER"
|
||||||
local terminal = "alacritty"
|
local terminal = "kitty"
|
||||||
|
|
||||||
hl.config({
|
local input = {
|
||||||
input = {
|
|
||||||
kb_layout = "us",
|
kb_layout = "us",
|
||||||
kb_variant = "intl",
|
kb_variant = "intl",
|
||||||
numlock_by_default = true,
|
numlock_by_default = true,
|
||||||
},
|
}
|
||||||
|
for key, value in pairs(machine.input or {}) do
|
||||||
|
input[key] = value
|
||||||
|
end
|
||||||
|
|
||||||
|
hl.config({
|
||||||
|
input = input,
|
||||||
general = {
|
general = {
|
||||||
gaps_in = 8,
|
gaps_in = 4,
|
||||||
-- Keep a thin black perimeter so the bottom border remains on-screen.
|
-- Keep a thin black perimeter so the bottom border remains on-screen.
|
||||||
gaps_out = 2,
|
gaps_out = 2,
|
||||||
border_size = 2,
|
border_size = 2,
|
||||||
@@ -35,16 +40,36 @@ hl.config({
|
|||||||
animations = {
|
animations = {
|
||||||
enabled = false,
|
enabled = false,
|
||||||
},
|
},
|
||||||
|
binds = {
|
||||||
|
-- Selecting the current workspace again returns to the last workspace.
|
||||||
|
workspace_back_and_forth = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Three-finger horizontal swipes change workspaces; touchscreens remain excluded
|
||||||
|
-- by Hyprland's default workspace_swipe_touch = false behavior.
|
||||||
|
hl.gesture({
|
||||||
|
fingers = 3,
|
||||||
|
direction = "horizontal",
|
||||||
|
action = "workspace",
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Dynamically hide the border when a workspace has exactly one tiled app.
|
||||||
|
hl.workspace_rule({
|
||||||
|
workspace = "w[t1]",
|
||||||
|
no_border = true,
|
||||||
})
|
})
|
||||||
|
|
||||||
-- Application and session bindings.
|
-- Application and session bindings.
|
||||||
local noctalia_ipc = "noctalia msg "
|
local noctalia_ipc = "noctalia msg "
|
||||||
|
local pi_status_ui_toggle = 'ui="${PI_STATUS_UI_BINARY:-$HOME/.local/bin/pi-status-ui}"; if test -x "$ui"; then setsid -f env TMPDIR=/tmp "$ui" --toggle >/tmp/pi-status-ui.log 2>&1; fi'
|
||||||
-- Tap and release Super on its own to open Noctalia's control-center home.
|
-- Tap and release Super on its own to open Noctalia's control-center home.
|
||||||
hl.bind(main_mod .. " + SUPER_L", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle control-center"), { release = true })
|
hl.bind(main_mod .. " + SUPER_L", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle control-center"), { release = true })
|
||||||
hl.bind(main_mod .. " + RETURN", hl.dsp.exec_cmd(terminal))
|
hl.bind(main_mod .. " + RETURN", hl.dsp.exec_cmd(terminal))
|
||||||
hl.bind(main_mod .. " + CTRL + RETURN", hl.dsp.exec_cmd(terminal .. " -e zellij"))
|
hl.bind(main_mod .. " + CTRL + RETURN", hl.dsp.exec_cmd(terminal .. " -e zellij"))
|
||||||
hl.bind(main_mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("rofi -show combi"))
|
hl.bind(main_mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("rofi -show combi"))
|
||||||
hl.bind(main_mod .. " + SPACE", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle launcher"))
|
hl.bind(main_mod .. " + SPACE", hl.dsp.exec_cmd(pi_status_ui_toggle))
|
||||||
|
hl.bind(main_mod .. " + N", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle alex/knowledge-lookup:panel"))
|
||||||
hl.bind(main_mod .. " + S", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle control-center"))
|
hl.bind(main_mod .. " + S", hl.dsp.exec_cmd(noctalia_ipc .. "panel-toggle control-center"))
|
||||||
hl.bind(main_mod .. " + SHIFT + F", hl.dsp.exec_cmd(noctalia_ipc .. "bar-toggle main"))
|
hl.bind(main_mod .. " + SHIFT + F", hl.dsp.exec_cmd(noctalia_ipc .. "bar-toggle main"))
|
||||||
hl.bind(main_mod .. " + SHIFT + R", hl.dsp.exec_cmd("hyprctl reload"))
|
hl.bind(main_mod .. " + SHIFT + R", hl.dsp.exec_cmd("hyprctl reload"))
|
||||||
@@ -52,7 +77,7 @@ hl.bind(main_mod .. " + CTRL + L", hl.dsp.exec_cmd("command -v hyprlock >/dev/nu
|
|||||||
hl.bind(main_mod .. " + CTRL + SHIFT + ALT + S", hl.dsp.exec_cmd("systemctl suspend"))
|
hl.bind(main_mod .. " + CTRL + SHIFT + ALT + S", hl.dsp.exec_cmd("systemctl suspend"))
|
||||||
hl.bind(main_mod .. " + CTRL + SHIFT + ALT + H", hl.dsp.exec_cmd("systemctl hibernate"))
|
hl.bind(main_mod .. " + CTRL + SHIFT + ALT + H", hl.dsp.exec_cmd("systemctl hibernate"))
|
||||||
hl.bind(main_mod .. " + SHIFT + C", hl.dsp.window.close())
|
hl.bind(main_mod .. " + SHIFT + C", hl.dsp.window.close())
|
||||||
hl.bind(main_mod .. " + CTRL + SHIFT + F", hl.dsp.exec_cmd("env LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 flameshot gui"))
|
hl.bind(main_mod .. " + CTRL + SHIFT + F", hl.dsp.exec_cmd("/home/alex/.local/bin/screenshot-select"))
|
||||||
|
|
||||||
-- Audio, media, brightness, and calculator keys.
|
-- Audio, media, brightness, and calculator keys.
|
||||||
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { locked = true })
|
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { locked = true })
|
||||||
@@ -64,10 +89,10 @@ hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
|
|||||||
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
|
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
|
||||||
hl.bind(main_mod .. " + XF86AudioRaiseVolume", hl.dsp.exec_cmd("playerctl next"), { locked = true })
|
hl.bind(main_mod .. " + XF86AudioRaiseVolume", hl.dsp.exec_cmd("playerctl next"), { locked = true })
|
||||||
hl.bind(main_mod .. " + XF86AudioLowerVolume", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
|
hl.bind(main_mod .. " + XF86AudioLowerVolume", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
|
||||||
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd('light -A 5 && notify-send "Brightness set to $(light)"'), { locked = true })
|
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd('light -A 5'), { locked = true })
|
||||||
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd('light -U 5 && notify-send "Brightness set to $(light)"'), { locked = true })
|
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd('light -U 5'), { locked = true })
|
||||||
hl.bind(main_mod .. " + XF86MonBrightnessDown", hl.dsp.exec_cmd('light -S 0.2 && notify-send "Brightness set to min"'), { locked = true })
|
hl.bind(main_mod .. " + XF86MonBrightnessDown", hl.dsp.exec_cmd('light -S 0.0'), { locked = true })
|
||||||
hl.bind(main_mod .. " + XF86MonBrightnessUp", hl.dsp.exec_cmd('light -S 100 && notify-send "Brightness set to max"'), { locked = true })
|
hl.bind(main_mod .. " + XF86MonBrightnessUp", hl.dsp.exec_cmd('light -S 100'), { locked = true })
|
||||||
hl.bind("XF86Calculator", hl.dsp.exec_cmd("qalculate-qt"), { locked = true })
|
hl.bind("XF86Calculator", hl.dsp.exec_cmd("qalculate-qt"), { locked = true })
|
||||||
|
|
||||||
-- Retain i3's hjkl and arrow-key directional workflow.
|
-- Retain i3's hjkl and arrow-key directional workflow.
|
||||||
@@ -88,7 +113,9 @@ for workspace = 1, 9 do
|
|||||||
hl.bind(main_mod .. " + SHIFT + " .. key, hl.dsp.window.move({ workspace = workspace }))
|
hl.bind(main_mod .. " + SHIFT + " .. key, hl.dsp.window.move({ workspace = workspace }))
|
||||||
hl.bind(main_mod .. " + CTRL + " .. key, hl.dsp.window.move({ workspace = workspace, follow = true }))
|
hl.bind(main_mod .. " + CTRL + " .. key, hl.dsp.window.move({ workspace = workspace, follow = true }))
|
||||||
end
|
end
|
||||||
-- Hyprland has no direct i3-style back_and_forth dispatcher; use numbered workspaces.
|
-- Match i3's back-and-forth workflow: Super+Tab switches to the last workspace;
|
||||||
|
-- selecting the current numbered workspace also returns to it.
|
||||||
|
hl.bind(main_mod .. " + TAB", hl.dsp.focus({ workspace = "previous" }))
|
||||||
hl.bind(main_mod .. " + CTRL + RIGHT", hl.dsp.focus({ workspace = "+1" }))
|
hl.bind(main_mod .. " + CTRL + RIGHT", hl.dsp.focus({ workspace = "+1" }))
|
||||||
hl.bind(main_mod .. " + CTRL + LEFT", hl.dsp.focus({ workspace = "-1" }))
|
hl.bind(main_mod .. " + CTRL + LEFT", hl.dsp.focus({ workspace = "-1" }))
|
||||||
|
|
||||||
@@ -112,7 +139,9 @@ local function float_rule(name, match, border_size)
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
hl.window_rule({ name = "obsidian-workspace", match = { class = "^obsidian$" }, workspace = "8" })
|
||||||
hl.window_rule({ name = "thunderbird-workspace", match = { class = "^Thunderbird$" }, workspace = "9" })
|
hl.window_rule({ name = "thunderbird-workspace", match = { class = "^Thunderbird$" }, workspace = "9" })
|
||||||
|
float_rule("pi-status-ui", { class = "^(pi-status-ui|Pi-status-ui)$" }, 1)
|
||||||
float_rule("alsamixer", { title = "^alsamixer$" }, 1)
|
float_rule("alsamixer", { title = "^alsamixer$" }, 1)
|
||||||
float_rule("qalculate", { title = "^Qalculate!$" })
|
float_rule("qalculate", { title = "^Qalculate!$" })
|
||||||
float_rule("psensor", { class = "^Psensor$" })
|
float_rule("psensor", { class = "^Psensor$" })
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Minimal Hyprlock screen using the current desktop as a blurred backdrop.
|
||||||
|
|
||||||
|
$font = Sans
|
||||||
|
|
||||||
|
general {
|
||||||
|
hide_cursor = true
|
||||||
|
}
|
||||||
|
|
||||||
|
background {
|
||||||
|
monitor =
|
||||||
|
path = screenshot
|
||||||
|
blur_passes = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
input-field {
|
||||||
|
monitor =
|
||||||
|
size = 20%, 5%
|
||||||
|
outline_thickness = 2
|
||||||
|
inner_color = rgba(0, 0, 0, 0.25)
|
||||||
|
outer_color = rgba(eeeeeecc)
|
||||||
|
check_color = rgba(8ec07ccc)
|
||||||
|
fail_color = rgba(fb4934cc)
|
||||||
|
|
||||||
|
font_color = rgb(eeeeee)
|
||||||
|
fade_on_empty = false
|
||||||
|
rounding = 12
|
||||||
|
font_family = $font
|
||||||
|
placeholder_text = Input password...
|
||||||
|
fail_text = $PAMFAIL
|
||||||
|
|
||||||
|
position = 0, -20
|
||||||
|
halign = center
|
||||||
|
valign = center
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
monitor =
|
||||||
|
text = $TIME
|
||||||
|
font_size = 72
|
||||||
|
font_family = $font
|
||||||
|
|
||||||
|
position = 0, 120
|
||||||
|
halign = center
|
||||||
|
valign = center
|
||||||
|
}
|
||||||
@@ -10,8 +10,10 @@ return {
|
|||||||
},
|
},
|
||||||
startup = {
|
startup = {
|
||||||
"noctalia",
|
"noctalia",
|
||||||
|
"hypridle",
|
||||||
-- Electron's tray icon registers only when the SNI watcher already exists.
|
-- Electron's tray icon registers only when the SNI watcher already exists.
|
||||||
"sh -c 'sleep 2; exec mullvad-vpn'",
|
"sh -c 'sleep 2; exec mullvad-vpn'",
|
||||||
|
"obsidian",
|
||||||
"thunderbird",
|
"thunderbird",
|
||||||
"nextcloud",
|
"nextcloud",
|
||||||
"spotify",
|
"spotify",
|
||||||
|
|||||||
@@ -1,11 +1,30 @@
|
|||||||
-- Laptop profile: leave monitor selection to Hyprland's hotplug support.
|
-- Laptop profile: leave monitor selection to Hyprland's hotplug support.
|
||||||
-- Noctalia replaces the i3-era Redshift, Polybar, Blueman Applet, and Picom stack.
|
-- Noctalia replaces the i3-era Redshift, Polybar, Blueman Applet, and Picom stack.
|
||||||
return {
|
return {
|
||||||
|
input = {
|
||||||
|
touchpad = {
|
||||||
|
-- Standard laptop behavior: one/two/three-finger taps map to
|
||||||
|
-- left/right/middle click, and a second tap held down starts a drag.
|
||||||
|
tap_to_click = true,
|
||||||
|
tap_button_map = "lrm",
|
||||||
|
tap_and_drag = true,
|
||||||
|
drag_lock = false,
|
||||||
|
clickfinger_behavior = true,
|
||||||
|
disable_while_typing = true,
|
||||||
|
|
||||||
|
-- Preserve natural two-finger scrolling at the default sensitivity.
|
||||||
|
natural_scroll = true,
|
||||||
|
scroll_factor = 1.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
monitors = {},
|
monitors = {},
|
||||||
startup = {
|
startup = {
|
||||||
"noctalia",
|
"noctalia",
|
||||||
-- Start after Noctalia has claimed the StatusNotifier watcher.
|
-- Start after Noctalia has claimed the StatusNotifier watcher.
|
||||||
"sh -c 'sleep 2; exec mullvad-vpn'",
|
"sh -c 'sleep 2; exec mullvad-vpn'",
|
||||||
|
"obsidian",
|
||||||
|
"thunderbird",
|
||||||
"nextcloud",
|
"nextcloud",
|
||||||
|
"spotify",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,80 @@ offline_mode = true
|
|||||||
external_ip_enabled = false
|
external_ip_enabled = false
|
||||||
telemetry_enabled = false
|
telemetry_enabled = false
|
||||||
|
|
||||||
|
# Noctalia owns idle handling so ScreenSaver/Wayland idle inhibitors (such as
|
||||||
|
# browser video playback) prevent these actions. Power displays down after five
|
||||||
|
# minutes, lock after ten minutes, then suspend-then-hibernate after thirty.
|
||||||
|
[idle]
|
||||||
|
behavior_order = ["lock", "screen-off", "suspend-then-hibernate"]
|
||||||
|
pre_action_fade_seconds = 2.0
|
||||||
|
|
||||||
|
[idle.behavior.lock]
|
||||||
|
action = "lock"
|
||||||
|
enabled = true
|
||||||
|
timeout = 600
|
||||||
|
|
||||||
|
[idle.behavior.screen-off]
|
||||||
|
action = "screen_off"
|
||||||
|
enabled = true
|
||||||
|
timeout = 300
|
||||||
|
|
||||||
|
[idle.behavior.suspend-then-hibernate]
|
||||||
|
action = "command"
|
||||||
|
command = "loginctl lock-session && systemctl suspend-then-hibernate"
|
||||||
|
enabled = true
|
||||||
|
timeout = 1800
|
||||||
|
|
||||||
[theme]
|
[theme]
|
||||||
mode = "dark"
|
mode = "dark"
|
||||||
source = "custom"
|
source = "custom"
|
||||||
custom_palette = "Ember OLED"
|
custom_palette = "Ember OLED"
|
||||||
pure_black_dark = true
|
pure_black_dark = true
|
||||||
|
|
||||||
|
# Noctalia merges files alphabetically. Keep reusable modules here; machine-specific
|
||||||
|
# layout and hardware settings live in 90-machine.toml, while 99-extend.toml is local.
|
||||||
|
[plugins]
|
||||||
|
enabled = ["alex/system-status", "alex/knowledge-lookup"]
|
||||||
|
|
||||||
|
[widget.cpu]
|
||||||
|
type = "alex/system-status:cpu"
|
||||||
|
|
||||||
|
[widget.ram]
|
||||||
|
type = "alex/system-status:memory"
|
||||||
|
|
||||||
|
[widget.network-rate]
|
||||||
|
type = "alex/system-status:network-rate"
|
||||||
|
|
||||||
|
[widget.idle-inhibitors]
|
||||||
|
type = "alex/system-status:inhibitors"
|
||||||
|
|
||||||
|
[widget.knowledge-lookup]
|
||||||
|
type = "alex/knowledge-lookup:bar"
|
||||||
|
|
||||||
|
[widget.temp]
|
||||||
|
type = "sysmon"
|
||||||
|
stat = "cpu_temp"
|
||||||
|
visualization = "none"
|
||||||
|
show_value = true
|
||||||
|
|
||||||
|
[widget.disk]
|
||||||
|
type = "sysmon"
|
||||||
|
stat = "disk_used_pct"
|
||||||
|
visualization = "none"
|
||||||
|
show_value = true
|
||||||
|
path = "/"
|
||||||
|
|
||||||
|
[desktop_widgets]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[wallpaper]
|
||||||
|
enabled = true
|
||||||
|
fill_color = "#000000"
|
||||||
|
|
||||||
|
[wallpaper.default]
|
||||||
|
path = "color:#000000"
|
||||||
|
|
||||||
|
[nightlight]
|
||||||
|
enabled = true
|
||||||
|
force = false
|
||||||
|
temperature_day = 6500
|
||||||
|
temperature_night = 2500
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
# Desktop-specific shell layout and hardware telemetry.
|
# Desktop-specific shell layout and hardware telemetry.
|
||||||
|
# Pi Status Bridge is installed locally from ~/projects/pi-status-bridge.
|
||||||
|
[plugins]
|
||||||
|
enabled = ["alex/system-status", "alex/pi-status-bridge", "alex/knowledge-lookup"]
|
||||||
|
|
||||||
|
[widget.pi-status]
|
||||||
|
type = "alex/pi-status-bridge:bridge"
|
||||||
|
|
||||||
[bar.main]
|
[bar.main]
|
||||||
position = "bottom"
|
position = "bottom"
|
||||||
layer = "overlay"
|
layer = "top"
|
||||||
|
scale = 0.95
|
||||||
capsule_radius = 5
|
capsule_radius = 5
|
||||||
center = ["group:g3", "notifications"]
|
center = ["group:g3", "notifications"]
|
||||||
end = ["bluetooth", "volume", "control-center", "group:g1", "session"]
|
end = ["group:g2", "group:g6", "group:g1", "session"]
|
||||||
font_weight = 400
|
font_weight = 400
|
||||||
margin_ends = 0
|
margin_ends = 0
|
||||||
padding = 0
|
padding = 0
|
||||||
panel_overlap = 3
|
panel_overlap = 3
|
||||||
radius = 0
|
radius = 0
|
||||||
shadow = false
|
shadow = false
|
||||||
smart_auto_hide = true
|
# Keep the desktop status bar visible and reserve its screen edge.
|
||||||
# The bar may overlay the thin perimeter while visible; hidden bars release the space.
|
auto_hide = false
|
||||||
reserve_space = false
|
smart_auto_hide = false
|
||||||
start = ["tray", "group:g4", "group:g2", "privacy"]
|
reserve_space = true
|
||||||
|
start = ["tray", "group:g4", "privacy"]
|
||||||
thickness = 39
|
thickness = 39
|
||||||
widget_spacing = 10
|
widget_spacing = 10
|
||||||
|
|
||||||
@@ -22,7 +31,7 @@ widget_spacing = 10
|
|||||||
enabled = true
|
enabled = true
|
||||||
fill = "surface_variant"
|
fill = "surface_variant"
|
||||||
id = "g1"
|
id = "g1"
|
||||||
members = ["caffeine", "date", "clock"]
|
members = ["idle-inhibitors", "caffeine", "date", "clock"]
|
||||||
opacity = 1.0
|
opacity = 1.0
|
||||||
padding = 6.0
|
padding = 6.0
|
||||||
|
|
||||||
@@ -30,7 +39,7 @@ padding = 6.0
|
|||||||
enabled = true
|
enabled = true
|
||||||
fill = "surface_variant"
|
fill = "surface_variant"
|
||||||
id = "g2"
|
id = "g2"
|
||||||
members = ["network_rx", "network_tx", "network"]
|
members = ["network", "bluetooth"]
|
||||||
opacity = 1.0
|
opacity = 1.0
|
||||||
padding = 6.0
|
padding = 6.0
|
||||||
|
|
||||||
@@ -47,64 +56,36 @@ radius = 5.0
|
|||||||
enabled = true
|
enabled = true
|
||||||
fill = "surface_variant"
|
fill = "surface_variant"
|
||||||
id = "g4"
|
id = "g4"
|
||||||
members = ["cpu", "temp", "ram", "gpu", "gpu_temp", "disk", "battery"]
|
members = ["pi-status", "cpu", "temp", "ram", "gpu", "gpu_temp", "disk"]
|
||||||
opacity = 1.0
|
opacity = 1.0
|
||||||
padding = 6.0
|
padding = 6.0
|
||||||
|
|
||||||
[widget.cpu]
|
[[bar.main.capsule_group]]
|
||||||
type = "alex/system-status:cpu"
|
enabled = true
|
||||||
|
fill = "surface_variant"
|
||||||
[widget.ram]
|
id = "g6"
|
||||||
type = "alex/system-status:memory"
|
members = ["input_volume", "volume"]
|
||||||
|
opacity = 1.0
|
||||||
[widget.temp]
|
padding = 6.0
|
||||||
type = "sysmon"
|
radius = 5.0
|
||||||
stat = "cpu_temp"
|
|
||||||
display = "text"
|
|
||||||
|
|
||||||
[widget.gpu]
|
[widget.gpu]
|
||||||
type = "sysmon"
|
type = "sysmon"
|
||||||
stat = "gpu_usage"
|
stat = "gpu_usage"
|
||||||
display = "graph"
|
visualization = "graph"
|
||||||
|
show_value = true
|
||||||
|
|
||||||
[widget.gpu_temp]
|
[widget.gpu_temp]
|
||||||
type = "sysmon"
|
type = "sysmon"
|
||||||
stat = "gpu_temp"
|
stat = "gpu_temp"
|
||||||
display = "text"
|
visualization = "none"
|
||||||
|
show_value = true
|
||||||
[widget.disk]
|
|
||||||
type = "sysmon"
|
|
||||||
stat = "disk_pct"
|
|
||||||
display = "text"
|
|
||||||
path = "/"
|
|
||||||
|
|
||||||
[battery.device."/org/freedesktop/UPower/devices/battery_hidpp_battery_0"]
|
|
||||||
warning_threshold = 20
|
|
||||||
|
|
||||||
[control_center]
|
[control_center]
|
||||||
sidebar = "full"
|
sidebar = "full"
|
||||||
sidebar_section = "full"
|
sidebar_section = "full"
|
||||||
width = 880
|
width = 880
|
||||||
|
|
||||||
[desktop_widgets]
|
|
||||||
enabled = false
|
|
||||||
|
|
||||||
[wallpaper]
|
|
||||||
enabled = true
|
|
||||||
fill_color = "#000000"
|
|
||||||
|
|
||||||
[wallpaper.default]
|
|
||||||
path = "color:#000000"
|
|
||||||
|
|
||||||
[system.monitor]
|
[system.monitor]
|
||||||
# GPU polling is disabled by Noctalia by default; enable it for the RTX 3090 widgets.
|
# GPU polling is disabled by Noctalia by default; enable it for the RTX 3090 widgets.
|
||||||
gpu_poll_seconds = 2.0
|
gpu_poll_seconds = 2.0
|
||||||
|
|
||||||
[plugins]
|
|
||||||
enabled = ["alex/system-status"]
|
|
||||||
|
|
||||||
[nightlight]
|
|
||||||
enabled = true
|
|
||||||
force = false
|
|
||||||
temperature_day = 6500
|
|
||||||
temperature_night = 2500
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# Laptop-specific Noctalia layout. Shared privacy/theme defaults live in 10-base.toml.
|
# Laptop-specific Noctalia layout. Shared privacy/theme defaults live in 10-base.toml.
|
||||||
[bar.main]
|
[bar.main]
|
||||||
position = "top"
|
position = "top"
|
||||||
layer = "overlay"
|
layer = "top"
|
||||||
|
scale = 0.95
|
||||||
capsule_radius = 5
|
capsule_radius = 5
|
||||||
center = ["group:g3", "notifications"]
|
center = ["group:g3", "notifications"]
|
||||||
end = ["bluetooth", "volume", "control-center", "group:g1", "session"]
|
end = ["group:g2", "group:g6", "group:g5", "group:g1", "session"]
|
||||||
font_weight = 400
|
font_weight = 400
|
||||||
margin_ends = 0
|
margin_ends = 0
|
||||||
padding = 0
|
padding = 0
|
||||||
@@ -14,7 +15,8 @@ shadow = false
|
|||||||
auto_hide = false
|
auto_hide = false
|
||||||
smart_auto_hide = false
|
smart_auto_hide = false
|
||||||
reserve_space = true
|
reserve_space = true
|
||||||
start = ["tray", "group:g4", "group:g2", "privacy"]
|
start = ["tray", "group:g4"]
|
||||||
|
# Accommodate the battery-rate widget's wattage + ETA lines without clipping.
|
||||||
thickness = 39
|
thickness = 39
|
||||||
widget_spacing = 10
|
widget_spacing = 10
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ widget_spacing = 10
|
|||||||
enabled = true
|
enabled = true
|
||||||
fill = "surface_variant"
|
fill = "surface_variant"
|
||||||
id = "g1"
|
id = "g1"
|
||||||
members = ["caffeine", "date", "clock"]
|
members = ["idle-inhibitors", "caffeine", "date", "clock"]
|
||||||
opacity = 1.0
|
opacity = 1.0
|
||||||
padding = 6.0
|
padding = 6.0
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ padding = 6.0
|
|||||||
enabled = true
|
enabled = true
|
||||||
fill = "surface_variant"
|
fill = "surface_variant"
|
||||||
id = "g2"
|
id = "g2"
|
||||||
members = ["network_rx", "network_tx", "network"]
|
members = ["network", "bluetooth"]
|
||||||
opacity = 1.0
|
opacity = 1.0
|
||||||
padding = 6.0
|
padding = 6.0
|
||||||
|
|
||||||
@@ -47,47 +49,40 @@ radius = 5.0
|
|||||||
enabled = true
|
enabled = true
|
||||||
fill = "surface_variant"
|
fill = "surface_variant"
|
||||||
id = "g4"
|
id = "g4"
|
||||||
members = ["cpu", "temp", "ram", "disk", "battery"]
|
members = ["cpu", "temp", "ram", "gpu-mode", "disk"]
|
||||||
opacity = 1.0
|
opacity = 1.0
|
||||||
padding = 6.0
|
padding = 6.0
|
||||||
|
|
||||||
[widget.cpu]
|
[[bar.main.capsule_group]]
|
||||||
type = "alex/system-status:cpu"
|
enabled = true
|
||||||
|
fill = "surface_variant"
|
||||||
|
id = "g5"
|
||||||
|
members = ["battery-rate", "battery", "power_profile"]
|
||||||
|
opacity = 1.0
|
||||||
|
padding = 6.0
|
||||||
|
radius = 5.0
|
||||||
|
|
||||||
[widget.ram]
|
[[bar.main.capsule_group]]
|
||||||
type = "alex/system-status:memory"
|
enabled = true
|
||||||
|
fill = "surface_variant"
|
||||||
|
id = "g6"
|
||||||
|
members = ["input_volume", "volume"]
|
||||||
|
opacity = 1.0
|
||||||
|
padding = 6.0
|
||||||
|
radius = 5.0
|
||||||
|
|
||||||
[widget.temp]
|
[widget.battery-rate]
|
||||||
type = "sysmon"
|
type = "alex/system-status:battery-rate"
|
||||||
stat = "cpu_temp"
|
|
||||||
display = "text"
|
|
||||||
|
|
||||||
[widget.disk]
|
[widget.gpu-mode]
|
||||||
type = "sysmon"
|
type = "alex/system-status:gpu-mode"
|
||||||
stat = "disk_pct"
|
|
||||||
display = "text"
|
# Only suspend-then-hibernate after 30 minutes when UPower confirms battery power.
|
||||||
path = "/"
|
# The command fails closed on AC or when UPower is unavailable.
|
||||||
|
[idle.behavior.suspend-then-hibernate]
|
||||||
|
command = "busctl get-property org.freedesktop.UPower /org/freedesktop/UPower org.freedesktop.UPower OnBattery 2>/dev/null | grep -qx 'b true' && loginctl lock-session && systemctl suspend-then-hibernate"
|
||||||
|
|
||||||
[control_center]
|
[control_center]
|
||||||
sidebar = "full"
|
sidebar = "full"
|
||||||
sidebar_section = "full"
|
sidebar_section = "full"
|
||||||
width = 720
|
width = 720
|
||||||
|
|
||||||
[desktop_widgets]
|
|
||||||
enabled = false
|
|
||||||
|
|
||||||
[wallpaper]
|
|
||||||
enabled = true
|
|
||||||
fill_color = "#000000"
|
|
||||||
|
|
||||||
[wallpaper.default]
|
|
||||||
path = "color:#000000"
|
|
||||||
|
|
||||||
[plugins]
|
|
||||||
enabled = ["alex/system-status"]
|
|
||||||
|
|
||||||
[nightlight]
|
|
||||||
enabled = true
|
|
||||||
force = false
|
|
||||||
temperature_day = 6500
|
|
||||||
temperature_night = 2500
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ set vcs_backend_svn disabled
|
|||||||
# Truncate the long commit messages to this length when shown in the statusbar.
|
# Truncate the long commit messages to this length when shown in the statusbar.
|
||||||
set vcs_msg_length 50
|
set vcs_msg_length 50
|
||||||
|
|
||||||
# Use one of the supported image preview protocols
|
# Render image previews through Kitty's native graphics protocol.
|
||||||
set preview_images true
|
set preview_images true
|
||||||
|
|
||||||
# Set the preview image method. Supported methods:
|
# Set the preview image method. Supported methods:
|
||||||
@@ -114,7 +114,7 @@ set preview_images true
|
|||||||
# Preview images in full color with the external command "ueberzug".
|
# Preview images in full color with the external command "ueberzug".
|
||||||
# Images are shown by using a child window.
|
# Images are shown by using a child window.
|
||||||
# Only for users who run X11 in GNU/Linux.
|
# Only for users who run X11 in GNU/Linux.
|
||||||
set preview_images_method ueberzug
|
set preview_images_method kitty
|
||||||
|
|
||||||
# Delay in seconds before displaying an image with the w3m method.
|
# Delay in seconds before displaying an image with the w3m method.
|
||||||
# Increase it in case of experiencing display corruption.
|
# Increase it in case of experiencing display corruption.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../idle-inhibitor-monitor.service
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Track session ScreenSaver idle inhibitors for Noctalia
|
||||||
|
After=graphical-session.target
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=%h/.local/bin/idle-inhibitor-monitor
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Pi Status Bridge home agent
|
||||||
|
After=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
Environment=PATH=/home/alex/.npm-global/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
|
ExecStart=/usr/bin/env pi-status-bridge --session-root %h/.local/state/pi-status-bridge/sessions
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
Executable
+189
@@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Track session ScreenSaver inhibitors and expose combined idle blockers as TSV.
|
||||||
|
|
||||||
|
logind exposes idle inhibitors through systemd-inhibit. Browser video wake locks
|
||||||
|
use org.freedesktop.ScreenSaver instead, which has no list API, so this monitor
|
||||||
|
observes Inhibit/UnInhibit calls and keeps their active state in a small JSON
|
||||||
|
file for the Noctalia widget and panel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
|
STATE_FILE = Path.home() / ".local/state/noctalia/idle-inhibitors.json"
|
||||||
|
SCREEN_SAVER = "org.freedesktop.ScreenSaver"
|
||||||
|
|
||||||
|
|
||||||
|
def write_state(inhibitors: dict[tuple[str, int], dict[str, str]]) -> None:
|
||||||
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {"inhibitors": list(inhibitors.values())}
|
||||||
|
with NamedTemporaryFile("w", dir=STATE_FILE.parent, delete=False) as file:
|
||||||
|
json.dump(payload, file, separators=(",", ":"))
|
||||||
|
file.write("\n")
|
||||||
|
temporary_name = file.name
|
||||||
|
os.replace(temporary_name, STATE_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def read_screensaver_inhibitors() -> list[dict[str, str]]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(STATE_FILE.read_text())
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return []
|
||||||
|
inhibitors = payload.get("inhibitors", [])
|
||||||
|
return [item for item in inhibitors if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def logind_inhibitors() -> list[dict[str, str]]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemd-inhibit", "--list", "--no-pager"],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
inhibitors = []
|
||||||
|
for line in result.stdout.splitlines()[1:]:
|
||||||
|
fields = line.split(maxsplit=6)
|
||||||
|
if len(fields) < 7 or "idle" not in fields[5].split(":"):
|
||||||
|
continue
|
||||||
|
reason_and_mode = fields[6].rsplit(maxsplit=1)
|
||||||
|
inhibitors.append(
|
||||||
|
{
|
||||||
|
"source": "logind",
|
||||||
|
"who": fields[0],
|
||||||
|
"user": fields[2],
|
||||||
|
"pid": fields[3],
|
||||||
|
"comm": fields[4],
|
||||||
|
"mode": reason_and_mode[-1],
|
||||||
|
"why": reason_and_mode[0] if len(reason_and_mode) == 2 else "No reason supplied",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return inhibitors
|
||||||
|
|
||||||
|
|
||||||
|
def combined_inhibitors() -> list[dict[str, str]]:
|
||||||
|
return logind_inhibitors() + read_screensaver_inhibitors()
|
||||||
|
|
||||||
|
|
||||||
|
def print_inhibitors() -> None:
|
||||||
|
for inhibitor in combined_inhibitors():
|
||||||
|
values = [
|
||||||
|
inhibitor.get("source", "screensaver"),
|
||||||
|
inhibitor.get("who", ""),
|
||||||
|
inhibitor.get("user", ""),
|
||||||
|
inhibitor.get("pid", ""),
|
||||||
|
inhibitor.get("comm", ""),
|
||||||
|
inhibitor.get("mode", ""),
|
||||||
|
inhibitor.get("why", ""),
|
||||||
|
]
|
||||||
|
print("\t".join(value.replace("\t", " ").replace("\n", " ") for value in values))
|
||||||
|
|
||||||
|
|
||||||
|
def monitor() -> None:
|
||||||
|
active: dict[tuple[str, int], dict[str, str]] = {}
|
||||||
|
pending: dict[tuple[str, int], dict[str, str]] = {}
|
||||||
|
write_state(active)
|
||||||
|
|
||||||
|
process = subprocess.Popen(
|
||||||
|
["stdbuf", "-oL", "busctl", "--user", "--json=short", "monitor"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(_signal: int, _frame: object) -> None:
|
||||||
|
process.terminate()
|
||||||
|
write_state({})
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, stop)
|
||||||
|
signal.signal(signal.SIGINT, stop)
|
||||||
|
|
||||||
|
assert process.stdout is not None
|
||||||
|
for line in process.stdout:
|
||||||
|
try:
|
||||||
|
message = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
message_type = message.get("type")
|
||||||
|
sender = message.get("sender", "")
|
||||||
|
payload = message.get("payload", {}).get("data", [])
|
||||||
|
|
||||||
|
if (
|
||||||
|
message_type == "method_call"
|
||||||
|
and message.get("interface") == SCREEN_SAVER
|
||||||
|
and message.get("member") == "Inhibit"
|
||||||
|
and len(payload) >= 2
|
||||||
|
):
|
||||||
|
pending[(sender, int(message.get("cookie", 0)))] = {
|
||||||
|
"source": "screensaver",
|
||||||
|
"who": str(payload[0]),
|
||||||
|
"user": "session",
|
||||||
|
"pid": "",
|
||||||
|
"comm": str(payload[0]),
|
||||||
|
"mode": "block",
|
||||||
|
"why": str(payload[1]),
|
||||||
|
}
|
||||||
|
elif (
|
||||||
|
message_type == "method_return"
|
||||||
|
and payload
|
||||||
|
and isinstance(payload[0], int)
|
||||||
|
):
|
||||||
|
request = (message.get("destination", ""), int(message.get("reply_cookie", 0)))
|
||||||
|
inhibitor = pending.pop(request, None)
|
||||||
|
if inhibitor is not None:
|
||||||
|
active[(request[0], int(payload[0]))] = inhibitor
|
||||||
|
write_state(active)
|
||||||
|
elif (
|
||||||
|
message_type == "method_call"
|
||||||
|
and message.get("interface") == SCREEN_SAVER
|
||||||
|
and message.get("member") == "UnInhibit"
|
||||||
|
and payload
|
||||||
|
and isinstance(payload[0], int)
|
||||||
|
):
|
||||||
|
cookie = int(payload[0])
|
||||||
|
active.pop((sender, cookie), None)
|
||||||
|
# Some clients reconnect before releasing a cookie; the cookie is
|
||||||
|
# globally unique for Noctalia, so clean up that fallback as well.
|
||||||
|
for key in [key for key in active if key[1] == cookie]:
|
||||||
|
active.pop(key, None)
|
||||||
|
write_state(active)
|
||||||
|
elif (
|
||||||
|
message_type == "signal"
|
||||||
|
and message.get("interface") == "org.freedesktop.DBus"
|
||||||
|
and message.get("member") == "NameOwnerChanged"
|
||||||
|
and len(payload) == 3
|
||||||
|
and payload[2] == ""
|
||||||
|
):
|
||||||
|
vanished = str(payload[0])
|
||||||
|
for key in [key for key in active if key[0] == vanished]:
|
||||||
|
active.pop(key, None)
|
||||||
|
for key in [key for key in pending if key[0] == vanished]:
|
||||||
|
pending.pop(key, None)
|
||||||
|
write_state(active)
|
||||||
|
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--count", action="store_true", help="print combined inhibitor count")
|
||||||
|
parser.add_argument("--list", action="store_true", help="print combined inhibitors as TSV")
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
|
||||||
|
if arguments.count:
|
||||||
|
print(len(combined_inhibitors()))
|
||||||
|
elif arguments.list:
|
||||||
|
print_inhibitors()
|
||||||
|
else:
|
||||||
|
monitor()
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# The opaque release window runs natively on Wayland; disable WebKit DMABUF,
|
||||||
|
# which is unreliable with this NVIDIA desktop's compositor path.
|
||||||
|
exec env WEBKIT_DISABLE_DMABUF_RENDERER=1 "$HOME/projects/pi-status-bridge/ui/src-tauri/target/release/pi-status-ui" "$@"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
.ruff_cache/
|
||||||
|
index-cache.json
|
||||||
|
index-cache-*.json
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Safely create one direct child in the configured knowledge vault."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> int:
|
||||||
|
print(message, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def child_name(raw: str) -> str | None:
|
||||||
|
name = raw.strip()
|
||||||
|
if not name or name in {".", ".."} or "/" in name or "\\" in name or "\0" in name:
|
||||||
|
return None
|
||||||
|
if name.startswith("."):
|
||||||
|
return None
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 5:
|
||||||
|
return fail("usage: create-entry.py VAULT KIND CURRENT_DIRECTORY NAME")
|
||||||
|
raw_vault, kind, current_directory, raw_name = sys.argv[1:]
|
||||||
|
vault = Path(raw_vault).expanduser().resolve()
|
||||||
|
if not vault.is_dir():
|
||||||
|
return fail(f"vault does not exist: {vault}")
|
||||||
|
if kind not in {"note", "directory"}:
|
||||||
|
return fail("kind must be note or directory")
|
||||||
|
current = (vault / current_directory).resolve()
|
||||||
|
if current != vault and vault not in current.parents:
|
||||||
|
return fail("current directory escaped the vault")
|
||||||
|
if not current.is_dir():
|
||||||
|
return fail("current directory no longer exists")
|
||||||
|
|
||||||
|
name = child_name(raw_name)
|
||||||
|
if name is None:
|
||||||
|
return fail("use a non-hidden direct name without slashes")
|
||||||
|
if kind == "note" and not name.lower().endswith(".md"):
|
||||||
|
name += ".md"
|
||||||
|
target = (current / name).resolve()
|
||||||
|
if target.parent != current:
|
||||||
|
return fail("target escaped the current directory")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if kind == "directory":
|
||||||
|
target.mkdir()
|
||||||
|
else:
|
||||||
|
title = target.stem
|
||||||
|
with target.open("x", encoding="utf-8") as handle:
|
||||||
|
handle.write(f"# {title}\n")
|
||||||
|
except FileExistsError:
|
||||||
|
return fail(f"already exists: {name}")
|
||||||
|
except OSError as error:
|
||||||
|
return fail(f"cannot create {name}: {error}")
|
||||||
|
|
||||||
|
relative = target.relative_to(vault).as_posix()
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"kind": kind,
|
||||||
|
"relative": relative,
|
||||||
|
"name": name,
|
||||||
|
"absolute": str(target),
|
||||||
|
},
|
||||||
|
sys.stdout,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Focus the most recently focused Obsidian window under Hyprland."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote, urlencode
|
||||||
|
|
||||||
|
OBSIDIAN_CLASS = "md.obsidian.Obsidian"
|
||||||
|
VAULT_ROOT: Path
|
||||||
|
OBSIDIAN_VAULT: str
|
||||||
|
|
||||||
|
|
||||||
|
def hyprctl(*arguments: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["hyprctl", *arguments],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def focus_expression(field: str, value: str) -> str:
|
||||||
|
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
return f'hl.dispatch(hl.dsp.focus({{ {field} = "{escaped}" }}))'
|
||||||
|
|
||||||
|
|
||||||
|
def note_uri() -> str | None:
|
||||||
|
if len(sys.argv) != 4:
|
||||||
|
print("usage: focus-obsidian.py VAULT VAULT_ID NOTE_PATH", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
note = Path(sys.argv[3]).resolve()
|
||||||
|
try:
|
||||||
|
relative = note.relative_to(VAULT_ROOT).as_posix()
|
||||||
|
except ValueError as error:
|
||||||
|
print(f"note escaped the knowledge vault: {error}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
return "obsidian://open?" + urlencode(
|
||||||
|
{"vault": OBSIDIAN_VAULT, "file": relative}, quote_via=quote
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_uri(uri: str) -> bool:
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
["obsidian", uri],
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
except OSError as error:
|
||||||
|
print(f"cannot open Obsidian: {error}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
global VAULT_ROOT, OBSIDIAN_VAULT
|
||||||
|
if len(sys.argv) != 4:
|
||||||
|
print("usage: focus-obsidian.py VAULT VAULT_ID NOTE_PATH", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
VAULT_ROOT = Path(sys.argv[1]).expanduser().resolve()
|
||||||
|
OBSIDIAN_VAULT = sys.argv[2]
|
||||||
|
if not VAULT_ROOT.is_dir():
|
||||||
|
print(f"vault does not exist: {VAULT_ROOT}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not OBSIDIAN_VAULT:
|
||||||
|
print("vault is not registered in Obsidian", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
uri = note_uri()
|
||||||
|
if uri is None or not dispatch_uri(uri):
|
||||||
|
return 1
|
||||||
|
target: dict[str, object] | None = None
|
||||||
|
for _ in range(150):
|
||||||
|
result = hyprctl("clients", "-j")
|
||||||
|
if result.returncode == 0:
|
||||||
|
try:
|
||||||
|
clients = json.loads(result.stdout)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
print(f"cannot parse hyprctl clients: {error}", file=sys.stderr)
|
||||||
|
clients = []
|
||||||
|
matches = [
|
||||||
|
client for client in clients if client.get("class") == OBSIDIAN_CLASS
|
||||||
|
]
|
||||||
|
if matches:
|
||||||
|
target = min(
|
||||||
|
matches, key=lambda client: client.get("focusHistoryID", 1_000_000)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
if target is None:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
workspace = target.get("workspace")
|
||||||
|
address = target.get("address")
|
||||||
|
if not isinstance(workspace, dict) or not isinstance(address, str):
|
||||||
|
return 1
|
||||||
|
workspace_id = workspace.get("id")
|
||||||
|
if not isinstance(workspace_id, int):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
workspace_expression = focus_expression("workspace", str(workspace_id))
|
||||||
|
selector = f"address:{address}"
|
||||||
|
window_expression = focus_expression("window", selector)
|
||||||
|
focused_samples = 0
|
||||||
|
for attempt in range(20):
|
||||||
|
if attempt == 0 or attempt % 5 == 0:
|
||||||
|
hyprctl("eval", workspace_expression)
|
||||||
|
hyprctl("eval", window_expression)
|
||||||
|
active = hyprctl("activewindow", "-j")
|
||||||
|
if active.returncode == 0:
|
||||||
|
try:
|
||||||
|
active_window = json.loads(active.stdout)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
print(f"cannot parse active Hyprland window: {error}", file=sys.stderr)
|
||||||
|
active_window = {}
|
||||||
|
focused_samples = (
|
||||||
|
focused_samples + 1 if active_window.get("address") == address else 0
|
||||||
|
)
|
||||||
|
if focused_samples >= 3:
|
||||||
|
# Re-send after focus: Electron occasionally consumes the URI
|
||||||
|
# before its renderer is ready, leaving the prior note visible.
|
||||||
|
if not dispatch_uri(uri):
|
||||||
|
return 1
|
||||||
|
time.sleep(0.35)
|
||||||
|
hyprctl("eval", window_expression)
|
||||||
|
return 0
|
||||||
|
time.sleep(0.1)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build bounded JSON cache chunks for a Markdown vault."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unicodedata
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
SKIP_DIRS = {
|
||||||
|
".git",
|
||||||
|
".obsidian",
|
||||||
|
".trash",
|
||||||
|
".stfolder",
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
}
|
||||||
|
HEADING = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
|
||||||
|
WORDS = re.compile(r"[^\W_]+", re.UNICODE)
|
||||||
|
CHUNK_BYTES = 96 * 1024
|
||||||
|
RECORD_BYTES = 48 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def visible_directory(name: str) -> bool:
|
||||||
|
return name not in SKIP_DIRS and not name.startswith(".")
|
||||||
|
|
||||||
|
|
||||||
|
def note_title(path: Path, content: str) -> str:
|
||||||
|
match = HEADING.search(content)
|
||||||
|
return match.group(1).strip() if match else path.stem
|
||||||
|
|
||||||
|
|
||||||
|
def folded(value: str) -> str:
|
||||||
|
"""Case-fold and strip accents so ASCII queries match Unicode notes."""
|
||||||
|
normalized = unicodedata.normalize("NFKD", value.casefold())
|
||||||
|
return "".join(
|
||||||
|
character for character in normalized if not unicodedata.combining(character)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def token_buckets(content: str) -> dict[str, dict[str, list[str]]]:
|
||||||
|
buckets: dict[str, dict[str, list[str]]] = {}
|
||||||
|
for token in sorted(set(WORDS.findall(content))):
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
by_length = buckets.setdefault(token[0], {})
|
||||||
|
by_length.setdefault(str(len(token)), []).append(token)
|
||||||
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
|
def encoded(record: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(record, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def fold_records(characters: set[str]) -> list[dict[str, Any]]:
|
||||||
|
mapping: dict[str, str] = {}
|
||||||
|
for character in characters:
|
||||||
|
variants = {character, character.lower(), character.upper(), character.title()}
|
||||||
|
for variant in variants:
|
||||||
|
for variant_character in variant:
|
||||||
|
mapping[variant_character] = folded(variant_character)
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
current: dict[str, str] = {}
|
||||||
|
for source, target in sorted(mapping.items()):
|
||||||
|
candidate = {**current, source: target}
|
||||||
|
record = {"kind": "fold", "map": candidate}
|
||||||
|
if current and len(encoded(record).encode("utf-8")) > RECORD_BYTES:
|
||||||
|
records.append({"kind": "fold", "map": current})
|
||||||
|
current = {source: target}
|
||||||
|
else:
|
||||||
|
current = candidate
|
||||||
|
if current:
|
||||||
|
records.append({"kind": "fold", "map": current})
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def directory_record(relative: str) -> dict[str, Any]:
|
||||||
|
record = {"kind": "directory", "relative": relative}
|
||||||
|
if len(encoded(record).encode("utf-8")) > RECORD_BYTES:
|
||||||
|
raise ValueError(f"directory metadata exceeded bounded size: {relative}")
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def note_record(title: str, relative: str) -> dict[str, Any]:
|
||||||
|
display_title = title[:256] + ("…" if len(title) > 256 else "")
|
||||||
|
record = {
|
||||||
|
"kind": "note",
|
||||||
|
"id": relative,
|
||||||
|
"title": display_title,
|
||||||
|
"relative": relative,
|
||||||
|
"titleRaw": display_title.casefold(),
|
||||||
|
"pathRaw": relative.casefold(),
|
||||||
|
"titleSearch": folded(display_title),
|
||||||
|
"pathSearch": folded(relative),
|
||||||
|
}
|
||||||
|
if len(encoded(record).encode("utf-8")) > RECORD_BYTES:
|
||||||
|
raise ValueError(f"note metadata exceeded bounded size: {relative}")
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def content_record(relative: str, fragment: str) -> dict[str, Any]:
|
||||||
|
content_search = folded(fragment)
|
||||||
|
return {
|
||||||
|
"kind": "content",
|
||||||
|
"id": relative,
|
||||||
|
"fragment": fragment,
|
||||||
|
"lines": fragment.split("\n"),
|
||||||
|
"contentRaw": fragment.casefold(),
|
||||||
|
"contentSearch": content_search,
|
||||||
|
"contentTokens": token_buckets(content_search),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def content_records(relative: str, content: str) -> list[dict[str, Any]]:
|
||||||
|
"""Split one note into bounded records without changing its original text."""
|
||||||
|
pending = [content]
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
while pending:
|
||||||
|
fragment = pending.pop()
|
||||||
|
record = content_record(relative, fragment)
|
||||||
|
if len(encoded(record).encode("utf-8")) <= RECORD_BYTES:
|
||||||
|
records.append(record)
|
||||||
|
continue
|
||||||
|
midpoint = max(len(fragment) // 2, 1)
|
||||||
|
if midpoint >= len(fragment):
|
||||||
|
raise ValueError(f"cannot split oversized content in {relative}")
|
||||||
|
pending.extend((fragment[midpoint:], fragment[:midpoint]))
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def index_records(root: Path) -> list[dict[str, Any]]:
|
||||||
|
notes: list[tuple[str, str, str]] = []
|
||||||
|
indexed_directories: set[str] = set()
|
||||||
|
characters: set[str] = set()
|
||||||
|
for current, directories, filenames in os.walk(root):
|
||||||
|
directories[:] = sorted(
|
||||||
|
name
|
||||||
|
for name in directories
|
||||||
|
if visible_directory(name) and not Path(current, name).is_symlink()
|
||||||
|
)
|
||||||
|
for directory in directories:
|
||||||
|
relative_directory = Path(current, directory).relative_to(root).as_posix()
|
||||||
|
indexed_directories.add(relative_directory)
|
||||||
|
characters.update(relative_directory)
|
||||||
|
for filename in sorted(filenames):
|
||||||
|
if not filename.lower().endswith(".md") or filename.startswith("."):
|
||||||
|
continue
|
||||||
|
path = Path(current, filename)
|
||||||
|
if path.is_symlink():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
relative = path.relative_to(root).as_posix()
|
||||||
|
title = note_title(path, content)
|
||||||
|
characters.update(title)
|
||||||
|
characters.update(relative)
|
||||||
|
characters.update(content)
|
||||||
|
notes.append((title, relative, content))
|
||||||
|
|
||||||
|
notes.sort(key=lambda note: (note[0].casefold(), note[1].casefold()))
|
||||||
|
records = fold_records(characters)
|
||||||
|
for relative in sorted(indexed_directories, key=str.casefold):
|
||||||
|
records.append(directory_record(relative))
|
||||||
|
for title, relative, content in notes:
|
||||||
|
records.append(note_record(title, relative))
|
||||||
|
records.extend(content_records(relative, content))
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def write_chunks(cache: Path, records: list[dict[str, Any]]) -> list[str]:
|
||||||
|
encoded_records = [encoded(record) for record in records]
|
||||||
|
chunks: list[list[str]] = []
|
||||||
|
current: list[str] = []
|
||||||
|
current_bytes = 2
|
||||||
|
for record in encoded_records:
|
||||||
|
record_bytes = len(record.encode("utf-8"))
|
||||||
|
if record_bytes > RECORD_BYTES:
|
||||||
|
raise ValueError("index record exceeded its bounded size")
|
||||||
|
separator_bytes = 1 if current else 0
|
||||||
|
if current and current_bytes + separator_bytes + record_bytes > CHUNK_BYTES:
|
||||||
|
chunks.append(current)
|
||||||
|
current = []
|
||||||
|
current_bytes = 2
|
||||||
|
separator_bytes = 0
|
||||||
|
current.append(record)
|
||||||
|
current_bytes += separator_bytes + record_bytes
|
||||||
|
if current or not chunks:
|
||||||
|
chunks.append(current)
|
||||||
|
|
||||||
|
names: list[str] = []
|
||||||
|
for index, chunk in enumerate(chunks):
|
||||||
|
chunk_name = f"index-cache-{index:03d}.json"
|
||||||
|
path = (cache.parent / chunk_name).resolve()
|
||||||
|
if path.parent != cache.parent:
|
||||||
|
raise ValueError("cache chunk escaped the plugin directory")
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", encoding="utf-8", dir=cache.parent, delete=False
|
||||||
|
) as handle:
|
||||||
|
handle.write("[" + ",".join(chunk) + "]")
|
||||||
|
temporary = Path(handle.name)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
names.append(chunk_name)
|
||||||
|
|
||||||
|
active = set(names)
|
||||||
|
for stale in cache.parent.glob(f"{cache.stem}-*.json"):
|
||||||
|
if stale.name not in active:
|
||||||
|
stale.unlink(missing_ok=True)
|
||||||
|
cache.unlink(missing_ok=True)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print("usage: index.py VAULT CACHE", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
root = Path(sys.argv[1]).expanduser().resolve()
|
||||||
|
requested_cache = Path(sys.argv[2]).expanduser().resolve()
|
||||||
|
plugin_dir = Path(__file__).resolve().parent
|
||||||
|
if (
|
||||||
|
requested_cache.parent != plugin_dir
|
||||||
|
or requested_cache.name != "index-cache.json"
|
||||||
|
):
|
||||||
|
print("cache must be index-cache.json in the plugin directory", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not root.is_dir():
|
||||||
|
print(f"vault does not exist: {root}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
records = index_records(root)
|
||||||
|
chunk_names = write_chunks(plugin_dir / "index-cache.json", records)
|
||||||
|
note_count = sum(record["kind"] == "note" for record in records)
|
||||||
|
json.dump(
|
||||||
|
{"chunks": chunk_names, "notes": note_count},
|
||||||
|
sys.stdout,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Safely move or delete entries inside the knowledge vault."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
VAULT: Path
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> int:
|
||||||
|
print(message, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def vault_entry(relative: str) -> Path | None:
|
||||||
|
if not relative or relative.startswith("/") or "\0" in relative:
|
||||||
|
return None
|
||||||
|
candidate = VAULT / relative
|
||||||
|
parent = candidate.parent.resolve()
|
||||||
|
if parent != VAULT and VAULT not in parent.parents:
|
||||||
|
return None
|
||||||
|
return parent / candidate.name
|
||||||
|
|
||||||
|
|
||||||
|
def move_entry(source_relative: str, destination_relative: str) -> int:
|
||||||
|
source = vault_entry(source_relative)
|
||||||
|
destination_directory = (VAULT / destination_relative).resolve()
|
||||||
|
if source is None or not (source.exists() or source.is_symlink()):
|
||||||
|
return fail("selected entry no longer exists")
|
||||||
|
if destination_directory != VAULT and VAULT not in destination_directory.parents:
|
||||||
|
return fail("destination escaped the vault")
|
||||||
|
if not destination_directory.is_dir():
|
||||||
|
return fail("destination directory no longer exists")
|
||||||
|
if source.is_dir() and (
|
||||||
|
destination_directory == source or source in destination_directory.parents
|
||||||
|
):
|
||||||
|
return fail("cannot move a directory into itself")
|
||||||
|
|
||||||
|
destination = destination_directory / source.name
|
||||||
|
if destination.exists() or destination.is_symlink():
|
||||||
|
return fail(f"already exists: {destination.name}")
|
||||||
|
try:
|
||||||
|
source.rename(destination)
|
||||||
|
except OSError as error:
|
||||||
|
return fail(f"cannot move {source.name}: {error}")
|
||||||
|
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"action": "move",
|
||||||
|
"relative": destination.relative_to(VAULT).as_posix(),
|
||||||
|
"kind": "directory" if destination.is_dir() else "note",
|
||||||
|
},
|
||||||
|
sys.stdout,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_entry(source_relative: str) -> int:
|
||||||
|
source = vault_entry(source_relative)
|
||||||
|
if source is None or not (source.exists() or source.is_symlink()):
|
||||||
|
return fail("selected entry no longer exists")
|
||||||
|
try:
|
||||||
|
if source.is_symlink():
|
||||||
|
source.unlink()
|
||||||
|
elif source.is_dir():
|
||||||
|
shutil.rmtree(source)
|
||||||
|
else:
|
||||||
|
source.unlink()
|
||||||
|
except OSError as error:
|
||||||
|
return fail(f"cannot delete {source.name}: {error}")
|
||||||
|
json.dump({"action": "delete", "relative": source_relative}, sys.stdout)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
global VAULT
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
return fail(
|
||||||
|
"usage: manage-entry.py VAULT ACTION SOURCE [DESTINATION_DIRECTORY]"
|
||||||
|
)
|
||||||
|
VAULT = Path(sys.argv[1]).expanduser().resolve()
|
||||||
|
if not VAULT.is_dir():
|
||||||
|
return fail(f"vault does not exist: {VAULT}")
|
||||||
|
action, source = sys.argv[2:4]
|
||||||
|
if action == "move" and len(sys.argv) == 5:
|
||||||
|
return move_entry(source, sys.argv[4])
|
||||||
|
if action == "delete" and len(sys.argv) == 4:
|
||||||
|
return delete_entry(source)
|
||||||
|
return fail("invalid file operation")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
id = "alex/knowledge-lookup"
|
||||||
|
name = "Knowledge Lookup"
|
||||||
|
version = "1.0.0"
|
||||||
|
plugin_api = 24
|
||||||
|
author = "alex"
|
||||||
|
license = "MIT"
|
||||||
|
dependencies = []
|
||||||
|
tags = ["productivity", "knowledge"]
|
||||||
|
icon = "notes"
|
||||||
|
description = "Search and preview a local Obsidian knowledge vault from the bar."
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "vault_path"
|
||||||
|
type = "folder"
|
||||||
|
label_key = "Knowledge vault directory"
|
||||||
|
description_key = "Directory containing the Markdown vault to browse"
|
||||||
|
default = ""
|
||||||
|
|
||||||
|
[[widget]]
|
||||||
|
id = "bar"
|
||||||
|
entry = "widget.luau"
|
||||||
|
|
||||||
|
[[panel]]
|
||||||
|
id = "panel"
|
||||||
|
entry = "panel.luau"
|
||||||
|
width = 1100
|
||||||
|
height = 720
|
||||||
|
placement = "floating"
|
||||||
|
position = "center"
|
||||||
|
keyboard_focus = "exclusive"
|
||||||
|
dismiss_on_outside_click = true
|
||||||
|
capture_keys = [
|
||||||
|
"j",
|
||||||
|
"k",
|
||||||
|
"h",
|
||||||
|
"l",
|
||||||
|
"ctrl+U0063",
|
||||||
|
"ctrl+U0061",
|
||||||
|
"ctrl+U006e",
|
||||||
|
"g",
|
||||||
|
"shift+U0047",
|
||||||
|
"q",
|
||||||
|
"o",
|
||||||
|
"r",
|
||||||
|
"x",
|
||||||
|
"p",
|
||||||
|
"d",
|
||||||
|
"y",
|
||||||
|
"n",
|
||||||
|
"slash",
|
||||||
|
"enter",
|
||||||
|
]
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate a vault path and resolve its registered Obsidian vault ID."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OBSIDIAN_CONFIG = Path.home() / ".config" / "obsidian" / "obsidian.json"
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> int:
|
||||||
|
json.dump({"ok": False, "error": message}, sys.stdout)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
return fail("No vault directory is configured")
|
||||||
|
raw_path = sys.argv[1].strip()
|
||||||
|
if not raw_path:
|
||||||
|
return fail("No vault directory is configured")
|
||||||
|
vault = Path(raw_path).expanduser().resolve()
|
||||||
|
if not vault.is_dir():
|
||||||
|
return fail(f"Vault directory was not found: {vault}")
|
||||||
|
|
||||||
|
vault_id: str | None = None
|
||||||
|
config_error: str | None = None
|
||||||
|
try:
|
||||||
|
config = json.loads(OBSIDIAN_CONFIG.read_text(encoding="utf-8"))
|
||||||
|
for registered_id, metadata in config.get("vaults", {}).items():
|
||||||
|
registered_path = Path(str(metadata.get("path", ""))).expanduser().resolve()
|
||||||
|
if registered_path == vault:
|
||||||
|
vault_id = str(registered_id)
|
||||||
|
break
|
||||||
|
except FileNotFoundError:
|
||||||
|
config_error = "Obsidian has not registered any vaults yet"
|
||||||
|
except (OSError, json.JSONDecodeError, TypeError) as error:
|
||||||
|
config_error = f"Cannot read Obsidian vault registration: {error}"
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"ok": True,
|
||||||
|
"path": str(vault),
|
||||||
|
"name": vault.name,
|
||||||
|
"vaultId": vault_id,
|
||||||
|
"obsidianError": config_error
|
||||||
|
or (
|
||||||
|
None
|
||||||
|
if vault_id
|
||||||
|
else "This directory is not registered as an Obsidian vault"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
json.dump(response, sys.stdout)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
--!strict
|
||||||
|
|
||||||
|
local PANEL_ID = "alex/knowledge-lookup:panel"
|
||||||
|
|
||||||
|
function update()
|
||||||
|
barWidget.setGlyph("book-2")
|
||||||
|
barWidget.setText("KB")
|
||||||
|
barWidget.setTooltip("Knowledge lookup · Super+N")
|
||||||
|
end
|
||||||
|
|
||||||
|
function onClick()
|
||||||
|
noctalia.togglePanel(PANEL_ID)
|
||||||
|
end
|
||||||
|
|
||||||
|
function onRightClick()
|
||||||
|
noctalia.runAsync({ "obsidian" })
|
||||||
|
end
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../../projects/pi-status-bridge/noctalia-v5-plugin/pi-status-bridge
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
-- Show instantaneous battery power above an ETA based on a time-weighted,
|
||||||
|
-- five-minute rolling average. Energy values are normalized to µWh so that
|
||||||
|
-- both energy_* and charge_* battery drivers can produce an estimate.
|
||||||
|
local command = [[
|
||||||
|
bat=/sys/class/power_supply/BAT0
|
||||||
|
|
||||||
|
number() {
|
||||||
|
if [ -r "$1" ]; then
|
||||||
|
value=$(cat "$1" 2>/dev/null)
|
||||||
|
case "$value" in
|
||||||
|
''|*[!0-9]*) ;;
|
||||||
|
*) printf '%s' "$value"; return 0 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
status=$(cat "$bat/status" 2>/dev/null) || exit 1
|
||||||
|
power=$(number "$bat/power_now")
|
||||||
|
voltage=$(number "$bat/voltage_now")
|
||||||
|
|
||||||
|
if [ -z "$power" ]; then
|
||||||
|
current=$(number "$bat/current_now")
|
||||||
|
if [ -n "$current" ] && [ -n "$voltage" ]; then
|
||||||
|
power=$(awk -v current="$current" -v voltage="$voltage" \
|
||||||
|
'BEGIN { printf "%.0f", current * voltage / 1000000 }')
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
energy_now=$(number "$bat/energy_now")
|
||||||
|
energy_full=$(number "$bat/energy_full")
|
||||||
|
|
||||||
|
if [ -z "$energy_now" ] || [ -z "$energy_full" ]; then
|
||||||
|
charge_now=$(number "$bat/charge_now")
|
||||||
|
charge_full=$(number "$bat/charge_full")
|
||||||
|
if [ -n "$charge_now" ] && [ -n "$charge_full" ] && [ -n "$voltage" ]; then
|
||||||
|
energy_now=$(awk -v charge="$charge_now" -v voltage="$voltage" \
|
||||||
|
'BEGIN { printf "%.0f", charge * voltage / 1000000 }')
|
||||||
|
energy_full=$(awk -v charge="$charge_full" -v voltage="$voltage" \
|
||||||
|
'BEGIN { printf "%.0f", charge * voltage / 1000000 }')
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\t%s\t%s\t%s\n' "$status" "$power" "$energy_now" "$energy_full"
|
||||||
|
]]
|
||||||
|
|
||||||
|
local sample_window_seconds = 5 * 60
|
||||||
|
local power_samples = {}
|
||||||
|
local sampled_state = nil
|
||||||
|
local request_in_flight = false
|
||||||
|
|
||||||
|
local function renderLines(top, bottom)
|
||||||
|
barWidget.render(ui.column({ gap = 0, align = "center" }, {
|
||||||
|
ui.label({ text = top, fontSize = 10 }),
|
||||||
|
ui.label({ text = bottom, fontSize = 9 }),
|
||||||
|
}))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resetSamples(state)
|
||||||
|
if sampled_state ~= state then
|
||||||
|
power_samples = {}
|
||||||
|
sampled_state = state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function addPowerSample(state, power, now)
|
||||||
|
resetSamples(state)
|
||||||
|
|
||||||
|
local latest = power_samples[#power_samples]
|
||||||
|
if latest ~= nil and latest.time == now then
|
||||||
|
latest.power = power
|
||||||
|
else
|
||||||
|
table.insert(power_samples, { time = now, power = power })
|
||||||
|
end
|
||||||
|
|
||||||
|
local cutoff = now - sample_window_seconds
|
||||||
|
while #power_samples > 1 and power_samples[2].time <= cutoff do
|
||||||
|
table.remove(power_samples, 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function rollingAverage(now)
|
||||||
|
if #power_samples == 0 then
|
||||||
|
return nil, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
if #power_samples == 1 then
|
||||||
|
return power_samples[1].power, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local cutoff = now - sample_window_seconds
|
||||||
|
local integral = 0
|
||||||
|
local duration = 0
|
||||||
|
|
||||||
|
for index = 2, #power_samples do
|
||||||
|
local earlier = power_samples[index - 1]
|
||||||
|
local later = power_samples[index]
|
||||||
|
local interval = later.time - earlier.time
|
||||||
|
local start_time = math.max(earlier.time, cutoff)
|
||||||
|
local end_time = math.min(later.time, now)
|
||||||
|
|
||||||
|
if interval > 0 and end_time > start_time then
|
||||||
|
local start_fraction = (start_time - earlier.time) / interval
|
||||||
|
local end_fraction = (end_time - earlier.time) / interval
|
||||||
|
local start_power = earlier.power + (later.power - earlier.power) * start_fraction
|
||||||
|
local end_power = earlier.power + (later.power - earlier.power) * end_fraction
|
||||||
|
local elapsed = end_time - start_time
|
||||||
|
|
||||||
|
integral = integral + (start_power + end_power) * elapsed / 2
|
||||||
|
duration = duration + elapsed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if duration == 0 then
|
||||||
|
return power_samples[#power_samples].power, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
return integral / duration, duration
|
||||||
|
end
|
||||||
|
|
||||||
|
local function formatDuration(hours)
|
||||||
|
local minutes = math.max(1, math.floor(hours * 60 + 0.5))
|
||||||
|
local whole_hours = math.floor(minutes / 60)
|
||||||
|
local remaining_minutes = minutes % 60
|
||||||
|
|
||||||
|
if whole_hours > 0 then
|
||||||
|
return string.format("%dh %02dm", whole_hours, remaining_minutes)
|
||||||
|
end
|
||||||
|
|
||||||
|
return string.format("%dm", remaining_minutes)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function formatSampleDuration(seconds)
|
||||||
|
local minutes = math.floor(seconds / 60)
|
||||||
|
return string.format("%dm %02ds", minutes, seconds % 60)
|
||||||
|
end
|
||||||
|
|
||||||
|
function update()
|
||||||
|
noctalia.setUpdateInterval(5000)
|
||||||
|
|
||||||
|
if request_in_flight then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
request_in_flight = true
|
||||||
|
noctalia.runAsync(command, function(result)
|
||||||
|
request_in_flight = false
|
||||||
|
|
||||||
|
if result.exitCode ~= 0 then
|
||||||
|
renderLines("—", "Unavailable")
|
||||||
|
barWidget.setTooltip("Battery rate and remaining-time estimate unavailable")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local state, power, energy_now, energy_full = string.match(
|
||||||
|
result.stdout,
|
||||||
|
"^([^\t]+)\t([^\t]*)\t([^\t]*)\t([^\r\n]*)"
|
||||||
|
)
|
||||||
|
power = tonumber(power)
|
||||||
|
energy_now = tonumber(energy_now)
|
||||||
|
energy_full = tonumber(energy_full)
|
||||||
|
|
||||||
|
if state ~= "Discharging" and state ~= "Charging" then
|
||||||
|
resetSamples(state)
|
||||||
|
renderLines("—", state)
|
||||||
|
barWidget.setTooltip("Battery: " .. state)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if power == nil or power <= 0 then
|
||||||
|
renderLines("—", "Calculating…")
|
||||||
|
barWidget.setTooltip("Battery: " .. state .. " · waiting for a power reading")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local now = os.time()
|
||||||
|
addPowerSample(state, power, now)
|
||||||
|
local average_power, sample_duration = rollingAverage(now)
|
||||||
|
local label = "Calculating…"
|
||||||
|
|
||||||
|
if average_power ~= nil and average_power > 0 and energy_now ~= nil and energy_full ~= nil then
|
||||||
|
local energy_remaining = energy_now
|
||||||
|
if state == "Charging" then
|
||||||
|
energy_remaining = energy_full - energy_now
|
||||||
|
end
|
||||||
|
|
||||||
|
if energy_remaining >= 0 then
|
||||||
|
local suffix = state == "Charging" and " to full" or " left"
|
||||||
|
label = formatDuration(energy_remaining / average_power) .. suffix
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local direction = state == "Charging" and "↑" or "↓"
|
||||||
|
renderLines(string.format("%s %.1f W", direction, power / 1000000), label)
|
||||||
|
barWidget.setTooltip(
|
||||||
|
string.format(
|
||||||
|
"Battery: %s · %.1f W now · %.1f W rolling average (%s sampled of 5m)",
|
||||||
|
state,
|
||||||
|
power / 1000000,
|
||||||
|
average_power / 1000000,
|
||||||
|
formatSampleDuration(sample_duration)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -11,6 +11,26 @@ local command = [[awk '
|
|||||||
local previous = {}
|
local previous = {}
|
||||||
local request_in_flight = false
|
local request_in_flight = false
|
||||||
|
|
||||||
|
local function colorForUsage(percent)
|
||||||
|
if percent >= 85 then
|
||||||
|
return "#ff453a" -- red
|
||||||
|
end
|
||||||
|
if percent >= 70 then
|
||||||
|
return "#ff8800" -- orange
|
||||||
|
end
|
||||||
|
if percent >= 50 then
|
||||||
|
return "#ffd60a" -- yellow
|
||||||
|
end
|
||||||
|
return "#ffffff" -- white
|
||||||
|
end
|
||||||
|
|
||||||
|
local function renderStatus(text, color)
|
||||||
|
barWidget.render(ui.row({ gap = 4, align = "center" }, {
|
||||||
|
ui.glyph({ name = "cpu", size = 14, color = color }),
|
||||||
|
ui.label({ text = text, color = color }),
|
||||||
|
}))
|
||||||
|
end
|
||||||
|
|
||||||
function update()
|
function update()
|
||||||
noctalia.setUpdateInterval(2000)
|
noctalia.setUpdateInterval(2000)
|
||||||
|
|
||||||
@@ -23,7 +43,8 @@ function update()
|
|||||||
request_in_flight = false
|
request_in_flight = false
|
||||||
|
|
||||||
if result.exitCode ~= 0 then
|
if result.exitCode ~= 0 then
|
||||||
barWidget.setText("CPU —")
|
renderStatus("CPU —", "#fff1e6")
|
||||||
|
barWidget.setTooltip("Unable to read CPU utilization")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -47,12 +68,19 @@ function update()
|
|||||||
previous[name] = { total = total, idle = idle }
|
previous[name] = { total = total, idle = idle }
|
||||||
end
|
end
|
||||||
|
|
||||||
barWidget.setGlyph("cpu")
|
|
||||||
if sampled > 0 then
|
if sampled > 0 then
|
||||||
barWidget.setText(string.format("%.0f%%", utilization))
|
-- Keep the aggregate display, but base its heat color on average
|
||||||
barWidget.setTooltip("Aggregate CPU utilization (100% per logical CPU)")
|
-- utilization per logical CPU so multicore systems do not stay red.
|
||||||
|
local per_cpu_utilization = utilization / sampled
|
||||||
|
renderStatus(string.format("%.0f%%", utilization), colorForUsage(per_cpu_utilization))
|
||||||
|
barWidget.setTooltip(
|
||||||
|
string.format(
|
||||||
|
"Aggregate CPU utilization (100%% per logical CPU) · %.0f%% average per CPU",
|
||||||
|
per_cpu_utilization
|
||||||
|
)
|
||||||
|
)
|
||||||
else
|
else
|
||||||
barWidget.setText("—")
|
renderStatus("—", "#fff1e6")
|
||||||
barWidget.setTooltip("Sampling CPU utilization…")
|
barWidget.setTooltip("Sampling CPU utilization…")
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Query EnvyControl once when this widget starts; GPU mode changes require a restart.
|
||||||
|
local command = "envycontrol --query"
|
||||||
|
local labels = {
|
||||||
|
integrated = "iGPU",
|
||||||
|
dedicated = "dGPU",
|
||||||
|
nvidia = "dGPU",
|
||||||
|
hybrid = "hybrid",
|
||||||
|
}
|
||||||
|
|
||||||
|
barWidget.setGlyph("device-desktop")
|
||||||
|
barWidget.setText("GPU…")
|
||||||
|
barWidget.setTooltip("Reading EnvyControl GPU mode…")
|
||||||
|
|
||||||
|
noctalia.runAsync(command, function(result)
|
||||||
|
if result.exitCode ~= 0 then
|
||||||
|
barWidget.setText("GPU?")
|
||||||
|
barWidget.setTooltip("Unable to read EnvyControl GPU mode")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local mode = string.lower(string.match(result.stdout, "([%a_-]+)") or "")
|
||||||
|
local label = labels[mode]
|
||||||
|
if label == nil then
|
||||||
|
barWidget.setText("GPU?")
|
||||||
|
barWidget.setTooltip("Unknown EnvyControl GPU mode: " .. mode)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
barWidget.setText(label)
|
||||||
|
barWidget.setTooltip("EnvyControl GPU mode: " .. mode)
|
||||||
|
end)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
-- Click-through details for both logind idle inhibitors and session
|
||||||
|
-- ScreenSaver requests such as browser video wake locks.
|
||||||
|
local command = (noctalia.getenv("HOME") or "") .. "/.local/bin/idle-inhibitor-monitor --list"
|
||||||
|
|
||||||
|
local inhibitors = {}
|
||||||
|
local status = "Loading idle inhibitors…"
|
||||||
|
local request_in_flight = false
|
||||||
|
|
||||||
|
local function renderPanel()
|
||||||
|
local children = {
|
||||||
|
ui.column({ gap = 2 }, {
|
||||||
|
ui.label({ text = "Idle inhibitors", fontSize = 18, fontWeight = "bold" }),
|
||||||
|
ui.label({ text = status, color = "on_surface_variant" }),
|
||||||
|
}),
|
||||||
|
ui.button({ text = "Refresh", onClick = "onRefresh" }),
|
||||||
|
}
|
||||||
|
|
||||||
|
if #inhibitors == 0 then
|
||||||
|
table.insert(children, ui.label({ text = "No process currently blocks automatic idle." }))
|
||||||
|
else
|
||||||
|
for _, inhibitor in ipairs(inhibitors) do
|
||||||
|
local title = inhibitor.comm ~= "" and inhibitor.comm or inhibitor.who
|
||||||
|
table.insert(children, ui.column({ gap = 2 }, {
|
||||||
|
ui.label({ text = string.format("%s · %s", inhibitor.source, title), fontWeight = "bold" }),
|
||||||
|
ui.label({ text = inhibitor.why ~= "" and inhibitor.why or "No reason supplied", maxLines = 2 }),
|
||||||
|
ui.label({
|
||||||
|
text = string.format("%s · %s · PID %s", inhibitor.mode, inhibitor.user, inhibitor.pid),
|
||||||
|
color = "on_surface_variant",
|
||||||
|
fontSize = 11,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
panel.render(ui.scroll({ padding = 20, gap = 12 }, children))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function refreshInhibitors()
|
||||||
|
if request_in_flight then return end
|
||||||
|
request_in_flight = true
|
||||||
|
status = "Loading idle inhibitors…"
|
||||||
|
renderPanel()
|
||||||
|
|
||||||
|
noctalia.runAsync(command, function(result)
|
||||||
|
request_in_flight = false
|
||||||
|
inhibitors = {}
|
||||||
|
|
||||||
|
if result.exitCode ~= 0 then
|
||||||
|
status = "Unable to inspect logind inhibitors."
|
||||||
|
renderPanel()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
for line in string.gmatch(result.stdout, "[^\r\n]+") do
|
||||||
|
local source, who, user, pid, comm, mode, why = string.match(
|
||||||
|
line,
|
||||||
|
"^([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t([^\t]*)\t(.*)$"
|
||||||
|
)
|
||||||
|
if source ~= nil then
|
||||||
|
table.insert(inhibitors, {
|
||||||
|
source = source,
|
||||||
|
who = who,
|
||||||
|
user = user,
|
||||||
|
pid = pid,
|
||||||
|
comm = comm,
|
||||||
|
mode = mode,
|
||||||
|
why = why,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
status = #inhibitors == 0
|
||||||
|
and "Automatic idle is eligible."
|
||||||
|
or string.format("%d active idle inhibitor%s.", #inhibitors, #inhibitors == 1 and "" or "s")
|
||||||
|
renderPanel()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function onOpen()
|
||||||
|
refreshInhibitors()
|
||||||
|
end
|
||||||
|
|
||||||
|
function onRefresh()
|
||||||
|
refreshInhibitors()
|
||||||
|
end
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- Show whether any process blocks automatic idle. The monitor combines logind
|
||||||
|
-- idle inhibitors with session ScreenSaver requests such as browser video wake locks.
|
||||||
|
local command = (noctalia.getenv("HOME") or "") .. "/.local/bin/idle-inhibitor-monitor --count"
|
||||||
|
|
||||||
|
local request_in_flight = false
|
||||||
|
|
||||||
|
function update()
|
||||||
|
noctalia.setUpdateInterval(5000)
|
||||||
|
|
||||||
|
if request_in_flight then return end
|
||||||
|
request_in_flight = true
|
||||||
|
noctalia.runAsync(command, function(result)
|
||||||
|
request_in_flight = false
|
||||||
|
|
||||||
|
local count = tonumber(result.stdout)
|
||||||
|
if result.exitCode ~= 0 or count == nil then
|
||||||
|
barWidget.setGlyph("circle-help")
|
||||||
|
barWidget.setText("Idle ?")
|
||||||
|
barWidget.setTooltip("Unable to inspect idle inhibitors")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if count > 0 then
|
||||||
|
barWidget.setGlyph("moon-off")
|
||||||
|
barWidget.setText(string.format("Idle %d", count))
|
||||||
|
barWidget.setTooltip(
|
||||||
|
string.format("%d idle inhibitor%s active · click for details", count, count == 1 and "" or "s")
|
||||||
|
)
|
||||||
|
else
|
||||||
|
barWidget.setGlyph("moon")
|
||||||
|
barWidget.setText("Idle")
|
||||||
|
barWidget.setTooltip("No active idle inhibitors · click for details")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function onClick()
|
||||||
|
noctalia.runAsync("noctalia msg panel-toggle alex/system-status:inhibitors-panel")
|
||||||
|
end
|
||||||
@@ -8,16 +8,37 @@ $1 == "SwapFree:" { swap_free = $2 }
|
|||||||
END {
|
END {
|
||||||
if (!total || !available) exit 1
|
if (!total || !available) exit 1
|
||||||
used = total - available
|
used = total - available
|
||||||
|
memory_used = used * 100 / total
|
||||||
if (swap_total > 0) {
|
if (swap_total > 0) {
|
||||||
swap_used = (swap_total - swap_free) * 100 / swap_total
|
swap_used = (swap_total - swap_free) * 100 / swap_total
|
||||||
printf "RAM %.1f/%.1f GiB S %.0f%%\n", used / 1048576, total / 1048576, swap_used
|
printf "%.1f/%.1f GiB S %.0f%%\t%.0f\t%.0f\n", used / 1048576, total / 1048576, swap_used, memory_used, swap_used
|
||||||
} else {
|
} else {
|
||||||
printf "RAM %.1f/%.1f GiB S —\n", used / 1048576, total / 1048576
|
printf "%.1f/%.1f GiB S —\t%.0f\t0\n", used / 1048576, total / 1048576, memory_used
|
||||||
}
|
}
|
||||||
}' /proc/meminfo]]
|
}' /proc/meminfo]]
|
||||||
|
|
||||||
local request_in_flight = false
|
local request_in_flight = false
|
||||||
|
|
||||||
|
local function colorForUsage(percent)
|
||||||
|
if percent >= 85 then
|
||||||
|
return "#ff453a" -- red
|
||||||
|
end
|
||||||
|
if percent >= 70 then
|
||||||
|
return "#ff8800" -- orange
|
||||||
|
end
|
||||||
|
if percent >= 50 then
|
||||||
|
return "#ffd60a" -- yellow
|
||||||
|
end
|
||||||
|
return "#ffffff" -- white
|
||||||
|
end
|
||||||
|
|
||||||
|
local function renderStatus(text, color)
|
||||||
|
barWidget.render(ui.row({ gap = 4, align = "center" }, {
|
||||||
|
ui.glyph({ name = "memory", size = 14, color = color }),
|
||||||
|
ui.label({ text = text, color = color }),
|
||||||
|
}))
|
||||||
|
end
|
||||||
|
|
||||||
function update()
|
function update()
|
||||||
noctalia.setUpdateInterval(5000)
|
noctalia.setUpdateInterval(5000)
|
||||||
|
|
||||||
@@ -30,14 +51,32 @@ function update()
|
|||||||
request_in_flight = false
|
request_in_flight = false
|
||||||
|
|
||||||
if result.exitCode ~= 0 then
|
if result.exitCode ~= 0 then
|
||||||
barWidget.setText("RAM —")
|
renderStatus("RAM —", "#fff1e6")
|
||||||
barWidget.setTooltip("Unable to read /proc/meminfo")
|
barWidget.setTooltip("Unable to read /proc/meminfo")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local text = string.gsub(result.stdout, "%s+$", "")
|
local text, memory_percent, swap_percent = string.match(
|
||||||
barWidget.setGlyph("memory")
|
result.stdout,
|
||||||
barWidget.setText(text)
|
"^([^\t]+)\t([%d.]+)\t([%d.]+)"
|
||||||
barWidget.setTooltip("RAM used / total · S = swap used")
|
)
|
||||||
|
memory_percent = tonumber(memory_percent)
|
||||||
|
swap_percent = tonumber(swap_percent)
|
||||||
|
|
||||||
|
if text == nil or memory_percent == nil or swap_percent == nil then
|
||||||
|
renderStatus("RAM —", "#fff1e6")
|
||||||
|
barWidget.setTooltip("Unable to parse memory utilization")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local highest_usage = math.max(memory_percent, swap_percent)
|
||||||
|
renderStatus(text, colorForUsage(highest_usage))
|
||||||
|
barWidget.setTooltip(
|
||||||
|
string.format(
|
||||||
|
"RAM used / total · %.0f%% RAM · %.0f%% swap",
|
||||||
|
memory_percent,
|
||||||
|
swap_percent
|
||||||
|
)
|
||||||
|
)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
-- Compact, two-line network throughput widget. It samples the interface used
|
||||||
|
-- by the default route so virtual and physical interfaces are not double-counted.
|
||||||
|
local command = [[
|
||||||
|
iface=$(ip route show default 2>/dev/null | awk '$1 == "default" { for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }')
|
||||||
|
|
||||||
|
if [ -z "$iface" ]; then
|
||||||
|
for path in /sys/class/net/*; do
|
||||||
|
candidate=${path##*/}
|
||||||
|
[ "$candidate" = "lo" ] && continue
|
||||||
|
[ "$(cat "$path/carrier" 2>/dev/null)" = "1" ] || continue
|
||||||
|
iface=$candidate
|
||||||
|
break
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "$iface" ] || exit 1
|
||||||
|
awk -v iface="$iface" '$1 ~ ("^" iface ":") { sub(":", "", $1); print iface "\t" $2 "\t" $10; exit }' /proc/net/dev
|
||||||
|
]]
|
||||||
|
|
||||||
|
local previous = nil
|
||||||
|
local request_in_flight = false
|
||||||
|
-- 82px accommodates the longest fixed field ("1023 KiB/s") at 10px while
|
||||||
|
-- removing the excess side padding from the original 112px capsule.
|
||||||
|
local label_width = 82
|
||||||
|
|
||||||
|
local function formatRate(bytes_per_second)
|
||||||
|
local units = { "B/s", "KiB/s", "MiB/s", "GiB/s" }
|
||||||
|
local value = bytes_per_second
|
||||||
|
local unit_index = 1
|
||||||
|
|
||||||
|
while value >= 1024 and unit_index < #units do
|
||||||
|
value = value / 1024
|
||||||
|
unit_index = unit_index + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
if unit_index == 1 then
|
||||||
|
return string.format("%d %s", math.floor(value + 0.5), units[unit_index])
|
||||||
|
end
|
||||||
|
|
||||||
|
if value >= 100 then
|
||||||
|
return string.format("%.0f %s", value, units[unit_index])
|
||||||
|
end
|
||||||
|
|
||||||
|
return string.format("%.1f %s", value, units[unit_index])
|
||||||
|
end
|
||||||
|
|
||||||
|
local function renderLines(download, upload)
|
||||||
|
-- The explicit minimum width and fixed ten-character value field keep the
|
||||||
|
-- capsule width stable while inheriting the bar font used by other modules.
|
||||||
|
barWidget.render(ui.column({ gap = 0, align = "center", minWidth = label_width }, {
|
||||||
|
ui.label({
|
||||||
|
text = string.format("↓ %10s", download),
|
||||||
|
maxLines = 1,
|
||||||
|
fontSize = 10,
|
||||||
|
}),
|
||||||
|
ui.label({
|
||||||
|
text = string.format("↑ %10s", upload),
|
||||||
|
maxLines = 1,
|
||||||
|
fontSize = 10,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
end
|
||||||
|
|
||||||
|
function update()
|
||||||
|
noctalia.setUpdateInterval(2000)
|
||||||
|
|
||||||
|
if request_in_flight then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
request_in_flight = true
|
||||||
|
noctalia.runAsync(command, function(result)
|
||||||
|
request_in_flight = false
|
||||||
|
|
||||||
|
if result.exitCode ~= 0 then
|
||||||
|
previous = nil
|
||||||
|
renderLines("—", "—")
|
||||||
|
barWidget.setTooltip("Network throughput unavailable: no active interface")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local iface, received, sent = string.match(
|
||||||
|
result.stdout,
|
||||||
|
"^([^\t]+)\t(%d+)\t(%d+)"
|
||||||
|
)
|
||||||
|
received = tonumber(received)
|
||||||
|
sent = tonumber(sent)
|
||||||
|
|
||||||
|
if iface == nil or received == nil or sent == nil then
|
||||||
|
previous = nil
|
||||||
|
renderLines("—", "—")
|
||||||
|
barWidget.setTooltip("Network throughput unavailable")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local now = os.time()
|
||||||
|
if previous == nil or previous.iface ~= iface or now <= previous.time then
|
||||||
|
previous = { iface = iface, received = received, sent = sent, time = now }
|
||||||
|
renderLines("Sampling…", "Sampling…")
|
||||||
|
barWidget.setTooltip("Sampling network throughput on " .. iface)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local elapsed = now - previous.time
|
||||||
|
local download = math.max(0, (received - previous.received) / elapsed)
|
||||||
|
local upload = math.max(0, (sent - previous.sent) / elapsed)
|
||||||
|
previous = { iface = iface, received = received, sent = sent, time = now }
|
||||||
|
|
||||||
|
renderLines(formatRate(download), formatRate(upload))
|
||||||
|
barWidget.setTooltip(
|
||||||
|
string.format(
|
||||||
|
"%s · Download %s · Upload %s",
|
||||||
|
iface,
|
||||||
|
formatRate(download),
|
||||||
|
formatRate(upload)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -3,7 +3,7 @@ name = "System Status"
|
|||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
plugin_api = 3
|
plugin_api = 3
|
||||||
author = "alex"
|
author = "alex"
|
||||||
description = "Low-overhead memory and swap indicator for the bar."
|
description = "Low-overhead system and network status indicators for the bar."
|
||||||
|
|
||||||
[[widget]]
|
[[widget]]
|
||||||
id = "cpu"
|
id = "cpu"
|
||||||
@@ -12,3 +12,23 @@ entry = "cpu.luau"
|
|||||||
[[widget]]
|
[[widget]]
|
||||||
id = "memory"
|
id = "memory"
|
||||||
entry = "memory.luau"
|
entry = "memory.luau"
|
||||||
|
|
||||||
|
[[widget]]
|
||||||
|
id = "battery-rate"
|
||||||
|
entry = "battery-rate.luau"
|
||||||
|
|
||||||
|
[[widget]]
|
||||||
|
id = "network-rate"
|
||||||
|
entry = "network-rate.luau"
|
||||||
|
|
||||||
|
[[widget]]
|
||||||
|
id = "gpu-mode"
|
||||||
|
entry = "gpu-mode.luau"
|
||||||
|
|
||||||
|
[[widget]]
|
||||||
|
id = "inhibitors"
|
||||||
|
entry = "inhibitors.luau"
|
||||||
|
|
||||||
|
[[panel]]
|
||||||
|
id = "inhibitors-panel"
|
||||||
|
entry = "inhibitors-panel.luau"
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
"npm:bigpowers",
|
"npm:bigpowers",
|
||||||
"npm:@gotgenes/pi-permission-system",
|
"npm:@gotgenes/pi-permission-system",
|
||||||
"npm:pi-intercom",
|
"npm:pi-intercom",
|
||||||
"npm:gentle-engram",
|
"npm:gentle-engram@0.1.8",
|
||||||
"npm:pi-lens",
|
"npm:pi-lens",
|
||||||
"npm:@juicesharp/rpiv-todo",
|
"npm:@juicesharp/rpiv-todo",
|
||||||
"npm:@majorgilles/pi-grill-me",
|
"npm:@majorgilles/pi-grill-me",
|
||||||
"git:git.commumedia.org/alex/pi-map"
|
"npm:pi-mcp-adapter"
|
||||||
],
|
],
|
||||||
"powerline": "full",
|
"powerline": "full",
|
||||||
"hideThinkingBlock": false,
|
"hideThinkingBlock": false,
|
||||||
|
|||||||
+17
-11
@@ -32,9 +32,12 @@ bind e new-window -n tmux.conf "exec \${EDITOR:-nvim} ~/.tmux.conf"
|
|||||||
|
|
||||||
# --- Vim-style panes ----------------------------------------------------------
|
# --- Vim-style panes ----------------------------------------------------------
|
||||||
# Keep the familiar split keys and always inherit the active pane's directory.
|
# Keep the familiar split keys and always inherit the active pane's directory.
|
||||||
bind - split-window -v -c "#{pane_current_path}"
|
bind m split-window -v -c "#{pane_current_path}"
|
||||||
bind | split-window -h -c "#{pane_current_path}"
|
bind n split-window -h -c "#{pane_current_path}"
|
||||||
bind c new-window -c "#{pane_current_path}"
|
bind c new-window -c "#{pane_current_path}"
|
||||||
|
# Re-tile the current window using Ctrl-a, Ctrl-h / Ctrl-a, Ctrl-k.
|
||||||
|
bind C-h select-layout even-vertical
|
||||||
|
bind C-k select-layout even-horizontal
|
||||||
|
|
||||||
bind -r h select-pane -L
|
bind -r h select-pane -L
|
||||||
bind -r j select-pane -D
|
bind -r j select-pane -D
|
||||||
@@ -47,17 +50,17 @@ bind -r L resize-pane -R 5
|
|||||||
bind -r \{ swap-pane -U
|
bind -r \{ swap-pane -U
|
||||||
bind -r \} swap-pane -D
|
bind -r \} swap-pane -D
|
||||||
bind z resize-pane -Z
|
bind z resize-pane -Z
|
||||||
bind x confirm-before -p "kill pane #P? (y/n)" kill-pane
|
bind w confirm-before -p "kill pane #P? (y/n)" kill-pane
|
||||||
|
|
||||||
# --- Windows, sessions, and buffers ------------------------------------------
|
# --- Windows, sessions, and buffers ------------------------------------------
|
||||||
bind -r n next-window
|
bind -r f next-window
|
||||||
bind -r p previous-window
|
bind -r b previous-window
|
||||||
bind Tab last-window
|
bind Tab last-window
|
||||||
bind w choose-tree -Zw
|
bind d choose-tree -Zw
|
||||||
bind s choose-tree -Zs
|
bind s choose-tree -Zs
|
||||||
bind f command-prompt -p "switch to session:" "switch-client -t '%%'"
|
bind g command-prompt -p "switch to session:" "switch-client -t '%%'"
|
||||||
bind [ choose-buffer
|
bind [ choose-buffer
|
||||||
bind ] paste-buffer
|
bind P paste-buffer # tmux buffer fallback; prefix + ] pastes the system clipboard
|
||||||
bind C-l send-keys C-l \; clear-history
|
bind C-l send-keys C-l \; clear-history
|
||||||
|
|
||||||
# --- Copy mode ----------------------------------------------------------------
|
# --- Copy mode ----------------------------------------------------------------
|
||||||
@@ -72,9 +75,12 @@ bind -T copy-mode-vi L send -X end-of-line
|
|||||||
bind -T copy-mode-vi C-u send -X halfpage-up
|
bind -T copy-mode-vi C-u send -X halfpage-up
|
||||||
bind -T copy-mode-vi C-d send -X halfpage-down
|
bind -T copy-mode-vi C-d send -X halfpage-down
|
||||||
|
|
||||||
# Prefer a native Wayland clipboard; use xclip elsewhere. set-clipboard keeps
|
# Keep tmux and the desktop clipboard in sync by default. `set-clipboard on`
|
||||||
# OSC 52-capable terminals in sync as well.
|
# handles OSC 52-capable terminals; these bindings cover local Wayland/X11.
|
||||||
if-shell 'command -v wl-copy >/dev/null 2>&1' 'bind -T copy-mode-vi y send -X copy-pipe-and-cancel "wl-copy"' 'bind -T copy-mode-vi y send -X copy-pipe-and-cancel "xclip -in -selection clipboard"'
|
# Copy-mode y writes to the system clipboard. Prefix + ] reads it back and
|
||||||
|
# pastes it; prefix + P remains available for the most recent tmux buffer.
|
||||||
|
bind -T copy-mode-vi y send -X copy-pipe-and-cancel 'if command -v wl-copy >/dev/null 2>&1; then wl-copy; elif command -v xclip >/dev/null 2>&1; then xclip -in -selection clipboard; fi'
|
||||||
|
bind ] run-shell 'if command -v wl-paste >/dev/null 2>&1; then wl-paste --no-newline | tmux load-buffer - && tmux paste-buffer; elif command -v xclip >/dev/null 2>&1; then xclip -out -selection clipboard | tmux load-buffer - && tmux paste-buffer; fi'
|
||||||
|
|
||||||
# --- Status line --------------------------------------------------------------
|
# --- Status line --------------------------------------------------------------
|
||||||
set -g status on
|
set -g status on
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Dotfiles
|
||||||
|
|
||||||
|
Personal, Arch-oriented user dotfiles and support scripts for shells, window management, applications, and package lists. This repository is a collection of machine-specific configuration, not a general-purpose installer.
|
||||||
|
|
||||||
|
## Status and scope
|
||||||
|
|
||||||
|
There is no authoritative root installation, linking, backup, overwrite, or rollback script. Do not infer one: review and apply only the individual files appropriate to a machine. Some filenames use `##class.arch_*` suffixes to distinguish machine classes. The repository also includes a nested README for the custom/vendored `st` terminal under `.config/st/`; it documents that component, not installation of this repository.
|
||||||
|
|
||||||
|
Several scripts assume paths below `~/.setup`, Arch tooling, and root access. Some repository scripts contain machine-specific connection details; do not publish, copy, or document such values. Use local, private configuration for hosts, addresses, accounts, and credentials.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- An Arch-based system for the supplied `pacman`/`yay` package workflows.
|
||||||
|
- The package manager or helper required by the script being considered (`pacman` and/or `yay`).
|
||||||
|
- Desktop/session tools matching the chosen configuration (for example X11/XMonad-related files where applicable).
|
||||||
|
- Root access only for the system-changing scripts described below.
|
||||||
|
|
||||||
|
Package lists in `.setup/packages/` are inputs for a user-managed installation; no repository command defines how to install an entire list. Inspect package names and select lists for the intended machine before installing anything.
|
||||||
|
|
||||||
|
## Safe review and user-level configuration
|
||||||
|
|
||||||
|
Start by reviewing the intended files and any machine-class variants. Typical areas are:
|
||||||
|
|
||||||
|
- `.zshrc`, `.bashrc`, `.terminal_aliases`, `.tmux.conf`, and `.p10k.zsh` — shell and terminal setup.
|
||||||
|
- `.xmonad/`, `.Xresources##class.*`, `.xprofile##class.*`, and `.config/` — desktop/window-manager and application configuration.
|
||||||
|
- `.scripts/` — optional shell, network, virtual-environment, and update helpers.
|
||||||
|
- `.setup/packages/` — package lists grouped by purpose.
|
||||||
|
|
||||||
|
Back up existing configuration before manually placing or linking a file. No test suite is provided; validate a change in the relevant shell/session or application and keep a rollback copy of the previous configuration.
|
||||||
|
|
||||||
|
## Package updates
|
||||||
|
|
||||||
|
`.scripts/updating.sh` defines, but does not invoke, two functions. It expects `~/.setup/packages/exclude_from_updating.list`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
source .scripts/updating.sh
|
||||||
|
update_system
|
||||||
|
# or
|
||||||
|
update_pacman_packages
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Warning: system-wide, potentially disruptive operation.** `update_system` runs `yay -Syu` and `update_pacman_packages` runs `sudo pacman -Syu`, both non-interactively and with the exclusion list. Review the exclusion list and pending package changes first. These functions update the running system; they are not tests.
|
||||||
|
|
||||||
|
## System-changing scripts
|
||||||
|
|
||||||
|
The following scripts mutate files outside this repository and must be inspected before use:
|
||||||
|
|
||||||
|
- `.setup/config/setup_touchpad.sh` copies an X11 configuration into `/etc/X11/xorg.conf.d/` when absent.
|
||||||
|
- `.setup/config/exclude_from_update.sh` edits `/etc/pacman.conf` with `sudo` based on `~/.setup/packages/exclude_from_updating.list`.
|
||||||
|
- `.setup/config/dinit_add_logs_to_all_services.sh` edits files in `/etc/dinit.d` and writes log specifications under `/var/log/dinit`; its `-o` option removes existing logfile lines before adding them.
|
||||||
|
|
||||||
|
> **Warning: destructive/system-wide operations.** These scripts can alter system package policy, service definitions, or input configuration. They have no repository-provided dry run or rollback. Run only on a machine you control after backups and after checking paths, permissions, and the script contents.
|
||||||
|
|
||||||
|
`.setup/config/set_default_programs.sh` sets the default HTTP/HTTPS handler through `xdg-mime`; review the desktop entry before using it.
|
||||||
|
|
||||||
|
## Custom `st` terminal
|
||||||
|
|
||||||
|
The nested `.config/st/README.md` documents its own `make` and `sudo make install` workflow. Those commands build/install only that terminal component, may modify the system, and are not a bootstrap procedure for all dotfiles.
|
||||||
Reference in New Issue
Block a user