🔧 chore(workspaces): update Workspaces and WorkspaceButton instances, remove Hyprland import

This commit is contained in:
sankalp committed 2025-06-22 18:47:25 +05:45
1 parent 822672c399
commit 8b4d829681
8 files changed
+219 -467

No files matched your search

+9 -1
View File
@@ -111,7 +111,15 @@ class DockComponents(Box):
for component_name, widget in components.items():
if component_name in self.component_visibility:
widget.set_visible(self.component_visibility[component_name])
# Special handling for applications component - let it manage its own visibility
if component_name == "applications":
# Only apply config visibility if the component has content
if hasattr(widget, '_update_visibility_based_on_content'):
widget._update_visibility_based_on_content()
else:
widget.set_visible(self.component_visibility[component_name])
else:
widget.set_visible(self.component_visibility[component_name])
def toggle_component_visibility(self, component_name):
components = {
+7 -8
View File
@@ -1,12 +1,11 @@
from gi.repository import GLib, Gdk
from fabric.widgets.label import Label
from fabric.widgets.box import Box
from fabric.widgets.circularprogressbar import CircularProgressBar
from fabric.widgets.button import Button
from services.battery import Battery as BatteryService
import utils.icons as icons
import config.data as data
import utils.icons as icons
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.circularprogressbar import CircularProgressBar
from fabric.widgets.label import Label
from gi.repository import Gdk, GLib
from services.battery import Battery as BatteryService
class Battery(Box):
+43 -32
View File
@@ -1,3 +1,5 @@
import config.data as data
import utils.icons as icons
from fabric.audio.service import Audio
from fabric.widgets.box import Box
from fabric.widgets.button import Button
@@ -5,11 +7,7 @@ from fabric.widgets.circularprogressbar import CircularProgressBar
from fabric.widgets.eventbox import EventBox
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.scale import Scale
from gi.repository import Gdk, GLib
import config.data as data
import utils.icons as icons
from services.brightness import Brightness as BrightnessService
@@ -22,17 +20,19 @@ class Brightness(Box):
return
self.progress_bar = CircularProgressBar(
name="button-brightness", size=28, line_width=2,
start_angle=150, end_angle=390,
name="button-brightness",
size=28,
line_width=2,
start_angle=150,
end_angle=390,
)
self.brightness_label = Label(
name="brightness-label", markup=icons.brightness_high
)
self.brightness_label = Label(name="brightness-label", markup=icons.brightness_high)
self.brightness_button = Button(child=self.brightness_label)
self.event_box = EventBox(
events=["scroll", "smooth-scroll"],
child=Overlay(
child=self.progress_bar,
overlays=self.brightness_button
),
child=Overlay(child=self.progress_bar, overlays=self.brightness_button),
)
self.event_box.connect("scroll-event", self.on_scroll)
self.add(self.event_box)
@@ -49,22 +49,20 @@ class Brightness(Box):
def on_scroll(self, _, event):
if self.brightness.max_screen == -1:
return
current_brightness = self.brightness.screen_brightness
max_brightness = self.brightness.max_screen
step = max(1, int(max_brightness * 0.01)) # 1% of max brightness
if event.direction == Gdk.ScrollDirection.SMOOTH:
if hasattr(event, 'delta_y') and abs(event.delta_y) > 0:
if hasattr(event, "delta_y") and abs(event.delta_y) > 0:
new_brightness = current_brightness - int(event.delta_y * step)
self.brightness.screen_brightness = new_brightness
# Remove delta_x handling
elif event.direction == Gdk.ScrollDirection.UP:
self.brightness.screen_brightness = current_brightness + step
elif event.direction == Gdk.ScrollDirection.DOWN:
self.brightness.screen_brightness = current_brightness - step
def on_progress_value_changed(self, widget, pspec):
if self._updating_from_brightness:
return
@@ -72,10 +70,15 @@ class Brightness(Box):
new_brightness = int(new_norm * self.brightness.max_screen)
self._pending_value = new_brightness
if self._update_source_id is None:
self._update_source_id = GLib.timeout_add(50, self._update_brightness_callback)
self._update_source_id = GLib.timeout_add(
50, self._update_brightness_callback
)
def _update_brightness_callback(self):
if self._pending_value is not None and self._pending_value != self.brightness.screen_brightness:
if (
self._pending_value is not None
and self._pending_value != self.brightness.screen_brightness
):
self.brightness.screen_brightness = self._pending_value
self._pending_value = None
return True
@@ -105,13 +108,17 @@ class Brightness(Box):
GLib.source_remove(self._update_source_id)
super().destroy()
class Volume(Box):
def __init__(self, **kwargs):
super().__init__(name="button-bar-vol", **kwargs)
self.audio = Audio()
self.progress_bar = CircularProgressBar(
name="button-volume", size=28, line_width=2,
start_angle=150, end_angle=390,
name="button-volume",
size=28,
line_width=2,
start_angle=150,
end_angle=390,
)
self.vol_label = Label(name="vol-label", markup=icons.vol_high)
self.vol_button = Button(on_clicked=self.toggle_mute, child=self.vol_label)
@@ -148,12 +155,12 @@ class Volume(Box):
def on_scroll(self, _, event):
if not self.audio.speaker:
return
current_volume = self.audio.speaker.volume
step = 1
if event.direction == Gdk.ScrollDirection.SMOOTH:
if hasattr(event, 'delta_y') and abs(event.delta_y) > 0:
if hasattr(event, "delta_y") and abs(event.delta_y) > 0:
new_volume = current_volume - int(event.delta_y * step)
self.audio.speaker.volume = new_volume
# Remove delta_x handling
@@ -165,7 +172,7 @@ class Volume(Box):
def on_speaker_changed(self, *_):
if not self.audio.speaker:
return
vol_high_icon = icons.vol_high
vol_medium_icon = icons.vol_medium
vol_mute_icon = icons.vol_off
@@ -178,7 +185,7 @@ class Volume(Box):
vol_off_icon = icons.bluetooth_disconnected
self.progress_bar.value = self.audio.speaker.volume / 100
if self.audio.speaker.muted:
self.vol_label.set_markup(vol_mute_icon)
self.progress_bar.add_style_class("muted")
@@ -196,13 +203,17 @@ class Volume(Box):
else:
self.vol_label.set_markup(vol_off_icon)
class MicroPhone(Box):
def __init__(self, **kwargs):
super().__init__(name="button-bar-mic", **kwargs)
self.audio = Audio()
self.progress_bar = CircularProgressBar(
name="button-mic", size=28, line_width=2,
start_angle=150, end_angle=390,
name="button-mic",
size=28,
line_width=2,
start_angle=150,
end_angle=390,
)
self.mic_label = Label(name="mic-label", markup=icons.mic)
self.mic_button = Button(on_clicked=self.toggle_mute, child=self.mic_label)
@@ -240,9 +251,8 @@ class MicroPhone(Box):
if not self.audio.microphone:
return
if event.direction == Gdk.ScrollDirection.SMOOTH:
if hasattr(event, 'delta_y') and abs(event.delta_y) > 0:
if hasattr(event, "delta_y") and abs(event.delta_y) > 0:
self.audio.microphone.volume -= event.delta_y
# Remove delta_x handling
elif event.direction == Gdk.ScrollDirection.UP:
self.audio.microphone.volume += 1
elif event.direction == Gdk.ScrollDirection.DOWN:
@@ -251,9 +261,9 @@ class MicroPhone(Box):
def on_microphone_changed(self, *_):
if not self.audio.microphone:
return
self.progress_bar.value = self.audio.microphone.volume / 100
if self.audio.microphone.muted:
self.mic_label.set_markup(icons.mic_mute)
self.progress_bar.add_style_class("muted")
@@ -271,11 +281,12 @@ class MicroPhone(Box):
else:
self.mic_label.set_markup(icons.mic_mute)
class Controls(Box):
def __init__(self, **kwargs):
brightness = BrightnessService.get_initial()
children = []
if (brightness.screen_brightness != -1):
if brightness.screen_brightness != -1:
children.append(Brightness())
children.extend([Volume(), MicroPhone()])
super().__init__(
+6 -21
View File
@@ -1,22 +1,19 @@
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.circularprogressbar import CircularProgressBar
import config.data as data
import utils.icons as icons
from services.network import NetworkClient
from fabric.bluetooth import BluetoothClient
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.circularprogressbar import CircularProgressBar
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from services.network import NetworkClient
class WifiIndicator(Button):
def __init__(self, **kwargs):
super().__init__(name="button-bar-network", **kwargs)
# Initialize NetworkClient
self.network = NetworkClient()
# Create progress bar and wifi label
self.progress_bar = CircularProgressBar(
name="button-network",
size=28,
@@ -26,13 +23,10 @@ class WifiIndicator(Button):
)
self.wifi_label = Label(name="network-label", markup=icons.loader)
# Create overlay with progress bar and label
self.add(Overlay(child=self.progress_bar, overlays=self.wifi_label))
# Connect signals
self.network.connect("ready", self.on_network_ready)
# If already ready, set up signals immediately
if self.network.is_ready:
self.setup_signals()
@@ -40,27 +34,23 @@ class WifiIndicator(Button):
self.setup_signals()
def setup_signals(self):
# Connect to NetworkClient signals
self.network.connect("changed", self.on_network_changed)
self.network.connect("wifi-device-added", self.on_wifi_device_added)
self.network.connect("wifi-device-removed", self.on_wifi_device_removed)
self.network.connect("ethernet-device-added", self.on_ethernet_device_added)
self.network.connect("ethernet-device-removed", self.on_ethernet_device_removed)
# Connect to WiFi device if available
if self.network.wifi_device:
self.network.wifi_device.connect("changed", self.on_wifi_changed)
self.network.wifi_device.connect("ap-added", self.on_wifi_changed)
self.network.wifi_device.connect("ap-removed", self.on_wifi_changed)
# Connect to Ethernet device if available
if self.network.ethernet_device:
self.network.ethernet_device.connect("changed", self.on_ethernet_changed)
self.update_state()
def update_state(self):
# Check ethernet first
if (
self.network.ethernet_device
and self.network.ethernet_device.state == "activated"
@@ -71,7 +61,6 @@ class WifiIndicator(Button):
self.set_tooltip_text(tooltip)
return
# Then check wifi
if not self.network.wifi_device:
self.wifi_label.set_markup(icons.cloud_off)
self.progress_bar.value = 0
@@ -132,19 +121,15 @@ class BluetoothIndicator(Button):
def __init__(self, **kwargs):
super().__init__(name="button-bar-bluetooth", **kwargs)
# Initialize BluetoothClient
self.bluetooth = BluetoothClient()
# Create bluetooth label
self.bt_label = Label(name="bluetooth-label", markup=icons.loader)
self.add(self.bt_label)
# Connect signals
self.bluetooth.connect("changed", self.on_bluetooth_changed)
self.bluetooth.connect("device-added", self.on_device_added)
self.bluetooth.connect("device-removed", self.on_device_removed)
# Initial state update
self.update_state()
def update_state(self):
+91 -63
View File
@@ -1,8 +1,9 @@
import json
import logging
import subprocess
import config.data as data
import psutil
import utils.icons as icons
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.circularprogressbar import CircularProgressBar
@@ -10,16 +11,8 @@ from fabric.widgets.label import Label
from fabric.widgets.revealer import Revealer
from gi.repository import GLib
import config.data as data
import utils.icons as icons
logger = logging.getLogger(__name__)
class MetricsProvider:
"""
Class responsible for obtaining centralized CPU, memory, disk usage, and battery metrics.
It updates periodically so that all widgets querying it display the same values.
"""
def __init__(self):
self.gpu = []
self.cpu = 0.0
@@ -47,13 +40,11 @@ class MetricsProvider:
return True
def _start_gpu_update_async(self):
"""Starts a new GLib thread to run nvtop in the background."""
self._gpu_update_running = True
GLib.Thread.new("nvtop-thread", lambda _: self._run_nvtop_in_thread(), None)
def _run_nvtop_in_thread(self):
"""Runs nvtop via subprocess in a separate GLib thread."""
output = None
error_message = None
try:
@@ -61,34 +52,29 @@ class MetricsProvider:
output = result
except FileNotFoundError:
error_message = "nvtop command not found."
logger.warning(error_message)
except subprocess.CalledProcessError as e:
error_message = f"nvtop failed with exit code {e.returncode}: {e.stderr.strip()}"
logger.error(error_message)
error_message = (
f"nvtop failed with exit code {e.returncode}: {e.stderr.strip()}"
)
except subprocess.TimeoutExpired:
error_message = "nvtop command timed out."
logger.error(error_message)
except Exception as e:
error_message = f"Unexpected error running nvtop: {e}"
logger.error(error_message)
GLib.idle_add(self._process_gpu_output, output, error_message)
self._gpu_update_running = False
def _process_gpu_output(self, output, error_message):
"""Process nvtop JSON output on the main loop."""
try:
if error_message:
logger.error(f"GPU update failed: {error_message}")
self.gpu = []
elif output:
try:
info = json.loads(output)
if not info or not isinstance(info, list):
logger.warning("Invalid nvtop data format")
self.gpu = []
return False
gpu_utils = []
for gpu in info:
if not isinstance(gpu, dict):
@@ -102,19 +88,15 @@ class MetricsProvider:
gpu_utils.append(util_value)
except (ValueError, TypeError):
continue
self.gpu = gpu_utils if gpu_utils else []
except (KeyError, ValueError, TypeError, AttributeError) as e:
logger.error(f"Failed parsing nvtop JSON: {e}")
except (KeyError, ValueError, TypeError, AttributeError):
self.gpu = []
else:
logger.warning("nvtop returned no output.")
self.gpu = []
except json.JSONDecodeError as e:
logger.error(f"JSON decode error: {e}")
except json.JSONDecodeError:
self.gpu = []
except Exception as e:
logger.error(f"Error processing nvtop output: {e}")
except Exception:
self.gpu = []
return False
@@ -130,45 +112,42 @@ class MetricsProvider:
result = subprocess.check_output(["nvtop", "-s"], text=True, timeout=5)
if not result.strip():
return []
info = json.loads(result)
if not info or not isinstance(info, list):
return []
valid_gpus = []
for gpu in info:
if not isinstance(gpu, dict):
continue
gpu_data = {
'device_name': gpu.get('device_name', 'GPU'),
'gpu_util': gpu.get('gpu_util', '0%'),
'mem_util': gpu.get('mem_util', '0%'),
'temperature': gpu.get('temperature', '0 C'),
'power': gpu.get('power', '0 W')
"device_name": gpu.get("device_name", "GPU"),
"gpu_util": gpu.get("gpu_util", "0%"),
"mem_util": gpu.get("mem_util", "0%"),
"temperature": gpu.get("temperature", "0 C"),
"power": gpu.get("power", "0 W"),
}
valid_gpus.append(gpu_data)
return valid_gpus
except FileNotFoundError:
logger.warning("nvtop not found; GPU info unavailable.")
return []
except subprocess.CalledProcessError as e:
logger.error(f"nvtop init sync failed: {e}")
except subprocess.CalledProcessError:
return []
except subprocess.TimeoutExpired:
logger.error("nvtop init call timed out.")
return []
except json.JSONDecodeError as e:
logger.error(f"Init JSON parse error: {e}")
except json.JSONDecodeError:
return []
except Exception as e:
logger.error(f"Unexpected error during GPU init: {e}")
except Exception:
return []
shared_provider = MetricsProvider()
class SingularMetric:
def __init__(self, id, name, icon):
self.name_markup = name
@@ -205,6 +184,7 @@ class SingularMetric:
def markup(self):
return f"{self.icon_markup} {self.name_markup}: {self.level.get_label()}"
class Metrics(Button):
def __init__(self, **kwargs):
super().__init__(name="metrics", **kwargs)
@@ -216,17 +196,53 @@ class Metrics(Button):
all_visible=True,
)
visible = getattr(data, "METRICS_VISIBLE", {'cpu': True, 'ram': True, 'swap': True, 'disk': True, 'gpu': True})
disks = [SingularMetric("disk", f"DISK ({path})" if len(data.METRICS_DISKS) != 1 else "DISK", icons.disk)
for path in data.METRICS_DISKS] if visible.get('disk', True) else []
visible = getattr(
data,
"METRICS_VISIBLE",
{"cpu": True, "ram": True, "swap": True, "disk": True, "gpu": True},
)
disks = (
[
SingularMetric(
"disk",
f"DISK ({path})" if len(data.METRICS_DISKS) != 1 else "DISK",
icons.disk,
)
for path in data.METRICS_DISKS
]
if visible.get("disk", True)
else []
)
gpu_info = shared_provider.get_gpu_info()
gpus = [SingularMetric(f"gpu", f"GPU ({v['device_name']})" if len(gpu_info) != 1 else "GPU", icons.gpu)
for v in gpu_info] if visible.get('gpu', True) else []
gpus = (
[
SingularMetric(
"gpu",
f"GPU ({v['device_name']})" if len(gpu_info) != 1 else "GPU",
icons.gpu,
)
for v in gpu_info
]
if visible.get("gpu", True)
else []
)
self.cpu = SingularMetric("cpu", "CPU", icons.cpu) if visible.get('cpu', True) else None
self.ram = SingularMetric("ram", "RAM", icons.memory) if visible.get('ram', True) else None
self.swap = SingularMetric("swap", "SWAP", icons.swap) if visible.get('swap', True) else None
self.cpu = (
SingularMetric("cpu", "CPU", icons.cpu)
if visible.get("cpu", True)
else None
)
self.ram = (
SingularMetric("ram", "RAM", icons.memory)
if visible.get("ram", True)
else None
)
self.swap = (
SingularMetric("swap", "SWAP", icons.swap)
if visible.get("swap", True)
else None
)
self.disk = disks
self.gpu = gpus
@@ -259,9 +275,12 @@ class Metrics(Button):
def on_click(self, widget):
if not data.VERTICAL:
self.labels_visible = not self.labels_visible
if self.cpu: self.cpu.revealer.set_reveal_child(self.labels_visible)
if self.ram: self.ram.revealer.set_reveal_child(self.labels_visible)
if self.swap: self.swap.revealer.set_reveal_child(self.labels_visible)
if self.cpu:
self.cpu.revealer.set_reveal_child(self.labels_visible)
if self.ram:
self.ram.revealer.set_reveal_child(self.labels_visible)
if self.swap:
self.swap.revealer.set_reveal_child(self.labels_visible)
for disk in self.disk:
disk.revealer.set_reveal_child(self.labels_visible)
for gpu in self.gpu:
@@ -289,11 +308,20 @@ class Metrics(Button):
gpu.level.set_label(self._format_percentage(int(gpus[i])))
tooltip_metrics = []
if self.disk: tooltip_metrics.extend(self.disk)
if self.ram: tooltip_metrics.append(self.ram)
if self.swap: tooltip_metrics.append(self.swap)
if self.cpu: tooltip_metrics.append(self.cpu)
if self.gpu: tooltip_metrics.extend(self.gpu)
self.set_tooltip_markup((" - " if not data.VERTICAL else "\n").join([v.markup() for v in tooltip_metrics]))
if self.disk:
tooltip_metrics.extend(self.disk)
if self.ram:
tooltip_metrics.append(self.ram)
if self.swap:
tooltip_metrics.append(self.swap)
if self.cpu:
tooltip_metrics.append(self.cpu)
if self.gpu:
tooltip_metrics.extend(self.gpu)
self.set_tooltip_markup(
(" - " if not data.VERTICAL else "\n").join(
[v.markup() for v in tooltip_metrics]
)
)
return True
+56 -157
View File
@@ -1,39 +1,34 @@
import os
import tempfile
import urllib.parse
import config.data as data
import requests
import utils.icons as icons
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.eventbox import EventBox
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.eventbox import EventBox
from fabric.widgets.label import Label
from fabric.widgets.stack import Stack
from gi.repository import GLib, Gdk, GdkPixbuf
from gi.repository import Gdk, GdkPixbuf, GLib
from services.mpris import MediaManager
from utils.circle_image import CircleImage
from utils.audio_visualizer import SpectrumRender
import urllib.parse
import os
import requests
import tempfile
from utils.circle_image import CircleImage
class MusicPlayer(EventBox):
def __init__(self, **kwargs):
super().__init__(
name="music-player",
events=["button-press"],
**kwargs
)
super().__init__(name="music-player", events=["button-press"], **kwargs)
orientation = "v" if data.VERTICAL else "h"
# Create main container box
self.main_container = Box(
name="music-player-container",
spacing=4,
orientation=orientation,
)
# Initialize media manager
self._media_manager = MediaManager()
self._media_manager.connect("player-appeared", self._on_player_changed)
self._media_manager.connect("player-vanished", self._on_player_changed)
@@ -41,84 +36,62 @@ class MusicPlayer(EventBox):
self._current_player = None
self._manually_selected_player = None
self._update_timeout_id = None
self._current_player_signals = [] # Track signal connections for current player
self._current_player_signals = []
# Marquee animation variables
self._marquee_timeout_id = None
self._marquee_position = 0
self._original_title = ""
self._title_needs_marquee = False
# Thumbnail spinning animation variables
self._spin_timeout_id = None
self._spin_angle = 0
# Visualizer state
self._show_visualizer = False
# Create music player view with stack for visualizer/text switching
self._create_music_player_view()
# Set the main container as child
self.add(self.main_container)
# Connect click handler for the entire music player
self.connect("button-press-event", self._on_music_player_clicked)
# Hide initially - will show when media players become available
self.set_visible(False)
# Delay initial update to allow MediaManager to initialize properly
GLib.timeout_add(100, self._delayed_initial_update)
def _create_music_player_view(self):
"""Create the music player view with stack for visualizer/text switching like PlayerSmall"""
orientation = "v" if data.VERTICAL else "h"
# Create CircleImage for album art thumbnails (clickable to toggle visualizer)
self.album_thumbnail = CircleImage(
name="music-album-thumbnail",
size=34, # Appropriate size for dock
size=34,
v_align="center",
visible=True, # Always visible, will show default or album art
visible=True,
)
# Make album thumbnail clickable to toggle visualizer
self.album_thumbnail.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
self.album_thumbnail.connect("button-press-event", self._on_thumbnail_clicked)
# Create track info display
self.track_label = Label(
name="music-track",
label="No Media",
ellipsize=0, # No ellipsize for marquee
ellipsize=0,
h_align="center",
)
# Fixed width to prevent box size changes
self.track_label.set_size_request(100, -1)
self.track_label.set_hexpand(False)
self.track_label.set_vexpand(False)
# Create spectrum visualizer lazily to save memory
self.cavalcade = None
self.cavalcade_box = None
# Create stack for crossfade between visualizer and text
self.center_stack = Stack(
name="music-player-stack",
transition_type="crossfade",
transition_duration=100,
v_align="center",
v_expand=False,
children=[self.track_label] # Start with just track label
children=[self.track_label],
)
# Start with track label visible
self.center_stack.set_visible_child(self.track_label)
# Create control buttons
self._create_controls()
# Create main music player layout using CenterBox like PlayerSmall
self.music_player_box = CenterBox(
name="music-player-centerbox",
orientation=orientation,
@@ -131,13 +104,10 @@ class MusicPlayer(EventBox):
end_children=self.controls_box,
)
# Add to main container
self.main_container.add(self.music_player_box)
def _load_album_art(self, art_url):
"""Load album art from URL and set it to the CircleImage"""
# Cache the last loaded URL to avoid reloading the same image
if hasattr(self, '_last_art_url') and self._last_art_url == art_url:
if hasattr(self, "_last_art_url") and self._last_art_url == art_url:
return
if not art_url:
@@ -146,9 +116,8 @@ class MusicPlayer(EventBox):
return
try:
if art_url.startswith('file://'):
# Handle local file URLs
file_path = urllib.parse.unquote(art_url[7:]) # Remove 'file://' prefix
if art_url.startswith("file://"):
file_path = urllib.parse.unquote(art_url[7:])
if os.path.exists(file_path):
self.album_thumbnail.set_image_from_file(file_path)
self._show_album_art()
@@ -156,22 +125,20 @@ class MusicPlayer(EventBox):
else:
self._load_fallback_image()
self._last_art_url = None
elif art_url.startswith(('http://', 'https://')):
# Handle remote URLs by downloading them first
elif art_url.startswith(("http://", "https://")):
response = requests.get(art_url)
if response.status_code == 200:
# Create a temporary file
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
with tempfile.NamedTemporaryFile(
delete=False, suffix=".jpg"
) as temp_file:
temp_file.write(response.content)
temp_path = temp_file.name
# Load the image from the temporary file
pixbuf = GdkPixbuf.Pixbuf.new_from_file(temp_path)
self.album_thumbnail.set_image_from_pixbuf(pixbuf)
self._show_album_art()
self._last_art_url = art_url
# Clean up the temporary file
os.unlink(temp_path)
else:
self._load_fallback_image()
@@ -184,7 +151,6 @@ class MusicPlayer(EventBox):
self._last_art_url = None
def _load_fallback_image(self):
"""Load fallback image from current wallpaper when no album art is available"""
fallback = os.path.expanduser("~/.current.wall")
try:
@@ -197,67 +163,54 @@ class MusicPlayer(EventBox):
self._hide_album_art()
def _show_album_art(self):
"""Show album art thumbnail"""
self.album_thumbnail.set_visible(True)
# Start spinning animation when showing album art
self._start_spin_animation()
def _hide_album_art(self):
"""Hide album art thumbnail"""
self.album_thumbnail.set_visible(False)
# Stop spinning animation when hiding album art
self._stop_spin_animation()
def _start_spin_animation(self):
"""Start the thumbnail spinning animation"""
if not self._spin_timeout_id:
self._spin_timeout_id = GLib.timeout_add(100, self._animate_spin) # 100ms to save CPU
self._spin_timeout_id = GLib.timeout_add(
100, self._animate_spin
) # 100ms to save CPU
def _stop_spin_animation(self):
"""Stop the thumbnail spinning animation"""
if self._spin_timeout_id:
GLib.source_remove(self._spin_timeout_id)
self._spin_timeout_id = None
# Reset angle to 0
self._spin_angle = 0
self.album_thumbnail.angle = 0
def _animate_spin(self):
"""Animate the thumbnail spinning"""
if not self._current_player or not self.album_thumbnail.get_visible():
return False
# Only spin when music is playing
if self._current_player.status == "playing":
self._spin_angle = (self._spin_angle + 5) % 360 # Rotate 2 degrees per frame
self._spin_angle = (self._spin_angle + 5) % 360
self.album_thumbnail.angle = self._spin_angle
return True # Continue animation
return True
def _update_player_indicators(self):
"""Update the player indicators to show all available players"""
# Get all available players
available_players = self._media_manager.players
# Only show indicators if there are multiple players to save memory
if len(available_players) <= 1:
# Clear indicators if only one or no players
for child in self.player_indicators_box.get_children():
self.player_indicators_box.remove(child)
return
# Clear existing indicators
for child in self.player_indicators_box.get_children():
self.player_indicators_box.remove(child)
current_player_name = self._current_player.player_name if self._current_player else "None"
current_player_name = (
self._current_player.player_name if self._current_player else "None"
)
# Create indicators for each player (only when multiple players exist)
for player in available_players:
player_name = player.player_name or "Unknown"
player_icon_markup = self._get_player_icon(player_name)
# Create clickable button with indicator label
indicator_button = Button(
name="music-player-indicator-button",
child=Label(
@@ -267,8 +220,7 @@ class MusicPlayer(EventBox):
can_focus=False,
)
# Set tooltip with player info
is_current_player = (player.player_name == current_player_name)
is_current_player = player.player_name == current_player_name
tooltip_text = f"Player: {player_name}"
if is_current_player:
tooltip_text += " (Active)"
@@ -276,29 +228,26 @@ class MusicPlayer(EventBox):
tooltip_text += " - Click to switch"
indicator_button.set_tooltip_text(tooltip_text)
# Add visual distinction for active player (you can style this with CSS)
if is_current_player:
indicator_button.get_child().set_name("music-player-indicator-active")
else:
indicator_button.get_child().set_name("music-player-indicator")
# Connect click handler with player reference
indicator_button.connect("clicked", self._on_player_indicator_clicked, player)
indicator_button.connect(
"clicked", self._on_player_indicator_clicked, player
)
self.player_indicators_box.add(indicator_button)
# Force refresh the indicators container
self.player_indicators_box.show_all()
def _create_controls(self):
"""Create the media control buttons"""
self.controls_box = Box(
name="music-controls",
orientation="h" if not data.VERTICAL else "v",
spacing=2,
)
# Previous button
self.prev_button = Button(
name="music-prev",
child=Label(markup=icons.prev),
@@ -307,7 +256,6 @@ class MusicPlayer(EventBox):
self.prev_button.connect("clicked", self._on_previous_clicked)
self.prev_button.set_tooltip_text("Previous Track")
# Play/Pause button
self.play_pause_button = Button(
name="music-play-pause",
child=Label(name="play-pause-icon", markup=icons.play),
@@ -316,7 +264,6 @@ class MusicPlayer(EventBox):
self.play_pause_button.connect("clicked", self._on_play_pause_clicked)
self.play_pause_button.set_tooltip_text("Play/Pause")
# Next button
self.next_button = Button(
name="music-next",
child=Label(markup=icons.next),
@@ -325,34 +272,32 @@ class MusicPlayer(EventBox):
self.next_button.connect("clicked", self._on_next_clicked)
self.next_button.set_tooltip_text("Next Track")
# Player indicators container
self.player_indicators_box = Box(
name="music-player-indicators",
orientation="h" if not data.VERTICAL else "v",
spacing=2,
)
# Add buttons and indicators to controls
self.controls_box.add(self.prev_button)
self.controls_box.add(self.play_pause_button)
self.controls_box.add(self.next_button)
self.controls_box.add(self.player_indicators_box)
def _delayed_initial_update(self):
"""Delayed initial update to allow MediaManager to initialize"""
self._update_display()
return False # Don't repeat this timeout
return False
def _connect_player_signals(self, player):
"""Connect to player signals for immediate updates"""
if player:
# Connect to metadata changes for immediate track/thumbnail updates
metadata_signal = player.connect("metadata_changed", self._on_metadata_changed)
status_signal = player.connect("playback_status_changed", self._on_playback_status_changed)
metadata_signal = player.connect(
"metadata_changed", self._on_metadata_changed
)
status_signal = player.connect(
"playback_status_changed", self._on_playback_status_changed
)
self._current_player_signals = [metadata_signal, status_signal]
def _disconnect_player_signals(self):
"""Disconnect from current player signals"""
if self._current_player and self._current_player_signals:
for signal_id in self._current_player_signals:
try:
@@ -362,50 +307,41 @@ class MusicPlayer(EventBox):
self._current_player_signals = []
def _on_metadata_changed(self, *_args):
"""Handle immediate metadata changes (track, artist, album art)"""
GLib.idle_add(self._update_display)
def _on_playback_status_changed(self, *_args):
"""Handle immediate playback status changes"""
GLib.idle_add(self._update_display)
def _on_player_changed(self, *_args):
"""Handle player appearance/disappearance"""
GLib.idle_add(self._update_display)
def _update_display(self):
"""Update the display based on current player state"""
# Store previous player to detect changes
previous_player = self._current_player
# Determine current player (manual selection takes priority)
if self._manually_selected_player:
# Check if manually selected player is still available
manually_selected_available = next(
(p for p in self._media_manager.players
if p.player_name == self._manually_selected_player.player_name),
None
(
p
for p in self._media_manager.players
if p.player_name == self._manually_selected_player.player_name
),
None,
)
if manually_selected_available:
self._current_player = manually_selected_available
self._manually_selected_player = manually_selected_available
else:
# Manual player no longer available, clear selection
self._manually_selected_player = None
self._current_player = self._media_manager.current_player
else:
self._current_player = self._media_manager.current_player
# Connect to new player signals if player changed
if self._current_player != previous_player:
self._disconnect_player_signals()
if self._current_player:
self._connect_player_signals(self._current_player)
if not self._current_player:
# No players available, hide the component
self.set_visible(False)
if self._update_timeout_id:
GLib.source_remove(self._update_timeout_id)
@@ -413,42 +349,33 @@ class MusicPlayer(EventBox):
if self._marquee_timeout_id:
GLib.source_remove(self._marquee_timeout_id)
self._marquee_timeout_id = None
# Stop spinning animation when no player
self._stop_spin_animation()
return False
self.set_visible(True)
# Try to load album art, use fallback if not available
album_art_url = self._current_player.album_image_url
if album_art_url:
self._load_album_art(album_art_url)
else:
# No album art available, use fallback image
self._load_fallback_image()
# Update stack display (text vs visualizer)
self._update_stack_display()
# Update player indicators to show all available players
self._update_player_indicators()
# Update play/pause button
play_pause_icon = self.play_pause_button.get_child()
if self._current_player.status == "playing":
play_pause_icon.set_markup(icons.pause)
else:
play_pause_icon.set_markup(icons.play)
# Update track info with marquee animation
track_title = self._current_player.track_title or "Unknown Track"
max_display_chars = 12
self._original_title = track_title
self._title_needs_marquee = len(track_title) > max_display_chars
# Reset marquee if title changed
if self._original_title != getattr(self, '_last_original_title', ''):
if self._original_title != getattr(self, "_last_original_title", ""):
self._last_original_title = self._original_title
self._marquee_position = 0
self._start_marquee_animation()
@@ -456,12 +383,10 @@ class MusicPlayer(EventBox):
self._update_marquee_text()
self._update_tooltips()
# Update button sensitivity
self.prev_button.set_sensitive(self._current_player.can_go_previous)
self.next_button.set_sensitive(self._current_player.can_go_next)
self.play_pause_button.set_sensitive(self._current_player.can_pause)
# Remove all polling - rely entirely on signals for updates
if self._update_timeout_id:
GLib.source_remove(self._update_timeout_id)
self._update_timeout_id = None
@@ -469,8 +394,6 @@ class MusicPlayer(EventBox):
return True
def _start_marquee_animation(self):
"""Start or restart the marquee animation"""
# Stop existing marquee animation
if self._marquee_timeout_id:
GLib.source_remove(self._marquee_timeout_id)
self._marquee_timeout_id = None
@@ -479,13 +402,12 @@ class MusicPlayer(EventBox):
self._marquee_timeout_id = GLib.timeout_add(250, self._animate_marquee)
def _animate_marquee(self):
"""Animate the marquee scrolling"""
if not self._current_player:
return False
self._marquee_position += 1
title_length = len(self._original_title)
loop_length = title_length + 3 # Add spacing between loops
loop_length = title_length + 3
if self._marquee_position >= loop_length:
self._marquee_position = 0
@@ -494,7 +416,6 @@ class MusicPlayer(EventBox):
return True
def _update_marquee_text(self):
"""Update the displayed text based on marquee position"""
if not self._current_player:
return
@@ -509,7 +430,7 @@ class MusicPlayer(EventBox):
display_title = extended_text[start_pos:end_pos]
else:
part1 = extended_text[start_pos:]
part2 = self._original_title[:end_pos - len(extended_text)]
part2 = self._original_title[: end_pos - len(extended_text)]
display_title = (part1 + part2)[:max_display_chars]
else:
display_title = self._original_title[:max_display_chars]
@@ -517,7 +438,6 @@ class MusicPlayer(EventBox):
self.track_label.set_label(display_title)
def _get_player_icon(self, player_name):
"""Get the appropriate icon for a player based on its name"""
if not player_name:
return icons.disc
@@ -527,102 +447,82 @@ class MusicPlayer(EventBox):
return icons.firefox
elif "spotify" in player_name_lower:
return icons.spotify
elif any(browser in player_name_lower for browser in [
"chromium", "chrome", "edge", "brave", "opera", "vivaldi"
]):
elif any(
browser in player_name_lower
for browser in ["chromium", "chrome", "edge", "brave", "opera", "vivaldi"]
):
return icons.chromium
else:
return icons.disc
def _update_tooltips(self):
"""Update tooltips with full track information"""
if not self._current_player:
return
# Get full track information for tooltip
full_track_title = self._current_player.track_title or "Unknown Track"
track_artist = self._current_player.track_artist or "Unknown Artist"
player_name = self._current_player.player_name or "Media Player"
# Create tooltip text with full information
tooltip_text = f"{full_track_title}\nby {track_artist}\n\nPlayer: {player_name}"
self.track_label.set_tooltip_text(tooltip_text)
def _on_play_pause_clicked(self, _button):
"""Handle play/pause button click"""
if self._current_player:
self._current_player.play_pause()
# No need for manual update - signal will trigger it
def _on_previous_clicked(self, _button):
"""Handle previous button click"""
if self._current_player:
self._current_player.previous()
# No need for manual update - signal will trigger it
def _on_next_clicked(self, _button):
"""Handle next button click"""
if self._current_player:
self._current_player.next()
# No need for manual update - signal will trigger it
def _on_player_indicator_clicked(self, _button, player):
"""Handle player indicator click to switch players"""
if player:
self._manually_selected_player = player
self._update_display()
def _on_thumbnail_clicked(self, _widget, _event):
"""Handle thumbnail click to toggle visualizer"""
if self._current_player:
self._show_visualizer = not self._show_visualizer
self._update_stack_display()
return True # Stop event propagation
return True
def _on_music_player_clicked(self, _widget, _event):
"""Handle music player click to toggle between text and visualizer view"""
if self._current_player:
self._show_visualizer = not self._show_visualizer
self._update_stack_display()
return True
def _create_visualizer_if_needed(self):
"""Create visualizer lazily to save memory"""
if not self.cavalcade:
self.cavalcade = SpectrumRender()
self.cavalcade_box = self.cavalcade.get_spectrum_box()
self.center_stack.add(self.cavalcade_box)
def _destroy_visualizer(self):
"""Destroy visualizer to free memory when not needed"""
if self.cavalcade_box:
self.center_stack.remove(self.cavalcade_box)
self.cavalcade_box = None
if self.cavalcade and hasattr(self.cavalcade, 'cava'):
if self.cavalcade and hasattr(self.cavalcade, "cava"):
self.cavalcade.cava.close()
self.cavalcade = None
def _update_stack_display(self):
"""Update stack to show either text or visualizer"""
if self._show_visualizer and self._current_player:
# Create visualizer only when needed
self._create_visualizer_if_needed()
if self.cavalcade_box:
self.cavalcade_box.set_visible(True)
self.cavalcade_box.show_all()
self.center_stack.set_visible_child(self.cavalcade_box)
else:
# Show text label in stack
self.center_stack.set_visible_child(self.track_label)
# Destroy visualizer to save memory when not in use
self._destroy_visualizer()
def __del__(self):
"""Cleanup when component is destroyed"""
# Disconnect player signals
self._disconnect_player_signals()
# Clean up all timeouts
if self._update_timeout_id:
GLib.source_remove(self._update_timeout_id)
if self._marquee_timeout_id:
@@ -630,5 +530,4 @@ class MusicPlayer(EventBox):
if self._spin_timeout_id:
GLib.source_remove(self._spin_timeout_id)
# Clean up visualizer
self._destroy_visualizer()
-170
View File
@@ -1,170 +0,0 @@
import config.data as data
from gi.repository import Gdk, GdkPixbuf, GLib, Gray, Gtk
from fabric.widgets.box import Box
import logging
import gi
gi.require_version("Gray", "0.1")
logger = logging.getLogger(__name__)
class SystemTray(Box):
def __init__(
self, pixel_size: int = 20, refresh_interval: int = 1, **kwargs
) -> None:
orientation = (
Gtk.Orientation.HORIZONTAL
if not data.VERTICAL
else Gtk.Orientation.VERTICAL
)
super().__init__(name="tray", orientation=orientation, spacing=8, **kwargs)
self.enabled = True
super().set_visible(False)
self.pixel_size = pixel_size
self.refresh_interval = refresh_interval
self.buttons_by_id = {}
self.items_by_id = {}
self.watcher = Gray.Watcher()
self.watcher.connect("item-added", self.on_watcher_item_added)
GLib.timeout_add_seconds(self.refresh_interval, self._refresh_all_items)
def set_visible(self, visible: bool):
self.enabled = visible
self._update_visibility()
def _update_visibility(self):
has = len(self.get_children()) > 0
super().set_visible(self.enabled and has)
def _get_item_pixbuf(self, item: Gray.Item) -> GdkPixbuf.Pixbuf:
try:
pm = Gray.get_pixmap_for_pixmaps(item.get_icon_pixmaps(), self.pixel_size)
if pm:
return pm.as_pixbuf(self.pixel_size, GdkPixbuf.InterpType.HYPER)
name = item.get_icon_name()
theme = Gtk.IconTheme.new()
path = item.get_icon_theme_path()
if path:
theme.prepend_search_path(path)
return theme.load_icon(
name, self.pixel_size, Gtk.IconLookupFlags.FORCE_SIZE
)
except GLib.Error as e:
logger.debug(f"Icon load error {e}")
return Gtk.IconTheme.get_default().load_icon(
"image-missing", self.pixel_size, Gtk.IconLookupFlags.FORCE_SIZE
)
def _refresh_item_ui(self, identifier: str, item: Gray.Item, button: Gtk.Button):
pixbuf = self._get_item_pixbuf(item)
img = button.get_image()
if isinstance(img, Gtk.Image):
img.set_from_pixbuf(pixbuf)
else:
new = Gtk.Image.new_from_pixbuf(pixbuf)
button.set_image(new)
new.show()
tip = None
if hasattr(item, "get_tooltip_text"):
tip = item.get_tooltip_text()
elif hasattr(item, "get_title"):
tip = item.get_title()
if tip:
button.set_tooltip_text(tip)
else:
button.set_has_tooltip(False)
def _refresh_all_items(self) -> bool:
for ident, item in self.items_by_id.items():
btn = self.buttons_by_id.get(ident)
if btn:
self._refresh_item_ui(ident, item, btn)
return True
def on_watcher_item_added(self, _, identifier: str):
item = self.watcher.get_item_for_identifier(identifier)
if not item:
return
if identifier in self.buttons_by_id:
self.buttons_by_id[identifier].destroy()
del self.buttons_by_id[identifier]
del self.items_by_id[identifier]
btn = self.do_bake_item_button(item)
self.buttons_by_id[identifier] = btn
self.items_by_id[identifier] = item
item.connect(
"notify::icon-pixmaps",
lambda itm, pspec: self._refresh_item_ui(identifier, itm, btn),
)
item.connect(
"notify::icon-name",
lambda itm, pspec: self._refresh_item_ui(identifier, itm, btn),
)
try:
item.connect(
"updated", lambda itm: self._refresh_item_ui(identifier, itm, btn)
)
except TypeError:
pass
item.connect(
"removed", lambda itm: self.on_item_instance_removed(identifier, itm)
)
self.add(btn)
btn.show_all()
self._update_visibility()
def do_bake_item_button(self, item: Gray.Item) -> Gtk.Button:
btn = Gtk.Button()
btn.connect("button-press-event", lambda b, e: self.on_button_click(b, item, e))
img = Gtk.Image.new_from_pixbuf(self._get_item_pixbuf(item))
btn.set_image(img)
tip = (
item.get_tooltip_text()
if hasattr(item, "get_tooltip_text")
else getattr(item, "get_title", lambda: None)()
)
if tip:
btn.set_tooltip_text(tip)
return btn
def on_item_instance_removed(self, identifier: str, removed_item: Gray.Item):
if self.items_by_id.get(identifier) is removed_item:
btn = self.buttons_by_id.pop(identifier, None)
self.items_by_id.pop(identifier, None)
if btn:
btn.destroy()
self._update_visibility()
def on_button_click(
self, button: Gtk.Button, item: Gray.Item, event: Gdk.EventButton
):
if event.button == Gdk.BUTTON_PRIMARY:
try:
item.activate(int(event.x_root), int(event.y_root))
except Exception as e:
logger.error(f"Activate error: {e}")
elif event.button == Gdk.BUTTON_SECONDARY:
menu = getattr(item, "get_menu", lambda: None)()
if isinstance(menu, Gtk.Menu):
menu.popup_at_widget(
button, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, event
)
else:
cm = getattr(item, "context_menu", None)
if cm:
try:
cm(int(event.x_root), int(event.y_root))
except Exception as e:
logger.error(f"ContextMenu error: {e}")
+7 -15
View File
@@ -1,21 +1,19 @@
import json
import subprocess
from fabric.hyprland.service import Hyprland
from fabric.hyprland.widgets import Workspaces, WorkspaceButton
from fabric.widgets.button import Button
import config.data as data
from fabric.widgets.box import Box
import config.data as data
from fabric.hyprland.service import Hyprland
from fabric.hyprland.widgets import WorkspaceButton, Workspaces
from fabric.widgets.box import Box
from fabric.widgets.button import Button
CHINESE_NUMERALS = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "〇"]
class Workspace(Button):
def __init__(self):
# Determine if invert style should be applied
should_invert = (
data.DOCK_THEME in ["Dense", "Edge"] or
(data.DOCK_THEME == "Pills" and data.DOCK_POSITION in ["Left", "Right"])
should_invert = data.DOCK_THEME in ["Dense", "Edge"] or (
data.DOCK_THEME == "Pills" and data.DOCK_POSITION in ["Left", "Right"]
)
super().__init__(
@@ -41,7 +39,6 @@ class Workspace(Button):
return json.loads(active.decode("utf-8"))["name"]
def _get_active_workspaces(self):
"""Get list of workspaces that have windows"""
clients_data = self.connection.send_command("j/clients").reply
clients = json.loads(clients_data.decode("utf-8"))
@@ -56,11 +53,9 @@ class Workspace(Button):
)
def _on_click(self, _widget):
"""Handle button click to cycle through workspaces"""
self._cycle_workspace()
def _cycle_workspace(self):
"""Core logic to cycle through workspaces"""
active_workspaces = self._get_active_workspaces()
if not active_workspaces:
@@ -87,7 +82,6 @@ class Workspace(Button):
self.set_label(str(self.activeWorkspace))
# Create workspace components using Workspaces widget
workspaces = Workspaces(
name="workspaces",
invert_scroll=True,
@@ -132,10 +126,8 @@ workspaces_num = Workspaces(
)
# Create the workspace button instance
workspaceactive = Workspace()
# Export the workspace widget based on configuration
workspace = Box(
name="workspaces-container",
children=[