initial commit
This commit is contained in:
parent
fb22fff6b5
commit
f409d8ba30
|
@ -0,0 +1,94 @@
|
||||||
|
# NEDM Waybar Integration
|
||||||
|
|
||||||
|
A Python script that displays NEDM workspace information and focused window details in Waybar.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- Shows current workspace number with a bullet indicator (●)
|
||||||
|
- Displays the name of the currently focused application (e.g., "foot", "firefox")
|
||||||
|
- Provides tooltip with detailed workspace and window information
|
||||||
|
- Automatically reconnects if NEDM restarts
|
||||||
|
- Updates in real-time as you switch workspaces or focus different windows
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- NEDM (cagebreak) window manager with IPC enabled
|
||||||
|
- Python 3
|
||||||
|
- Waybar
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Place the script in your scripts directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp waybar-nedm-workspaces.py ~/scripts/
|
||||||
|
chmod +x ~/scripts/waybar-nedm-workspaces.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add the custom module to your Waybar configuration.
|
||||||
|
|
||||||
|
## Waybar Configuration
|
||||||
|
|
||||||
|
### Add to waybar config.json
|
||||||
|
|
||||||
|
Add `"custom/nedm-workspaces"` to your modules list:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"modules-left": ["custom/nedm-workspaces"],
|
||||||
|
"custom/nedm-workspaces": {
|
||||||
|
"exec": "python3 ~/scripts/waybar-nedm-workspaces.py",
|
||||||
|
"return-type": "json",
|
||||||
|
"restart-interval": 1,
|
||||||
|
"tooltip": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add styling to style.css
|
||||||
|
|
||||||
|
```css
|
||||||
|
#custom-nedm-workspaces {
|
||||||
|
background-color: #2e3440;
|
||||||
|
color: #d8dee9;
|
||||||
|
padding: 0 10px;
|
||||||
|
margin: 0 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#custom-nedm-workspaces.disconnected {
|
||||||
|
background-color: #bf616a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#custom-nedm-workspaces.error {
|
||||||
|
background-color: #d08770;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
The script displays information in the format: `WS: ●2 | foot`
|
||||||
|
|
||||||
|
- `WS:` - Workspace indicator
|
||||||
|
- `●2` - Current workspace number with bullet
|
||||||
|
- `foot` - Name of the focused application
|
||||||
|
|
||||||
|
## Security Note
|
||||||
|
|
||||||
|
This script uses process names from PIDs instead of window titles for security. It does NOT require NEDM to be run with the `-b` flag, which would expose window titles but compromise security.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If the script shows "NEDM: Disconnected":
|
||||||
|
|
||||||
|
1. Ensure NEDM is running with IPC enabled
|
||||||
|
2. Check that the IPC socket exists in `/run/user/$(id -u)/`
|
||||||
|
3. Verify NEDM was started without disabling IPC
|
||||||
|
|
||||||
|
If it shows generic "View X" instead of process names:
|
||||||
|
|
||||||
|
- The process may have terminated
|
||||||
|
- The script will fall back to view IDs when process information is unavailable
|
||||||
|
|
|
@ -0,0 +1,265 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
class NEDMWorkspaceTracker:
|
||||||
|
def __init__(self):
|
||||||
|
self.current_workspace = 1 # Default workspace (1-based)
|
||||||
|
self.current_window_title = "" # Current window title
|
||||||
|
self.socket_path = None
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
def find_socket(self):
|
||||||
|
"""Find NEDM IPC socket"""
|
||||||
|
# Check environment variable first
|
||||||
|
socket_path = os.getenv('NEDM_SOCKET') or os.getenv('CAGEBREAK_SOCKET')
|
||||||
|
if socket_path and os.path.exists(socket_path):
|
||||||
|
return socket_path
|
||||||
|
|
||||||
|
# Search in XDG_RUNTIME_DIR or /tmp for cagebreak-ipc socket
|
||||||
|
runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/tmp')
|
||||||
|
uid = os.getuid()
|
||||||
|
|
||||||
|
# Look for socket files matching pattern
|
||||||
|
try:
|
||||||
|
for file in os.listdir(runtime_dir):
|
||||||
|
if file.startswith(f'cagebreak-ipc.{uid}.') and file.endswith('.sock'):
|
||||||
|
socket_path = os.path.join(runtime_dir, file)
|
||||||
|
if os.path.exists(socket_path):
|
||||||
|
return socket_path
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def connect_to_nedm(self):
|
||||||
|
"""Connect to NEDM IPC socket"""
|
||||||
|
self.socket_path = self.find_socket()
|
||||||
|
if not self.socket_path:
|
||||||
|
self.connected = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
self.sock.connect(self.socket_path)
|
||||||
|
self.connected = True
|
||||||
|
# Query current state after connecting
|
||||||
|
self.query_current_state()
|
||||||
|
return True
|
||||||
|
except (socket.error, OSError):
|
||||||
|
self.connected = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def query_current_state(self):
|
||||||
|
"""Query NEDM for current workspace and window state"""
|
||||||
|
if not self.connected:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send dump command to get current state
|
||||||
|
self.sock.send(b'dump\n')
|
||||||
|
# Give it a moment to respond
|
||||||
|
self.sock.settimeout(1.0)
|
||||||
|
response = self.sock.recv(8192) # Increased buffer for dump response
|
||||||
|
self.sock.settimeout(None)
|
||||||
|
|
||||||
|
if response:
|
||||||
|
response_str = response.decode('utf-8', errors='ignore')
|
||||||
|
# Parse the response to extract current workspace and window info
|
||||||
|
try:
|
||||||
|
# Response format should be: "cg-ipc"<JSON>NULL
|
||||||
|
if response_str.startswith('cg-ipc'):
|
||||||
|
json_part = response_str[6:].split('\0')[0]
|
||||||
|
if json_part:
|
||||||
|
dump_data = json.loads(json_part)
|
||||||
|
self.parse_dump_data(dump_data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except (socket.error, OSError, socket.timeout):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def parse_dump_data(self, dump_data):
|
||||||
|
"""Parse dump data to extract current workspace and focused window"""
|
||||||
|
try:
|
||||||
|
# Get current output name and current workspace from dump data
|
||||||
|
curr_output = dump_data.get('curr_output', '')
|
||||||
|
views_curr_id = dump_data.get('views_curr_id')
|
||||||
|
tiles_curr_id = dump_data.get('tiles_curr_id')
|
||||||
|
|
||||||
|
# Find the output and get workspace info
|
||||||
|
outputs = dump_data.get('outputs', {})
|
||||||
|
if curr_output in outputs:
|
||||||
|
output_data = outputs[curr_output]
|
||||||
|
self.current_workspace = output_data.get('curr_workspace', 0) # Already 1-based in dump output
|
||||||
|
|
||||||
|
# Find the focused view title by matching view_id and tile_id
|
||||||
|
workspaces = output_data.get('workspaces', [])
|
||||||
|
curr_ws_idx = output_data.get('curr_workspace', 0) - 1 # Convert to 0-based for array access
|
||||||
|
if workspaces and len(workspaces) > curr_ws_idx and curr_ws_idx >= 0:
|
||||||
|
workspace = workspaces[curr_ws_idx]
|
||||||
|
|
||||||
|
# Find the view in the focused tile
|
||||||
|
tiles = workspace.get('tiles', [])
|
||||||
|
for tile in tiles:
|
||||||
|
if tile.get('id') == tiles_curr_id:
|
||||||
|
view_id = tile.get('view_id') # NEDM uses 'view_id' not 'view'
|
||||||
|
# Now find the view with this ID
|
||||||
|
views = workspace.get('views', [])
|
||||||
|
for view in views:
|
||||||
|
if view.get('id') == view_id:
|
||||||
|
# Get window title if available (requires NEDM -b flag for security)
|
||||||
|
title = view.get('title', '')
|
||||||
|
if title:
|
||||||
|
self.current_window_title = title
|
||||||
|
else:
|
||||||
|
# Try to get process name from PID as fallback
|
||||||
|
pid = view.get('pid')
|
||||||
|
if pid:
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
proc_name = subprocess.check_output(['ps', '-p', str(pid), '-o', 'comm='],
|
||||||
|
stderr=subprocess.DEVNULL).decode().strip()
|
||||||
|
if proc_name:
|
||||||
|
self.current_window_title = proc_name
|
||||||
|
else:
|
||||||
|
self.current_window_title = f"View {view_id}"
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
self.current_window_title = f"View {view_id}"
|
||||||
|
else:
|
||||||
|
self.current_window_title = f"View {view_id}"
|
||||||
|
break
|
||||||
|
break
|
||||||
|
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def listen_for_events(self):
|
||||||
|
"""Listen for workspace events from NEDM"""
|
||||||
|
if not self.connected:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
while self.connected:
|
||||||
|
data = self.sock.recv(4096)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Parse NEDM IPC protocol: "cg-ipc"<JSON>NULL
|
||||||
|
# Handle multiple messages concatenated together
|
||||||
|
data_str = data.decode('utf-8', errors='ignore')
|
||||||
|
|
||||||
|
# Split by null terminator to handle multiple messages
|
||||||
|
messages = data_str.split('\0')
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
if message.startswith('cg-ipc'):
|
||||||
|
json_part = message[6:] # Remove "cg-ipc" prefix
|
||||||
|
if json_part: # Only process non-empty JSON parts
|
||||||
|
try:
|
||||||
|
event = json.loads(json_part)
|
||||||
|
self.handle_event(event)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except (socket.error, OSError):
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
def handle_event(self, event):
|
||||||
|
"""Handle workspace-related events"""
|
||||||
|
event_name = event.get('event_name')
|
||||||
|
|
||||||
|
if event_name == 'switch_ws':
|
||||||
|
self.current_workspace = event.get('new_workspace', 1) # Already 1-based
|
||||||
|
# Clear window title when switching workspaces since we don't know the focused window
|
||||||
|
self.current_window_title = ""
|
||||||
|
elif event_name == 'focus_tile':
|
||||||
|
workspace = event.get('new_workspace') # Use new_workspace for focus_tile events
|
||||||
|
if workspace is not None:
|
||||||
|
self.current_workspace = workspace # focus_tile events already use 1-based indexing
|
||||||
|
# Get window title from focus_tile event
|
||||||
|
view_title = event.get('view_title', '')
|
||||||
|
self.current_window_title = view_title
|
||||||
|
elif event_name in ['view_map', 'view_unmap']:
|
||||||
|
# When windows are mapped/unmapped, query current state to update info
|
||||||
|
self.query_current_state()
|
||||||
|
elif event_name == 'close':
|
||||||
|
# When a window is closed, clear the title
|
||||||
|
self.current_window_title = ""
|
||||||
|
elif event_name == 'cycle_views':
|
||||||
|
# When cycling views, this might change the focused window
|
||||||
|
self.query_current_state()
|
||||||
|
elif event_name == 'dump':
|
||||||
|
# Handle dump responses
|
||||||
|
self.parse_dump_data(event)
|
||||||
|
|
||||||
|
def get_workspace_info(self):
|
||||||
|
"""Get current workspace information"""
|
||||||
|
if not self.connected:
|
||||||
|
return {"text": "NEDM: Disconnected", "class": "disconnected"}
|
||||||
|
|
||||||
|
# Format the display text
|
||||||
|
if self.current_window_title:
|
||||||
|
# Truncate long titles to keep the bar readable
|
||||||
|
title = self.current_window_title[:30] + "..." if len(self.current_window_title) > 30 else self.current_window_title
|
||||||
|
display_text = f"WS: ●{self.current_workspace} | {title}"
|
||||||
|
tooltip_text = f"Workspace {self.current_workspace}: {self.current_window_title}"
|
||||||
|
else:
|
||||||
|
display_text = f"WS: ●{self.current_workspace}"
|
||||||
|
tooltip_text = f"Current workspace: {self.current_workspace}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"text": display_text,
|
||||||
|
"class": "workspaces",
|
||||||
|
"tooltip": tooltip_text
|
||||||
|
}
|
||||||
|
|
||||||
|
# Global tracker instance
|
||||||
|
tracker = NEDMWorkspaceTracker()
|
||||||
|
|
||||||
|
def signal_handler(signum, frame):
|
||||||
|
"""Handle termination signals gracefully"""
|
||||||
|
if tracker.connected:
|
||||||
|
try:
|
||||||
|
tracker.sock.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main loop for waybar module"""
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
|
# Try to connect to NEDM
|
||||||
|
if tracker.connect_to_nedm():
|
||||||
|
# Start listening thread for events
|
||||||
|
event_thread = threading.Thread(target=tracker.listen_for_events, daemon=True)
|
||||||
|
event_thread.start()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Try to reconnect if disconnected
|
||||||
|
if not tracker.connected:
|
||||||
|
if tracker.connect_to_nedm():
|
||||||
|
event_thread = threading.Thread(target=tracker.listen_for_events, daemon=True)
|
||||||
|
event_thread.start()
|
||||||
|
|
||||||
|
workspace_info = tracker.get_workspace_info()
|
||||||
|
print(json.dumps(workspace_info), flush=True)
|
||||||
|
time.sleep(1) # Update every second
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
# If something goes wrong, show error and continue
|
||||||
|
error_info = {"text": "NEDM: Error", "class": "error"}
|
||||||
|
print(json.dumps(error_info), flush=True)
|
||||||
|
time.sleep(5) # Wait longer on error
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
Loading…
Reference in New Issue