Initial commit
This commit is contained in:
commit
fd68d9f463
10 changed files with 662 additions and 0 deletions
24
LICENSE
Normal file
24
LICENSE
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
Copyright (c) 2025 Daniel Fichtinger <daniel@ficd.sh>
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
1. Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
3. Neither the name of the author nor the names of its contributors may
|
||||||
|
be used to endorse or promote products derived from this software
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND
|
||||||
|
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
|
||||||
|
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||||
|
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||||
|
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
||||||
|
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGE.
|
29
README.md
Normal file
29
README.md
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# niri scripts
|
||||||
|
|
||||||
|
This repository contains scripts and Waybar modules for Niri.
|
||||||
|
|
||||||
|
## picker
|
||||||
|
|
||||||
|
A simple window picker script. Use a `dmenu` compliant picker (`fuzzel` by
|
||||||
|
default) to pick a window and switch focus to it.
|
||||||
|
|
||||||
|
## recorder
|
||||||
|
|
||||||
|
A script and Waybar module to simplify the process of making and sharing screen
|
||||||
|
recordings. Features:
|
||||||
|
|
||||||
|
- Select a screen or region for recording.
|
||||||
|
- Automatically compress and copy recording URI to clipboard for easy pasting
|
||||||
|
into applications like Discord.
|
||||||
|
- Cleans up old recordings.
|
||||||
|
- Waybar module allows starting/stopping recording, displays recording status.
|
||||||
|
|
||||||
|
Check the [README](./recorder/README.md) for usage instructions.
|
||||||
|
|
||||||
|
## windows
|
||||||
|
|
||||||
|
A Waybar module that provides an indicator of open windows in the focused
|
||||||
|
workspace. Helpful for those that take Niri's scrolling a tad too far, and often
|
||||||
|
end up with too many windows in a single workspace.
|
||||||
|
|
||||||
|
Check the [README](./windows/README.md) for usage instructions.
|
64
picker/window-picker.py
Executable file
64
picker/window-picker.py
Executable file
|
@ -0,0 +1,64 @@
|
||||||
|
#!/bin/env python
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
|
||||||
|
# JSON object to represent a window in Niri
|
||||||
|
type WindowJson = dict[str, int | str | bool]
|
||||||
|
|
||||||
|
# Get a list of open windows from Niri
|
||||||
|
def get_windows():
|
||||||
|
command = "niri msg -j windows"
|
||||||
|
process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
data: list[WindowJson] = json.loads(process.communicate()[0])
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
# Generate a string representation for each window.
|
||||||
|
# Map the string to its Niri window ID
|
||||||
|
def get_string_id_mapping(window_list: list[WindowJson]):
|
||||||
|
mapping: dict[str, int] = {}
|
||||||
|
for idx, window in enumerate(window_list):
|
||||||
|
s = f"{idx}: {window.get("app_id")}: {window.get("title")}"
|
||||||
|
id = window.get("id")
|
||||||
|
assert type(id) == int
|
||||||
|
mapping[s] = id
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
# Generate the string to be sent to fuzzel
|
||||||
|
def get_input_string(mapping: dict[str, int]):
|
||||||
|
m = max([len(s) for s in mapping.keys()])
|
||||||
|
return "\n".join(mapping.keys()), m
|
||||||
|
|
||||||
|
def spawn_picker(cmd, input_string, m):
|
||||||
|
# cmd = "fuzzel --dmenu -I --placeholder=Select a window:"
|
||||||
|
cmd = f"{cmd} --width {min(m, 120) + 1}"
|
||||||
|
process = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
|
||||||
|
selection = process.communicate(input=input_string.encode("UTF-8"))
|
||||||
|
if process.returncode != 2:
|
||||||
|
return selection[0].decode("UTF-8").strip("\n")
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def switch_window(id: int):
|
||||||
|
cmd = f"niri msg action focus-window --id {id}"
|
||||||
|
process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
|
||||||
|
process.communicate()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = ArgumentParser()
|
||||||
|
parser.add_argument("-p", "--picker", required=False, help="Set a picker command. Must take a newline delimited string on stdin and return the selection on stdout (default: %(default)s)", default="fuzzel --dmenu --placeholder=Select a window:", type=str, metavar="COMMAND")
|
||||||
|
args = parser.parse_args()
|
||||||
|
picker_cmd = args.picker
|
||||||
|
# print("picker:", picker_cmd)
|
||||||
|
wl = get_windows()
|
||||||
|
mapping = get_string_id_mapping(wl)
|
||||||
|
input_string, m= get_input_string(mapping)
|
||||||
|
selection = spawn_picker(picker_cmd, input_string, m)
|
||||||
|
if selection is None:
|
||||||
|
exit(1)
|
||||||
|
try:
|
||||||
|
id = mapping[selection]
|
||||||
|
except KeyError:
|
||||||
|
exit(1)
|
||||||
|
switch_window(id)
|
53
recorder/README.md
Normal file
53
recorder/README.md
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
# Niri Recorder
|
||||||
|
|
||||||
|
Niri recorder is a shell script and waybar module to for creating screen
|
||||||
|
recordings meant to be easily shared online.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- `socat`
|
||||||
|
- `bash`
|
||||||
|
- `wf-recorder`
|
||||||
|
- `niri`
|
||||||
|
- `jq`
|
||||||
|
- `ffmpeg`
|
||||||
|
- `slurp`
|
||||||
|
- `python3` (optional)
|
||||||
|
- `waybar` (optional)
|
||||||
|
- `wl-clipboard`
|
||||||
|
|
||||||
|
## Recorder
|
||||||
|
|
||||||
|
A utility script meant to be triggered via keybinding. It will record your
|
||||||
|
screen, then automatically compress the recording and copy its URI path to your
|
||||||
|
clipboard, so you can easily paste it inside applications like Discord and
|
||||||
|
Matrix. You do not need the Waybar module to use the script.
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
|
||||||
|
`recorder.sh`
|
||||||
|
|
||||||
|
1. Run the script once to begin recording. Arguments:
|
||||||
|
|
||||||
|
- `screen` \[default]: record entire screen. Select screen if multi-monitor.
|
||||||
|
- `region`: Select a region to record.
|
||||||
|
|
||||||
|
2. Run the script again to stop recording.
|
||||||
|
|
||||||
|
- Arguments don't matter this time.
|
||||||
|
- Compression and copying to clipboard is done automatically.
|
||||||
|
|
||||||
|
## Waybar Module
|
||||||
|
|
||||||
|
There is an included waybar module. This module shows the current recording
|
||||||
|
state. You can also use it to start/stop the recording with your mouse. Please
|
||||||
|
see `recorder_config.jsonc` for an example of how to setup the custom module.
|
||||||
|
|
||||||
|
You can also use `test_waybar.sh` to test the module without creating any
|
||||||
|
recordings. After you've loaded the Waybar module, simply run the script, and
|
||||||
|
you should see the module responding to socket messages.
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- Thanks to [Axlefublr](https://axlefublr.github.io/screen-recording/) for the
|
||||||
|
method to optimize the compression of the video.
|
9
recorder/module_config.jsonc
Normal file
9
recorder/module_config.jsonc
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"custom/recorder": {
|
||||||
|
"exec": "/path/to/recorder.py",
|
||||||
|
"return-type": "json",
|
||||||
|
"restart-interval": "never",
|
||||||
|
"on-click": "/path/to/recorder.sh screen",
|
||||||
|
"on-click-right": "/path/to/recorder.sh region"
|
||||||
|
}
|
||||||
|
}
|
107
recorder/recorder.py
Executable file
107
recorder/recorder.py
Executable file
|
@ -0,0 +1,107 @@
|
||||||
|
#!/bin/env python
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# helper prints dict as json string
|
||||||
|
def p(obj):
|
||||||
|
print(json.dumps(obj), flush=True)
|
||||||
|
|
||||||
|
SOCKET = "/tmp/recorder-sock.sock"
|
||||||
|
# default tooltip
|
||||||
|
DEF_TT = "Click to record screen. Right-click to record region."
|
||||||
|
# default text
|
||||||
|
DEF_TEXT = "rec"
|
||||||
|
|
||||||
|
# Remove socket already exists
|
||||||
|
try:
|
||||||
|
os.unlink(SOCKET)
|
||||||
|
except OSError:
|
||||||
|
# doesn't exist, we're good
|
||||||
|
pass
|
||||||
|
|
||||||
|
# track singleton delayed task
|
||||||
|
delayed_task = None
|
||||||
|
|
||||||
|
# async function to send a message with delay
|
||||||
|
async def delayed_msg(delay, message):
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
p(message)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# function handles message from socket
|
||||||
|
def handle_message(data: str, loop):
|
||||||
|
global delayed_task
|
||||||
|
# always cancel delayed task
|
||||||
|
# if there's new message
|
||||||
|
if delayed_task:
|
||||||
|
delayed_task.cancel()
|
||||||
|
out = {}
|
||||||
|
out_s = ""
|
||||||
|
out_t = ""
|
||||||
|
# process the message type
|
||||||
|
if data:
|
||||||
|
if data == "REC":
|
||||||
|
out_s = "on"
|
||||||
|
out_t = "Recording in progress. Click to stop."
|
||||||
|
elif data == "CMP":
|
||||||
|
out_s = "compressing"
|
||||||
|
out_t = "Recording is being compressed."
|
||||||
|
elif data == "CPD":
|
||||||
|
out_s = "copied"
|
||||||
|
out_t = "Recording has been copied to clipboard."
|
||||||
|
elif data == "STP":
|
||||||
|
out_s = "done"
|
||||||
|
out_t = "Recording has been stopped."
|
||||||
|
elif data == "ERR":
|
||||||
|
out_s = "error"
|
||||||
|
out_t = "Recording has encountered an error."
|
||||||
|
# format the output
|
||||||
|
out["text"] = f"rec: {out_s}" if out_s != "" else DEF_TEXT
|
||||||
|
out["tooltip"] = out_t if out_t != "" else DEF_TT
|
||||||
|
# print to waybar
|
||||||
|
p(out)
|
||||||
|
|
||||||
|
# check if delayed message should be sent afterwards
|
||||||
|
if data in ["ERR", "CPD", "STP"]:
|
||||||
|
# probably redundant but... for good measure lol
|
||||||
|
if delayed_task:
|
||||||
|
delayed_task.cancel()
|
||||||
|
# delayed print of default output
|
||||||
|
delayed_out = {"text": DEF_TEXT, "tooltip": DEF_TT}
|
||||||
|
delayed_task = loop.create_task(delayed_msg(5, delayed_out))
|
||||||
|
|
||||||
|
# start the server
|
||||||
|
async def server():
|
||||||
|
# pointer to this event loop
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
# open our socket
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
|
||||||
|
server.bind(SOCKET)
|
||||||
|
# needed for async
|
||||||
|
server.setblocking(False)
|
||||||
|
# only one connection at a time
|
||||||
|
server.listen(1)
|
||||||
|
# main loop; runs until waybar exits
|
||||||
|
while True:
|
||||||
|
# receive data from socket
|
||||||
|
conn, _ = await loop.sock_accept(server)
|
||||||
|
with conn:
|
||||||
|
# parse string
|
||||||
|
data = (await loop.sock_recv(conn, 1024)).decode().strip()
|
||||||
|
# handle the data
|
||||||
|
handle_message(data, loop)
|
||||||
|
|
||||||
|
# main function
|
||||||
|
async def main():
|
||||||
|
# start by outputing default contents
|
||||||
|
p({"text": DEF_TEXT, "tooltip": DEF_TT})
|
||||||
|
# start the server
|
||||||
|
await server()
|
||||||
|
|
||||||
|
# entry point
|
||||||
|
asyncio.run(main())
|
69
recorder/recorder.sh
Executable file
69
recorder/recorder.sh
Executable file
|
@ -0,0 +1,69 @@
|
||||||
|
#!/bin/env bash
|
||||||
|
|
||||||
|
# depends: wf-recorder, libnotify
|
||||||
|
|
||||||
|
TEMPDIR="/tmp/niri-recorder"
|
||||||
|
mkdir -p "$TEMPDIR"
|
||||||
|
LOCK="$TEMPDIR/lock"
|
||||||
|
RFMT=".mkv"
|
||||||
|
OFMT=".mp4"
|
||||||
|
RAW="$TEMPDIR/raw$RFMT"
|
||||||
|
OUTDIR="$HOME/Videos/niri-recorder"
|
||||||
|
mkdir -p "$OUTDIR"
|
||||||
|
|
||||||
|
# sends current recording state to socket
|
||||||
|
function sig {
|
||||||
|
echo "$1" | socat - UNIX-CONNECT:/tmp/recorder-sock.sock
|
||||||
|
}
|
||||||
|
|
||||||
|
# function generates unique name for recording
|
||||||
|
function compname {
|
||||||
|
now=$(date +"%y-%m-%d-%H:%M:%S")
|
||||||
|
echo "$OUTDIR/$now$OFMT"
|
||||||
|
}
|
||||||
|
|
||||||
|
# First we need to check if the recording
|
||||||
|
# lockfile exists.
|
||||||
|
if [[ -f "$LOCK" ]]; then
|
||||||
|
# Stop the recording
|
||||||
|
sig "STP"
|
||||||
|
kill "$(cat "$LOCK")"
|
||||||
|
# remove lockfile
|
||||||
|
rm "$LOCK"
|
||||||
|
outpath="$(compname)"
|
||||||
|
sig "CMP"
|
||||||
|
# compress the recording
|
||||||
|
ffmpeg -y -i "$RAW" -c:v libx264 -preset slow -crf 21 -r 30 -b:v 2M -maxrate 3M -bufsize 4M -c:a aac -b:a 96k -movflags +faststart "$outpath" || (sig "ERR"; exit 1)
|
||||||
|
# copy URI path to clipboard
|
||||||
|
wl-copy -t 'text/uri-list' <<<"file://$outpath" || (sig "ERR"; exit 1)
|
||||||
|
sig "CPD"
|
||||||
|
# delete the raw recording
|
||||||
|
rm "$RAW"
|
||||||
|
|
||||||
|
else
|
||||||
|
# count how many monitors are attached
|
||||||
|
num_mon=$(niri msg --json outputs | jq 'keys | length')
|
||||||
|
wf_flags="-Dyf"
|
||||||
|
if [ "$1" = "region" ];then
|
||||||
|
# select a screen region
|
||||||
|
sel=$(slurp) || exit 1
|
||||||
|
wf-recorder -g "$sel" "$wf_flags" "$RAW" &
|
||||||
|
elif [ "$1" = "screen" ] || [ "$1" = "" ] && (( num_mon > 1 )); then
|
||||||
|
# select entire screen
|
||||||
|
sel=$(slurp -o) || exit 1
|
||||||
|
wf-recorder -g "$sel" "$wf_flags" "$RAW" &
|
||||||
|
else
|
||||||
|
# this runs when screen is specified and there's only one monitor
|
||||||
|
# it also runs with no args bc screen is default
|
||||||
|
wf-recorder "$wf_flags" "$RAW" &
|
||||||
|
fi
|
||||||
|
|
||||||
|
sig "REC"
|
||||||
|
# create lockfile
|
||||||
|
touch "$LOCK"
|
||||||
|
|
||||||
|
# save recorder's process id to lockfile
|
||||||
|
PID=$!
|
||||||
|
echo "$PID" > "$LOCK"
|
||||||
|
fi
|
||||||
|
|
14
recorder/test-waybar.sh
Executable file
14
recorder/test-waybar.sh
Executable file
|
@ -0,0 +1,14 @@
|
||||||
|
#!/bin/env bash
|
||||||
|
|
||||||
|
echo "REC" | socat - UNIX-CONNECT:/tmp/recorder-sock.sock; \
|
||||||
|
sleep 2; \
|
||||||
|
echo "ERR" | socat - UNIX-CONNECT:/tmp/recorder-sock.sock; \
|
||||||
|
sleep 2; \
|
||||||
|
echo "REC" | socat - UNIX-CONNECT:/tmp/recorder-sock.sock; \
|
||||||
|
sleep 2; \
|
||||||
|
echo "STP" | socat - UNIX-CONNECT:/tmp/recorder-sock.sock; \
|
||||||
|
sleep 2; \
|
||||||
|
echo "CMP" | socat - UNIX-CONNECT:/tmp/recorder-sock.sock; \
|
||||||
|
sleep 2; \
|
||||||
|
echo "CPD" | socat - UNIX-CONNECT:/tmp/recorder-sock.sock; \
|
||||||
|
sleep 2;
|
23
windows/README.md
Normal file
23
windows/README.md
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# niri-windows
|
||||||
|
|
||||||
|
Waybar module for counting windows open in current workspace.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Tracks the state of windows per workspace.
|
||||||
|
- Shows number of windows in the focused workspace.
|
||||||
|
- Hover to show the applications and window titles.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Add the following snippet to the module configuration section of your Waybar
|
||||||
|
config:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"custom/colcount": {
|
||||||
|
"exec": "~/path/to/niri-windows.py",
|
||||||
|
"return-type": "json",
|
||||||
|
"restart-interval": "never",
|
||||||
|
"format": "{} ",
|
||||||
|
},
|
||||||
|
```
|
270
windows/niri-windows.py
Executable file
270
windows/niri-windows.py
Executable file
|
@ -0,0 +1,270 @@
|
||||||
|
#!/bin/env python
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
from typing import override
|
||||||
|
|
||||||
|
|
||||||
|
# helper prints dict as json string
|
||||||
|
def p(obj):
|
||||||
|
print(json.dumps(obj), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger()
|
||||||
|
log.setLevel(logging.DEBUG)
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
# log_level = os.environ["NIRILOG"]
|
||||||
|
if "NIRILOG" in os.environ:
|
||||||
|
handler.setLevel(logging.DEBUG)
|
||||||
|
else:
|
||||||
|
handler.setLevel(logging.ERROR)
|
||||||
|
log.addHandler(handler)
|
||||||
|
SOCKET = os.environ["NIRI_SOCKET"]
|
||||||
|
|
||||||
|
|
||||||
|
class Window:
|
||||||
|
def __init__(self, id: int, title: str, app_id: str) -> None:
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.app_id = app_id
|
||||||
|
|
||||||
|
@override
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.app_id}: {self.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class Workspace:
|
||||||
|
def __init__(self, id: int, output: str) -> None:
|
||||||
|
self.id = id
|
||||||
|
self.windows: dict[int, Window] = {}
|
||||||
|
# self.windows: set[int] = set()
|
||||||
|
self.output = output
|
||||||
|
|
||||||
|
@override
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return str(list(self.windows))
|
||||||
|
|
||||||
|
def add(self, id, title: str, app_id: str):
|
||||||
|
w = Window(id, title, app_id)
|
||||||
|
self.windows[id] = w
|
||||||
|
|
||||||
|
def remove(self, id):
|
||||||
|
self.windows.pop(id)
|
||||||
|
|
||||||
|
def query(self, id) -> bool:
|
||||||
|
return id in self.windows
|
||||||
|
|
||||||
|
def change_output(self, new: str):
|
||||||
|
self.output = new
|
||||||
|
|
||||||
|
def count(self) -> int:
|
||||||
|
return len(self.windows)
|
||||||
|
|
||||||
|
def get_windows(self):
|
||||||
|
return list(self.windows.values())
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self):
|
||||||
|
self.focused_workspace: int = 0
|
||||||
|
self.active_workspaces: dict[str, int] = {}
|
||||||
|
# output name -> workspace: id -> window count
|
||||||
|
self.workspaces: dict[int, Workspace] = {}
|
||||||
|
self.first_run = True
|
||||||
|
|
||||||
|
@override
|
||||||
|
def __str__(self) -> str:
|
||||||
|
s = ""
|
||||||
|
s += f"focused: {self.focused_workspace}\n"
|
||||||
|
for output, id in self.active_workspaces.items():
|
||||||
|
s += f"{output}: {id}\n"
|
||||||
|
for id, ws in self.workspaces.items():
|
||||||
|
s += f"id: {id}, win: {ws}\n"
|
||||||
|
return s
|
||||||
|
|
||||||
|
def set_activated(self, id: int):
|
||||||
|
output = self.workspaces[id].output
|
||||||
|
self.active_workspaces[output] = id
|
||||||
|
|
||||||
|
def set_focused(self, id: int):
|
||||||
|
self.focused_workspace = id
|
||||||
|
|
||||||
|
def update_workspaces(self, arr: list[dict]):
|
||||||
|
ids = []
|
||||||
|
for ws in arr:
|
||||||
|
id = ws["id"]
|
||||||
|
output = ws["output"]
|
||||||
|
if id not in self.workspaces:
|
||||||
|
self.workspaces[id] = Workspace(id, output)
|
||||||
|
ids.append(id)
|
||||||
|
if ws["is_focused"]:
|
||||||
|
self.focused_workspace = id
|
||||||
|
if ws["is_active"]:
|
||||||
|
self.active_workspaces[output] = id
|
||||||
|
to_pop = []
|
||||||
|
for id in self.workspaces.keys():
|
||||||
|
if id not in ids:
|
||||||
|
to_pop.append(id)
|
||||||
|
for id in to_pop:
|
||||||
|
self.workspaces.pop(id)
|
||||||
|
|
||||||
|
def add_window(
|
||||||
|
self, workspace_id: int, window_id: int, title: str, app_id: str
|
||||||
|
):
|
||||||
|
self.workspaces[workspace_id].add(window_id, title, app_id)
|
||||||
|
|
||||||
|
def remove_window(self, window_id: int):
|
||||||
|
for workspace in self.workspaces.values():
|
||||||
|
if workspace.query(window_id):
|
||||||
|
workspace.remove(window_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
def update_windows(self, arr: list[dict]):
|
||||||
|
for win in arr:
|
||||||
|
window_id: int = win["id"]
|
||||||
|
workspace_id: int = win["workspace_id"]
|
||||||
|
title: str = win["title"]
|
||||||
|
app_id: str = win["app_id"]
|
||||||
|
self.add_window(workspace_id, window_id, title, app_id)
|
||||||
|
|
||||||
|
def get_count(self, output: str | None = None) -> int:
|
||||||
|
if output is None:
|
||||||
|
return self.workspaces[self.focused_workspace].count()
|
||||||
|
else:
|
||||||
|
id = self.active_workspaces[output]
|
||||||
|
return self.workspaces[id].count()
|
||||||
|
|
||||||
|
def get_windows(
|
||||||
|
self, output: str | None = None, ws_id: int | None = None
|
||||||
|
):
|
||||||
|
if ws_id is None:
|
||||||
|
if output is None:
|
||||||
|
ws_id = self.focused_workspace
|
||||||
|
else:
|
||||||
|
ws_id = self.active_workspaces[output]
|
||||||
|
return self.workspaces[ws_id].get_windows()
|
||||||
|
|
||||||
|
|
||||||
|
state = State()
|
||||||
|
|
||||||
|
|
||||||
|
def display():
|
||||||
|
print(generate_message(), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_message() -> str:
|
||||||
|
obj = {"text": generate_text(), "tooltip": generate_tooltip()}
|
||||||
|
return json.dumps(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_tooltip(output=None) -> str:
|
||||||
|
windows = state.get_windows(output)
|
||||||
|
s = ""
|
||||||
|
for _, w in enumerate(windows):
|
||||||
|
s += f"{str(w)}\r\n"
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def generate_text(icon="", output=None):
|
||||||
|
count = state.get_count(output)
|
||||||
|
out = " ".join([icon] * count)
|
||||||
|
if log.level <= logging.DEBUG:
|
||||||
|
log.debug(str(state))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# function handles message from socket
|
||||||
|
def handle_message(event: dict):
|
||||||
|
log.debug("Handling message.")
|
||||||
|
log.debug(event)
|
||||||
|
should_display = False
|
||||||
|
match next(iter(event)):
|
||||||
|
case "WorkspacesChanged":
|
||||||
|
workspaces: list[dict] = event["WorkspacesChanged"][
|
||||||
|
"workspaces"
|
||||||
|
]
|
||||||
|
state.update_workspaces(workspaces)
|
||||||
|
log.info("Updated workspaces.")
|
||||||
|
should_display = True
|
||||||
|
case "WindowsChanged":
|
||||||
|
windows: list[dict] = event["WindowsChanged"]["windows"]
|
||||||
|
state.update_windows(windows)
|
||||||
|
log.info("Updated windows.")
|
||||||
|
should_display = True
|
||||||
|
# workspaces : list[dict] = event["WorkspacesChanged"]["workspaces"]
|
||||||
|
# state.process_changed(workspaces)
|
||||||
|
case "WorkspaceActivated":
|
||||||
|
ev = event["WorkspaceActivated"]
|
||||||
|
if ev["focused"]:
|
||||||
|
state.set_focused(ev["id"])
|
||||||
|
log.info("Changed focused workspace.")
|
||||||
|
state.set_activated(ev["id"])
|
||||||
|
should_display = True
|
||||||
|
case "WindowOpenedOrChanged":
|
||||||
|
# This event also handles window moved across workspace
|
||||||
|
window = event["WindowOpenedOrChanged"]["window"]
|
||||||
|
window_id, workspace_id = window["id"], window["workspace_id"]
|
||||||
|
state.remove_window(window_id)
|
||||||
|
title = window["title"]
|
||||||
|
app_id = window["app_id"]
|
||||||
|
state.add_window(workspace_id, window_id, title, app_id)
|
||||||
|
log.info("Updated window.")
|
||||||
|
should_display = True
|
||||||
|
case "WindowClosed":
|
||||||
|
# TODO: update Workspace to track window IDs
|
||||||
|
ev = event["WindowClosed"]
|
||||||
|
id: int = ev["id"]
|
||||||
|
state.remove_window(id)
|
||||||
|
log.info("Removed window.")
|
||||||
|
should_display = True
|
||||||
|
return should_display
|
||||||
|
|
||||||
|
|
||||||
|
# start the server
|
||||||
|
def server():
|
||||||
|
# pointer to this event loop
|
||||||
|
# open our socket
|
||||||
|
log.info(f"Connecting to Niri socket @ {SOCKET}")
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
||||||
|
client.connect(SOCKET)
|
||||||
|
# needed for async
|
||||||
|
client.sendall('"EventStream"'.encode() + b"\n")
|
||||||
|
client.shutdown(socket.SHUT_WR)
|
||||||
|
|
||||||
|
# only one connection at a time
|
||||||
|
log.info("Connection successful, starting event loop.")
|
||||||
|
# main loop; runs until waybar exits
|
||||||
|
while True:
|
||||||
|
# receive data from socket
|
||||||
|
data = client.recv(4096)
|
||||||
|
if not data:
|
||||||
|
log.debug("No data!")
|
||||||
|
for line in data.split(b"\n"):
|
||||||
|
if line.strip():
|
||||||
|
try:
|
||||||
|
event = json.loads(line)
|
||||||
|
if handle_message(event):
|
||||||
|
display()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(
|
||||||
|
"Malformed JSON:",
|
||||||
|
line.decode(errors="replace"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# main function
|
||||||
|
def main():
|
||||||
|
# start by outputing default contents
|
||||||
|
# start the server
|
||||||
|
try:
|
||||||
|
server()
|
||||||
|
except Exception as e:
|
||||||
|
print(e, flush=True)
|
||||||
|
log.error(e)
|
||||||
|
|
||||||
|
|
||||||
|
# entry point
|
||||||
|
main()
|
Loading…
Add table
Add a link
Reference in a new issue