This commit is contained in:
2026-06-03 21:32:45 +02:00
parent f2328ff319
commit 1e869b49c7
126 changed files with 41986 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
"""
Ax-Shell modules package.
Contains UI components and functionality modules.
"""
+627
View File
@@ -0,0 +1,627 @@
import json
import os
from fabric.hyprland.service import HyprlandEvent
from fabric.hyprland.widgets import HyprlandLanguage as Language
from fabric.hyprland.widgets import HyprlandWorkspaces as Workspaces
from fabric.hyprland.widgets import WorkspaceButton, get_hyprland_connection
from fabric.utils.helpers import exec_shell_command_async
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.datetime import DateTime
from fabric.widgets.label import Label
from fabric.widgets.revealer import Revealer
from gi.repository import Gdk, GLib, Gtk
import config.data as data
import modules.icons as icons
from modules.controls import ControlSmall
from modules.dock import Dock
from modules.metrics import Battery, MetricsSmall, NetworkApplet
from modules.systemprofiles import Systemprofiles
from modules.systemtray import SystemTray
from modules.weather import Weather
from widgets.wayland import WaylandWindow as Window
CHINESE_NUMERALS = ["", "", "", "", "", "", "", "", "", ""]
# Tooltips
tooltip_apps = f"""<b><u>Launcher</u></b>
<b>• Apps:</b> Type to search.
<b>• Calculator [Prefix "="]:</b> Solve a math expression.
e.g. "=2+2"
<b>• Converter [Prefix ";"]:</b> Convert between units.
e.g. ";100 USD to EUR", ";10 km to miles"
<b>• Special Commands [Prefix ":"]:</b>
:update - Open {data.APP_NAME_CAP}'s updater.
:d - Open Dashboard.
:w - Open Wallpapers."""
tooltip_power = """<b>Power Menu</b>"""
tooltip_tools = """<b>Toolbox</b>"""
tooltip_overview = """<b>Overview</b>"""
class Bar(Window):
def __init__(self, monitor_id: int = 0, **kwargs):
self.monitor_id = monitor_id
super().__init__(
name="bar",
layer="top",
exclusivity="auto",
visible=True,
all_visible=True,
monitor=monitor_id,
)
self.anchor_var = ""
self.margin_var = ""
match data.BAR_POSITION:
case "Top":
self.anchor_var = "left top right"
case "Bottom":
self.anchor_var = "left bottom right"
case "Left":
self.anchor_var = "left" if data.CENTERED_BAR else "left top bottom"
case "Right":
self.anchor_var = "right" if data.CENTERED_BAR else "top right bottom"
case _:
self.anchor_var = "left top right"
if data.VERTICAL:
match data.BAR_THEME:
case "Edge":
self.margin_var = "-8px -8px -8px -8px"
case _:
self.margin_var = "-4px -8px -4px -4px"
else:
match data.BAR_THEME:
case "Edge":
self.margin_var = "-8px -8px -8px -8px"
case _:
if data.BAR_POSITION == "Bottom":
self.margin_var = "-8px -4px -4px -4px"
else:
self.margin_var = "-4px -4px -8px -4px"
self.set_anchor(self.anchor_var)
self.set_margin(self.margin_var)
self.notch = kwargs.get("notch", None)
self.component_visibility = data.BAR_COMPONENTS_VISIBILITY
self.dock_instance = None
self.integrated_dock_widget = None
# Calculate workspace range based on monitor_id
# Monitor 0: workspaces 1-10, Monitor 1: workspaces 11-20, etc.
start_workspace = self.monitor_id * 10 + 1
end_workspace = start_workspace + 10
workspace_range = range(start_workspace, end_workspace)
self.workspaces = Workspaces(
name="workspaces",
invert_scroll=True,
empty_scroll=True,
v_align="fill",
orientation="h" if not data.VERTICAL else "v",
spacing=8,
buttons=[
WorkspaceButton(
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
id=i,
label=None,
style_classes=["vertical"] if data.VERTICAL else None,
)
for i in workspace_range
],
buttons_factory=(
None
if data.BAR_HIDE_SPECIAL_WORKSPACE
else Workspaces.default_buttons_factory
),
)
self.workspaces_num = Workspaces(
name="workspaces-num",
invert_scroll=True,
empty_scroll=True,
v_align="fill",
orientation="h" if not data.VERTICAL else "v",
spacing=0 if not data.BAR_WORKSPACE_USE_CHINESE_NUMERALS else 4,
buttons=[
WorkspaceButton(
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
id=i,
label=(
CHINESE_NUMERALS[(i - start_workspace)]
if data.BAR_WORKSPACE_USE_CHINESE_NUMERALS
and 0 <= (i - start_workspace) < len(CHINESE_NUMERALS)
else str(i)
),
)
for i in workspace_range
],
buttons_factory=(
None
if data.BAR_HIDE_SPECIAL_WORKSPACE
else Workspaces.default_buttons_factory
),
)
self.ws_container = Box(
name="workspaces-container",
children=(
self.workspaces
if not data.BAR_WORKSPACE_SHOW_NUMBER
else self.workspaces_num
),
)
self.button_tools = Button(
name="button-bar",
tooltip_markup=tooltip_tools,
on_clicked=lambda *_: self.tools_menu(),
child=Label(name="button-bar-label", markup=icons.toolbox),
)
self.connection = get_hyprland_connection()
self.button_tools.connect("enter_notify_event", self.on_button_enter)
self.button_tools.connect("leave_notify_event", self.on_button_leave)
self.systray = SystemTray()
self.weather = Weather()
self.sysprofiles = Systemprofiles()
self.network = NetworkApplet()
self.lang_label = Label(name="lang-label")
self.language = Button(
name="language", h_align="center", v_align="center", child=self.lang_label
)
self.on_language_switch()
self.connection.connect("event::activelayout", self.on_language_switch)
# Determine date-time format based on the new setting
if data.DATETIME_12H_FORMAT:
time_format_horizontal = "%I:%M %p"
time_format_vertical = "%I\n%M\n%p"
else:
time_format_horizontal = "%H:%M"
time_format_vertical = "%H\n%M"
self.date_time = DateTime(
name="date-time",
formatters=(
[time_format_horizontal]
if not data.VERTICAL
else [time_format_vertical]
),
h_align="center" if not data.VERTICAL else "fill",
v_align="center",
h_expand=True,
v_expand=True,
style_classes=["vertical"] if data.VERTICAL else [],
)
self.button_apps = Button(
name="button-bar",
tooltip_markup=tooltip_apps,
on_clicked=lambda *_: self.search_apps(),
child=Label(name="button-bar-label", markup=icons.apps),
)
self.button_apps.connect("enter_notify_event", self.on_button_enter)
self.button_apps.connect("leave_notify_event", self.on_button_leave)
self.button_power = Button(
name="button-bar",
tooltip_markup=tooltip_power,
on_clicked=lambda *_: self.power_menu(),
child=Label(name="button-bar-label", markup=icons.shutdown),
)
self.button_power.connect("enter_notify_event", self.on_button_enter)
self.button_power.connect("leave_notify_event", self.on_button_leave)
self.button_overview = Button(
name="button-bar",
tooltip_markup=tooltip_overview,
on_clicked=lambda *_: self.overview(),
child=Label(name="button-bar-label", markup=icons.windows),
)
self.button_overview.connect("enter_notify_event", self.on_button_enter)
self.button_overview.connect("leave_notify_event", self.on_button_leave)
self.control = ControlSmall()
self.metrics = MetricsSmall()
self.battery = Battery()
self.apply_component_props()
self.rev_right = [
self.metrics,
self.control,
]
self.revealer_right = Revealer(
name="bar-revealer",
transition_type="slide-left",
child_revealed=True,
child=Box(
name="bar-revealer-box",
orientation="h",
spacing=4,
children=self.rev_right if not data.VERTICAL else None,
),
)
self.boxed_revealer_right = Box(
name="boxed-revealer",
children=[
self.revealer_right,
],
)
self.rev_left = [
self.weather,
self.sysprofiles,
self.network,
]
self.revealer_left = Revealer(
name="bar-revealer",
transition_type="slide-right",
child_revealed=True,
child=Box(
name="bar-revealer-box",
orientation="h",
spacing=4,
children=self.rev_left if not data.VERTICAL else None,
),
)
self.boxed_revealer_left = Box(
name="boxed-revealer",
children=[
self.revealer_left,
],
)
self.h_start_children = [
self.button_apps,
self.ws_container,
self.button_overview,
self.boxed_revealer_left,
]
self.h_end_children = [
self.boxed_revealer_right,
self.battery,
self.systray,
self.button_tools,
self.language,
self.date_time,
self.button_power,
]
self.v_start_children = [
self.button_apps,
self.systray,
self.control,
self.sysprofiles,
self.network,
self.button_tools,
]
self.v_center_children = [
self.button_overview,
self.ws_container,
self.weather,
]
self.v_end_children = [
self.battery,
self.metrics,
self.language,
self.date_time,
self.button_power,
]
self.v_all_children = []
self.v_all_children.extend(self.v_start_children)
self.v_all_children.extend(self.v_center_children)
self.v_all_children.extend(self.v_end_children)
# Create embedded dock when bar is in center position (regardless of DOCK_ENABLED setting)
should_embed_dock = (
data.BAR_POSITION == "Bottom"
or (data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Top", "Bottom"])
)
if should_embed_dock:
if not data.VERTICAL:
self.dock_instance = Dock(integrated_mode=True)
self.integrated_dock_widget = self.dock_instance.wrapper
is_centered_bar = data.VERTICAL and getattr(data, "CENTERED_BAR", False)
bar_center_actual_children = None
if self.integrated_dock_widget is not None:
bar_center_actual_children = self.integrated_dock_widget
elif data.VERTICAL:
bar_center_actual_children = Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=4,
children=(
self.v_all_children if is_centered_bar else self.v_center_children
),
)
self.bar_inner = CenterBox(
name="bar-inner",
orientation=(
Gtk.Orientation.HORIZONTAL
if not data.VERTICAL
else Gtk.Orientation.VERTICAL
),
h_align="fill",
v_align="fill",
start_children=(
None
if is_centered_bar
else Box(
name="start-container",
spacing=4,
orientation=(
Gtk.Orientation.HORIZONTAL
if not data.VERTICAL
else Gtk.Orientation.VERTICAL
),
children=(
self.h_start_children
if not data.VERTICAL
else self.v_start_children
),
)
),
center_children=bar_center_actual_children,
end_children=(
None
if is_centered_bar
else Box(
name="end-container",
spacing=4,
orientation=(
Gtk.Orientation.HORIZONTAL
if not data.VERTICAL
else Gtk.Orientation.VERTICAL
),
children=(
self.h_end_children
if not data.VERTICAL
else self.v_end_children
),
)
),
)
self.children = self.bar_inner
self.hidden = False
self.themed_children = [
self.button_apps,
self.button_overview,
self.button_power,
self.button_tools,
self.language,
self.date_time,
self.ws_container,
self.weather,
self.network,
self.battery,
self.metrics,
self.systray,
self.control,
]
if self.integrated_dock_widget:
self.themed_children.append(self.integrated_dock_widget)
current_theme = data.BAR_THEME
theme_classes = ["pills", "dense", "edge", "edgecenter"]
for tc in theme_classes:
self.bar_inner.remove_style_class(tc)
self.style = None
match current_theme:
case "Pills":
self.style = "pills"
case "Dense":
self.style = "dense"
case "Edge":
if data.VERTICAL and data.CENTERED_BAR:
self.style = "edgecenter"
else:
self.style = "edge"
case _:
self.style = "pills"
self.bar_inner.add_style_class(self.style)
if self.integrated_dock_widget and hasattr(
self.integrated_dock_widget, "add_style_class"
):
for theme_class_to_remove in ["pills", "dense", "edge"]:
style_context = self.integrated_dock_widget.get_style_context()
if style_context.has_class(theme_class_to_remove):
self.integrated_dock_widget.remove_style_class(
theme_class_to_remove
)
self.integrated_dock_widget.add_style_class(self.style)
if data.BAR_THEME == "Dense" or data.BAR_THEME == "Edge":
for child in self.themed_children:
if hasattr(child, "add_style_class"):
child.add_style_class("invert")
match data.BAR_POSITION:
case "Top":
self.bar_inner.add_style_class("top")
case "Bottom":
self.bar_inner.add_style_class("bottom")
case "Left":
self.bar_inner.add_style_class("left")
case "Right":
self.bar_inner.add_style_class("right")
case _:
self.bar_inner.add_style_class("top")
if data.VERTICAL:
self.bar_inner.add_style_class("vertical")
self.systray._update_visibility()
self.chinese_numbers()
def apply_component_props(self):
components = {
"button_apps": self.button_apps,
"systray": self.systray,
"control": self.control,
"network": self.network,
"button_tools": self.button_tools,
"button_overview": self.button_overview,
"ws_container": self.ws_container,
"weather": self.weather,
"battery": self.battery,
"metrics": self.metrics,
"language": self.language,
"date_time": self.date_time,
"button_power": self.button_power,
"sysprofiles": self.sysprofiles,
}
for component_name, widget in components.items():
if component_name in self.component_visibility:
widget.set_visible(self.component_visibility[component_name])
def toggle_component_visibility(self, component_name):
components = {
"button_apps": self.button_apps,
"systray": self.systray,
"control": self.control,
"network": self.network,
"button_tools": self.button_tools,
"button_overview": self.button_overview,
"ws_container": self.ws_container,
"weather": self.weather,
"battery": self.battery,
"metrics": self.metrics,
"language": self.language,
"date_time": self.date_time,
"button_power": self.button_power,
"sysprofiles": self.sysprofiles,
}
if component_name in components and component_name in self.component_visibility:
self.component_visibility[component_name] = not self.component_visibility[
component_name
]
components[component_name].set_visible(
self.component_visibility[component_name]
)
config_file = os.path.expanduser(
f"~/.config/{data.APP_NAME}/config/config.json"
)
if os.path.exists(config_file):
try:
with open(config_file, "r") as f:
config = json.load(f)
config[f"bar_{component_name}_visible"] = self.component_visibility[
component_name
]
with open(config_file, "w") as f:
json.dump(config, f, indent=4)
except Exception as e:
print(f"Error updating config file: {e}")
return self.component_visibility[component_name]
return None
def on_button_enter(self, widget, event):
window = widget.get_window()
if window:
window.set_cursor(Gdk.Cursor.new_from_name(widget.get_display(), "hand2"))
def on_button_leave(self, widget, event):
window = widget.get_window()
if window:
window.set_cursor(None)
def on_button_clicked(self, *args):
exec_shell_command_async("notify-send 'Botón presionado' '¡Funciona!'")
def search_apps(self):
if self.notch:
self.notch.open_notch("launcher")
def overview(self):
if self.notch:
self.notch.open_notch("overview")
def power_menu(self):
if self.notch:
self.notch.open_notch("power")
def tools_menu(self):
if self.notch:
self.notch.open_notch("tools")
def on_language_switch(self, _=None, event: HyprlandEvent = None):
try:
lang_data = (
event.data[1]
if event and event.data and len(event.data) > 1
else Language().get_label()
)
except json.JSONDecodeError:
lang_data = "UNK" # Fallback to default language label
self.language.set_tooltip_text(lang_data)
if not data.VERTICAL:
self.lang_label.set_label(lang_data[:3].upper())
else:
self.lang_label.add_style_class("icon")
self.lang_label.set_markup(icons.keyboard)
def toggle_hidden(self):
self.hidden = not self.hidden
if self.hidden:
self.bar_inner.add_style_class("hidden")
else:
self.bar_inner.remove_style_class("hidden")
# Ensure notch is above bar when bar is shown
if self.notch:
# Focus the notch window to bring it to front
GLib.idle_add(lambda: exec_shell_command_async("hyprctl dispatch focuswindow class:notch") if self.notch else None)
def chinese_numbers(self):
if data.BAR_WORKSPACE_USE_CHINESE_NUMERALS:
self.workspaces_num.add_style_class("chinese")
else:
self.workspaces_num.remove_style_class("chinese")
+160
View File
@@ -0,0 +1,160 @@
from fabric.bluetooth import BluetoothClient, BluetoothDevice
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
import modules.icons as icons
class BluetoothDeviceSlot(CenterBox):
def __init__(self, device: BluetoothDevice, **kwargs):
super().__init__(name="bluetooth-device", **kwargs)
self.device = device
self.device.connect("changed", self.on_changed)
self.device.connect(
"notify::closed", lambda *_: self.device.closed and self.destroy()
)
self.connection_label = Label(name="bluetooth-connection", markup=icons.bluetooth_disconnected)
self.connect_button = Button(
name="bluetooth-connect",
label="Connect",
on_clicked=lambda *_: self.device.set_connecting(not self.device.connected),
style_classes=["connected"] if self.device.connected else None,
)
self.start_children = [
Box(
spacing=8,
h_expand=True,
h_align="fill",
children=[
Image(icon_name=device.icon_name + "-symbolic", size=16),
Label(label=device.name, h_expand=True, h_align="start", ellipsization="end"),
self.connection_label,
],
)
]
self.end_children = self.connect_button
self.device.emit("changed")
def on_changed(self, *_):
self.connection_label.set_markup(
icons.bluetooth_connected if self.device.connected else icons.bluetooth_disconnected
)
if self.device.connecting:
self.connect_button.set_label(
"Connecting..." if not self.device.connecting else "..."
)
else:
self.connect_button.set_label(
"Connect" if not self.device.connected else "Disconnect"
)
if self.device.connected:
self.connect_button.add_style_class("connected")
else:
self.connect_button.remove_style_class("connected")
return
class BluetoothConnections(Box):
def __init__(self, **kwargs):
super().__init__(
name="bluetooth",
spacing=4,
orientation="vertical",
**kwargs,
)
self.widgets = kwargs["widgets"]
self.buttons = self.widgets.buttons.bluetooth_button
self.bt_status_text = self.buttons.bluetooth_status_text
self.bt_status_button = self.buttons.bluetooth_status_button
self.bt_icon = self.buttons.bluetooth_icon
self.bt_label = self.buttons.bluetooth_label
self.bt_menu_button = self.buttons.bluetooth_menu_button
self.bt_menu_label = self.buttons.bluetooth_menu_label
self.client = BluetoothClient(on_device_added=self.on_device_added)
self.scan_label = Label(name="bluetooth-scan-label", markup=icons.radar)
self.scan_button = Button(
name="bluetooth-scan",
child=self.scan_label,
tooltip_text="Scan for Bluetooth devices",
on_clicked=lambda *_: self.client.toggle_scan()
)
self.back_button = Button(
name="bluetooth-back",
child=Label(name="bluetooth-back-label", markup=icons.chevron_left),
on_clicked=lambda *_: self.widgets.show_notif()
)
self.client.connect("notify::enabled", lambda *_: self.status_label())
self.client.connect(
"notify::scanning",
lambda *_: self.update_scan_label()
)
self.paired_box = Box(spacing=2, orientation="vertical")
self.available_box = Box(spacing=2, orientation="vertical")
content_box = Box(spacing=4, orientation="vertical")
content_box.add(self.paired_box)
content_box.add(Label(name="bluetooth-section", label="Available"))
content_box.add(self.available_box)
self.children = [
CenterBox(
name="bluetooth-header",
start_children=self.back_button,
center_children=Label(name="bluetooth-text", label="Bluetooth Devices"),
end_children=self.scan_button
),
ScrolledWindow(
name="bluetooth-devices",
min_content_size=(-1, -1),
child=content_box,
v_expand=True,
propagate_width=False,
propagate_height=False,
),
]
self.client.notify("scanning")
self.client.notify("enabled")
def status_label(self):
print(self.client.enabled)
if self.client.enabled:
self.bt_status_text.set_label("Enabled")
for i in [self.bt_status_button, self.bt_status_text, self.bt_icon, self.bt_label, self.bt_menu_button, self.bt_menu_label]:
i.remove_style_class("disabled")
self.bt_icon.set_markup(icons.bluetooth)
else:
self.bt_status_text.set_label("Disabled")
for i in [self.bt_status_button, self.bt_status_text, self.bt_icon, self.bt_label, self.bt_menu_button, self.bt_menu_label]:
i.add_style_class("disabled")
self.bt_icon.set_markup(icons.bluetooth_off)
def on_device_added(self, client: BluetoothClient, address: str):
if not (device := client.get_device(address)):
return
slot = BluetoothDeviceSlot(device)
if device.paired:
return self.paired_box.add(slot)
return self.available_box.add(slot)
def update_scan_label(self):
if self.client.scanning:
self.scan_label.add_style_class("scanning")
self.scan_button.add_style_class("scanning")
self.scan_button.set_tooltip_text("Stop scanning for Bluetooth devices")
else:
self.scan_label.remove_style_class("scanning")
self.scan_button.remove_style_class("scanning")
self.scan_button.set_tooltip_text("Scan for Bluetooth devices")
+472
View File
@@ -0,0 +1,472 @@
import subprocess
import gi
from fabric.utils.helpers import exec_shell_command_async
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from gi.repository import Gdk, GLib, Gtk
import config.data as data
gi.require_version('Gtk', '3.0')
import modules.icons as icons
from services.network import NetworkClient
def add_hover_cursor(widget):
widget.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK)
widget.connect("enter-notify-event", lambda w, e: w.get_window().set_cursor(Gdk.Cursor.new_from_name(w.get_display(), "pointer")) if w.get_window() else None)
widget.connect("leave-notify-event", lambda w, e: w.get_window().set_cursor(None) if w.get_window() else None)
class NetworkButton(Box):
def __init__(self, **kwargs):
self.widgets_instance = kwargs.pop("widgets")
self.network_client = NetworkClient()
self._animation_timeout_id = None
self._animation_step = 0
self._animation_direction = 1
self.network_icon = Label(
name="network-icon",
markup=None,
)
self.network_label = Label(
name="network-label",
label="Wi-Fi",
justification="left",
)
self.network_label_box = Box(children=[self.network_label, Box(h_expand=True)])
self.network_ssid = Label(
name="network-ssid",
justification="left",
)
self.network_ssid_box = Box(children=[self.network_ssid, Box(h_expand=True)])
self.network_text = Box(
name="network-text",
orientation="v",
h_align="start",
v_align="center",
children=[self.network_label_box, self.network_ssid_box],
)
self.network_status_box = Box(
h_align="start",
v_align="center",
spacing=10,
children=[self.network_icon, self.network_text],
)
self.network_status_button = Button(
name="network-status-button",
h_expand=True,
child=self.network_status_box,
on_clicked=lambda *_: self.network_client.wifi_device.toggle_wifi() if self.network_client.wifi_device else None,
)
add_hover_cursor(self.network_status_button)
self.network_menu_label = Label(
name="network-menu-label",
markup=icons.chevron_right,
)
self.network_menu_button = Button(
name="network-menu-button",
child=self.network_menu_label,
on_clicked=lambda *_: self.widgets_instance.show_network_applet(),
)
add_hover_cursor(self.network_menu_button)
super().__init__(
name="network-button",
orientation="h",
h_align="fill",
v_align="fill",
h_expand=True,
v_expand=True,
spacing=0,
children=[self.network_status_button, self.network_menu_button],
)
self.widgets_list_internal = [self, self.network_icon, self.network_label,
self.network_ssid, self.network_status_button,
self.network_menu_button, self.network_menu_label]
self.network_client.connect('device-ready', self._on_wifi_ready)
GLib.idle_add(self._initial_update)
def _initial_update(self):
self.update_state()
return False
def _on_wifi_ready(self, *args):
if self.network_client.wifi_device:
self.network_client.wifi_device.connect('notify::enabled', self.update_state)
self.network_client.wifi_device.connect('notify::ssid', self.update_state)
self.update_state()
def _animate_searching(self):
"""Animate wifi icon when searching for networks"""
wifi_icons = [icons.wifi_0, icons.wifi_1, icons.wifi_2, icons.wifi_3, icons.wifi_2, icons.wifi_1]
wifi = self.network_client.wifi_device
if not self.network_icon or not wifi or not wifi.enabled:
self._stop_animation()
return False
if wifi.state == "activated" and wifi.ssid != "Disconnected":
self._stop_animation()
return False
GLib.idle_add(self.network_icon.set_markup, wifi_icons[self._animation_step])
self._animation_step = (self._animation_step + 1) % len(wifi_icons)
return True
def _start_animation(self):
if self._animation_timeout_id is None:
self._animation_step = 0
self._animation_direction = 1
self._animation_timeout_id = GLib.timeout_add(500, self._animate_searching)
def _stop_animation(self):
if self._animation_timeout_id is not None:
GLib.source_remove(self._animation_timeout_id)
self._animation_timeout_id = None
def update_state(self, *args):
"""Update the button state based on network status"""
wifi = self.network_client.wifi_device
ethernet = self.network_client.ethernet_device
if wifi and not wifi.enabled:
self._stop_animation()
self.network_icon.set_markup(icons.wifi_off)
for widget in self.widgets_list_internal:
widget.add_style_class("disabled")
self.network_ssid.set_label("Disabled")
return
for widget in self.widgets_list_internal:
widget.remove_style_class("disabled")
if wifi and wifi.enabled:
if wifi.state == "activated" and wifi.ssid != "Disconnected":
self._stop_animation()
self.network_ssid.set_label(wifi.ssid)
if wifi.strength > 0:
strength = wifi.strength
if strength < 25:
self.network_icon.set_markup(icons.wifi_0)
elif strength < 50:
self.network_icon.set_markup(icons.wifi_1)
elif strength < 75:
self.network_icon.set_markup(icons.wifi_2)
else:
self.network_icon.set_markup(icons.wifi_3)
else:
self.network_ssid.set_label("Enabled")
self._start_animation()
try:
primary_device = self.network_client.primary_device
except AttributeError:
primary_device = "wireless"
if primary_device == "wired":
self._stop_animation()
if ethernet and ethernet.internet == "activated":
self.network_icon.set_markup(icons.world)
else:
self.network_icon.set_markup(icons.world_off)
else:
if not wifi:
self._stop_animation()
self.network_icon.set_markup(icons.wifi_off)
elif wifi.state == "activated" and wifi.ssid != "Disconnected" and wifi.strength > 0:
self._stop_animation()
strength = wifi.strength
if strength < 25:
self.network_icon.set_markup(icons.wifi_0)
elif strength < 50:
self.network_icon.set_markup(icons.wifi_1)
elif strength < 75:
self.network_icon.set_markup(icons.wifi_2)
else:
self.network_icon.set_markup(icons.wifi_3)
else:
self._start_animation()
class BluetoothButton(Box):
def __init__(self, **kwargs):
super().__init__(
name="bluetooth-button",
orientation="h",
h_align="fill",
v_align="fill",
h_expand=True,
v_expand=True,
)
self.widgets = kwargs["widgets"]
self.bluetooth_icon = Label(
name="bluetooth-icon",
markup=icons.bluetooth,
)
self.bluetooth_label = Label(
name="bluetooth-label",
label="Bluetooth",
justification="left",
)
self.bluetooth_label_box = Box(children=[self.bluetooth_label, Box(h_expand=True)])
self.bluetooth_status_text = Label(
name="bluetooth-status",
label="Disabled",
justification="left",
)
self.bluetooth_status_box = Box(children=[self.bluetooth_status_text, Box(h_expand=True)])
self.bluetooth_text = Box(
orientation="v",
h_align="start",
v_align="center",
children=[self.bluetooth_label_box, self.bluetooth_status_box],
)
self.bluetooth_status_container = Box(
h_align="start",
v_align="center",
spacing=10,
children=[self.bluetooth_icon, self.bluetooth_text],
)
self.bluetooth_status_button = Button(
name="bluetooth-status-button",
h_expand=True,
child=self.bluetooth_status_container,
on_clicked=lambda *_: self.widgets.bluetooth.client.toggle_power(),
)
add_hover_cursor(self.bluetooth_status_button)
self.bluetooth_menu_label = Label(
name="bluetooth-menu-label",
markup=icons.chevron_right,
)
self.bluetooth_menu_button = Button(
name="bluetooth-menu-button",
on_clicked=lambda *_: self.widgets.show_bt(),
child=self.bluetooth_menu_label,
)
add_hover_cursor(self.bluetooth_menu_button)
self.add(self.bluetooth_status_button)
self.add(self.bluetooth_menu_button)
class NightModeButton(Button):
def __init__(self):
self.night_mode_icon = Label(
name="night-mode-icon",
markup=icons.night,
)
self.night_mode_label = Label(
name="night-mode-label",
label="Night Mode",
justification="left",
)
self.night_mode_label_box = Box(children=[self.night_mode_label, Box(h_expand=True)])
self.night_mode_status = Label(
name="night-mode-status",
label="Enabled",
justification="left",
)
self.night_mode_status_box = Box(children=[self.night_mode_status, Box(h_expand=True)])
self.night_mode_text = Box(
name="night-mode-text",
orientation="v",
h_align="start",
v_align="center",
children=[self.night_mode_label_box, self.night_mode_status_box],
)
self.night_mode_box = Box(
h_align="start",
v_align="center",
spacing=10,
children=[self.night_mode_icon, self.night_mode_text],
)
super().__init__(
name="night-mode-button",
h_expand=True,
child=self.night_mode_box,
on_clicked=self.toggle_hyprsunset,
)
add_hover_cursor(self)
self.widgets = [self, self.night_mode_label, self.night_mode_status, self.night_mode_icon]
self.check_hyprsunset()
def toggle_hyprsunset(self, *args):
"""
Toggle the 'hyprsunset' process:
- If running, kill it and mark as 'Disabled'.
- If not running, start it and mark as 'Enabled'.
"""
GLib.Thread.new("hyprsunset-toggle", self._toggle_hyprsunset_thread, None)
def _toggle_hyprsunset_thread(self, user_data):
"""Background thread to check and toggle hyprsunset without blocking UI."""
try:
subprocess.check_output(["pgrep", "hyprsunset"])
exec_shell_command_async("pkill hyprsunset")
GLib.idle_add(self.night_mode_status.set_label, "Disabled")
GLib.idle_add(self._add_disabled_style)
except subprocess.CalledProcessError:
exec_shell_command_async("hyprsunset -t 3500")
GLib.idle_add(self.night_mode_status.set_label, "Enabled")
GLib.idle_add(self._remove_disabled_style)
def _add_disabled_style(self):
"""Helper to add disabled style to all widgets."""
for widget in self.widgets:
widget.add_style_class("disabled")
def _remove_disabled_style(self):
"""Helper to remove disabled style from all widgets."""
for widget in self.widgets:
widget.remove_style_class("disabled")
def check_hyprsunset(self, *args):
"""
Update the button state based on whether hyprsunset is running.
"""
GLib.Thread.new("hyprsunset-check", self._check_hyprsunset_thread, None)
def _check_hyprsunset_thread(self, user_data):
"""Background thread to check hyprsunset status without blocking UI."""
try:
subprocess.check_output(["pgrep", "hyprsunset"])
GLib.idle_add(self.night_mode_status.set_label, "Enabled")
GLib.idle_add(self._remove_disabled_style)
except subprocess.CalledProcessError:
GLib.idle_add(self.night_mode_status.set_label, "Disabled")
GLib.idle_add(self._add_disabled_style)
class CaffeineButton(Button):
def __init__(self):
self.caffeine_icon = Label(
name="caffeine-icon",
markup=icons.coffee,
)
self.caffeine_label = Label(
name="caffeine-label",
label="Caffeine",
justification="left",
)
self.caffeine_label_box = Box(children=[self.caffeine_label, Box(h_expand=True)])
self.caffeine_status = Label(
name="caffeine-status",
label="Enabled",
justification="left",
)
self.caffeine_status_box = Box(children=[self.caffeine_status, Box(h_expand=True)])
self.caffeine_text = Box(
name="caffeine-text",
orientation="v",
h_align="start",
v_align="center",
children=[self.caffeine_label_box, self.caffeine_status_box],
)
self.caffeine_box = Box(
h_align="start",
v_align="center",
spacing=10,
children=[self.caffeine_icon, self.caffeine_text],
)
super().__init__(
name="caffeine-button",
h_expand=True,
child=self.caffeine_box,
on_clicked=self.toggle_inhibit,
)
add_hover_cursor(self)
self.widgets = [self, self.caffeine_label, self.caffeine_status, self.caffeine_icon]
self.check_inhibit()
def toggle_inhibit(self, *args, external=False):
"""
Toggle the 'ax-inhibit' process:
- If running, kill it and mark as 'Disabled' (add 'disabled' class).
- If not running, start it and mark as 'Enabled' (remove 'disabled' class).
"""
GLib.Thread.new("caffeine-toggle", self._toggle_inhibit_thread, external)
def _toggle_inhibit_thread(self, external):
"""Background thread to toggle inhibit without blocking UI."""
try:
subprocess.check_output(["pgrep", "ax-inhibit"])
exec_shell_command_async("pkill ax-inhibit")
GLib.idle_add(self.caffeine_status.set_label, "Disabled")
GLib.idle_add(self._add_disabled_style)
except subprocess.CalledProcessError:
exec_shell_command_async(f"python {data.HOME_DIR}/.config/{data.APP_NAME_CAP}/scripts/inhibit.py")
GLib.idle_add(self.caffeine_status.set_label, "Enabled")
GLib.idle_add(self._remove_disabled_style)
if external:
# Different if enabled or disabled
status = "Disabled" if self.caffeine_status.get_label() == "Disabled" else "Enabled"
message = "Disabled 💤" if status == "Disabled" else "Enabled ☀️"
exec_shell_command_async(f"notify-send '☕ Caffeine' '{message}' -a '{data.APP_NAME_CAP}' -e")
def _add_disabled_style(self):
"""Helper to add disabled style to all widgets."""
for widget in self.widgets:
widget.add_style_class("disabled")
def _remove_disabled_style(self):
"""Helper to remove disabled style from all widgets."""
for widget in self.widgets:
widget.remove_style_class("disabled")
def check_inhibit(self, *args):
GLib.Thread.new("caffeine-check", self._check_inhibit_thread, None)
def _check_inhibit_thread(self, user_data):
"""Background thread to check inhibit status without blocking UI."""
try:
subprocess.check_output(["pgrep", "ax-inhibit"])
GLib.idle_add(self.caffeine_status.set_label, "Enabled")
GLib.idle_add(self._remove_disabled_style)
except subprocess.CalledProcessError:
GLib.idle_add(self.caffeine_status.set_label, "Disabled")
GLib.idle_add(self._add_disabled_style)
class Buttons(Gtk.Grid):
def __init__(self, **kwargs):
super().__init__(name="buttons-grid")
self.set_row_homogeneous(True)
self.set_column_homogeneous(True)
self.set_row_spacing(4)
self.set_column_spacing(4)
self.set_vexpand(False)
self.widgets = kwargs["widgets"]
self.network_button = NetworkButton(widgets=self.widgets)
self.bluetooth_button = BluetoothButton(widgets=self.widgets)
self.night_mode_button = NightModeButton()
self.caffeine_button = CaffeineButton()
if data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]):
self.attach(self.network_button, 0, 0, 1, 1)
self.attach(self.bluetooth_button, 1, 0, 1, 1)
self.attach(self.night_mode_button, 0, 1, 1, 1)
self.attach(self.caffeine_button, 1, 1, 1, 1)
else:
self.attach(self.network_button, 0, 0, 1, 1)
self.attach(self.bluetooth_button, 1, 0, 1, 1)
self.attach(self.night_mode_button, 2, 0, 1, 1)
self.attach(self.caffeine_button, 3, 0, 1, 1)
self.show_all()
+374
View File
@@ -0,0 +1,374 @@
import calendar
import subprocess
from datetime import datetime, timedelta
import gi
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
import modules.icons as icons
gi.require_version("Gtk", "3.0")
from gi.repository import GLib, Gtk, Gio
class Calendar(Gtk.Box):
def __init__(self, view_mode="month"):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8, name="calendar")
self.view_mode = view_mode
self.first_weekday = 0 # Default: Monday, will be updated async
self.set_halign(Gtk.Align.CENTER)
self.set_hexpand(False)
self.current_day_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
if self.view_mode == "month":
self.current_shown_date = self.current_day_date.replace(day=1)
self.current_year = self.current_shown_date.year
self.current_month = self.current_shown_date.month
self.current_day = self.current_day_date.day # Solo para resaltar en create_month_view
self.previous_key = (self.current_year, self.current_month)
elif self.view_mode == "week":
# current_shown_date es el primer día (según locale) de la semana actual
days_to_subtract = (self.current_day_date.weekday() - self.first_weekday + 7) % 7
self.current_shown_date = self.current_day_date - timedelta(days=days_to_subtract)
self.current_year = self.current_shown_date.year # Para el header
self.current_month = self.current_shown_date.month # Para el header
iso_year, iso_week, _ = self.current_shown_date.isocalendar()
self.previous_key = (iso_year, iso_week)
self.set_halign(Gtk.Align.FILL)
self.set_hexpand(True)
self.set_valign(Gtk.Align.CENTER)
self.set_vexpand(False)
self.cache_threshold = 3 # Umbral para mantener vistas en caché
self.month_views = {} # Reutilizado para vistas de semana también
self.prev_button = Gtk.Button( # Nombre genérico del botón
name="prev-month-button",
child=Label(name="month-button-label", markup=icons.chevron_left) # CSS puede ser genérico
)
self.prev_button.connect("clicked", self.on_prev_clicked)
self.month_label = Gtk.Label(name="month-label") # El nombre es histórico, pero muestra mes/año
self.next_button = Gtk.Button( # Nombre genérico del botón
name="next-month-button",
child=Label(name="month-button-label", markup=icons.chevron_right) # CSS puede ser genérico
)
self.next_button.connect("clicked", self.on_next_clicked)
self.header = CenterBox(
spacing=4,
name="header",
start_children=[self.prev_button],
center_children=[self.month_label],
end_children=[self.next_button],
)
self.add(self.header)
self.weekday_row = Gtk.Box(spacing=4, name="weekday-row")
self.pack_start(self.weekday_row, False, False, 0)
self.stack = Gtk.Stack(name="calendar-stack")
self.stack.set_transition_duration(250)
self.pack_start(self.stack, True, True, 0)
self.update_header() # Llamar antes de update_calendar para que el primer header sea correcto
self.update_calendar()
self.setup_periodic_update()
self.setup_dbus_listeners()
# Initialize locale settings asynchronously
GLib.Thread.new("calendar-locale", self._init_locale_settings_thread, None)
def _init_locale_settings_thread(self, user_data):
"""Background thread to initialize locale settings without blocking UI."""
try:
origin_date_str = subprocess.check_output(["locale", "week-1stday"], text=True).strip()
first_weekday_val = int(subprocess.check_output(["locale", "first_weekday"], text=True).strip())
origin_date = datetime.fromisoformat(origin_date_str)
# Esta lógica calcula el día de la semana (0-6, Lunes=0) que es considerado el primero
# según la configuración regional combinada de week-1stday y first_weekday.
date_of_first_day_of_week_config = origin_date + timedelta(days=first_weekday_val - 1)
new_first_weekday = date_of_first_day_of_week_config.weekday() # Lunes=0, ..., Domingo=6
# Update the first_weekday on main thread and refresh calendar if needed
GLib.idle_add(self._update_first_weekday, new_first_weekday)
except Exception as e:
print(f"Error getting locale first weekday: {e}")
# Keep default value (0 = Monday)
def _update_first_weekday(self, new_first_weekday):
"""Update first weekday setting and refresh calendar if changed."""
if self.first_weekday != new_first_weekday:
self.first_weekday = new_first_weekday
# Clear cache and refresh calendar with new locale settings
self.month_views.clear()
# Remove all current stack children to force regeneration
for child in self.stack.get_children():
self.stack.remove(child)
# Update header (which includes weekday labels) and calendar
self.update_header()
self.update_calendar()
return False # Don't repeat this idle callback
def setup_periodic_update(self):
# Check for date changes every second
GLib.timeout_add(1000, self.check_date_change)
def setup_dbus_listeners(self):
# Listen for system suspend/resume events
bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
bus.signal_subscribe(
None, # sender
'org.freedesktop.login1.Manager', # interface
'PrepareForSleep', # signal
'/org/freedesktop/login1', # path
None, # arg0
Gio.DBusSignalFlags.NONE,
self.on_suspend_resume, # callback
None # user_data
)
def check_date_change(self):
now = datetime.now()
current_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
if current_date != self.current_day_date:
self.on_midnight()
return True # Continue the timer
def on_suspend_resume(self, connection, sender_name, object_path, interface_name, signal_name, parameters, user_data):
# Check date when resuming from suspend
self.check_date_change()
def on_midnight(self):
now = datetime.now()
self.current_day_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
key_to_remove_for_today_highlight = None
if self.view_mode == "month":
# Actualizar la fecha base para la vista de mes si es necesario (aunque usualmente no cambia a medianoche)
self.current_shown_date = self.current_day_date.replace(day=1)
self.current_year = self.current_shown_date.year
self.current_month = self.current_shown_date.month
self.current_day = self.current_day_date.day # Actualizar el día actual
key_to_remove_for_today_highlight = (self.current_year, self.current_month)
elif self.view_mode == "week":
days_to_subtract = (self.current_day_date.weekday() - self.first_weekday + 7) % 7
self.current_shown_date = self.current_day_date - timedelta(days=days_to_subtract)
self.current_year = self.current_shown_date.year # Para el header
self.current_month = self.current_shown_date.month # Para el header
iso_year, iso_week, _ = self.current_shown_date.isocalendar()
key_to_remove_for_today_highlight = (iso_year, iso_week)
# Eliminar la vista actual de la caché para forzar la regeneración con el nuevo "hoy" resaltado
if key_to_remove_for_today_highlight and key_to_remove_for_today_highlight in self.month_views:
widget = self.month_views.pop(key_to_remove_for_today_highlight)
self.stack.remove(widget)
# Si la vista eliminada era la actual, previous_key podría quedar desactualizado
# pero update_calendar lo corregirá al establecer la nueva vista.
self.update_calendar() # Esto regenerará la vista si fue eliminada y actualizará el resaltado
return False # Importante para que el timeout no se repita automáticamente
def update_header(self):
# self.current_shown_date es el primer día del mes (modo mes) o el primer día de la semana (modo semana)
# El encabezado siempre muestra el mes y año de self.current_shown_date
self.month_label.set_text(
self.current_shown_date.strftime("%B %Y").capitalize()
)
for child in self.weekday_row.get_children():
self.weekday_row.remove(child)
day_initials = self.get_weekday_initials()
for day_initial in day_initials:
label = Gtk.Label(label=day_initial.upper(), name="weekday-label")
self.weekday_row.pack_start(label, True, True, 0)
self.weekday_row.show_all()
def update_calendar(self):
new_key = None
child_name = "" # Renombrado de child_name_prefix
view_widget = None
if self.view_mode == "month":
new_key = (self.current_year, self.current_month)
child_name = f"{self.current_year}_{self.current_month}"
if new_key not in self.month_views:
view_widget = self.create_month_view(self.current_year, self.current_month)
elif self.view_mode == "week":
iso_year, iso_week, _ = self.current_shown_date.isocalendar()
new_key = (iso_year, iso_week)
child_name = f"{iso_year}_w{iso_week}"
if new_key not in self.month_views:
# Pasar self.current_shown_date directamente a create_week_view
view_widget = self.create_week_view(self.current_shown_date)
if new_key is None: return
if new_key > self.previous_key:
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT)
elif new_key < self.previous_key:
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_RIGHT)
# else: no transition if key is the same (e.g. on_midnight for same month/week)
self.previous_key = new_key
if view_widget: # Si se creó una nueva vista
self.month_views[new_key] = view_widget
self.stack.add_titled(view_widget, child_name, child_name)
self.stack.set_visible_child_name(child_name)
# El encabezado se actualiza ANTES de llamar a update_calendar en __init__ y on_clicked,
# y también en on_midnight si es necesario.
# Pero si la vista cambia (ej. de Enero a Febrero), el encabezado debe reflejarlo.
self.update_header() # Asegurar que el header está sincronizado con la vista actual
self.stack.show_all()
self.prune_cache()
def prune_cache(self):
def get_key_index(key_tuple):
year, num = key_tuple # num es month o week_number
if self.view_mode == "month": # Asumiendo que la clave es (año, mes)
return year * 12 + (num - 1)
else: # Asumiendo que la clave es (año_iso, semana_iso)
return year * 53 + num # Usar 53 para cubrir años con 53 semanas ISO
current_index = get_key_index(self.previous_key) # previous_key es la clave de la vista actual
keys_to_remove = []
for key_iter in self.month_views:
if abs(get_key_index(key_iter) - current_index) > self.cache_threshold:
keys_to_remove.append(key_iter)
for key_to_remove in keys_to_remove:
widget = self.month_views.pop(key_to_remove)
self.stack.remove(widget)
def create_month_view(self, year, month):
grid = Gtk.Grid(column_homogeneous=True, row_homogeneous=False, name="calendar-grid")
cal = calendar.Calendar(firstweekday=self.first_weekday)
month_days = cal.monthdayscalendar(year, month)
while len(month_days) < 6: # Asegurar 6 filas para consistencia visual
month_days.append([0] * 7) # [0] representa un día vacío
for row, week in enumerate(month_days):
for col, day_num in enumerate(week):
day_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="day-box")
top_spacer = Gtk.Box(hexpand=True, vexpand=True)
middle_box = Gtk.Box(hexpand=True, vexpand=True)
bottom_spacer = Gtk.Box(hexpand=True, vexpand=True)
if day_num == 0:
label = Label(name="day-empty", markup=icons.dot)
else:
label = Gtk.Label(label=str(day_num), name="day-label")
day_date_obj = datetime(year, month, day_num)
if day_date_obj == self.current_day_date:
label.get_style_context().add_class("current-day")
middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0)
middle_box.pack_start(label, False, False, 0)
middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0)
day_box.pack_start(top_spacer, True, True, 0)
day_box.pack_start(middle_box, True, True, 0)
day_box.pack_start(bottom_spacer, True, True, 0)
grid.attach(day_box, col, row, 1, 1)
grid.show_all()
return grid
def create_week_view(self, first_day_of_week_to_display):
grid = Gtk.Grid(column_homogeneous=True, row_homogeneous=False, name="calendar-grid-week-view") # Podría tener estilo diferente
# El mes de referencia para atenuar es el mes de first_day_of_week_to_display
# que es self.current_shown_date, y su mes es self.current_month (actualizado en nav)
reference_month_for_dimming = first_day_of_week_to_display.month
for col in range(7):
current_day_in_loop = first_day_of_week_to_display + timedelta(days=col)
day_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="day-box") # Reusar estilo de day-box
top_spacer = Gtk.Box(hexpand=True, vexpand=True)
middle_box = Gtk.Box(hexpand=True, vexpand=True)
bottom_spacer = Gtk.Box(hexpand=True, vexpand=True)
label = Gtk.Label(label=str(current_day_in_loop.day), name="day-label")
if current_day_in_loop == self.current_day_date:
label.get_style_context().add_class("current-day")
if current_day_in_loop.month != reference_month_for_dimming:
label.get_style_context().add_class("dim-label") # Necesita CSS: .dim-label { opacity: 0.5; } o similar
middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0)
middle_box.pack_start(label, False, False, 0)
middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0)
day_box.pack_start(top_spacer, True, True, 0)
day_box.pack_start(middle_box, True, True, 0)
day_box.pack_start(bottom_spacer, True, True, 0)
grid.attach(day_box, col, 0, 1, 1) # Todos los días en la fila 0
# Para mantener una altura similar a la vista mensual, se podrían añadir filas vacías.
# Esto es opcional y depende del diseño deseado.
# for r_idx in range(1, 6): # Añadir 5 filas vacías
# empty_row_placeholder = Gtk.Box(name="day-empty-placeholder", hexpand=True, vexpand=True, height_request=20) # Ajustar altura
# grid.attach(empty_row_placeholder, 0, r_idx, 7, 1) # Abarca las 7 columnas
grid.show_all()
return grid
def get_weekday_initials(self):
# Genera las iniciales de los días de la semana comenzando por self.first_weekday
# datetime(2024, 1, 1) es Lunes. Su weekday() es 0.
# Si self.first_weekday es 0 (Lunes), queremos que el primer día sea Lunes.
# i=0: datetime(2024, 1, 1 + 0) -> Lunes
# Si self.first_weekday es 6 (Domingo), queremos que el primer día sea Domingo.
# i=0: datetime(2024, 1, 1 + 6) -> Domingo
# Esta lógica es correcta.
return [(datetime(2024, 1, 1) + timedelta(days=(self.first_weekday + i) % 7)).strftime("%a")[:1] for i in range(7)]
def on_prev_clicked(self, widget):
if self.view_mode == "month":
current_month_val = self.current_shown_date.month
current_year_val = self.current_shown_date.year
if current_month_val == 1:
self.current_shown_date = self.current_shown_date.replace(year=current_year_val - 1, month=12)
else:
self.current_shown_date = self.current_shown_date.replace(month=current_month_val - 1)
self.current_year = self.current_shown_date.year
self.current_month = self.current_shown_date.month
elif self.view_mode == "week":
self.current_shown_date -= timedelta(days=7)
self.current_year = self.current_shown_date.year # Actualizar para el header
self.current_month = self.current_shown_date.month # Actualizar para el header y dimming
# self.update_header() # Se llama dentro de update_calendar
self.update_calendar()
def on_next_clicked(self, widget):
if self.view_mode == "month":
current_month_val = self.current_shown_date.month
current_year_val = self.current_shown_date.year
if current_month_val == 12:
self.current_shown_date = self.current_shown_date.replace(year=current_year_val + 1, month=1)
else:
self.current_shown_date = self.current_shown_date.replace(month=current_month_val + 1)
self.current_year = self.current_shown_date.year
self.current_month = self.current_shown_date.month
elif self.view_mode == "week":
self.current_shown_date += timedelta(days=7)
self.current_year = self.current_shown_date.year # Actualizar para el header
self.current_month = self.current_shown_date.month # Actualizar para el header y dimming
# self.update_header() # Se llama dentro de update_calendar
self.update_calendar()
+350
View File
@@ -0,0 +1,350 @@
import configparser
import ctypes
import os
import re
import signal
import struct
import subprocess
from math import pi
from fabric.utils.helpers import get_relative_path
from fabric.widgets.overlay import Overlay
from gi.repository import Gdk, GLib, Gtk
from loguru import logger
def get_bars(file_path):
config = configparser.ConfigParser()
config.read(file_path)
return int(config['general']['bars'])
CAVA_CONFIG = get_relative_path("../config/cavalcade/cava.ini")
bars = get_bars(CAVA_CONFIG)
def set_death_signal():
"""
Set the death signal of the child process to SIGTERM so that if the parent
process is killed, the child (cava) is automatically terminated.
"""
libc = ctypes.CDLL("libc.so.6")
PR_SET_PDEATHSIG = 1
libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM)
class Cava:
"""
CAVA wrapper.
Launch cava process with certain settings and read output.
"""
NONE = 0
RUNNING = 1
RESTARTING = 2
CLOSING = 3
def data_handler(self, *a, **kw):
"""Call all registered handlers with the provided arguments."""
for h in self._handlers:
h(*a, **kw)
def register_handler(self, handler):
self._handlers.append(handler)
def __init__(self):
self.bars = bars
self.path = "/tmp/cava.fifo"
self.cava_config_file = CAVA_CONFIG
self._handlers = []
self._started = False
self.command = ["cava", "-p", self.cava_config_file]
self.state = self.NONE
self.process = None
self.env = dict(os.environ)
self.env["LC_ALL"] = "en_US.UTF-8" # not sure if it's necessary
is_16bit = True
self.byte_type, self.byte_size, self.byte_norm = ("H", 2, 65535) if is_16bit else ("B", 1, 255)
if not os.path.exists(self.path):
os.mkfifo(self.path)
self.fifo_fd = None
self.fifo_dummy_fd = None
self.io_watch_id = None
def _run_process(self):
try:
self.process = subprocess.Popen(
self.command,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=self.env,
preexec_fn=set_death_signal # Ensure cava gets killed when the parent dies.
)
self.state = self.RUNNING
except Exception:
logger.exception("Fail to launch cava")
def _start_io_reader(self):
# Open FIFO in non-blocking mode for reading
self.fifo_fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK)
# Open dummy write end to prevent getting an EOF on our FIFO
self.fifo_dummy_fd = os.open(self.path, os.O_WRONLY | os.O_NONBLOCK)
self.io_watch_id = GLib.io_add_watch(self.fifo_fd, GLib.IO_IN, self._io_callback)
def _io_callback(self, source, condition):
chunk = self.byte_size * self.bars # number of bytes for given format
try:
if self.fifo_fd is None:
return False
data = os.read(self.fifo_fd, chunk)
except OSError as e:
if e.errno == 11: # EAGAIN - would block, normal for non-blocking
return True
elif e.errno == 9: # EBADF - bad file descriptor
GLib.idle_add(self.restart)
return False
else:
return False
except Exception:
return False
# When no data is read, do not remove the IO watch immediately.
if len(data) < chunk:
if len(data) == 0:
# No data available, continue watching
return True
else:
return True
try:
fmt = self.byte_type * self.bars # format string for struct.unpack
sample = [i / self.byte_norm for i in struct.unpack(fmt, data)]
GLib.idle_add(self.data_handler, sample)
except (struct.error, Exception):
return True
return True
def _on_stop(self):
if self.state == self.RESTARTING:
self.start()
elif self.state == self.RUNNING:
self.state = self.NONE
def start(self):
"""Launch cava"""
if self._started:
return
self._start_io_reader()
self._run_process()
self._started = True
def restart(self):
"""Restart cava process"""
if self.state == self.RUNNING:
self.state = self.RESTARTING
if self.process and self.process.poll() is None:
self.process.kill()
elif self.state == self.NONE:
self.start()
def close(self):
"""Stop cava process"""
self.state = self.CLOSING
# Stop IO watch first
if self.io_watch_id:
GLib.source_remove(self.io_watch_id)
self.io_watch_id = None
# Close file descriptors safely
if self.fifo_fd is not None:
try:
os.close(self.fifo_fd)
except OSError:
pass
finally:
self.fifo_fd = None
if self.fifo_dummy_fd is not None:
try:
os.close(self.fifo_dummy_fd)
except OSError:
pass
finally:
self.fifo_dummy_fd = None
# Kill process if still running
if self.process and self.process.poll() is None:
try:
self.process.kill()
self.process.wait(timeout=2.0) # Wait up to 2 seconds
except subprocess.TimeoutExpired:
self.process.kill()
except Exception:
pass
# Remove FIFO file
if os.path.exists(self.path):
try:
os.remove(self.path)
except OSError:
pass
class AttributeDict(dict):
"""Dictionary with keys as attributes. Does nothing but easy reading"""
def __getattr__(self, attr):
return self.get(attr, 3)
def __setattr__(self, attr, value):
self[attr] = value
class Spectrum:
"""Spectrum drawing"""
def __init__(self):
self.silence_value = 0
self.audio_sample = []
self.color = None
self._cached_color = None
self._color_file_mtime = 0
self.area = Gtk.DrawingArea()
self.area.connect("draw", self.redraw)
self.area.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
self.sizes = AttributeDict()
self.sizes.area = AttributeDict()
self.sizes.bar = AttributeDict()
self.silence = 10
self.max_height = 12
self.area.connect("configure-event", self.size_update)
self.color_update()
def is_silence(self, value):
"""Check if volume level critically low during last iterations"""
self.silence_value = 0 if value > 0 else self.silence_value + 1
return self.silence_value > self.silence
def update(self, data):
"""Audio data processing"""
self.color_update_cached()
self.audio_sample = data
if not self.is_silence(self.audio_sample[0]):
self.area.queue_draw()
elif self.silence_value == (self.silence + 1):
self.audio_sample = [0] * self.sizes.number
self.area.queue_draw()
def redraw(self, widget, cr):
"""Draw spectrum graph"""
cr.set_source_rgba(*self.color)
dx = 3
center_y = self.sizes.area.height / 2 # center vertical of the drawing area
for i, value in enumerate(self.audio_sample):
width = self.sizes.area.width / self.sizes.number - self.sizes.padding
radius = width / 2
height = max(self.sizes.bar.height * min(value, 1), self.sizes.zero) / 2
if height == self.sizes.zero / 2 + 1:
height *= 0.5
height = min(height, self.max_height)
# Draw rectangle and arcs for rounded ends
cr.rectangle(dx, center_y - height, width, height * 2)
cr.arc(dx + radius, center_y - height, radius, 0, 2 * pi)
cr.arc(dx + radius, center_y + height, radius, 0, 2 * pi)
cr.close_path()
dx += width + self.sizes.padding
cr.fill()
def size_update(self, *args):
"""Update drawing geometry"""
self.sizes.number = bars
self.sizes.padding = 100 / bars
self.sizes.zero = 0
self.sizes.area.width = self.area.get_allocated_width()
self.sizes.area.height = self.area.get_allocated_height() - 2
tw = self.sizes.area.width - self.sizes.padding * (self.sizes.number - 1)
self.sizes.bar.width = max(int(tw / self.sizes.number), 1)
self.sizes.bar.height = self.sizes.area.height
def color_update_cached(self):
"""Set drawing color with caching to avoid file reads on every frame"""
color_file = get_relative_path("../styles/colors.css")
try:
# Check if file has been modified
current_mtime = os.path.getmtime(color_file)
if current_mtime != self._color_file_mtime or self._cached_color is None:
self._color_file_mtime = current_mtime
color = "#a5c8ff" # default value
with open(color_file, "r") as f:
content = f.read()
m = re.search(r"--primary:\s*(#[0-9a-fA-F]{6})", content)
if m:
color = m.group(1)
red = int(color[1:3], 16) / 255
green = int(color[3:5], 16) / 255
blue = int(color[5:7], 16) / 255
self._cached_color = Gdk.RGBA(red=red, green=green, blue=blue, alpha=1.0)
self.color = self._cached_color
except Exception:
if self._cached_color is None:
# Fallback to default color
self._cached_color = Gdk.RGBA(red=0.647, green=0.784, blue=1.0, alpha=1.0)
self.color = self._cached_color
def color_update(self):
"""Set drawing color according to current settings by reading primary color from CSS"""
color = "#a5c8ff" # default value
try:
with open(get_relative_path("../styles/colors.css"), "r") as f:
content = f.read()
m = re.search(r"--primary:\s*(#[0-9a-fA-F]{6})", content)
if m:
color = m.group(1)
except Exception:
pass
red = int(color[1:3], 16) / 255
green = int(color[3:5], 16) / 255
blue = int(color[5:7], 16) / 255
self.color = Gdk.RGBA(red=red, green=green, blue=blue, alpha=1.0)
_instances = {}
def getCava() -> Cava:
if "cava" not in _instances:
_instances["cava"] = Cava()
return _instances["cava"]
class SpectrumRender:
def __init__(self, mode=None, **kwargs):
super().__init__(**kwargs)
self.mode = mode
self.draw = Spectrum()
self.cava = getCava()
self.cava.register_handler(self.draw.update)
self.cava.start()
def get_spectrum_box(self):
# Get the spectrum box
box = Overlay(name="cavalcade", h_align='center', v_align='center')
box.set_size_request(180, 40)
box.add_overlay(self.draw.area)
return box
+519
View File
@@ -0,0 +1,519 @@
import os
import re
import subprocess
import sys
import tempfile
from fabric.utils import idle_add, remove_handler
from fabric.utils.helpers import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.entry import Entry
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from gi.repository import Gdk, GdkPixbuf, GLib
import modules.icons as icons
class ClipHistory(Box):
def __init__(self, **kwargs):
super().__init__(
name="clip-history",
visible=False,
all_visible=False,
**kwargs,
)
self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-")
self.image_cache = {}
self.notch = kwargs["notch"]
self.selected_index = -1
self._arranger_handler = 0
self.clipboard_items = []
self._loading = False
self._pending_updates = False
self.viewport = Box(name="viewport", spacing=4, orientation="v")
self.search_entry = Entry(
name="search-entry",
placeholder="Search Clipboard History...",
h_expand=True,
h_align="fill",
notify_text=self.filter_items,
on_activate=lambda entry, *_: self.use_selected_item(),
on_key_press_event=self.on_search_entry_key_press,
)
self.search_entry.props.xalign = 0.5
self.scrolled_window = ScrolledWindow(
name="scrolled-window",
spacing=10,
h_expand=True,
v_expand=True,
h_align="fill",
v_align="fill",
child=self.viewport,
propagate_width=False,
propagate_height=False,
)
self.header_box = Box(
name="header_box",
spacing=10,
orientation="h",
children=[
Button(
name="clear-button",
child=Label(name="clear-label", markup=icons.trash),
on_clicked=lambda *_: self.clear_history(),
),
self.search_entry,
Button(
name="close-button",
child=Label(name="close-label", markup=icons.cancel),
tooltip_text="Exit",
on_clicked=lambda *_: self.close()
),
],
)
self.history_box = Box(
name="launcher-box",
spacing=10,
h_expand=True,
orientation="v",
children=[
self.header_box,
self.scrolled_window,
],
)
self.add(self.history_box)
self.show_all()
def close(self):
"""Close the clipboard history panel"""
self.viewport.children = []
self.selected_index = -1
self.notch.close_notch()
def open(self):
"""Open the clipboard history panel and load items"""
if self._loading:
return
self._loading = True
self.search_entry.set_text("")
self.search_entry.grab_focus()
# Use GLib.Thread for proper async execution
GLib.Thread.new("cliphist-loader", self._load_clipboard_items_thread, None)
def _load_clipboard_items_thread(self, user_data):
"""Background thread worker for loading clipboard items"""
try:
result = subprocess.run(
["cliphist", "list"],
capture_output=True,
check=True
)
# Decode stdout with error handling
stdout_str = result.stdout.decode('utf-8', errors='replace')
lines = stdout_str.strip().split('\n')
new_items = []
for line in lines:
if not line or "<meta http-equiv" in line:
continue
new_items.append(line)
# Update UI from main thread
GLib.idle_add(self._update_items, new_items)
except subprocess.CalledProcessError as e:
print(f"Error loading clipboard history: {e}", file=sys.stderr)
except Exception as e:
print(f"Unexpected error: {e}", file=sys.stderr)
finally:
GLib.idle_add(self._loading_finished)
def _loading_finished(self):
"""Handle loading completion on main thread"""
self._loading = False
if self._pending_updates:
self._pending_updates = False
GLib.Thread.new("cliphist-loader", self._load_clipboard_items_thread, None)
return False
def _update_items(self, new_items):
"""Update the items list from main thread"""
self.clipboard_items = new_items
self.display_clipboard_items()
def display_clipboard_items(self, filter_text=""):
"""Display clipboard items in the viewport"""
remove_handler(self._arranger_handler) if self._arranger_handler else None
self.viewport.children = []
self.selected_index = -1
filtered_items = []
for item in self.clipboard_items:
content = item.split('\t', 1)[1] if '\t' in item else item
if filter_text.lower() in content.lower():
filtered_items.append(item)
if not filtered_items:
container = Box(
name="no-clip-container",
orientation="v",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True
)
label = Label(
name="no-clip",
markup=icons.clipboard,
h_align="center",
v_align="center",
)
container.add(label)
self.viewport.add(container)
return
self._display_items_batch(filtered_items, 0, 10)
def _display_items_batch(self, items, start, batch_size):
"""Display items in batches to keep UI responsive"""
end = min(start + batch_size, len(items))
for i in range(start, end):
item = items[i]
self.viewport.add(self.create_clipboard_item(item))
if end < len(items):
GLib.idle_add(self._display_items_batch, items, end, batch_size)
else:
if self.search_entry.get_text() and self.viewport.get_children():
self.update_selection(0)
def create_clipboard_item(self, item):
"""Create a button for a clipboard item"""
parts = item.split('\t', 1)
item_id = parts[0] if len(parts) > 1 else "0"
content = parts[1] if len(parts) > 1 else item
display_text = content.strip()
if len(display_text) > 100:
display_text = display_text[:97] + "..."
is_image = self.is_image_data(content)
if is_image:
button = Button(
name="slot-button",
child=Box(
name="slot-box",
orientation="h",
spacing=10,
children=[
Image(name="clip-icon", h_align="start"),
Label(
name="clip-label",
label="[Image]",
ellipsization="end",
v_align="center",
h_align="start",
h_expand=True,
),
],
),
tooltip_text="Image in clipboard",
on_clicked=lambda *_, id=item_id: self.paste_item(id),
)
self._load_image_preview_async(item_id, button)
else:
button = self.create_text_item_button(item_id, display_text)
button.connect("key-press-event", lambda widget, event, id=item_id: self.on_item_key_press(widget, event, id))
button.set_can_focus(True)
button.add_events(Gdk.EventMask.KEY_PRESS_MASK)
return button
def _load_image_preview_async(self, item_id, button):
"""Load image preview asynchronously using background thread"""
GLib.Thread.new("image-preview", self._load_image_preview_thread, (item_id, button))
def _load_image_preview_thread(self, data):
"""Background thread worker for loading image preview"""
item_id, button = data
try:
if item_id in self.image_cache:
pixbuf = self.image_cache[item_id]
GLib.idle_add(self._update_image_button, button, pixbuf)
return
result = subprocess.run(
["cliphist", "decode", item_id],
capture_output=True,
check=True
)
loader = GdkPixbuf.PixbufLoader()
loader.write(result.stdout)
loader.close()
pixbuf = loader.get_pixbuf()
width, height = pixbuf.get_width(), pixbuf.get_height()
max_size = 72
if width > height:
new_width = max_size
new_height = int(height * (max_size / width))
else:
new_height = max_size
new_width = int(width * (max_size / height))
pixbuf = pixbuf.scale_simple(new_width, new_height, GdkPixbuf.InterpType.BILINEAR)
self.image_cache[item_id] = pixbuf
GLib.idle_add(self._update_image_button, button, pixbuf)
except Exception as e:
print(f"Error loading image preview: {e}", file=sys.stderr)
def _update_image_button(self, button, pixbuf):
"""Update the button with the loaded image preview"""
box = button.get_child()
if box and len(box.get_children()) > 0:
image_widget = box.get_children()[0]
if isinstance(image_widget, Image):
image_widget.set_from_pixbuf(pixbuf)
def create_text_item_button(self, item_id, display_text):
"""Create a button for a text clipboard item"""
return Button(
name="slot-button",
child=Box(
name="slot-box",
orientation="h",
spacing=10,
children=[
Label(
name="clip-icon",
markup=icons.clip_text,
h_align="start",
),
Label(
name="clip-label",
label=display_text,
ellipsization="end",
v_align="center",
h_align="start",
h_expand=True,
),
],
),
tooltip_text=display_text,
on_clicked=lambda *_: self.paste_item(item_id),
)
def is_image_data(self, content):
"""Determine if clipboard content is likely an image"""
return (
content.startswith("data:image/") or
content.startswith("\x89PNG") or
content.startswith("GIF8") or
content.startswith("\xff\xd8\xff") or
re.match(r'^\s*<img\s+', content) is not None or
"binary" in content.lower() and any(ext in content.lower() for ext in ["jpg", "jpeg", "png", "bmp", "gif"])
)
def paste_item(self, item_id):
"""Copy the selected item to the clipboard and close (async)"""
GLib.Thread.new("paste-item", self._paste_item_thread, item_id)
def _paste_item_thread(self, item_id):
"""Background thread worker for pasting clipboard item"""
try:
result = subprocess.run(
["cliphist", "decode", item_id],
capture_output=True,
check=True
)
subprocess.run(
["wl-copy"],
input=result.stdout,
check=True
)
GLib.idle_add(self.close)
except subprocess.CalledProcessError as e:
print(f"Error pasting clipboard item: {e}", file=sys.stderr)
def delete_item(self, item_id):
"""Delete the selected clipboard item (async)"""
GLib.Thread.new("delete-item", self._delete_item_thread, item_id)
def _delete_item_thread(self, item_id):
"""Background thread worker for deleting clipboard item"""
try:
subprocess.run(
["cliphist", "delete", item_id],
check=True
)
self._pending_updates = True
if not self._loading:
GLib.Thread.new("cliphist-loader", self._load_clipboard_items_thread, None)
except subprocess.CalledProcessError as e:
print(f"Error deleting clipboard item: {e}", file=sys.stderr)
def clear_history(self):
"""Clear all clipboard history (async)"""
GLib.Thread.new("clear-history", self._clear_history_thread, None)
def _clear_history_thread(self, user_data):
"""Background thread worker for clearing clipboard history"""
try:
subprocess.run(["cliphist", "wipe"], check=True)
self._pending_updates = True
if not self._loading:
GLib.Thread.new("cliphist-loader", self._load_clipboard_items_thread, None)
except subprocess.CalledProcessError as e:
print(f"Error clearing clipboard history: {e}", file=sys.stderr)
def filter_items(self, entry, *_):
"""Filter clipboard items based on search text"""
self.display_clipboard_items(entry.get_text())
def on_search_entry_key_press(self, widget, event):
"""Handle key presses in the search entry"""
if event.keyval == Gdk.KEY_Down:
self.move_selection(1)
return True
elif event.keyval == Gdk.KEY_Up:
self.move_selection(-1)
return True
elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
self.use_selected_item()
return True
elif event.keyval == Gdk.KEY_Delete:
self.delete_selected_item()
return True
elif event.keyval == Gdk.KEY_Escape:
self.close()
return True
return False
def update_selection(self, new_index):
"""Update the selected item in the viewport"""
children = self.viewport.get_children()
if self.selected_index != -1 and self.selected_index < len(children):
current_button = children[self.selected_index]
current_button.get_style_context().remove_class("selected")
if new_index != -1 and new_index < len(children):
new_button = children[new_index]
new_button.get_style_context().add_class("selected")
self.selected_index = new_index
self.scroll_to_selected(new_button)
else:
self.selected_index = -1
def move_selection(self, delta):
"""Move the selection up or down"""
children = self.viewport.get_children()
if not children:
return
if self.selected_index == -1 and delta == 1:
new_index = 0
else:
new_index = self.selected_index + delta
new_index = max(0, min(new_index, len(children) - 1))
self.update_selection(new_index)
def scroll_to_selected(self, button):
"""Scroll to ensure the selected item is visible"""
def scroll():
adj = self.scrolled_window.get_vadjustment()
alloc = button.get_allocation()
if alloc.height == 0:
return False
y = alloc.y
height = alloc.height
page_size = adj.get_page_size()
current_value = adj.get_value()
visible_top = current_value
visible_bottom = current_value + page_size
if y < visible_top:
adj.set_value(y)
elif y + height > visible_bottom:
new_value = y + height - page_size
adj.set_value(new_value)
return False
GLib.idle_add(scroll)
def use_selected_item(self):
"""Use (paste) the selected clipboard item"""
children = self.viewport.get_children()
if not children or self.selected_index == -1 or self.selected_index >= len(self.clipboard_items):
return
item_line = self.clipboard_items[self.selected_index]
item_id = item_line.split('\t', 1)[0]
self.paste_item(item_id)
def delete_selected_item(self):
"""Delete the selected clipboard item"""
children = self.viewport.get_children()
if not children or self.selected_index == -1:
return
item_line = self.clipboard_items[self.selected_index]
item_id = item_line.split('\t', 1)[0]
self.delete_item(item_id)
def on_item_key_press(self, widget, event, item_id):
"""Handle key press events on clipboard items"""
if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
self.paste_item(item_id)
return True
return False
def __del__(self):
"""Clean up temporary files on destruction"""
try:
if hasattr(self, 'tmp_dir') and os.path.exists(self.tmp_dir):
import shutil
shutil.rmtree(self.tmp_dir)
self.image_cache.clear()
except Exception as e:
print(f"Error cleaning up temporary files: {e}", file=sys.stderr)
+872
View File
@@ -0,0 +1,872 @@
from fabric.audio.service import Audio
from fabric.widgets.box import Box
from fabric.widgets.button import Button
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 modules.icons as icons
from services.brightness import Brightness
class VolumeSlider(Scale):
def __init__(self, **kwargs):
super().__init__(
name="control-slider",
orientation="h",
h_expand=True,
h_align="fill",
has_origin=True,
increments=(0.01, 0.1),
**kwargs,
)
self.audio = Audio()
self.audio.connect("notify::speaker", self.on_new_speaker)
if self.audio.speaker:
self.audio.speaker.connect("changed", self.on_speaker_changed)
self.connect("change-value", self.on_change_value)
self.add_style_class("vol")
self._pending_value = None
self._update_source_id = None
self._debounce_timeout = 100
self._updating_from_audio = False
self.on_speaker_changed()
def on_new_speaker(self, *args):
if self.audio.speaker:
self.audio.speaker.connect("changed", self.on_speaker_changed)
self.on_speaker_changed()
def on_change_value(self, widget, scroll, value):
if self._updating_from_audio:
return False
if self.audio.speaker:
self._pending_value = value * 100
if self._update_source_id is not None:
GLib.source_remove(self._update_source_id)
self._update_source_id = GLib.timeout_add(
self._debounce_timeout, self._update_volume_callback
)
return False
def _update_volume_callback(self):
if self._pending_value is not None and self.audio.speaker:
self.audio.speaker.volume = self._pending_value
self._pending_value = None
self._update_source_id = None
return False
def on_speaker_changed(self, *_):
if not self.audio.speaker:
return
self._updating_from_audio = True
self.value = self.audio.speaker.volume / 100
self._updating_from_audio = False
if self.audio.speaker.muted:
self.add_style_class("muted")
else:
self.remove_style_class("muted")
class MicSlider(Scale):
def __init__(self, **kwargs):
super().__init__(
name="control-slider",
orientation="h",
h_expand=True,
has_origin=True,
increments=(0.01, 0.1),
**kwargs,
)
self.audio = Audio()
self.audio.connect("notify::microphone", self.on_new_microphone)
if self.audio.microphone:
self.audio.microphone.connect("changed", self.on_microphone_changed)
self.connect("change-value", self.on_change_value)
self.add_style_class("mic")
self._updating_from_audio = False
self.on_microphone_changed()
def on_new_microphone(self, *args):
if self.audio.microphone:
self.audio.microphone.connect("changed", self.on_microphone_changed)
self.on_microphone_changed()
def on_change_value(self, widget, scroll, value):
if self._updating_from_audio:
return False
if self.audio.microphone:
self.audio.microphone.volume = value * 100
return False
def on_microphone_changed(self, *_):
if not self.audio.microphone:
return
self._updating_from_audio = True
self.value = self.audio.microphone.volume / 100
self._updating_from_audio = False
if self.audio.microphone.muted:
self.add_style_class("muted")
else:
self.remove_style_class("muted")
class BrightnessSlider(Scale):
def __init__(self, **kwargs):
super().__init__(
name="control-slider",
orientation="h",
h_expand=True,
has_origin=True,
increments=(5, 10),
**kwargs,
)
self.client = Brightness.get_initial()
if self.client.screen_brightness == -1:
self.destroy()
return
self.set_range(0, self.client.max_screen)
self.set_value(self.client.screen_brightness)
self.add_style_class("brightness")
self._pending_value = None
self._update_source_id = None
self._updating_from_brightness = False
self._debounce_timeout = 100
self.connect("change-value", self.on_scale_move)
self.client.connect("screen", self.on_brightness_changed)
def on_scale_move(self, widget, scroll, moved_pos):
if self._updating_from_brightness:
return False
self._pending_value = moved_pos
if self._update_source_id is not None:
GLib.source_remove(self._update_source_id)
self._update_source_id = GLib.timeout_add(
self._debounce_timeout, self._update_brightness_callback
)
return False
def _update_brightness_callback(self):
if self._pending_value is not None:
value_to_set = self._pending_value
self._pending_value = None
if value_to_set != self.client.screen_brightness:
self.client.screen_brightness = value_to_set
self._update_source_id = None
return False
else:
self._update_source_id = None
return False
def on_brightness_changed(self, client, _):
self._updating_from_brightness = True
self.set_value(self.client.screen_brightness)
self._updating_from_brightness = False
percentage = int((self.client.screen_brightness / self.client.max_screen) * 100)
self.set_tooltip_text(f"{percentage}%")
def destroy(self):
if self._update_source_id is not None:
GLib.source_remove(self._update_source_id)
super().destroy()
class BrightnessSmall(Box):
def __init__(self, **kwargs):
super().__init__(name="button-bar-brightness", **kwargs)
self.brightness = Brightness.get_initial()
if self.brightness.screen_brightness == -1:
self.destroy()
return
self.progress_bar = CircularProgressBar(
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_button = Button(child=self.brightness_label)
self.event_box = EventBox(
events=["scroll", "smooth-scroll"],
child=Overlay(child=self.progress_bar, overlays=self.brightness_button),
)
self.event_box.connect("scroll-event", self.on_scroll)
self.add(self.event_box)
self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
self._updating_from_brightness = False
self._pending_value = None
self._update_source_id = None
self._debounce_timeout = 100
# Don't connect to progress_bar value changes - only use scroll events
self.brightness.connect("screen", self.on_brightness_changed)
self.on_brightness_changed()
def on_scroll(self, widget, event):
if self.brightness.max_screen == -1:
return
step_size = 5
current_brightness = self.brightness.screen_brightness
if event.delta_y < 0:
new_brightness = min(
current_brightness + step_size, self.brightness.max_screen
)
elif event.delta_y > 0:
new_brightness = max(current_brightness - step_size, 0)
else:
return
# Directly update brightness, don't touch progress_bar
self._pending_value = new_brightness
if self._update_source_id is not None:
GLib.source_remove(self._update_source_id)
self._update_source_id = GLib.timeout_add(
self._debounce_timeout, self._update_brightness_callback
)
def _update_brightness_callback(self):
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
self._update_source_id = None
return False
def on_brightness_changed(self, *args):
if self.brightness.max_screen == -1:
return
normalized = (
self.brightness.screen_brightness / self.brightness.max_screen
if self.brightness.max_screen > 0
else 0
)
self._updating_from_brightness = True
self.progress_bar.value = normalized
self._updating_from_brightness = False
brightness_percentage = int(normalized * 100)
if brightness_percentage >= 75:
self.brightness_label.set_markup(icons.brightness_high)
elif brightness_percentage >= 24:
self.brightness_label.set_markup(icons.brightness_medium)
else:
self.brightness_label.set_markup(icons.brightness_low)
self.set_tooltip_text(f"{brightness_percentage}%")
def destroy(self):
if self._update_source_id is not None:
GLib.source_remove(self._update_source_id)
super().destroy()
class VolumeSmall(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,
)
self.vol_label = Label(name="vol-label", markup=icons.vol_high)
self.vol_button = Button(on_clicked=self.toggle_mute, child=self.vol_label)
self.event_box = EventBox(
events=["scroll", "smooth-scroll"],
child=Overlay(child=self.progress_bar, overlays=self.vol_button),
)
self.audio.connect("notify::speaker", self.on_new_speaker)
if self.audio.speaker:
self.audio.speaker.connect("changed", self.on_speaker_changed)
self.event_box.connect("scroll-event", self.on_scroll)
self.add(self.event_box)
self.on_speaker_changed()
self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
def on_new_speaker(self, *args):
if self.audio.speaker:
self.audio.speaker.connect("changed", self.on_speaker_changed)
self.on_speaker_changed()
def toggle_mute(self, event):
current_stream = self.audio.speaker
if current_stream:
current_stream.muted = not current_stream.muted
if current_stream.muted:
self.on_speaker_changed()
self.progress_bar.add_style_class("muted")
self.vol_label.add_style_class("muted")
else:
self.on_speaker_changed()
self.progress_bar.remove_style_class("muted")
self.vol_label.remove_style_class("muted")
def on_scroll(self, _, event):
if not self.audio.speaker:
return
if event.direction == Gdk.ScrollDirection.SMOOTH:
if abs(event.delta_y) > 0:
self.audio.speaker.volume -= event.delta_y
if abs(event.delta_x) > 0:
self.audio.speaker.volume += event.delta_x
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
vol_off_icon = icons.vol_mute
if "bluetooth" in self.audio.speaker.icon_name:
vol_high_icon = icons.bluetooth_connected
vol_medium_icon = icons.bluetooth
vol_mute_icon = icons.bluetooth_off
vol_off_icon = icons.bluetooth_disconnected
self.progress_bar.value = self.audio.speaker.volume / 100
if self.audio.speaker.muted:
self.vol_button.get_child().set_markup(vol_mute_icon)
self.progress_bar.add_style_class("muted")
self.vol_label.add_style_class("muted")
self.set_tooltip_text("Muted")
return
else:
self.progress_bar.remove_style_class("muted")
self.vol_label.remove_style_class("muted")
self.set_tooltip_text(f"{round(self.audio.speaker.volume)}%")
if self.audio.speaker.volume > 74:
self.vol_button.get_child().set_markup(vol_high_icon)
elif self.audio.speaker.volume > 0:
self.vol_button.get_child().set_markup(vol_medium_icon)
else:
self.vol_button.get_child().set_markup(vol_off_icon)
class MicSmall(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,
)
self.mic_label = Label(name="mic-label", markup=icons.mic)
self.mic_button = Button(on_clicked=self.toggle_mute, child=self.mic_label)
self.event_box = EventBox(
events=["scroll", "smooth-scroll"],
child=Overlay(child=self.progress_bar, overlays=self.mic_button),
)
self.audio.connect("notify::microphone", self.on_new_microphone)
if self.audio.microphone:
self.audio.microphone.connect("changed", self.on_microphone_changed)
self.event_box.connect("scroll-event", self.on_scroll)
self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
self.add(self.event_box)
self.on_microphone_changed()
def on_new_microphone(self, *args):
if self.audio.microphone:
self.audio.microphone.connect("changed", self.on_microphone_changed)
self.on_microphone_changed()
def toggle_mute(self, event):
current_stream = self.audio.microphone
if current_stream:
current_stream.muted = not current_stream.muted
if current_stream.muted:
self.mic_button.get_child().set_markup(icons.mic_mute)
self.progress_bar.add_style_class("muted")
self.mic_label.add_style_class("muted")
else:
self.on_microphone_changed()
self.progress_bar.remove_style_class("muted")
self.mic_label.remove_style_class("muted")
def on_scroll(self, _, event):
if not self.audio.microphone:
return
if event.direction == Gdk.ScrollDirection.SMOOTH:
if abs(event.delta_y) > 0:
self.audio.microphone.volume -= event.delta_y
if abs(event.delta_x) > 0:
self.audio.microphone.volume += event.delta_x
def on_microphone_changed(self, *_):
if not self.audio.microphone:
return
if self.audio.microphone.muted:
self.mic_button.get_child().set_markup(icons.mic_mute)
self.progress_bar.add_style_class("muted")
self.mic_label.add_style_class("muted")
self.set_tooltip_text("Muted")
return
else:
self.progress_bar.remove_style_class("muted")
self.mic_label.remove_style_class("muted")
self.progress_bar.value = self.audio.microphone.volume / 100
self.set_tooltip_text(f"{round(self.audio.microphone.volume)}%")
if self.audio.microphone.volume >= 1:
self.mic_button.get_child().set_markup(icons.mic)
else:
self.mic_button.get_child().set_markup(icons.mic_mute)
class BrightnessIcon(Box):
def __init__(self, **kwargs):
super().__init__(name="brightness-icon", **kwargs)
self.brightness = Brightness.get_initial()
if self.brightness.screen_brightness == -1:
self.destroy()
return
self.brightness_label = Label(
name="brightness-label-dash",
markup=icons.brightness_high,
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self.brightness_button = Button(
child=self.brightness_label,
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self.event_box = EventBox(
events=["scroll", "smooth-scroll"],
child=self.brightness_button,
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self.event_box.connect("scroll-event", self.on_scroll)
self.add(self.event_box)
self._pending_value = None
self._update_source_id = None
self._updating_from_brightness = False
self.brightness.connect("screen", self.on_brightness_changed)
self.on_brightness_changed()
self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
def on_scroll(self, _, event):
if self.brightness.max_screen == -1:
return
step_size = 5
current_brightness = self.brightness.screen_brightness
if event.direction == Gdk.ScrollDirection.SMOOTH:
if event.delta_y < 0:
new_brightness = min(
current_brightness + step_size, self.brightness.max_screen
)
elif event.delta_y > 0:
new_brightness = max(current_brightness - step_size, 0)
else:
return
else:
if event.direction == Gdk.ScrollDirection.UP:
new_brightness = min(
current_brightness + step_size, self.brightness.max_screen
)
elif event.direction == Gdk.ScrollDirection.DOWN:
new_brightness = max(current_brightness - step_size, 0)
else:
return
self._pending_value = new_brightness
if self._update_source_id is None:
self._update_source_id = GLib.timeout_add(
100, self._update_brightness_callback
)
def _update_brightness_callback(self):
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
else:
self._update_source_id = None
return False
def on_brightness_changed(self, *args):
if self.brightness.max_screen == -1:
return
self._updating_from_brightness = True
normalized = self.brightness.screen_brightness / self.brightness.max_screen
brightness_percentage = int(normalized * 100)
if brightness_percentage >= 75:
self.brightness_label.set_markup("󰃠")
elif brightness_percentage >= 24:
self.brightness_label.set_markup("󰃠")
else:
self.brightness_label.set_markup("󰃠")
self.set_tooltip_text(f"{brightness_percentage}%")
self._updating_from_brightness = False
def destroy(self):
if self._update_source_id is not None:
GLib.source_remove(self._update_source_id)
super().destroy()
class VolumeIcon(Box):
def __init__(self, **kwargs):
super().__init__(name="vol-icon", **kwargs)
self.audio = Audio()
self.vol_label = Label(
name="vol-label-dash",
markup="",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self.vol_button = Button(
on_clicked=self.toggle_mute,
child=self.vol_label,
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self.event_box = EventBox(
events=["scroll", "smooth-scroll"],
child=self.vol_button,
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self.event_box.connect("scroll-event", self.on_scroll)
self.add(self.event_box)
self._pending_value = None
self._update_source_id = None
self._periodic_update_source_id = None
self.audio.connect("notify::speaker", self.on_new_speaker)
if self.audio.speaker:
self.audio.speaker.connect("changed", self.on_speaker_changed)
self._periodic_update_source_id = GLib.timeout_add_seconds(
2, self.update_device_icon
)
self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
def on_scroll(self, _, event):
if not self.audio.speaker:
return
step_size = 5
current_volume = self.audio.speaker.volume
if event.direction == Gdk.ScrollDirection.SMOOTH:
if event.delta_y < 0:
new_volume = min(current_volume + step_size, 100)
elif event.delta_y > 0:
new_volume = max(current_volume - step_size, 0)
else:
return
else:
if event.direction == Gdk.ScrollDirection.UP:
new_volume = min(current_volume + step_size, 100)
elif event.direction == Gdk.ScrollDirection.DOWN:
new_volume = max(current_volume - step_size, 0)
else:
return
self._pending_value = new_volume
if self._update_source_id is None:
self._update_source_id = GLib.timeout_add(100, self._update_volume_callback)
def _update_volume_callback(self):
if (
self._pending_value is not None
and self._pending_value != self.audio.speaker.volume
):
self.audio.speaker.volume = self._pending_value
self._pending_value = None
return True
else:
self._update_source_id = None
return False
def on_new_speaker(self, *args):
if self.audio.speaker:
self.audio.speaker.connect("changed", self.on_speaker_changed)
self.on_speaker_changed()
def toggle_mute(self, event):
current_stream = self.audio.speaker
if current_stream:
current_stream.muted = not current_stream.muted
self.on_speaker_changed()
def on_speaker_changed(self, *_):
if not self.audio.speaker:
self.vol_label.set_markup("")
self.remove_style_class("muted")
self.vol_label.remove_style_class("muted")
self.vol_button.remove_style_class("muted")
self.set_tooltip_text("No audio device")
return
if self.audio.speaker.muted:
self.vol_label.set_markup(icons.headphones)
self.add_style_class("muted")
self.vol_label.add_style_class("muted")
self.vol_button.add_style_class("muted")
self.set_tooltip_text("Muted")
else:
self.remove_style_class("muted")
self.vol_label.remove_style_class("muted")
self.vol_button.remove_style_class("muted")
self.update_device_icon()
self.set_tooltip_text(f"{round(self.audio.speaker.volume)}%")
def update_device_icon(self):
if not self.audio.speaker:
self.vol_label.set_markup("")
return True
if self.audio.speaker.muted:
return True
try:
device_type = self.audio.speaker.port.type
if device_type == "headphones":
self.vol_label.set_markup(icons.headphones)
elif device_type == "speaker":
self.vol_label.set_markup(icons.headphones)
else:
self.vol_label.set_markup(icons.headphones)
except AttributeError:
self.vol_label.set_markup(icons.headphones)
return True
def destroy(self):
if self._update_source_id is not None:
GLib.source_remove(self._update_source_id)
if (
hasattr(self, "_periodic_update_source_id")
and self._periodic_update_source_id is not None
):
GLib.source_remove(self._periodic_update_source_id)
super().destroy()
class MicIcon(Box):
def __init__(self, **kwargs):
super().__init__(name="mic-icon", **kwargs)
self.audio = Audio()
self.mic_label = Label(
name="mic-label-dash",
markup=icons.mic,
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self.mic_button = Button(
on_clicked=self.toggle_mute,
child=self.mic_label,
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self.event_box = EventBox(
events=["scroll", "smooth-scroll"],
child=self.mic_button,
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self.event_box.connect("scroll-event", self.on_scroll)
self.add(self.event_box)
self._pending_value = None
self._update_source_id = None
self.audio.connect("notify::microphone", self.on_new_microphone)
if self.audio.microphone:
self.audio.microphone.connect("changed", self.on_microphone_changed)
self.on_microphone_changed()
self.add_events(Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.SMOOTH_SCROLL_MASK)
def on_scroll(self, _, event):
if not self.audio.microphone:
return
step_size = 5
current_volume = self.audio.microphone.volume
if event.direction == Gdk.ScrollDirection.SMOOTH:
if event.delta_y < 0:
new_volume = min(current_volume + step_size, 100)
elif event.delta_y > 0:
new_volume = max(current_volume - step_size, 0)
else:
return
else:
if event.direction == Gdk.ScrollDirection.UP:
new_volume = min(current_volume + step_size, 100)
elif event.direction == Gdk.ScrollDirection.DOWN:
new_volume = max(current_volume - step_size, 0)
else:
return
self._pending_value = new_volume
if self._update_source_id is None:
self._update_source_id = GLib.timeout_add(100, self._update_volume_callback)
def _update_volume_callback(self):
if (
self._pending_value is not None
and self._pending_value != self.audio.microphone.volume
):
self.audio.microphone.volume = self._pending_value
self._pending_value = None
return True
else:
self._update_source_id = None
return False
def on_new_microphone(self, *args):
if self.audio.microphone:
self.audio.microphone.connect("changed", self.on_microphone_changed)
self.on_microphone_changed()
def toggle_mute(self, event):
current_stream = self.audio.microphone
if current_stream:
current_stream.muted = not current_stream.muted
if current_stream.muted:
self.mic_button.get_child().set_markup("")
self.mic_label.add_style_class("muted")
self.mic_button.add_style_class("muted")
else:
self.on_microphone_changed()
self.mic_label.remove_style_class("muted")
self.mic_button.remove_style_class("muted")
def on_microphone_changed(self, *_):
if not self.audio.microphone:
return
if self.audio.microphone.muted:
self.mic_button.get_child().set_markup("")
self.add_style_class("muted")
self.mic_label.add_style_class("muted")
self.set_tooltip_text("Muted")
return
else:
self.remove_style_class("muted")
self.mic_label.remove_style_class("muted")
self.set_tooltip_text(f"{round(self.audio.microphone.volume)}%")
if self.audio.microphone.volume >= 1:
self.mic_button.get_child().set_markup("")
else:
self.mic_button.get_child().set_markup("")
def destroy(self):
if self._update_source_id is not None:
GLib.source_remove(self._update_source_id)
super().destroy()
class ControlSliders(Box):
def __init__(self, **kwargs):
super().__init__(
name="control-sliders",
orientation="h",
spacing=8,
**kwargs,
)
brightness = Brightness.get_initial()
if brightness.screen_brightness != -1:
brightness_row = Box(
orientation="h", spacing=0, h_expand=True, h_align="fill"
)
brightness_row.add(BrightnessIcon())
brightness_row.add(BrightnessSlider())
self.add(brightness_row)
volume_row = Box(orientation="h", spacing=0, h_expand=True, h_align="fill")
volume_row.add(VolumeIcon())
volume_row.add(VolumeSlider())
self.add(volume_row)
mic_row = Box(orientation="h", spacing=0, h_expand=True, h_align="fill")
mic_row.add(MicIcon())
mic_row.add(MicSlider())
self.add(mic_row)
self.show_all()
class ControlSmall(Box):
def __init__(self, **kwargs):
brightness = Brightness.get_initial()
children = []
if brightness.screen_brightness != -1:
children.append(BrightnessSmall())
children.extend([VolumeSmall(), MicSmall()])
super().__init__(
name="control-small",
orientation="h" if not data.VERTICAL else "v",
spacing=4,
children=children,
**kwargs,
)
self.show_all()
+71
View File
@@ -0,0 +1,71 @@
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.shapes import Corner
from widgets.wayland import WaylandWindow as Window
class MyCorner(Box):
def __init__(self, corner):
super().__init__(
name="corner-container",
children=Corner(
name="corner",
orientation=corner,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
size=20,
),
)
class Corners(Window):
def __init__(self):
super().__init__(
name="corners",
layer="bottom",
anchor="top bottom left right",
exclusivity="normal",
# pass_through=True,
visible=False,
all_visible=False,
)
self.all_corners = Box(
name="all-corners",
orientation="v",
h_expand=True,
v_expand=True,
h_align="fill",
v_align="fill",
children=[
Box(
name="top-corners",
orientation="h",
h_align="fill",
children=[
MyCorner("top-left"),
Box(h_expand=True),
MyCorner("top-right"),
],
),
Box(v_expand=True),
Box(
name="bottom-corners",
orientation="h",
h_align="fill",
children=[
MyCorner("bottom-left"),
Box(h_expand=True),
MyCorner("bottom-right"),
],
),
],
)
self.add(self.all_corners)
self.show_all()
+158
View File
@@ -0,0 +1,158 @@
import random
import gi
from fabric.utils import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.stack import Stack
import config.data as data
gi.require_version("Gtk", "3.0")
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk
import modules.icons as icons
from modules.kanban import Kanban
from modules.mixer import Mixer
from modules.pins import Pins
from modules.wallpapers import WallpaperSelector
from modules.widgets import Widgets
class Dashboard(Box):
def __init__(self, **kwargs):
super().__init__(
name="dashboard",
orientation="v",
spacing=8,
h_align="center",
v_align="center",
h_expand=True,
visible=True,
all_visible=True,
)
self.notch = kwargs["notch"]
self.widgets = Widgets(notch=self.notch)
self.pins = Pins()
self.kanban = Kanban()
self.wallpapers = WallpaperSelector()
self.mixer = Mixer()
self.stack = Stack(
name="stack",
transition_type="slide-left-right",
transition_duration=500,
v_expand=True,
v_align="fill",
h_expand=True,
h_align="fill",
)
self.stack.set_homogeneous(False)
self.switcher = Gtk.StackSwitcher(
name="switcher",
spacing=8,
)
self.stack.add_titled(self.widgets, "widgets", "Widgets")
self.stack.add_titled(self.pins, "pins", "Pins")
self.stack.add_titled(self.kanban, "kanban", "Kanban")
self.stack.add_titled(self.wallpapers, "wallpapers", "Wallpapers")
self.stack.add_titled(self.mixer, "mixer", "Mixer")
self.switcher.set_stack(self.stack)
self.switcher.set_hexpand(True)
self.switcher.set_homogeneous(True)
self.switcher.set_can_focus(True)
self.stack.connect("notify::visible-child", self.on_visible_child_changed)
self.add(self.switcher)
self.add(self.stack)
if data.PANEL_THEME == "Panel" and (
data.BAR_POSITION in ["Left", "Right"]
or data.PANEL_POSITION in ["Start", "End"]
):
GLib.idle_add(self._setup_switcher_icons)
# Close on right click if the event isn't handled
self.connect(
"button-release-event",
lambda widget, event: (event.button == 3 and self.notch.close_notch()),
)
self.show_all()
def _setup_switcher_icons(self):
icon_details_map = {
"Widgets": {"icon": icons.widgets, "name": "widgets"},
"Pins": {"icon": icons.pins, "name": "pins"},
"Kanban": {"icon": icons.kanban, "name": "kanban"},
"Wallpapers": {"icon": icons.wallpapers, "name": "wallpapers"},
"Mixer": {"icon": icons.speaker, "name": "mixer"},
}
buttons = self.switcher.get_children()
for btn in buttons:
if isinstance(btn, Gtk.ToggleButton):
original_gtk_label = None
for child_widget in btn.get_children():
if isinstance(child_widget, Gtk.Label):
original_gtk_label = child_widget
break
if original_gtk_label:
label_text = original_gtk_label.get_text()
if label_text in icon_details_map:
details = icon_details_map[label_text]
icon_markup = details["icon"]
css_name_suffix = details["name"]
btn.remove(original_gtk_label)
new_icon_label = Label(
name=f"switcher-icon-{css_name_suffix}", markup=icon_markup
)
btn.add(new_icon_label)
new_icon_label.show_all()
return GLib.SOURCE_REMOVE
def go_to_next_child(self):
children = self.stack.get_children()
current_index = self.get_current_index(children)
next_index = (current_index + 1) % len(children)
self.stack.set_visible_child(children[next_index])
def go_to_previous_child(self):
children = self.stack.get_children()
current_index = self.get_current_index(children)
previous_index = (current_index - 1 + len(children)) % len(children)
self.stack.set_visible_child(children[previous_index])
def get_current_index(self, children):
current_child = self.stack.get_visible_child()
return children.index(current_child) if current_child in children else -1
def on_visible_child_changed(self, stack, param):
visible = stack.get_visible_child()
if visible == self.wallpapers:
self.wallpapers.search_entry.set_text("")
self.wallpapers.search_entry.grab_focus()
def go_to_section(self, section_name):
"""Navigate to a specific section in the dashboard."""
if section_name == "widgets":
self.stack.set_visible_child(self.widgets)
elif section_name == "pins":
self.stack.set_visible_child(self.pins)
elif section_name == "kanban":
self.stack.set_visible_child(self.kanban)
elif section_name == "wallpapers":
self.stack.set_visible_child(self.wallpapers)
elif section_name == "mixer":
self.stack.set_visible_child(self.mixer)
+857
View File
@@ -0,0 +1,857 @@
import json
import logging
import cairo
from fabric.hyprland.widgets import get_hyprland_connection
from fabric.utils import (exec_shell_command, exec_shell_command_async,
get_relative_path, idle_add, remove_handler)
from fabric.utils.helpers import get_desktop_applications
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.eventbox import EventBox
from fabric.widgets.image import Image
from fabric.widgets.revealer import Revealer
from gi.repository import Gdk, GLib, Gtk
import config.data as data
from modules.corners import MyCorner
from utils.icon_resolver import IconResolver
from widgets.wayland import WaylandWindow as Window
def read_config():
"""Read and return the full configuration from the JSON file, handling missing file."""
config_path = get_relative_path("../config/dock.json")
try:
with open(config_path, "r") as file:
config_data = json.load(file)
if "pinned_apps" in config_data and config_data["pinned_apps"] and isinstance(config_data["pinned_apps"][0], str):
all_apps = get_desktop_applications()
app_map = {app.name: app for app in all_apps if app.name}
old_pinned = config_data["pinned_apps"]
config_data["pinned_apps"] = []
for app_id in old_pinned:
app = app_map.get(app_id)
if app:
app_data_obj = {
"name": app.name,
"display_name": app.display_name,
"window_class": app.window_class,
"executable": app.executable,
"command_line": app.command_line
}
config_data["pinned_apps"].append(app_data_obj)
else:
config_data["pinned_apps"].append({"name": app_id})
except (FileNotFoundError, json.JSONDecodeError):
config_data = {"pinned_apps": []}
return config_data
def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface:
alloc = widget.get_allocation()
surface = cairo.ImageSurface(
cairo.Format.ARGB32,
alloc.width,
alloc.height,
)
cr = cairo.Context(surface)
cr.set_source_rgba(255, 255, 255, 0)
cr.rectangle(0, 0, alloc.width, alloc.height)
cr.fill()
widget.draw(cr)
return surface
class Dock(Window):
_instances = []
def __init__(self, monitor_id: int = 0, integrated_mode: bool = False, **kwargs):
self.monitor_id = monitor_id
self.integrated_mode = integrated_mode
self.icon_size = 20 if self.integrated_mode else data.DOCK_ICON_SIZE
self.effective_occlusion_size = 36 + self.icon_size
self.always_show = data.DOCK_ALWAYS_SHOW if not self.integrated_mode else False
anchor_to_set: str
revealer_transition_type: str
self.actual_dock_is_horizontal: bool
main_box_orientation_val: Gtk.Orientation
main_box_h_align_val: str
dock_wrapper_orientation_val: Gtk.Orientation
if not self.integrated_mode:
self.actual_dock_is_horizontal = not data.VERTICAL
if self.actual_dock_is_horizontal:
anchor_to_set = "bottom"
revealer_transition_type = "slide-up"
main_box_orientation_val = Gtk.Orientation.VERTICAL
main_box_h_align_val = "center"
dock_wrapper_orientation_val = Gtk.Orientation.HORIZONTAL
else:
if data.BAR_POSITION == "Left":
anchor_to_set = "right"
revealer_transition_type = "slide-left"
elif data.BAR_POSITION == "Right":
anchor_to_set = "left"
revealer_transition_type = "slide-right"
else:
anchor_to_set = "right"
revealer_transition_type = "slide-left"
main_box_orientation_val = Gtk.Orientation.HORIZONTAL
main_box_h_align_val = "end" if anchor_to_set == "right" else "start"
dock_wrapper_orientation_val = Gtk.Orientation.VERTICAL
super().__init__(
name="dock-window",
layer="top",
anchor=anchor_to_set,
margin="0px 0px 0px 0px",
exclusivity="auto" if self.always_show else "none",
monitor=monitor_id,
**kwargs,
)
Dock._instances.append(self)
else:
self.actual_dock_is_horizontal = True
dock_wrapper_orientation_val = Gtk.Orientation.HORIZONTAL
anchor_to_set = "bottom"
revealer_transition_type = "slide-up"
main_box_orientation_val = Gtk.Orientation.VERTICAL
main_box_h_align_val = "center"
if not self.integrated_mode:
match data.BAR_POSITION:
case "Top":
self.set_margin("-8px 0px 0px 0px")
case "Bottom":
self.set_margin("0px 0px 0px 0px")
case "Left":
self.set_margin("0px 0px 0px -8px")
case "Right":
self.set_margin("0px -8px 0px 0px")
case _:
self.set_margin("0px 0px 0px 0px")
self.config = read_config()
self.conn = get_hyprland_connection()
self.icon_resolver = IconResolver()
self.pinned = self.config.get("pinned_apps", [])
self.config_path = get_relative_path("../config/dock.json")
self.app_map = {}
self._all_apps = get_desktop_applications()
self.app_identifiers = self._build_app_identifiers_map()
self.hide_id = None
self._arranger_handler = None
self._drag_in_progress = False
self.is_mouse_over_dock_area = False
self._prevent_occlusion = False
self._forced_occlusion = False
self.view = Box(name="viewport", spacing=4)
self.wrapper = Box(name="dock", children=[self.view], style_classes=["left"] if data.BAR_POSITION == "Right" else [])
self.wrapper.set_orientation(dock_wrapper_orientation_val)
self.view.set_orientation(dock_wrapper_orientation_val)
if self.integrated_mode:
self.wrapper.add_style_class("integrated")
else:
if dock_wrapper_orientation_val == Gtk.Orientation.VERTICAL:
self.wrapper.add_style_class("vertical")
else:
self.wrapper.remove_style_class("vertical")
match data.DOCK_THEME:
case "Pills":
self.wrapper.add_style_class("pills")
case "Dense":
self.wrapper.add_style_class("dense")
case "Edge":
self.wrapper.add_style_class("edge")
case _:
self.wrapper.add_style_class("pills")
if not self.integrated_mode:
self.dock_eventbox = EventBox()
self.dock_eventbox.add(self.wrapper)
self.dock_eventbox.connect("enter-notify-event", self._on_dock_enter)
self.dock_eventbox.connect("leave-notify-event", self._on_dock_leave)
self.corner_left = Box()
self.corner_right = Box()
self.corner_top = Box()
self.corner_bottom = Box()
if self.actual_dock_is_horizontal:
self.corner_left = Box(
name="dock-corner-left", orientation=Gtk.Orientation.VERTICAL, h_align="start",
children=[Box(v_expand=True, v_align="fill"), MyCorner("bottom-right")]
)
self.corner_right = Box(
name="dock-corner-right", orientation=Gtk.Orientation.VERTICAL, h_align="end",
children=[Box(v_expand=True, v_align="fill"), MyCorner("bottom-left")]
)
self.dock_full = Box(
name="dock-full", orientation=Gtk.Orientation.HORIZONTAL, h_expand=True, h_align="fill",
children=[self.corner_left, self.dock_eventbox, self.corner_right]
)
else:
if anchor_to_set == "right":
self.corner_top = Box(
name="dock-corner-top", orientation=Gtk.Orientation.HORIZONTAL, v_align="start",
children=[Box(h_expand=True, h_align="fill"), MyCorner("bottom-right")]
)
self.corner_bottom = Box(
name="dock-corner-bottom", orientation=Gtk.Orientation.HORIZONTAL, v_align="end",
children=[Box(h_expand=True, h_align="fill"), MyCorner("top-right")]
)
else:
self.corner_top = Box(
name="dock-corner-top", orientation=Gtk.Orientation.HORIZONTAL, v_align="start",
children=[MyCorner("bottom-left"), Box(h_expand=True, h_align="fill")]
)
self.corner_bottom = Box(
name="dock-corner-bottom", orientation=Gtk.Orientation.HORIZONTAL, v_align="end",
children=[MyCorner("top-left"), Box(h_expand=True, h_align="fill")]
)
self.dock_full = Box(
name="dock-full", orientation=Gtk.Orientation.VERTICAL, v_expand=True, v_align="fill",
children=[self.corner_top, self.dock_eventbox, self.corner_bottom]
)
self.dock_revealer = Revealer(
name="dock-revealer",
transition_type=revealer_transition_type,
transition_duration=250,
child_revealed=False,
child=self.dock_full
)
self.hover_activator = EventBox()
self.hover_activator.set_size_request(-1 if self.actual_dock_is_horizontal else 1, 1 if self.actual_dock_is_horizontal else -1)
self.hover_activator.connect("enter-notify-event", self._on_hover_enter)
self.hover_activator.connect("leave-notify-event", self._on_hover_leave)
self.main_box = Box(
orientation=main_box_orientation_val,
children=[self.hover_activator, self.dock_revealer] if data.BAR_POSITION != "Right" else [self.dock_revealer, self.hover_activator],
h_align=main_box_h_align_val,
)
self.add(self.main_box)
if data.DOCK_THEME in ["Edge", "Dense"]:
for corner in [self.corner_left, self.corner_right, self.corner_top, self.corner_bottom]:
corner.set_visible(False)
# Hide normal dock when it should be embedded in the bar OR when dock is disabled
should_be_embedded = (data.BAR_POSITION == "Bottom") or (data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Top", "Bottom"])
if should_be_embedded or not data.DOCK_ENABLED:
self.set_visible(False)
if self.always_show:
self.dock_full.add_style_class("occluded")
self.view.drag_source_set(
Gdk.ModifierType.BUTTON1_MASK,
[Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
Gdk.DragAction.MOVE
)
self.view.drag_dest_set(
Gtk.DestDefaults.ALL,
[Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
Gdk.DragAction.MOVE
)
self.view.connect("drag-data-get", self.on_drag_data_get)
self.view.connect("drag-data-received", self.on_drag_data_received)
self.view.connect("drag-begin", self.on_drag_begin)
self.view.connect("drag-end", self.on_drag_end)
if self.conn.ready:
self.update_dock()
if not self.integrated_mode: GLib.timeout_add(500, self.check_occlusion_state)
else:
self.conn.connect("event::ready", self.update_dock)
if not self.integrated_mode: self.conn.connect("event::ready", lambda *args: GLib.timeout_add(250, self.check_occlusion_state))
# Listen to window events to update dock when apps open/close
self.conn.connect("event::openwindow", self.update_dock)
self.conn.connect("event::closewindow", self.update_dock)
if not self.integrated_mode:
self.conn.connect("event::workspace", self.check_hide)
GLib.timeout_add_seconds(2, self.check_config_change)
def _build_app_identifiers_map(self):
identifiers = {}
for app in self._all_apps:
if app.name: identifiers[app.name.lower()] = app
if app.display_name: identifiers[app.display_name.lower()] = app
if app.window_class: identifiers[app.window_class.lower()] = app
if app.executable: identifiers[app.executable.split('/')[-1].lower()] = app
if app.command_line: identifiers[app.command_line.split()[0].split('/')[-1].lower()] = app
return identifiers
def _normalize_window_class(self, class_name):
if not class_name: return ""
normalized = class_name.lower()
suffixes = [".bin", ".exe", ".so", "-bin", "-gtk"]
for suffix in suffixes:
if normalized.endswith(suffix):
normalized = normalized[:-len(suffix)]
return normalized
def _classes_match(self, class1, class2):
if not class1 or not class2: return False
norm1 = self._normalize_window_class(class1)
norm2 = self._normalize_window_class(class2)
return norm1 == norm2
def on_drag_begin(self, widget, drag_context):
self._drag_in_progress = True
Gtk.drag_set_icon_surface(drag_context, createSurfaceFromWidget(widget))
def _on_hover_enter(self, *args):
if self.integrated_mode: return
self.is_mouse_over_dock_area = True
if self.hide_id:
GLib.source_remove(self.hide_id)
self.hide_id = None
self.dock_revealer.set_reveal_child(True)
if not self.always_show:
self.dock_full.remove_style_class("occluded")
def _on_hover_leave(self, *args):
if self.integrated_mode: return
self.is_mouse_over_dock_area = False
if self._forced_occlusion:
self.dock_revealer.set_reveal_child(False)
else:
self.delay_hide()
def _on_dock_enter(self, widget, event):
if self.integrated_mode: return True
self.is_mouse_over_dock_area = True
if self.hide_id:
GLib.source_remove(self.hide_id)
self.hide_id = None
self.dock_revealer.set_reveal_child(True)
if not self.always_show:
self.dock_full.remove_style_class("occluded")
return True
def _on_dock_leave(self, widget, event):
if self.integrated_mode: return True
if event.detail == Gdk.NotifyType.INFERIOR:
return False
self.is_mouse_over_dock_area = False
if self._forced_occlusion:
self.dock_revealer.set_reveal_child(False)
else:
self.delay_hide()
if not self.always_show:
self.dock_full.add_style_class("occluded")
return True
def find_app(self, app_identifier):
if not app_identifier: return None
if isinstance(app_identifier, dict):
for key in ["window_class", "executable", "command_line", "name", "display_name"]:
if key in app_identifier and app_identifier[key]:
app = self.find_app_by_key(app_identifier[key])
if app: return app
return None
return self.find_app_by_key(app_identifier)
def find_app_by_key(self, key_value):
if not key_value: return None
normalized_id = str(key_value).lower()
if normalized_id in self.app_identifiers:
return self.app_identifiers[normalized_id]
for app in self._all_apps:
if app.name and normalized_id in app.name.lower(): return app
if app.display_name and normalized_id in app.display_name.lower(): return app
if app.window_class and normalized_id in app.window_class.lower(): return app
if app.executable and normalized_id in app.executable.lower(): return app
if app.command_line and normalized_id in app.command_line.lower(): return app
return None
def update_app_map(self):
self._all_apps = get_desktop_applications()
self.app_map = {app.name: app for app in self._all_apps if app.name}
self.app_identifiers = self._build_app_identifiers_map()
def create_button(self, app_identifier, instances):
desktop_app = self.find_app(app_identifier)
icon_img = None
display_name = None
if desktop_app:
icon_img = desktop_app.get_icon_pixbuf(size=self.icon_size)
display_name = desktop_app.display_name or desktop_app.name
id_value = app_identifier["name"] if isinstance(app_identifier, dict) else app_identifier
if not icon_img:
icon_img = self.icon_resolver.get_icon_pixbuf(id_value, self.icon_size)
if not icon_img:
icon_img = self.icon_resolver.get_icon_pixbuf("application-x-executable-symbolic", self.icon_size)
if not icon_img:
icon_img = self.icon_resolver.get_icon_pixbuf("image-missing", self.icon_size)
items = [Image(pixbuf=icon_img)]
tooltip = display_name or (id_value if isinstance(id_value, str) else "Unknown")
if not display_name and instances and instances[0].get("title"):
tooltip = instances[0]["title"]
button = Button(
child= Box(name="dock-icon", orientation="v", h_align="center", children=items),
on_clicked=lambda *a: self.handle_app(app_identifier, instances, desktop_app),
tooltip_text=tooltip, name="dock-app-button",
)
button.app_identifier = app_identifier
button.desktop_app = desktop_app
button.instances = instances
if instances: button.add_style_class("instance")
button.drag_source_set(
Gdk.ModifierType.BUTTON1_MASK,
[Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
Gdk.DragAction.MOVE
)
button.connect("drag-begin", self.on_drag_begin)
button.connect("drag-end", self.on_drag_end)
button.drag_dest_set(
Gtk.DestDefaults.ALL,
[Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
Gdk.DragAction.MOVE
)
button.connect("drag-data-get", self.on_drag_data_get)
button.connect("drag-data-received", self.on_drag_data_received)
button.connect("enter-notify-event", self._on_child_enter)
return button
def handle_app(self, app_identifier, instances, desktop_app=None):
if not instances:
if not desktop_app: desktop_app = self.find_app(app_identifier)
if desktop_app:
launch_success = desktop_app.launch()
if not launch_success:
if desktop_app.command_line: exec_shell_command_async(f"nohup {desktop_app.command_line} &")
elif desktop_app.executable: exec_shell_command_async(f"nohup {desktop_app.executable} &")
else:
cmd_to_run = None
if isinstance(app_identifier, dict):
if "command_line" in app_identifier and app_identifier["command_line"]: cmd_to_run = app_identifier['command_line']
elif "executable" in app_identifier and app_identifier["executable"]: cmd_to_run = app_identifier['executable']
elif "name" in app_identifier and app_identifier["name"]: cmd_to_run = app_identifier['name']
elif isinstance(app_identifier, str): cmd_to_run = app_identifier
if cmd_to_run: exec_shell_command_async(f"nohup {cmd_to_run} &")
else:
focused = self.get_focused()
idx = next((i for i, inst in enumerate(instances) if inst["address"] == focused), -1)
next_inst = instances[(idx + 1) % len(instances)]
exec_shell_command(f"hyprctl dispatch focuswindow address:{next_inst['address']}")
def _on_child_enter(self, widget, event):
if self.integrated_mode: return False
self.is_mouse_over_dock_area = True
if self.hide_id:
GLib.source_remove(self.hide_id)
self.hide_id = None
return False
def delay_hide(self):
if self.integrated_mode: return
if self.hide_id:
GLib.source_remove(self.hide_id)
self.hide_id = GLib.timeout_add(250, self.hide_dock_if_not_hovered)
def hide_dock_if_not_hovered(self):
if self.integrated_mode:
return False
self.hide_id = None
if not self.is_mouse_over_dock_area and not self._drag_in_progress and not self._prevent_occlusion:
if not self.always_show:
self.dock_revealer.set_reveal_child(False)
return False
def check_hide(self, *args):
if self.integrated_mode:
return
if self.is_mouse_over_dock_area or self._drag_in_progress or self._prevent_occlusion:
return
clients = self.get_clients()
current_ws = self.get_workspace()
ws_clients = [w for w in clients if w["workspace"]["id"] == current_ws]
if self.always_show:
if not self.dock_revealer.get_reveal_child():
self.dock_revealer.set_reveal_child(True)
self.dock_full.remove_style_class("occluded")
else:
if self.dock_revealer.get_reveal_child():
self.dock_revealer.set_reveal_child(False)
self.dock_full.add_style_class("occluded")
def update_dock(self, *args):
self.update_app_map()
arranger_handler = getattr(self, "_arranger_handler", None)
if arranger_handler: remove_handler(arranger_handler)
clients = self.get_clients()
running_windows = {}
for c in clients:
window_id = None
if class_name := c.get("initialClass", "").lower(): window_id = class_name
elif class_name := c.get("class", "").lower(): window_id = class_name
elif title := c.get("title", "").lower():
possible_name = title.split(" - ")[0].strip()
if possible_name and len(possible_name) > 1: window_id = possible_name
else: window_id = title
if not window_id: window_id = "unknown-app"
running_windows.setdefault(window_id, []).append(c)
normalized_id = self._normalize_window_class(window_id)
if normalized_id != window_id:
running_windows.setdefault(normalized_id, []).extend(running_windows[window_id])
pinned_buttons = []
used_window_classes = set()
for app_data_item in self.pinned:
app = self.find_app(app_data_item)
instances = []
matched_class = None
possible_identifiers = []
if isinstance(app_data_item, dict):
for key in ["window_class", "executable", "command_line", "name", "display_name"]:
if key in app_data_item and app_data_item[key]: possible_identifiers.append(app_data_item[key].lower())
elif isinstance(app_data_item, str): possible_identifiers.append(app_data_item.lower())
if app:
if app.window_class: possible_identifiers.append(app.window_class.lower())
if app.executable: possible_identifiers.append(app.executable.split('/')[-1].lower())
if app.command_line:
cmd_parts = app.command_line.split()
if cmd_parts: possible_identifiers.append(cmd_parts[0].split('/')[-1].lower())
if app.name: possible_identifiers.append(app.name.lower())
if app.display_name: possible_identifiers.append(app.display_name.lower())
possible_identifiers = list(set(possible_identifiers))
for identifier in possible_identifiers:
if identifier in running_windows:
instances = running_windows[identifier]; matched_class = identifier; break
normalized = self._normalize_window_class(identifier)
if normalized in running_windows:
instances = running_windows[normalized]; matched_class = normalized; break
for window_class_key in running_windows:
if len(identifier) >= 3 and identifier in window_class_key:
instances = running_windows[window_class_key]; matched_class = window_class_key
break
if matched_class: break
if matched_class:
used_window_classes.add(matched_class)
used_window_classes.add(self._normalize_window_class(matched_class))
pinned_buttons.append(self.create_button(app_data_item, instances))
open_buttons = []
for class_name, instances in running_windows.items():
if class_name not in used_window_classes:
app = None
app = self.app_identifiers.get(class_name)
if not app:
norm_class = self._normalize_window_class(class_name)
app = self.app_identifiers.get(norm_class)
if not app: app = self.find_app_by_key(class_name)
if not app and instances and instances[0].get("title"):
title = instances[0].get("title", "")
potential_name = title.split(" - ")[0].strip()
if len(potential_name) > 2: app = self.find_app_by_key(potential_name)
if app:
app_data_obj = {
"name": app.name, "display_name": app.display_name,
"window_class": app.window_class, "executable": app.executable,
"command_line": app.command_line
}
identifier = app_data_obj
else: identifier = class_name
open_buttons.append(self.create_button(identifier, instances))
children = pinned_buttons
separator_orientation = Gtk.Orientation.VERTICAL if self.view.get_orientation() == Gtk.Orientation.HORIZONTAL else Gtk.Orientation.HORIZONTAL
if pinned_buttons and open_buttons:
children += [Box(orientation=separator_orientation, v_expand=False, h_expand=False, h_align="center", v_align="center", name="dock-separator")]
children += open_buttons
self.view.children = children
if not self.integrated_mode:
idle_add(self._update_size)
self._drag_in_progress = False
if not self.integrated_mode:
self.check_occlusion_state()
def _update_size(self):
if self.integrated_mode: return False
width, _ = self.view.get_preferred_width()
self.set_size_request(width, -1)
return False
def get_clients(self):
try: return json.loads(self.conn.send_command("j/clients").reply.decode())
except json.JSONDecodeError: return []
def get_focused(self):
try: return json.loads(self.conn.send_command("j/activewindow").reply.decode()).get("address", "")
except json.JSONDecodeError: return ""
def get_workspace(self):
try: return json.loads(self.conn.send_command("j/activeworkspace").reply.decode()).get("id", 0)
except json.JSONDecodeError: return 0
def check_occlusion_state(self):
if self.integrated_mode:
return False
# When forced occlusion is active, only show on hover
if self._forced_occlusion:
if self.is_mouse_over_dock_area:
if not self.dock_revealer.get_reveal_child():
self.dock_revealer.set_reveal_child(True)
self.dock_full.remove_style_class("occluded")
else:
if self.dock_revealer.get_reveal_child():
self.dock_revealer.set_reveal_child(False)
self.dock_full.add_style_class("occluded")
return True
if self.is_mouse_over_dock_area or self._drag_in_progress or self._prevent_occlusion:
if not self.dock_revealer.get_reveal_child():
self.dock_revealer.set_reveal_child(True)
if not self.always_show:
self.dock_full.remove_style_class("occluded")
return True
if self.always_show:
if not self.dock_revealer.get_reveal_child():
self.dock_revealer.set_reveal_child(True)
self.dock_full.remove_style_class("occluded")
else:
if self.dock_revealer.get_reveal_child():
self.dock_revealer.set_reveal_child(False)
self.dock_full.add_style_class("occluded")
return True
def _find_drag_target(self, widget):
children = self.view.get_children()
while widget is not None and widget not in children:
widget = widget.get_parent() if hasattr(widget, "get_parent") else None
return widget
def on_drag_data_get(self, widget, drag_context, data_obj, info, time):
target = self._find_drag_target(widget.get_parent() if isinstance(widget, Box) else widget)
if target is not None:
index = self.view.get_children().index(target)
data_obj.set_text(str(index), -1)
def on_drag_data_received(self, widget, drag_context, x, y, data_obj, info, time):
target = self._find_drag_target(widget.get_parent() if isinstance(widget, Box) else widget)
if target is None: return
try: source_index = int(data_obj.get_text())
except (TypeError, ValueError): return
children = self.view.get_children()
try: target_index = children.index(target)
except ValueError: return
if source_index != target_index:
separator_index = -1
for i, child_item_loop in enumerate(children):
if child_item_loop.get_name() == "dock-separator":
separator_index = i; break
cross_section_drag = (separator_index != -1 and
((source_index < separator_index and target_index > separator_index) or
(source_index > separator_index and target_index < separator_index)))
child_item_to_move = children.pop(source_index)
children.insert(target_index, child_item_to_move)
self.view.children = children
self.update_pinned_apps(skip_update=not cross_section_drag)
if cross_section_drag: GLib.idle_add(self.update_dock)
def on_drag_end(self, widget, drag_context):
if not self._drag_in_progress:
return
def process_drag_end():
display = Gdk.Display.get_default()
_, x, y, _ = display.get_pointer()
# Get the widget's allocation to check if drag ended outside
alloc = self.view.get_allocation()
widget_x = alloc.x
widget_y = alloc.y
widget_width = alloc.width
widget_height = alloc.height
# Check if pointer is outside the dock area
if not (widget_x <= x <= widget_x + widget_width and widget_y <= y <= widget_y + widget_height):
app_id_dragged = widget.app_identifier
instances_dragged = widget.instances
# Remove pinned app
app_index_dragged = -1
for i, pinned_app_item in enumerate(self.pinned):
if isinstance(app_id_dragged, dict) and isinstance(pinned_app_item, dict):
if app_id_dragged.get("name") == pinned_app_item.get("name"):
app_index_dragged = i
break
elif app_id_dragged == pinned_app_item:
app_index_dragged = i
break
if app_index_dragged >= 0:
self.pinned.pop(app_index_dragged)
self.config["pinned_apps"] = self.pinned
self.update_pinned_apps_file()
self.update_dock()
elif instances_dragged:
address = instances_dragged[0].get("address")
if address:
exec_shell_command(f"hyprctl dispatch focuswindow address:{address}")
self._drag_in_progress = False
if not self.integrated_mode:
self.check_occlusion_state()
GLib.idle_add(process_drag_end)
def check_config_change(self):
new_config = read_config()
if not self.integrated_mode:
new_always_show = data.DOCK_ALWAYS_SHOW
if self.always_show != new_always_show:
self.always_show = new_always_show
self.check_occlusion_state()
if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []):
self.config = new_config
self.pinned = self.config.get("pinned_apps", [])
self.update_app_map()
self.update_dock()
return True
def update_pinned_apps_file(self):
config_path = get_relative_path("../config/dock.json")
try:
with open(config_path, "w") as file:
json.dump(self.config, file, indent=4)
return True
except Exception as e:
logging.error(f"Failed to write dock config: {e}")
return False
def update_pinned_apps(self, skip_update=False):
pinned_children_data = []
for child_widget in self.view.get_children():
if child_widget.get_name() == "dock-separator": break
if hasattr(child_widget, "app_identifier"):
if hasattr(child_widget, "desktop_app") and child_widget.desktop_app:
app = child_widget.desktop_app
app_data_obj = {
"name": app.name, "display_name": app.display_name,
"window_class": app.window_class, "executable": app.executable,
"command_line": app.command_line
}
pinned_children_data.append(app_data_obj)
else:
pinned_children_data.append(child_widget.app_identifier)
self.config["pinned_apps"] = pinned_children_data
self.pinned = pinned_children_data
file_updated = self.update_pinned_apps_file()
if file_updated and not skip_update:
self.update_dock()
@staticmethod
def notify_config_change():
for dock_instance in Dock._instances:
GLib.idle_add(dock_instance.check_config_change_immediate)
def check_config_change_immediate(self):
new_config = read_config()
if not self.integrated_mode:
previous_always_show = self.always_show
self.always_show = data.DOCK_ALWAYS_SHOW
if previous_always_show != self.always_show:
self.check_occlusion_state()
if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []):
self.config = new_config
self.pinned = self.config.get("pinned_apps", [])
self.update_app_map()
self.update_dock()
return False
@staticmethod
def update_visibility(visible):
for dock in Dock._instances:
dock.set_visible(visible)
if visible:
GLib.idle_add(dock.check_occlusion_state)
else:
if hasattr(dock, 'dock_revealer') and dock.dock_revealer.get_reveal_child():
dock.dock_revealer.set_reveal_child(False)
def force_occlusion(self):
"""Force dock to hide and act as if always_show is False."""
if self.integrated_mode:
return
# Save current always_show state
self._saved_always_show = self.always_show
# Set to False to enable hover behavior
self.always_show = False
self._forced_occlusion = True
if not self.is_mouse_over_dock_area:
self.dock_revealer.set_reveal_child(False)
def restore_from_occlusion(self):
"""Restore dock to its previous always_show state."""
if self.integrated_mode:
return
self._forced_occlusion = False
# Restore saved always_show state
if hasattr(self, '_saved_always_show'):
self.always_show = self._saved_always_show
delattr(self, '_saved_always_show')
self.check_occlusion_state()
+321
View File
@@ -0,0 +1,321 @@
import os
import subprocess
import ijson
from fabric.utils import remove_handler
from fabric.utils.helpers import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.entry import Entry
from fabric.widgets.label import Label
from fabric.widgets.stack import Stack
from gi.repository import Gdk
import config.data as data
import modules.icons as icons
vertical_mode = data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"])
emoji_rows = 3 if not vertical_mode else 9
emoji_columns = 9 if not vertical_mode else 5
class EmojiPicker(Box):
def __init__(self, **kwargs):
super().__init__(
name="emoji",
visible=False,
all_visible=False,
**kwargs,
)
self.notch = kwargs["notch"]
self.selected_index = -1
self.emojis_per_page = emoji_columns * emoji_rows
self.current_page_index = 0
self.filtered_emojis = []
self.total_pages = 0
self._arranger_handler: int = 0
self._all_emojis = self._load_emoji_data()
self.stack = Stack(
name="viewport",
spacing=4,
orientation="v",
transition_type="slide-up-down",
transition_duration=200,
)
self.search_entry = Entry(
name="search-entry",
placeholder="Search Emojis...",
h_expand=True,
notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()),
on_activate=lambda entry, *_: self.on_search_entry_activate(entry.get_text()),
on_key_press_event=self.on_search_entry_key_press,
)
self.search_entry.props.xalign = 0.5
self.header_box = Box(
name="header_box",
spacing=10,
orientation="h",
children=[
self.search_entry,
Button(
name="close-button",
child=Label(name="close-label", markup=icons.cancel),
tooltip_text="Exit",
on_clicked=lambda *_: self.close_picker()
),
],
)
self.picker_box = Box(
name="picker-box",
spacing=10,
h_expand=True,
orientation="v",
children=[
self.header_box,
self.stack,
],
)
self.resize_viewport()
self.add(self.picker_box)
self.show_all()
def _load_emoji_data(self):
emoji_data = {}
emoji_file_path = get_relative_path("../assets/emoji.json")
if not os.path.exists(emoji_file_path):
print(f"Emoji JSON file not found at: {emoji_file_path}")
return {}
with open(emoji_file_path, 'r') as f:
for emoji_char, emoji_info in ijson.kvitems(f, ''):
emoji_data[emoji_char] = emoji_info
return emoji_data
def close_picker(self):
self.stack.children = []
self.selected_index = -1
self.notch.close_notch()
def open_picker(self):
self.search_entry.set_text("")
self.current_page_index = 0
self.arrange_viewport()
self.search_entry.grab_focus()
def arrange_viewport(self, query: str = ""):
remove_handler(self._arranger_handler) if self._arranger_handler else None
self.stack.children = []
self.selected_index = -1
self.current_page_index = 0
self.filtered_emojis = [
(emoji_char, emoji_info)
for emoji_char, emoji_info in self._all_emojis.items()
if query.casefold() in (emoji_info.get("name", "") + " " + emoji_info.get("group", "")).casefold()
]
self.total_pages = (len(self.filtered_emojis) + self.emojis_per_page - 1) // self.emojis_per_page if self.filtered_emojis else 0
self.load_page(self.current_page_index)
should_resize = not query
if should_resize:
self.resize_viewport()
if query.strip() != "" and self.get_all_emoji_buttons():
self.update_selection(0)
def load_page(self, page_index):
self.update_selection(-1)
page_box = Box(name=f"page-box-{page_index}", orientation="v", spacing=4)
start_index = page_index * self.emojis_per_page
end_index = min((page_index + 1) * self.emojis_per_page, len(self.filtered_emojis))
page_emojis = self.filtered_emojis[start_index:end_index]
grid_box = Box(name="emoji-grid-box", orientation="v", spacing=2)
row_box = None
for i, (emoji_char, emoji_info) in enumerate(page_emojis):
if i % emoji_columns == 0:
row_box = Box(name="emoji-row-box", orientation="h", spacing=2)
grid_box.add(row_box)
if row_box is not None:
row_box.add(self.bake_emoji_slot(emoji_char, emoji_info))
page_box.add(grid_box)
self.stack.add_named(page_box, f"page-{page_index}")
self.stack.set_visible_child_name(f"page-{page_index}")
page_box.show_all()
buttons = self.get_all_emoji_buttons()
if buttons and self.selected_index != -1:
page_relative_index = self.selected_index % self.emojis_per_page
if page_relative_index < len(buttons):
self.update_selection(page_relative_index)
else:
self.update_selection(len(buttons) - 1)
def resize_viewport(self):
return False
def bake_emoji_slot(self, emoji_char: str, emoji_info: dict, **kwargs) -> Button:
button = Button(
name="emoji-slot-button",
child=Box(
name="emoji-slot-box",
orientation="horizontal",
halign="center",
valign="center",
children=[
Label(
name="emoji-char-label",
label=emoji_char,
use_markup=True,
v_align="center",
h_align="center",
css_name="emoji-char-label"
),
],
),
tooltip_text=emoji_info.get("name", "Unknown"),
on_clicked=lambda *_: (self.copy_emoji_to_clipboard(emoji_char), self.close_picker()),
**kwargs,
)
return button
def update_selection(self, new_index: int):
buttons = self.get_all_emoji_buttons()
if not buttons:
self.selected_index = -1
return
if self.selected_index != -1 and self.selected_index < len(buttons):
current_button = buttons[self.selected_index]
current_button.get_style_context().remove_class("selected")
if not buttons or current_button not in buttons:
self.selected_index = -1
if 0 <= new_index < len(buttons):
new_button = buttons[new_index]
new_button.get_style_context().add_class("selected")
self.selected_index = new_index
else:
self.selected_index = -1
def get_all_emoji_buttons(self):
buttons = []
current_page = self.stack.get_visible_child()
if current_page and current_page.get_children():
if current_page.get_children()[0].get_children():
for row_box in current_page.get_children()[0].get_children():
buttons.extend(row_box.get_children())
return buttons
def on_search_entry_activate(self, text):
buttons = self.get_all_emoji_buttons()
if buttons:
if self.selected_index != -1:
buttons[self.selected_index].clicked()
elif buttons and text.strip() != "":
buttons[0].clicked()
def on_search_entry_key_press(self, widget, event):
if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right):
self.move_selection_2d(event.keyval)
return True
elif event.keyval == Gdk.KEY_Escape:
self.close_picker()
return True
return False
def move_selection_2d(self, keyval):
buttons = self.get_all_emoji_buttons()
total_items_current_page = len(buttons)
if total_items_current_page == 0:
return
rows = emoji_rows
columns = emoji_columns
if self.selected_index == -1:
if keyval in (Gdk.KEY_Down, Gdk.KEY_Right):
new_index = 0
elif keyval in (Gdk.KEY_Up, Gdk.KEY_Left):
new_index = total_items_current_page - 1
else:
return
else:
current_index_page = self.selected_index
row = current_index_page // columns
col = current_index_page % columns
if keyval == Gdk.KEY_Right:
new_col = (col + 1) % columns
new_row = row
if new_col == 0:
new_row = (row + 1)
elif keyval == Gdk.KEY_Left:
new_col = (col - 1) % columns
new_row = row
if new_col == (columns - 1):
new_row = (row - 1)
elif keyval == Gdk.KEY_Down:
new_row = row + 1
new_col = col
elif keyval == Gdk.KEY_Up:
new_row = row - 1
new_col = col
else:
return
if new_row >= rows:
if self.current_page_index < self.total_pages - 1:
current_col = col # Keep track of current column
self.current_page_index += 1
self.load_page(self.current_page_index)
new_index = current_col # Try to keep the same column
if new_index >= total_items_current_page: # if column is out of bound, select last
new_index = total_items_current_page - 1
self.selected_index = -1
self.update_selection(new_index)
return
else:
new_index = total_items_current_page - 1
elif new_row < 0:
if self.current_page_index > 0:
current_col = col # Keep track of current column
self.current_page_index -= 1
self.load_page(self.current_page_index)
new_index = (rows - 1) * columns + current_col # Select last row, same column
if new_index >= total_items_current_page: # if column is out of bound, select last
new_index = total_items_current_page -1
self.selected_index = -1
self.update_selection(new_index)
return
else:
new_index = 0
else:
new_index = new_row * columns + new_col
if new_index >= total_items_current_page:
new_index = total_items_current_page - 1
if new_index < 0:
new_index = 0
elif new_index >= total_items_current_page:
new_index = total_items_current_page - 1
self.update_selection(new_index)
def copy_emoji_to_clipboard(self, emoji_char: str):
try:
subprocess.run(["wl-copy"], input=emoji_char.encode('utf-8'), check=True)
except subprocess.CalledProcessError as e:
print(f"Clipboard copy failed: {e}")
+197
View File
@@ -0,0 +1,197 @@
# Parameters
font_family: str = "tabler-icons"
font_weight: str = "normal"
span: str = f"<span font-family='{font_family}' font-weight='{font_weight}'>"
# Panels
apps: str = "&#xf1fd;"
dashboard: str = "&#xea87;"
chat: str = "&#xf59f;"
windows: str = "&#xefe6;"
# Bar
colorpicker: str = "&#xebe6;"
media: str = "&#xf00d;"
# Toolbox
toolbox: str = "&#xebca;"
ssfull: str = "&#xec3c;"
ssregion: str = "&#xf201;"
sswindow: str = "&#xeaea;"
screenshots: str = "&#xeb0a;"
screenrecord: str = "&#xed22;"
recordings: str = "&#xeafa;"
ocr: str = "&#xfcc3;"
gamemode: str = "&#xf026;"
gamemode_off: str = "&#xf111;"
close: str = "&#xeb55;"
# Circles
temp: str = "&#xeb38;"
disk: str = "&#xea88;"
battery: str = "&#xea38;"
memory: str = "&#xfa97;"
cpu: str = "&#xef8e;"
gpu: str = "&#xf233;"
# AIchat
reload: str = "&#xf3ae;"
detach: str = "&#xea99;"
# Wallpapers
add: str = "&#xeb0b;"
sort: str = "&#xeb5a;"
circle: str = "&#xf671;"
# Chevrons
chevron_up: str = "&#xea62;"
chevron_down: str = "&#xea5f;"
chevron_left: str = "&#xea60;"
chevron_right: str = "&#xea61;"
# Power
lock: str = "&#xeae2;"
suspend: str = "&#xece7;"
logout: str = "&#xeba8;"
reboot: str = "&#xeb13;"
shutdown: str = "&#xeb0d;"
# Power Manager
power_saving: str = "&#xed4f;"
power_balanced: str = "&#xfa77;"
power_performance: str = "&#xec45;"
charging: str = "&#xefef;"
discharging: str = "&#xefe9;"
alert: str = "&#xea06;"
bat_charging: str = "&#xeeca;"
bat_discharging: str = "&#xf0a1;"
bat_low: str = "&#xff1d;"
bat_full: str = "&#xea38;"
# Applets
wifi_0: str = "&#xeba3;"
wifi_1: str = "&#xeba4;"
wifi_2: str = "&#xeba5;"
wifi_3: str = "&#xeb52;"
world: str = "&#xeb54;"
world_off: str = "&#xf1ca;"
bluetooth: str = "&#xea37;"
night: str = "&#xeaf8;"
coffee: str = "&#xef0e;"
notifications: str = "&#xea35;"
wifi_off: str = "&#xecfa;"
bluetooth_off: str = "&#xeceb;"
night_off: str = "&#xf162;"
notifications_off: str = "&#xece9;"
notifications_clear: str = "&#xf814;"
download: str = "&#xea96;"
upload: str = "&#xeb47;"
# Bluetooth
bluetooth_connected: str = "&#xecea;"
bluetooth_disconnected: str = "&#xf081;"
# Player
pause: str = "&#xf690;"
play: str = "&#xf691;"
stop: str = "&#xf695;"
skip_back: str = "&#xf693;"
skip_forward: str = "&#xf694;"
prev: str = "&#xf697;"
next: str = "&#xf696;"
shuffle: str = "&#xf000;"
repeat: str = "&#xeb72;"
music: str = "&#xeafc;"
rewind_backward_5: str = "&#xfabf;"
rewind_forward_5: str = "&#xfac7;"
# Volume
vol_off: str = "&#xf1c3;"
vol_mute: str = "&#xeb50;"
vol_medium: str = "&#xeb4f;"
vol_high: str = "&#xeb51;"
mic: str = "&#xeaf0;"
mic_mute: str = "&#xed16;"
speaker: str = "&#x10045;"
headphones: str = "&#xfa3c;"
mic_filled: str = "&#xfe0f;"
# Overview
circle_plus: str = "&#xea69;"
# Pins
paperclip: str = "&#xeb02;"
# Clipboard Manager
clipboard: str = "&#xea6f;"
clip_text: str = "&#xf089;"
# Confirm
accept: str = "&#xea5e;"
cancel: str = "&#xeb55;"
trash: str = "&#xeb41;"
# Config
config: str = "&#xeb20;"
# Icons
firefox: str = "&#xecfd;"
chromium: str = "&#xec18;"
spotify: str = "&#xfe86;"
disc: str = "&#x1003e;"
disc_off: str = "&#xf118;"
# Brightness
brightness_low: str = "&#xeb7d;"
brightness_medium: str = "&#xeb7e;"
brightness_high: str = "&#xeb30;"
brightness: str = "&#xee1a;"
# Dashboard
widgets: str = "&#xf02c;"
pins: str = "&#xec9c;"
kanban: str = "&#xec3f;"
wallpapers: str = "&#xeb61;"
sparkles: str = "&#xf6d7;"
# Misc
dot: str = "&#xf698;"
palette: str = "&#xeb01;"
cloud_off: str = "&#xed3e;"
loader: str = "&#xeca3;"
radar: str = "&#xf017;"
emoji: str = "&#xeaf7;"
keyboard: str = "&#xebd6;"
terminal: str = "&#xebef;"
timer_off: str = "&#xf146;"
timer_on: str = "&#xf756;"
spy: str = "&#xf227;"
# Dice
dice_1: str = "&#xf08b;"
dice_2: str = "&#xf08c;"
dice_3: str = "&#xf08d;"
dice_4: str = "&#xf08e;"
dice_5: str = "&#xf08f;"
dice_6: str = "&#xf090;"
exceptions: list[str] = ["font_family", "font_weight", "span"]
def apply_span() -> None:
global_dict = globals()
for key in global_dict:
if key not in exceptions and not key.startswith("__"):
global_dict[key] = f"{span}{global_dict[key]}</span>"
apply_span()
+364
View File
@@ -0,0 +1,364 @@
import json
import os
from pathlib import Path
import cairo
import gi
from fabric.widgets.box import Box
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
import config.data as data
import modules.icons as icons
gi.require_version('Gtk', '3.0')
from gi.repository import Gdk, GLib, GObject, Gtk
def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface:
alloc = widget.get_allocation()
surface = cairo.ImageSurface(cairo.Format.ARGB32, alloc.width, alloc.height)
cr = cairo.Context(surface)
cr.set_source_rgba(0, 0, 0, 0)
cr.rectangle(0, 0, alloc.width, alloc.height)
cr.fill()
widget.draw(cr)
return surface
class InlineEditor(Gtk.Box):
__gsignals__ = {
'confirmed': (GObject.SignalFlags.RUN_LAST, None, (str,)),
'canceled': (GObject.SignalFlags.RUN_LAST, None, ())
}
def __init__(self, initial_text=""):
super().__init__(name="inline-editor", spacing=4)
self.text_view = Gtk.TextView()
self.text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
buffer = self.text_view.get_buffer()
buffer.set_text(initial_text)
self.text_view.connect("key-press-event", self.on_key_press)
confirm_btn = Gtk.Button(name="kanban-btn", child=Label(name="kanban-btn-label", markup=icons.accept))
confirm_btn.connect("clicked", self.on_confirm)
confirm_btn.get_style_context().add_class("flat")
cancel_btn = Gtk.Button(name="kanban-btn", child=Label(name="kanban-btn-neg", markup=icons.cancel))
cancel_btn.connect("clicked", self.on_cancel)
cancel_btn.get_style_context().add_class("flat")
sw = ScrolledWindow(name="scrolled-window", propagate_height=False, propagate_width=False)
sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
sw.set_min_content_height(50)
sw.add(self.text_view)
self.button_box = Box(children=[confirm_btn, cancel_btn], spacing=4)
self.center_box = CenterBox(center_children=[self.button_box], orientation="v")
self.pack_start(sw, True, True, 0)
self.pack_start(self.center_box, False, False, 0)
self.show_all()
def on_confirm(self, widget):
buffer = self.text_view.get_buffer()
start, end = buffer.get_bounds()
text = buffer.get_text(start, end, True).strip()
if text:
self.emit('confirmed', text)
else:
self.emit('canceled')
def on_cancel(self, widget):
self.emit('canceled')
def on_key_press(self, widget, event):
if event.keyval == Gdk.KEY_Escape:
self.emit('canceled')
return True
if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
state = event.get_state()
if state & Gdk.ModifierType.SHIFT_MASK:
buffer = self.text_view.get_buffer()
cursor_iter = buffer.get_iter_at_mark(buffer.get_insert())
buffer.insert(cursor_iter, "\n")
return True
else:
self.on_confirm(widget)
return True
return False
class KanbanNote(Gtk.EventBox):
__gsignals__ = {
'changed': (GObject.SignalFlags.RUN_LAST, None, ()),
}
def __init__(self, text):
super().__init__()
self.text = text
self.setup_ui()
self.setup_dnd()
self.connect("button-press-event", self.on_button_press)
def setup_ui(self):
self.box = Gtk.Box(name="kanban-note", spacing=4)
self.label = Gtk.Label(label=self.text)
self.label.set_line_wrap(True)
self.label.set_line_wrap_mode(Gtk.WrapMode.WORD)
self.delete_btn = Gtk.Button(name="kanban-btn", child=Label(name="kanban-btn-neg", markup=icons.trash))
self.delete_btn.connect("clicked", self.on_delete_clicked)
self.center_btn = CenterBox(orientation="v", start_children=[self.delete_btn])
self.box.pack_start(self.label, True, True, 0)
self.box.pack_start(self.center_btn, False, False, 0)
self.add(self.box)
self.show_all()
def setup_dnd(self):
self.drag_source_set(
Gdk.ModifierType.BUTTON1_MASK,
[Gtk.TargetEntry.new('UTF8_STRING', Gtk.TargetFlags.SAME_APP, 0)],
Gdk.DragAction.MOVE
)
self.connect("drag-data-get", self.on_drag_data_get)
self.connect("drag-data-delete", self.on_drag_data_delete)
self.connect("drag-begin", self.on_drag_begin)
def on_button_press(self, widget, event):
if event.type != Gdk.EventType._2BUTTON_PRESS:
return True
self.start_edit()
return False
def on_drag_begin(self, widget, context):
surface = createSurfaceFromWidget(self)
Gtk.drag_set_icon_surface(context, surface)
def on_drag_data_get(self, widget, drag_context, data, info, time):
data.set_text(self.label.get_text(), -1)
def on_drag_data_delete(self, widget, drag_context):
self.get_parent().destroy()
def on_delete_clicked(self, button):
self.get_parent().destroy()
def start_edit(self):
row = self.get_parent()
editor = InlineEditor(self.label.get_text())
def on_confirmed(editor, text):
self.label.set_text(text)
row.remove(editor)
row.add(self)
row.show_all()
self.emit('changed')
def on_canceled(editor):
row.remove(editor)
row.add(self)
row.show_all()
editor.connect('confirmed', on_confirmed)
editor.connect('canceled', on_canceled)
row.remove(self)
row.add(editor)
row.show_all()
GLib.timeout_add(50, lambda: (editor.text_view.grab_focus(), False))
class KanbanColumn(Gtk.Frame):
__gsignals__ = {
'changed': (GObject.SignalFlags.RUN_LAST, None, ()),
}
def __init__(self, title):
super().__init__(name="kanban-column")
self.title = title
self.setup_ui()
self.setup_dnd()
self.set_hexpand(True)
self.set_vexpand(True)
def setup_ui(self):
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
self.listbox = Gtk.ListBox()
self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
self.add_btn = Gtk.Button(name="kanban-btn-add", child=Label(name="kanban-btn-label", markup=icons.add))
header = CenterBox(name="kanban-header", center_children=[Label(name="column-header", label=self.title)], end_children=[self.add_btn])
self.box.pack_start(header, False, False, 0)
self.add_btn.connect("clicked", self.on_add_clicked)
self.scroller = ScrolledWindow(name="scrolled-window", propagate_height=False, propagate_width=False)
self.scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self.scroller.add(self.listbox)
self.scroller.set_vexpand(True)
self.box.pack_start(self.scroller, True, True, 0)
self.box.pack_start(self.add_btn, False, False, 0)
self.add(self.box)
self.show_all()
def setup_dnd(self):
self.listbox.drag_dest_set(
Gtk.DestDefaults.ALL,
[Gtk.TargetEntry.new('UTF8_STRING', Gtk.TargetFlags.SAME_APP, 0)],
Gdk.DragAction.MOVE
)
self.listbox.connect("drag-data-received", self.on_drag_data_received)
self.listbox.connect("drag-motion", self.on_drag_motion)
self.listbox.connect("drag-leave", self.on_drag_leave)
def on_add_clicked(self, button):
editor = InlineEditor()
row = Gtk.ListBoxRow(name="kanban-row")
row.add(editor)
self.listbox.add(row)
self.listbox.show_all()
editor.text_view.grab_focus()
def on_confirmed(editor, text):
note = KanbanNote(text)
note.connect('changed', lambda x: self.emit('changed'))
row.remove(editor)
row.add(note)
self.listbox.show_all()
self.emit('changed')
def on_canceled(editor):
row.destroy()
def scroll_to_bottom():
adj = self.scroller.get_vadjustment()
adj.set_value(adj.get_upper())
editor.connect('confirmed', on_confirmed)
editor.connect('canceled', on_canceled)
GLib.idle_add(scroll_to_bottom) # ensure this is called after row is loaded
def add_note(self, text, suppress_signal=False):
note = KanbanNote(text)
note.connect('changed', lambda x: self.emit('changed'))
row = Gtk.ListBoxRow(name="kanban-row")
row.add(note)
row.connect('destroy', lambda x: self.emit('changed'))
self.listbox.add(row)
self.listbox.show_all()
if not suppress_signal:
self.emit('changed')
def get_notes(self):
return [
row.get_children()[0].label.get_text()
for row in self.listbox.get_children()
if isinstance(row.get_children()[0], KanbanNote)
]
def clear_notes(self, suppress_signal=False):
for row in self.listbox.get_children():
row.destroy()
if not suppress_signal:
self.emit('changed')
def on_drag_data_received(self, widget, drag_context, x, y, data, info, time):
text = data.get_text()
if text:
row = self.listbox.get_row_at_y(y)
new_note = KanbanNote(text)
new_note.connect('changed', lambda x: self.emit('changed'))
new_row = Gtk.ListBoxRow(name="kanban-row")
new_row.add(new_note)
new_row.connect('destroy', lambda x: self.emit('changed'))
if row:
self.listbox.insert(new_row, row.get_index())
else:
self.listbox.add(new_row)
self.listbox.show_all()
drag_context.finish(True, False, time)
self.emit('changed')
def on_drag_motion(self, widget, drag_context, x, y, time):
Gdk.drag_status(drag_context, Gdk.DragAction.MOVE, time)
return True
def on_drag_leave(self, widget, drag_context, time):
widget.get_parent().get_parent().drag_unhighlight()
class Kanban(Gtk.Box):
STATE_FILE = Path(os.path.expanduser("~/.kanban.json"))
def __init__(self):
super().__init__(name="kanban")
self.grid = Gtk.Grid(column_spacing=4, column_homogeneous=True, row_spacing=4, row_homogeneous=True)
self.grid.set_vexpand(True)
self.add(self.grid)
self.columns = [
KanbanColumn("To Do"),
KanbanColumn("In Progress"),
KanbanColumn("Done")
]
vertical_mode = True if data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]) else False
for i, column in enumerate(self.columns):
if vertical_mode == False:
self.grid.attach(column, i, 0, 1, 1)
else:
self.grid.attach(column, 0, i, 1, 1)
column.connect('changed', lambda x: self.save_state())
self.load_state()
self.show_all()
def save_state(self):
state = {
"columns": [
{"title": col.title, "notes": col.get_notes()}
for col in self.columns
]
}
try:
with open(self.STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
except Exception as e:
print(f"Error saving state: {e}")
def load_state(self):
try:
with open(self.STATE_FILE, "r") as f:
state = json.load(f)
for col_data in state["columns"]:
for column in self.columns:
if column.title == col_data["title"]:
column.clear_notes(suppress_signal=True)
for note_text in col_data["notes"]:
column.add_note(note_text, suppress_signal=True)
break
except FileNotFoundError:
pass
except Exception as e:
print(f"Error loading state: {e}")
+787
View File
@@ -0,0 +1,787 @@
import json
import math
import operator
import os
import re
import subprocess
from collections.abc import Iterator
import numpy as np
from fabric.utils import (DesktopApp, exec_shell_command_async,
get_desktop_applications, idle_add, remove_handler)
from fabric.utils.helpers import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.entry import Entry
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from gi.repository import Gdk, GLib
import config.data as data
import modules.icons as icons
from modules.dock import Dock
from modules.updater import run_updater
from utils.conversion import Conversion
tooltip_settings = f"<b>Open {data.APP_NAME_CAP} Settings</b>"
tooltip_close = "<b>Close</b>"
class AppLauncher(Box):
def __init__(self, **kwargs):
super().__init__(
name="app-launcher",
visible=False,
all_visible=False,
**kwargs,
)
self.notch = kwargs["notch"]
self.selected_index = -1
self._arranger_handler: int = 0
self._all_apps = get_desktop_applications()
self.converter = Conversion()
self.calc_history_path = f"{data.CACHE_DIR}/calc.json"
if os.path.exists(self.calc_history_path):
with open(self.calc_history_path, "r") as f:
self.calc_history = json.load(f)
else:
self.calc_history = []
self.conversion_history_path = f"{data.CACHE_DIR}/conversion.json"
if os.path.exists(self.conversion_history_path):
with open(self.conversion_history_path, "r") as f:
self.conversion_history = json.load(f)
else:
self.conversion_history = []
self.viewport = Box(name="viewport", spacing=4, orientation="v")
self.search_entry = Entry(
name="search-entry",
placeholder="Search Applications...",
h_expand=True,
h_align="fill",
notify_text=self.notify_text,
on_activate=lambda entry, *_: self.on_search_entry_activate(entry.get_text()),
on_key_press_event=self.on_search_entry_key_press,
)
self.search_entry.props.xalign = 0.5
self.scrolled_window = ScrolledWindow(
name="scrolled-window",
spacing=10,
h_expand=True,
v_expand=True,
h_align="fill",
v_align="fill",
child=self.viewport,
propagate_width=False,
propagate_height=False,
)
self.header_box = Box(
name="header_box",
spacing=10,
orientation="h",
children=[
Button(
name="config-button",
tooltip_markup=tooltip_settings,
child=Label(name="config-label", markup=icons.config),
on_clicked=lambda *_: (exec_shell_command_async(f"python {get_relative_path('../config/config.py')}"), self.close_launcher()),
),
self.search_entry,
Button(
name="close-button",
tooltip_markup=tooltip_close,
child=Label(name="close-label", markup=icons.cancel),
tooltip_text="Exit",
on_clicked=lambda *_: self.close_launcher()
),
],
)
self.launcher_box = Box(
name="launcher-box",
spacing=10,
h_expand=True,
orientation="v",
children=[
self.header_box,
self.scrolled_window,
],
)
self.resize_viewport()
self.add(self.launcher_box)
self.show_all()
def close_launcher(self):
self.viewport.children = []
self.selected_index = -1
self.notch.close_notch()
def open_launcher(self):
self._all_apps = get_desktop_applications()
self.arrange_viewport()
def clear_selection():
entry = self.search_entry
if entry.get_text():
pos = len(entry.get_text())
entry.set_position(pos)
entry.select_region(pos, pos)
return False
GLib.idle_add(clear_selection)
def ensure_initialized(self):
"""Make sure the launcher is initialized with apps list before opening"""
if not hasattr(self, '_initialized'):
self._all_apps = get_desktop_applications()
self._initialized = True
return True
return False
def arrange_viewport(self, query: str = ""):
if query.startswith("="):
self.update_calculator_viewport()
return
if query.startswith(";"):
# In conversion mode, update history view once (not per keystroke)
self.update_conversion_viewport()
return
remove_handler(self._arranger_handler) if self._arranger_handler else None
self.viewport.children = []
self.selected_index = -1
def extract_command_name(command_line):
"""Extract base command name from command line, removing paths and arguments"""
if not command_line:
return ""
# Remove common shell wrappers
if command_line.startswith("/bin/sh -c"):
# Handle wrapped commands like "/bin/sh -c "\$SHELL -i -c scrcpy""
return ""
# Split by spaces and take first part (the command)
cmd = command_line.split()[0] if command_line.split() else ""
# Extract just the command name from full paths
if "/" in cmd:
cmd = cmd.split("/")[-1]
return cmd
filtered_apps_iter = iter(
sorted(
[
app
for app in self._all_apps
if query.casefold()
in (
(app.display_name or "")
+ (" " + app.name + " ")
+ (app.generic_name or "")
+ (" " + (app.command_line or "") + " ")
+ (" " + (app.executable or "") + " ")
+ (" " + extract_command_name(app.command_line) + " ")
).casefold()
],
key=lambda app: (app.display_name or "").casefold(),
)
)
should_resize = operator.length_hint(filtered_apps_iter) == len(self._all_apps)
self._arranger_handler = idle_add(
lambda apps_iter: self.add_next_application(apps_iter) or self.handle_arrange_complete(should_resize, query),
filtered_apps_iter,
pin=True,
)
def handle_arrange_complete(self, should_resize, query):
if query.strip() != "" and self.viewport.get_children():
self.update_selection(0)
return False
def add_next_application(self, apps_iter: Iterator[DesktopApp]):
if not (app := next(apps_iter, None)):
return False
self.viewport.add(self.bake_application_slot(app))
return True
def resize_viewport(self):
# Removed set_min_content_width to prevent size retention issues
# when switching between modules in the notch stack
pass
def bake_application_slot(self, app: DesktopApp, **kwargs) -> Button:
button = Button(
name="slot-button",
child=Box(
name="slot-box",
orientation="h",
spacing=10,
children=[
Image(name="app-icon", pixbuf=app.get_icon_pixbuf(size=24), h_align="start"),
Label(
name="app-label",
label=app.display_name or "Unknown",
ellipsization="end",
v_align="center",
h_align="center",
),
Label(
name="app-desc",
label=app.description or "",
ellipsization="end",
v_align="center",
h_align="start",
h_expand=True,
),
],
),
tooltip_text=app.description,
on_clicked=lambda *_: (app.launch(), self.close_launcher()),
**kwargs,
)
return button
def update_selection(self, new_index: int):
if self.selected_index != -1 and self.selected_index < len(self.viewport.get_children()):
current_button = self.viewport.get_children()[self.selected_index]
current_button.get_style_context().remove_class("selected")
if new_index != -1 and new_index < len(self.viewport.get_children()):
new_button = self.viewport.get_children()[new_index]
new_button.get_style_context().add_class("selected")
self.selected_index = new_index
self.scroll_to_selected(new_button)
else:
self.selected_index = -1
def scroll_to_selected(self, button):
def scroll():
adj = self.scrolled_window.get_vadjustment()
alloc = button.get_allocation()
if alloc.height == 0:
return False
y = alloc.y
height = alloc.height
page_size = adj.get_page_size()
current_value = adj.get_value()
visible_top = current_value
visible_bottom = current_value + page_size
if y < visible_top:
adj.set_value(y)
elif y + height > visible_bottom:
new_value = y + height - page_size
adj.set_value(new_value)
return False
GLib.idle_add(scroll)
def on_search_entry_activate(self, text):
if text.startswith("="):
if self.selected_index == -1:
self.evaluate_calculator_expression(text)
return
if text.startswith(";"):
# If in calculator mode and no history item is selected, evaluate new expression.
if self.selected_index == -1:
self.evaluate_calculator_expression(text)
return
match text:
case ":w":
self.notch.open_notch("wallpapers")
case ":d":
self.notch.open_notch("dashboard")
case ":p":
self.notch.open_notch("power")
case ":update":
GLib.idle_add(lambda: run_updater(force=True))
case ":settings":
exec_shell_command_async(f"python {get_relative_path('../config/config.py')}")
self.close_launcher()
case ":config":
exec_shell_command_async(f"python {get_relative_path('../config/config.py')}")
self.close_launcher()
case _:
children = self.viewport.get_children()
if children:
if text.strip() == "" and self.selected_index == -1:
return
selected_index = self.selected_index if self.selected_index != -1 else 0
if 0 <= selected_index < len(children):
children[selected_index].clicked()
def on_search_entry_key_press(self, widget, event):
text = widget.get_text()
if text.startswith("="):
if event.keyval == Gdk.KEY_Down:
self.move_selection(1)
return True
elif event.keyval == Gdk.KEY_Up:
self.move_selection(-1)
return True
elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
if self.selected_index != -1 and self.selected_index < len(self.calc_history):
if event.state & Gdk.ModifierType.SHIFT_MASK:
self.delete_selected_calc_history()
else:
selected_text = self.calc_history[self.selected_index]
self.copy_text_to_clipboard(selected_text)
self.selected_index = -1
else:
self.selected_index = -1
self.evaluate_calculator_expression(text)
return True
elif event.keyval == Gdk.KEY_Escape:
self.close_launcher()
return True
return False
if text.startswith(";"):
if event.keyval == Gdk.KEY_Down:
self.move_selection(1)
return True
elif event.keyval == Gdk.KEY_Up:
self.move_selection(-1)
return True
elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
# In conversion mode, if a history item is highlighted:
if self.selected_index != -1 and self.selected_index < len(self.conversion_history):
if event.state & Gdk.ModifierType.SHIFT_MASK:
# Shift+Enter deletes the selected calculator history item
self.delete_selected_conversion_history()
else:
# Normal Enter copies the result
selected_text = self.conversion_history[self.selected_index]
self.copy_text_to_clipboard(selected_text)
# Clear selection so new expressions are evaluated on further Return presses
self.selected_index = -1
else:
# Force reset selection index
self.selected_index = -1
# No item selected, evaluate the expression
self.evaluate_conversion_expression(text)
return True
elif event.keyval == Gdk.KEY_Escape:
self.close_launcher()
return True
return False
else:
if event.keyval == Gdk.KEY_Down:
self.move_selection(1)
return True
elif event.keyval == Gdk.KEY_Up:
self.move_selection(-1)
return True
elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter) and (event.state & Gdk.ModifierType.SHIFT_MASK):
self.add_selected_app_to_dock()
return True
elif event.keyval == Gdk.KEY_Escape:
self.close_launcher()
return True
return False
def notify_text(self, entry, *_):
"""Handle text changes in the search entry"""
text = entry.get_text()
if text.startswith("="):
self.update_calculator_viewport()
self.selected_index = -1
elif text.startswith(";"):
self.update_conversion_viewport()
# Always reset selection when typing a new expression
self.selected_index = -1
else:
self.arrange_viewport(text)
def add_selected_app_to_dock(self):
"""Adds the currently selected application to the dock.json file with comprehensive metadata."""
children = self.viewport.get_children()
if not children or self.selected_index == -1 or self.selected_index >= len(children):
return
selected_button = children[self.selected_index]
selected_app = next((app for app in self._all_apps if app.display_name == selected_button.get_child().get_children()[1].props.label), None)
if not selected_app:
return
app_data = {k: v for k, v in {
"name": selected_app.name,
"display_name": selected_app.display_name,
"window_class": selected_app.window_class,
"executable": selected_app.executable,
"command_line": selected_app.command_line,
"icon_name": selected_app.icon_name
}.items() if v is not None}
config_path = get_relative_path("../config/dock.json")
try:
with open(config_path, "r") as file:
data = json.load(file)
except (FileNotFoundError, json.JSONDecodeError):
data = {"pinned_apps": []}
already_pinned = False
for pinned_app in data.get("pinned_apps", []):
if isinstance(pinned_app, dict) and pinned_app.get("name") == app_data["name"]:
already_pinned = True
pinned_app.update(app_data)
break
elif isinstance(pinned_app, str) and pinned_app == app_data["name"]:
already_pinned = True
data["pinned_apps"].remove(pinned_app)
data["pinned_apps"].append(app_data)
break
if not already_pinned:
data.setdefault("pinned_apps", []).append(app_data)
with open(config_path, "w") as file:
json.dump(data, file, indent=4)
Dock.notify_config_change()
def move_selection(self, delta: int):
children = self.viewport.get_children()
if not children:
return
if self.selected_index == -1 and delta == 1:
new_index = 0
else:
new_index = self.selected_index + delta
new_index = max(0, min(new_index, len(children) - 1))
self.update_selection(new_index)
def save_calc_history(self):
with open(self.calc_history_path, "w") as f:
json.dump(self.calc_history, f)
def save_conversion_history(self):
with open(self.conversion_history_path, "w") as f:
json.dump(self.conversion_history, f)
def evaluate_calculator_expression(self, text: str):
print(f"Evaluating calculator expression: {text}")
expr = text.lstrip("=").strip()
if not expr:
return
replacements = {
"^": "**",
"×": "*",
"÷": "/",
"π": "np.pi",
"pi": "np.pi",
"e": "np.e",
"sin(": "np.sin(",
"cos(": "np.cos(",
"tan(": "np.tan(",
"log(": "np.log10(",
"ln(": "np.log(",
"sqrt(": "np.sqrt(",
"abs(": "np.abs(",
"exp(": "np.exp("
}
for old, new in replacements.items():
expr = expr.replace(old, new)
expr = re.sub(r'(\d+)!', r'np.factorial(\1)', expr)
for old, new in [("[", "("), ("]", ")"), ("{", "("), ("}", ")")]:
expr = expr.replace(old, new)
safe_dict = {
'np': np,
'math': math,
'arange': np.arange,
'linspace': np.linspace,
'array': np.array
}
try:
result = eval(expr, {"__builtins__": None}, safe_dict)
if isinstance(result, np.ndarray):
if result.size > 10:
result_str = f"Array of shape {result.shape}"
else:
result_str = str(result)
elif isinstance(result, (int, float, np.number)):
if isinstance(result, (int, np.integer)) or result.is_integer():
result_str = str(int(result))
else:
result_str = f"{float(result):.10g}"
else:
result_str = str(result)
except Exception as e:
result_str = f"Error: {str(e)}"
self.calc_history.insert(0, f"{text} => {result_str}")
self.save_calc_history()
self.update_calculator_viewport()
def evaluate_conversion_expression(self, text: str):
print(f"Evaluating conversion expression: {text}")
expr = text.lstrip(";").strip()
if not expr:
return
# Add loading entry
loading_entry = f"{text} => Loading..."
self.conversion_history.insert(0, loading_entry)
self.update_conversion_viewport()
# Perform conversion in thread
def do_conversion():
try:
result_value, result_type = self.converter.parse_input_and_convert(expr)
if result_type is None:
result_str = f"{result_value:.2f}"
else:
result_str = f"{result_value:.2f} {result_type}"
except:
result_str = "Error: Invalid conversion expression"
# Update the history entry
GLib.idle_add(self._update_conversion_result, text, result_str)
GLib.Thread.new("conversion", do_conversion, None)
def _update_conversion_result(self, text, result_str):
# Replace the loading entry with the result
if self.conversion_history and self.conversion_history[0].startswith(f"{text} => Loading"):
self.conversion_history[0] = f"{text} => {result_str}"
else:
# Fallback: insert new
self.conversion_history.insert(0, f"{text} => {result_str}")
self.save_conversion_history()
self.update_conversion_viewport()
def update_calculator_viewport(self):
self.viewport.children = []
for item in self.calc_history:
btn = self.create_calc_history_button(item)
self.viewport.add(btn)
if self.selected_index >= len(self.calc_history):
self.selected_index = -1
def update_conversion_viewport(self):
self.viewport.children = []
for item in self.conversion_history:
btn = self.create_conversion_history_button(item)
self.viewport.add(btn)
# Don't reset selection index here automatically
# Ensure selection state stays valid
if self.selected_index >= len(self.conversion_history):
self.selected_index = -1
def create_calc_history_button(self, text: str) -> Button:
if "=>" in text:
parts = text.split("=>")
expression = parts[0].strip()
result = parts[1].strip()
display_text = text
if len(result) > 50:
display_text = f"{expression} => {result[:47]}..."
btn = Button(
name="slot-button",
child=Box(
name="calc-slot-box",
orientation="h",
spacing=10,
children=[
Label(
name="calc-label",
label=display_text,
ellipsization="end",
v_align="center",
h_align="center",
),
],
),
tooltip_text=text,
on_clicked=lambda *_: self.copy_text_to_clipboard(text),
)
else:
btn = Button(
name="slot-button",
child=Box(
name="calc-slot-box",
orientation="h",
spacing=10,
children=[
Label(
name="calc-label",
label=text,
ellipsization="end",
v_align="center",
h_align="center",
),
],
),
tooltip_text=text,
on_clicked=lambda *_: self.copy_text_to_clipboard(text),
)
return btn
def create_conversion_history_button(self, text: str) -> Button:
# Parse the result to create a more readable display
if "=>" in text:
parts = text.split("=>")
expression = parts[0].strip()
result = parts[1].strip()
# For very long results, truncate for display but keep full in tooltip
display_text = text
if len(result) > 50: # Truncate long results
display_text = f"{expression} => {result[:47]}..."
btn = Button(
name="slot-button", # reuse existing CSS styling
child=Box(
name="calc-slot-box",
orientation="h",
spacing=10,
children=[
Label(
name="calc-label",
label=display_text,
ellipsization="end",
v_align="center",
h_align="center",
),
],
),
tooltip_text=text,
on_clicked=lambda *_: self.copy_text_to_clipboard(text),
)
else:
# Fallback for non-calculation entries
btn = Button(
name="slot-button",
child=Box(
name="calc-slot-box",
orientation="h",
spacing=10,
children=[
Label(
name="calc-label",
label=text,
ellipsization="end",
v_align="center",
h_align="center",
),
],
),
tooltip_text=text,
on_clicked=lambda *_: self.copy_text_to_clipboard(text),
)
return btn
def copy_text_to_clipboard(self, text: str):
parts = text.split("=>", 1)
copy_text = parts[1].strip() if len(parts) > 1 else text
try:
subprocess.run(["wl-copy"], input=copy_text.encode(), check=True)
except subprocess.CalledProcessError as e:
print(f"Clipboard copy failed: {e}")
def delete_selected_calc_history(self):
if self.selected_index != -1 and self.selected_index < len(self.calc_history):
current_index = self.selected_index
del self.calc_history[current_index]
self.save_calc_history()
new_index = 0 if current_index == 0 else current_index - 1
self.selected_index = -1
self.update_calculator_viewport()
if len(self.calc_history) > 0:
self.update_selection(min(new_index, len(self.calc_history) - 1))
def delete_selected_conversion_history(self):
if self.selected_index != -1 and self.selected_index < len(self.conversion_history):
# Store the current index before deletion
current_index = self.selected_index
# Delete the item
del self.conversion_history[current_index]
self.save_conversion_history()
# Determine the new selection index
# If we deleted the first item, stay at index 0
# Otherwise, move to the previous item
new_index = 0 if current_index == 0 else current_index - 1
# Reset selection before updating viewport
self.selected_index = -1
# Update the viewport
self.update_conversion_viewport()
# If we still have items, select the determined index
if len(self.conversion_history) > 0:
self.update_selection(min(new_index, len(self.conversion_history) - 1))
+717
View File
@@ -0,0 +1,717 @@
import json
import logging
import subprocess
import time
import psutil
from fabric.core.fabricator import Fabricator
from fabric.utils.helpers import invoke_repeater
from fabric.widgets.box import Box
from fabric.widgets.button import Button
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.revealer import Revealer
from fabric.widgets.scale import Scale
from gi.repository import GLib
import config.data as data
from modules.upower.upower import UPowerManager
import modules.icons as icons
from services.network import NetworkClient
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
self.mem = 0.0
self.disk = []
self.upower = UPowerManager()
self.display_device = self.upower.get_display_device()
self.bat_percent = 0.0
self.bat_charging = None
self.bat_time = 0
self._gpu_update_running = False
self._gpu_update_counter = 0
GLib.timeout_add_seconds(2, self._update)
def _update(self):
self.cpu = psutil.cpu_percent(interval=0)
self.mem = psutil.virtual_memory().percent
self.disk = [psutil.disk_usage(path).percent for path in data.BAR_METRICS_DISKS]
self._gpu_update_counter += 1
if self._gpu_update_counter >= 5: # Update GPU every 10 seconds (5 * 2s)
self._gpu_update_counter = 0
if not self._gpu_update_running:
self._start_gpu_update_async()
battery = self.upower.get_full_device_information(self.display_device)
if battery is None:
self.bat_percent = 0.0
self.bat_charging = None
self.bat_time = 0
else:
self.bat_percent = battery['Percentage']
self.bat_charging = battery['State'] == 1
self.bat_time = battery['TimeToFull'] if self.bat_charging else battery['TimeToEmpty']
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:
result = subprocess.check_output(["nvtop", "-s"], text=True, timeout=10)
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)
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:
info = json.loads(output)
try:
self.gpu = [
(
int(v["gpu_util"].strip("%"))
if v["gpu_util"] is not None
else 0
)
for v in info
]
except (KeyError, ValueError, TypeError) as e:
logger.error(f"Failed parsing nvtop JSON: {e}")
self.gpu = []
else:
logger.warning("nvtop returned no output.")
self.gpu = []
except json.JSONDecodeError as e:
logger.error(f"JSON decode error: {e}")
self.gpu = []
except Exception as e:
logger.error(f"Error processing nvtop output: {e}")
self.gpu = []
return False
def get_metrics(self):
return (self.cpu, self.mem, self.disk, self.gpu)
def get_battery(self):
return (self.bat_percent, self.bat_charging, self.bat_time)
def get_gpu_info(self):
try:
result = subprocess.check_output(["nvtop", "-s"], text=True, timeout=5)
return json.loads(result)
except FileNotFoundError:
logger.warning("nvtop not found; GPU info unavailable.")
return []
except subprocess.CalledProcessError as e:
logger.error(f"nvtop init sync failed: {e}")
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}")
return []
except Exception as e:
logger.error(f"Unexpected error during GPU init: {e}")
return []
shared_provider = MetricsProvider()
class SingularMetric:
def __init__(self, id, name, icon):
self.usage = Scale(
name=f"{id}-usage",
value=0.25,
orientation='v',
inverted=True,
v_align='fill',
v_expand=True,
)
self.label = Label(
name=f"{id}-label",
markup=icon,
)
self.box = Box(
name=f"{id}-box",
orientation='v',
spacing=8,
children=[
self.usage,
self.label,
]
)
self.box.set_tooltip_markup(f"{icon} {name}")
class Metrics(Box):
def __init__(self, **kwargs):
super().__init__(
name="metrics",
spacing=8,
h_align="center",
v_align="fill",
visible=True,
all_visible=True,
)
visible = getattr(data, "METRICS_VISIBLE", {'cpu': True, 'ram': True, 'disk': True, 'gpu': True})
disks = [SingularMetric("disk", f"DISK ({path})" if len(data.BAR_METRICS_DISKS) != 1 else "DISK", icons.disk)
for path in data.BAR_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 []
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.disk = disks
self.gpu = gpus
self.scales = []
if self.disk: self.scales.extend([v.box for v in self.disk])
if self.ram: self.scales.append(self.ram.box)
if self.cpu: self.scales.append(self.cpu.box)
if self.gpu: self.scales.extend([v.box for v in self.gpu])
if self.cpu: self.cpu.usage.set_sensitive(False)
if self.ram: self.ram.usage.set_sensitive(False)
for disk in self.disk:
disk.usage.set_sensitive(False)
for gpu in self.gpu:
gpu.usage.set_sensitive(False)
for x in self.scales:
self.add(x)
GLib.timeout_add_seconds(2, self.update_status)
def update_status(self):
cpu, mem, disks, gpus = shared_provider.get_metrics()
if self.cpu:
self.cpu.usage.value = cpu / 100.0
if self.ram:
self.ram.usage.value = mem / 100.0
for i, disk in enumerate(self.disk):
if i < len(disks):
disk.usage.value = disks[i] / 100.0
for i, gpu in enumerate(self.gpu):
if i < len(gpus):
gpu.usage.value = gpus[i] / 100.0
return True
class SingularMetricSmall:
def __init__(self, id, name, icon):
self.name_markup = name
self.icon_markup = icon
self.icon = Label(name="metrics-icon", markup=icon)
self.circle = CircularProgressBar(
name="metrics-circle",
value=0,
size=28,
line_width=2,
start_angle=150,
end_angle=390,
style_classes=id,
child=self.icon,
)
self.level = Label(name="metrics-level", style_classes=id, label="0%")
self.revealer = Revealer(
name=f"metrics-{id}-revealer",
transition_duration=250,
transition_type="slide-left",
child=self.level,
child_revealed=False,
)
self.box = Box(
name=f"metrics-{id}-box",
orientation="h",
spacing=0,
children=[self.circle, self.revealer],
)
def markup(self):
return f"{self.icon_markup} {self.name_markup}" if not data.VERTICAL else f"{self.icon_markup} {self.name_markup}: {self.level.get_label()}"
class MetricsSmall(Button):
def __init__(self, **kwargs):
super().__init__(name="metrics-small", **kwargs)
main_box = Box(
spacing=0,
orientation="h" if not data.VERTICAL else "v",
visible=True,
all_visible=True,
)
visible = getattr(data, "METRICS_SMALL_VISIBLE", {'cpu': True, 'ram': True, 'disk': True, 'gpu': True})
disks = [SingularMetricSmall("disk", f"DISK ({path})" if len(data.BAR_METRICS_DISKS) != 1 else "DISK", icons.disk)
for path in data.BAR_METRICS_DISKS] if visible.get('disk', True) else []
gpu_info = shared_provider.get_gpu_info()
gpus = [SingularMetricSmall(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 []
self.cpu = SingularMetricSmall("cpu", "CPU", icons.cpu) if visible.get('cpu', True) else None
self.ram = SingularMetricSmall("ram", "RAM", icons.memory) if visible.get('ram', True) else None
self.disk = disks
self.gpu = gpus
for disk in self.disk:
main_box.add(disk.box)
main_box.add(Box(name="metrics-sep"))
if self.ram:
main_box.add(self.ram.box)
main_box.add(Box(name="metrics-sep"))
if self.cpu:
main_box.add(self.cpu.box)
for gpu in self.gpu:
main_box.add(Box(name="metrics-sep"))
main_box.add(gpu.box)
self.add(main_box)
self.connect("enter-notify-event", self.on_mouse_enter)
self.connect("leave-notify-event", self.on_mouse_leave)
GLib.timeout_add_seconds(2, self.update_metrics)
self.hide_timer = None
self.hover_counter = 0
def _format_percentage(self, value: int) -> str:
"""Formato natural del porcentaje sin forzar ancho fijo."""
return f"{value}%"
def on_mouse_enter(self, widget, event):
if not data.VERTICAL:
self.hover_counter += 1
if self.hide_timer is not None:
GLib.source_remove(self.hide_timer)
self.hide_timer = None
if self.cpu: self.cpu.revealer.set_reveal_child(True)
if self.ram: self.ram.revealer.set_reveal_child(True)
for disk in self.disk:
disk.revealer.set_reveal_child(True)
for gpu in self.gpu:
gpu.revealer.set_reveal_child(True)
return False
def on_mouse_leave(self, widget, event):
if not data.VERTICAL:
if self.hover_counter > 0:
self.hover_counter -= 1
if self.hover_counter == 0:
if self.hide_timer is not None:
GLib.source_remove(self.hide_timer)
self.hide_timer = GLib.timeout_add(500, self.hide_revealer)
return False
def hide_revealer(self):
if not data.VERTICAL:
if self.cpu: self.cpu.revealer.set_reveal_child(False)
if self.ram: self.ram.revealer.set_reveal_child(False)
for disk in self.disk:
disk.revealer.set_reveal_child(False)
for gpu in self.gpu:
gpu.revealer.set_reveal_child(False)
self.hide_timer = None
return False
def update_metrics(self):
cpu, mem, disks, gpus = shared_provider.get_metrics()
if self.cpu:
self.cpu.circle.set_value(cpu / 100.0)
self.cpu.level.set_label(self._format_percentage(int(cpu)))
if self.ram:
self.ram.circle.set_value(mem / 100.0)
self.ram.level.set_label(self._format_percentage(int(mem)))
for i, disk in enumerate(self.disk):
if i < len(disks):
disk.circle.set_value(disks[i] / 100.0)
disk.level.set_label(self._format_percentage(int(disks[i])))
for i, gpu in enumerate(self.gpu):
if i < len(gpus):
gpu.circle.set_value(gpus[i] / 100.0)
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.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
class Battery(Button):
def __init__(self, **kwargs):
super().__init__(name="metrics-small", **kwargs)
main_box = Box(
spacing=0,
orientation="h",
visible=True,
all_visible=True,
)
self.bat_icon = Label(name="metrics-icon", markup=icons.battery)
self.bat_circle = CircularProgressBar(
name="metrics-circle",
value=0,
size=28,
line_width=2,
start_angle=150,
end_angle=390,
style_classes="bat",
child=self.bat_icon,
)
self.bat_level = Label(name="metrics-level", style_classes="bat", label="100%")
self.bat_revealer = Revealer(
name="metrics-bat-revealer",
transition_duration=250,
transition_type="slide-left",
child=self.bat_level,
child_revealed=False,
)
self.bat_box = Box(
name="metrics-bat-box",
orientation="h",
spacing=0,
children=[self.bat_circle, self.bat_revealer],
)
main_box.add(self.bat_box)
self.add(main_box)
self.connect("enter-notify-event", self.on_mouse_enter)
self.connect("leave-notify-event", self.on_mouse_leave)
self.batt_fabricator = Fabricator(
poll_from=lambda v: shared_provider.get_battery(),
on_changed=lambda f, v: self.update_battery,
interval=1000,
stream=False,
default_value=0
)
self.batt_fabricator.changed.connect(self.update_battery)
GLib.idle_add(self.update_battery, None, shared_provider.get_battery())
self.hide_timer = None
self.hover_counter = 0
def _format_percentage(self, value: int) -> str:
"""Formato natural del porcentaje sin forzar ancho fijo."""
return f"{value}%"
def on_mouse_enter(self, widget, event):
if not data.VERTICAL:
self.hover_counter += 1
if self.hide_timer is not None:
GLib.source_remove(self.hide_timer)
self.hide_timer = None
self.bat_revealer.set_reveal_child(True)
return False
def on_mouse_leave(self, widget, event):
if not data.VERTICAL:
if self.hover_counter > 0:
self.hover_counter -= 1
if self.hover_counter == 0:
if self.hide_timer is not None:
GLib.source_remove(self.hide_timer)
self.hide_timer = GLib.timeout_add(500, self.hide_revealer)
return False
def hide_revealer(self):
if not data.VERTICAL:
self.bat_revealer.set_reveal_child(False)
self.hide_timer = None
return False
def update_battery(self, sender, battery_data):
value, charging, time = battery_data
if value == 0:
self.set_visible(False)
else:
self.set_visible(True)
self.bat_circle.set_value(value / 100)
percentage = int(value)
self.bat_level.set_label(self._format_percentage(percentage))
if percentage <= 15:
self.bat_icon.add_style_class("alert")
self.bat_circle.add_style_class("alert")
else:
self.bat_icon.remove_style_class("alert")
self.bat_circle.remove_style_class("alert")
if time < 60:
time_status = f"{int(time)}sec"
elif time < 60 * 60:
time_status = f"{int(time / 60)}min"
else:
time_status = f"{int(time / 60 / 60)}h"
if percentage == 100 and charging == False:
self.bat_icon.set_markup(icons.battery)
charging_status = f"{icons.bat_full} Fully Charged - {time_status} left"
elif percentage == 100 and charging == True:
self.bat_icon.set_markup(icons.battery)
charging_status = f"{icons.bat_full} Fully Charged"
elif charging == True:
self.bat_icon.set_markup(icons.charging)
charging_status = f"{icons.bat_charging} Charging - {time_status} left"
elif percentage <= 15 and charging == False:
self.bat_icon.set_markup(icons.alert)
charging_status = f"{icons.bat_low} Low Battery - {time_status} left"
elif charging == False:
self.bat_icon.set_markup(icons.discharging)
charging_status = f"{icons.bat_discharging} Discharging - {time_status} left"
else:
self.bat_icon.set_markup(icons.battery)
charging_status = "Battery"
self.set_tooltip_markup(f"{charging_status}" if not data.VERTICAL else f"{charging_status}: {percentage}%")
class NetworkApplet(Button):
def __init__(self, **kwargs):
super().__init__(name="button-bar", **kwargs)
self.download_label = Label(name="download-label", markup="Download: 0 B/s")
self.network_client = NetworkClient()
self.upload_label = Label(name="upload-label", markup="Upload: 0 B/s")
self.wifi_label = Label(name="network-icon-label", markup="WiFi: Unknown")
self.is_mouse_over = False
self.downloading = False
self.uploading = False
self.download_icon = Label(name="download-icon-label", markup=icons.download, v_align="center", h_align="center", h_expand=True, v_expand=True)
self.upload_icon = Label(name="upload-icon-label", markup=icons.upload, v_align="center", h_align="center", h_expand=True, v_expand=True)
self.download_box = Box(
children=[self.download_icon, self.download_label],
)
self.upload_box = Box(
children=[self.upload_label, self.upload_icon],
)
self.download_revealer = Revealer(child=self.download_box, transition_type = "slide-right" if not data.VERTICAL else "slide-down", child_revealed=False)
self.upload_revealer = Revealer(child=self.upload_box, transition_type="slide-left" if not data.VERTICAL else "slide-up",child_revealed=False)
self.children = Box(
orientation="h" if not data.VERTICAL else "v",
children=[self.upload_revealer, self.wifi_label, self.download_revealer],
)
if data.VERTICAL:
self.download_label.set_visible(False)
self.upload_label.set_visible(False)
self.upload_icon.set_margin_top(4)
self.download_icon.set_margin_bottom(4)
self.last_counters = psutil.net_io_counters()
self.last_time = time.time()
invoke_repeater(1000, self.update_network)
self.connect("enter-notify-event", self.on_mouse_enter)
self.connect("leave-notify-event", self.on_mouse_leave)
def update_network(self):
current_time = time.time()
elapsed = current_time - self.last_time
current_counters = psutil.net_io_counters()
download_speed = (current_counters.bytes_recv - self.last_counters.bytes_recv) / elapsed
upload_speed = (current_counters.bytes_sent - self.last_counters.bytes_sent) / elapsed
download_str = self.format_speed(download_speed)
upload_str = self.format_speed(upload_speed)
self.download_label.set_markup(download_str)
self.upload_label.set_markup(upload_str)
self.downloading = (download_speed >= 10e6)
self.uploading = (upload_speed >= 2e6)
if not self.is_mouse_over:
if self.downloading:
self.download_urgent()
elif self.uploading:
self.upload_urgent()
else:
self.remove_urgent()
show_download = self.downloading or (self.is_mouse_over and not data.VERTICAL)
show_upload = self.uploading or (self.is_mouse_over and not data.VERTICAL)
self.download_revealer.set_reveal_child(show_download)
self.upload_revealer.set_reveal_child(show_upload)
primary_device = None
if self.network_client:
primary_device = self.network_client.primary_device
tooltip_base = ""
tooltip_vertical = ""
if primary_device == "wired" and self.network_client.ethernet_device:
ethernet_state = self.network_client.ethernet_device.internet
if ethernet_state == "activated":
self.wifi_label.set_markup(icons.world)
elif ethernet_state == "activating":
self.wifi_label.set_markup(icons.world)
else:
self.wifi_label.set_markup(icons.world_off)
tooltip_base = "Ethernet Connection"
tooltip_vertical = f"SSID: Ethernet\nUpload: {upload_str}\nDownload: {download_str}"
elif self.network_client and self.network_client.wifi_device:
if self.network_client.wifi_device.ssid != "Disconnected":
strength = self.network_client.wifi_device.strength
if strength >= 75:
self.wifi_label.set_markup(icons.wifi_3)
elif strength >= 50:
self.wifi_label.set_markup(icons.wifi_2)
elif strength >= 25:
self.wifi_label.set_markup(icons.wifi_1)
else:
self.wifi_label.set_markup(icons.wifi_0)
tooltip_base = self.network_client.wifi_device.ssid
tooltip_vertical = f"SSID: {self.network_client.wifi_device.ssid}\nUpload: {upload_str}\nDownload: {download_str}"
else:
self.wifi_label.set_markup(icons.world_off)
tooltip_base = "Disconnected"
tooltip_vertical = f"SSID: Disconnected\nUpload: {upload_str}\nDownload: {download_str}"
else:
self.wifi_label.set_markup(icons.world_off)
tooltip_base = "Disconnected"
tooltip_vertical = f"SSID: Disconnected\nUpload: {upload_str}\nDownload: {download_str}"
if data.VERTICAL:
self.set_tooltip_text(tooltip_vertical)
else:
self.set_tooltip_text(tooltip_base)
self.last_counters = current_counters
self.last_time = current_time
return True
def format_speed(self, speed):
if speed < 1024:
return f"{speed:.0f} B/s"
elif speed < 1024 * 1024:
return f"{speed / 1024:.1f} KB/s"
else:
return f"{speed / (1024 * 1024):.1f} MB/s"
def on_mouse_enter(self, *_):
self.is_mouse_over = True
if not data.VERTICAL:
self.download_revealer.set_reveal_child(True)
self.upload_revealer.set_reveal_child(True)
return
def on_mouse_leave(self, *_):
self.is_mouse_over = False
if not data.VERTICAL:
self.download_revealer.set_reveal_child(self.downloading)
self.upload_revealer.set_reveal_child(self.uploading)
if self.downloading:
self.download_urgent()
elif self.uploading:
self.upload_urgent()
else:
self.remove_urgent()
return
def upload_urgent(self):
self.add_style_class("upload")
self.wifi_label.add_style_class("urgent")
self.upload_label.add_style_class("urgent")
self.upload_icon.add_style_class("urgent")
self.download_icon.add_style_class("urgent")
self.download_label.add_style_class("urgent")
self.upload_revealer.set_reveal_child(True)
self.download_revealer.set_reveal_child(self.downloading)
return
def download_urgent(self):
self.add_style_class("download")
self.wifi_label.add_style_class("urgent")
self.download_label.add_style_class("urgent")
self.download_icon.add_style_class("urgent")
self.upload_icon.add_style_class("urgent")
self.upload_label.add_style_class("urgent")
self.download_revealer.set_reveal_child(True)
self.upload_revealer.set_reveal_child(self.uploading)
return
def remove_urgent(self):
self.remove_style_class("download")
self.remove_style_class("upload")
self.wifi_label.remove_style_class("urgent")
self.download_label.remove_style_class("urgent")
self.upload_label.remove_style_class("urgent")
self.download_icon.remove_style_class("urgent")
self.upload_icon.remove_style_class("urgent")
return
+233
View File
@@ -0,0 +1,233 @@
import math
import gi
from fabric.audio.service import Audio
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.scale import Scale
from fabric.widgets.scrolledwindow import ScrolledWindow
from gi.repository import GLib
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
import config.data as data
vertical_mode = (
True
if data.PANEL_THEME == "Panel"
and (
data.BAR_POSITION in ["Left", "Right"]
or data.PANEL_POSITION in ["Start", "End"]
)
else False
)
class MixerSlider(Scale):
def __init__(self, stream, **kwargs):
super().__init__(
name="control-slider",
orientation="h",
h_expand=True,
h_align="fill",
has_origin=True,
increments=(0.01, 0.1),
style_classes=["no-icon"],
**kwargs,
)
self.stream = stream
self._updating_from_stream = False
self.set_value(stream.volume / 100)
self.set_size_request(-1, 30) # Fixed height for sliders
self.connect("value-changed", self.on_value_changed)
stream.connect("changed", self.on_stream_changed)
# Apply appropriate style class based on stream type
if hasattr(stream, "type"):
if "microphone" in stream.type.lower() or "input" in stream.type.lower():
self.add_style_class("mic")
else:
self.add_style_class("vol")
else:
# Default to volume style
self.add_style_class("vol")
# Set initial tooltip and muted state
self.set_tooltip_text(f"{stream.volume:.0f}%")
self.update_muted_state()
def on_value_changed(self, _):
if self._updating_from_stream:
return
if self.stream:
self.stream.volume = self.value * 100
self.set_tooltip_text(f"{self.value * 100:.0f}%")
def on_stream_changed(self, stream):
self._updating_from_stream = True
self.value = stream.volume / 100
self.set_tooltip_text(f"{stream.volume:.0f}%")
self.update_muted_state()
self._updating_from_stream = False
def update_muted_state(self):
if self.stream.muted:
self.add_style_class("muted")
else:
self.remove_style_class("muted")
class MixerSection(Box):
def __init__(self, title, **kwargs):
super().__init__(
name="mixer-section",
orientation="v",
spacing=8,
h_expand=True,
v_expand=False, # Prevent vertical stretching
)
self.title_label = Label(
name="mixer-section-title",
label=title,
h_expand=True,
h_align="fill",
)
self.content_box = Box(
name="mixer-content",
orientation="v",
spacing=8,
h_expand=True,
v_expand=False, # Prevent vertical stretching
)
self.add(self.title_label)
self.add(self.content_box)
def update_streams(self, streams):
for child in self.content_box.get_children():
self.content_box.remove(child)
for stream in streams:
label_text = stream.description
if hasattr(stream, "type") and "application" in stream.type.lower():
label_text = getattr(stream, "name", stream.description)
stream_container = Box(
orientation="v",
spacing=4,
h_expand=True,
v_expand=False, # Prevent vertical stretching
)
label = Label(
name="mixer-stream-label",
label=f"[{math.ceil(stream.volume)}%] {stream.description}",
h_expand=True,
h_align="start",
v_align="center",
ellipsization="end",
max_chars_width=45,
height_request=20, # Fixed height for labels
)
slider = MixerSlider(stream)
stream_container.add(label)
stream_container.add(slider)
self.content_box.add(stream_container)
self.content_box.show_all()
class Mixer(Box):
def __init__(self, **kwargs):
super().__init__(
name="mixer",
orientation="v",
spacing=8,
h_expand=True,
v_expand=True, # Allow Mixer to expand to parent height
)
try:
self.audio = Audio()
except Exception as e:
error_label = Label(
label=f"Audio service unavailable: {str(e)}",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
)
self.add(error_label)
return
self.main_container = Box(
orientation="h" if not vertical_mode else "v",
spacing=8,
h_expand=True,
v_expand=True, # Allow main_container to expand
)
self.main_container.set_homogeneous(True) # Equal sizing for outputs and inputs
# ScrolledWindow for Outputs
self.outputs_scrolled = ScrolledWindow(
name="outputs-scrolled",
h_expand=True,
v_expand=False, # Prevent vertical expansion
vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, # Vertical scrollbar when needed
hscrollbar_policy=Gtk.PolicyType.NEVER, # Disable horizontal scrollbar
)
self.outputs_section = MixerSection("Outputs")
self.outputs_scrolled.add(self.outputs_section)
self.outputs_scrolled.set_size_request(-1, 150) # Fixed height of 150px
self.outputs_scrolled.set_max_content_height(150) # Enforce max height
# ScrolledWindow for Inputs
self.inputs_scrolled = ScrolledWindow(
name="inputs-scrolled",
h_expand=True,
v_expand=False, # Prevent vertical expansion
vscrollbar_policy=Gtk.PolicyType.AUTOMATIC, # Vertical scrollbar when needed
hscrollbar_policy=Gtk.PolicyType.NEVER, # Disable horizontal scrollbar
)
self.inputs_section = MixerSection("Inputs")
self.inputs_scrolled.add(self.inputs_section)
self.inputs_scrolled.set_size_request(-1, 150) # Fixed height of 150px
self.inputs_scrolled.set_max_content_height(150) # Enforce max height
self.main_container.add(self.outputs_scrolled)
self.main_container.add(self.inputs_scrolled)
self.add(self.main_container)
self.set_size_request(-1, 300) # Optional: Set total height to 300px (150px per section)
self.audio.connect("changed", self.on_audio_changed)
self.audio.connect("stream-added", self.on_audio_changed)
self.audio.connect("stream-removed", self.on_audio_changed)
self.update_mixer()
self.show_all()
def on_audio_changed(self, *args):
self.update_mixer()
def update_mixer(self):
outputs = []
inputs = []
if self.audio.speaker:
outputs.append(self.audio.speaker)
outputs.extend(self.audio.applications)
if self.audio.microphone:
inputs.append(self.audio.microphone)
inputs.extend(self.audio.recorders)
self.outputs_section.update_streams(outputs)
self.inputs_section.update_streams(inputs)
+194
View File
@@ -0,0 +1,194 @@
import gi
gi.require_version('Gtk', '3.0')
gi.require_version('NM', '1.0')
from fabric.utils import bulk_connect
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from gi.repository import NM, GLib, Gtk
import modules.icons as icons
from services.network import NetworkClient
class WifiAccessPointSlot(CenterBox):
def __init__(self, ap_data: dict, network_service: NetworkClient, wifi_service, **kwargs):
super().__init__(name="wifi-ap-slot", **kwargs)
self.ap_data = ap_data
self.network_service = network_service
self.wifi_service = wifi_service
ssid = ap_data.get("ssid", "Unknown SSID")
icon_name = ap_data.get("icon-name", "network-wireless-signal-none-symbolic")
self.is_active = False
active_ap_details = ap_data.get("active-ap")
if active_ap_details and hasattr(active_ap_details, 'get_bssid') and active_ap_details.get_bssid() == ap_data.get("bssid"):
self.is_active = True
self.ap_icon = Image(icon_name=icon_name, size=24)
self.ap_label = Label(label=ssid, h_expand=True, h_align="start", ellipsization="end")
self.connect_button = Button(
name="wifi-connect-button",
label="Connected" if self.is_active else "Connect",
sensitive=not self.is_active,
on_clicked=self._on_connect_clicked,
style_classes=["connected"] if self.is_active else None,
)
self.set_start_children([
Box(spacing=8, h_expand=True, h_align="fill", children=[
self.ap_icon,
self.ap_label,
])
])
self.set_end_children([self.connect_button])
def _on_connect_clicked(self, _):
if not self.is_active and self.ap_data.get("bssid"):
self.connect_button.set_label("Connecting...")
self.connect_button.set_sensitive(False)
self.network_service.connect_wifi_bssid(self.ap_data["bssid"])
class NetworkConnections(Box):
def __init__(self, **kwargs):
super().__init__(
name="network-connections",
orientation="vertical",
spacing=4,
**kwargs,
)
self.widgets = kwargs.get("widgets")
self.network_client = NetworkClient()
self.status_label = Label(label="Initializing Wi-Fi...", h_expand=True, h_align="center")
self.back_button = Button(
name="network-back",
child=Label(name="network-back-label", markup=icons.chevron_left),
on_clicked=lambda *_: self.widgets.show_notif()
)
self.wifi_toggle_button_icon = Label(markup=icons.wifi_3)
self.wifi_toggle_button = Button(
name="wifi-toggle-button",
child=self.wifi_toggle_button_icon,
tooltip_text="Toggle Wi-Fi",
on_clicked=self._toggle_wifi
)
self.refresh_button_icon = Label(name="network-refresh-label", markup=icons.reload)
self.refresh_button = Button(
name="network-refresh",
child=self.refresh_button_icon,
tooltip_text="Scan for Wi-Fi networks",
on_clicked=self._refresh_access_points
)
header_box = CenterBox(
name="network-header",
start_children=[self.back_button],
center_children=[Label(name="network-title", label="Wi-Fi Networks")],
end_children=[Box(orientation="horizontal", spacing=4, children=[self.refresh_button])]
)
self.ap_list_box = Box(orientation="vertical", spacing=4)
scrolled_window = ScrolledWindow(
name="network-ap-scrolled-window",
child=self.ap_list_box,
h_expand=True,
v_expand=True,
propagate_width=False,
propagate_height=False,
)
self.add(header_box)
self.add(self.status_label)
self.add(scrolled_window)
self.network_client.connect("device-ready", self._on_device_ready)
self.wifi_toggle_button.set_sensitive(False)
self.refresh_button.set_sensitive(False)
def _on_device_ready(self, _client):
if self.network_client.wifi_device:
self.network_client.wifi_device.connect("changed", self._load_access_points)
self.network_client.wifi_device.connect("notify::enabled", self._update_wifi_status_ui)
self._update_wifi_status_ui()
if self.network_client.wifi_device.enabled:
self._load_access_points()
else:
self.status_label.set_label("Wi-Fi disabled.")
self.status_label.set_visible(True)
else:
self.status_label.set_label("Wi-Fi device not available.")
self.status_label.set_visible(True)
self.wifi_toggle_button.set_sensitive(False)
self.refresh_button.set_sensitive(False)
def _update_wifi_status_ui(self, *args):
if self.network_client.wifi_device:
enabled = self.network_client.wifi_device.enabled
self.wifi_toggle_button.set_sensitive(True)
self.refresh_button.set_sensitive(enabled)
if enabled:
self.wifi_toggle_button_icon.set_markup(icons.wifi_3)
else:
self.wifi_toggle_button_icon.set_markup(icons.wifi_off)
self.status_label.set_label("Wi-Fi disabled.")
self.status_label.set_visible(True)
self._clear_ap_list()
if enabled and not self.ap_list_box.get_children():
GLib.idle_add(self._refresh_access_points)
else:
self.wifi_toggle_button.set_sensitive(False)
self.refresh_button.set_sensitive(False)
def _toggle_wifi(self, _):
if self.network_client.wifi_device:
self.network_client.wifi_device.toggle_wifi()
def _refresh_access_points(self, _=None):
if self.network_client.wifi_device and self.network_client.wifi_device.enabled:
self.status_label.set_label("Scanning for Wi-Fi networks...")
self.status_label.set_visible(True)
self._clear_ap_list()
self.network_client.wifi_device.scan()
return False
def _clear_ap_list(self):
for child in self.ap_list_box.get_children():
child.destroy()
def _load_access_points(self, *args):
if not self.network_client.wifi_device or not self.network_client.wifi_device.enabled:
self._clear_ap_list()
self.status_label.set_label("Wi-Fi disabled.")
self.status_label.set_visible(True)
return
self._clear_ap_list()
access_points = self.network_client.wifi_device.access_points
if not access_points:
self.status_label.set_label("No Wi-Fi networks found.")
self.status_label.set_visible(True)
else:
self.status_label.set_visible(False)
sorted_aps = sorted(access_points, key=lambda x: x.get("strength", 0), reverse=True)
for ap_data in sorted_aps:
slot = WifiAccessPointSlot(ap_data, self.network_client, self.network_client.wifi_device)
self.ap_list_box.add(slot)
self.ap_list_box.show_all()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+442
View File
@@ -0,0 +1,442 @@
# Thanks to https://github.com/muhchaudhary for the original code. You are a legend.
import json
import cairo
import gi
from fabric.hyprland.service import Hyprland
from fabric.utils.helpers import get_desktop_applications
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.eventbox import EventBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from loguru import logger
import config.data as data
import modules.icons as icons
# WIP icon resolver (app_id to guessing the icon name)
from utils.icon_resolver import IconResolver
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk
screen = Gdk.Screen.get_default()
CURRENT_WIDTH = screen.get_width()
CURRENT_HEIGHT = screen.get_height()
icon_resolver = IconResolver()
connection = Hyprland()
BASE_SCALE = 0.1 # Base scale factor for overview
# Credit to Aylur for the drag and drop code
TARGET = [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)]
# Credit to Aylur for the createSurfaceFromWidget code
def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface:
alloc = widget.get_allocation()
surface = cairo.ImageSurface(
cairo.Format.ARGB32,
alloc.width,
alloc.height,
)
cr = cairo.Context(surface)
cr.set_source_rgba(255, 255, 255, 0)
cr.rectangle(0, 0, alloc.width, alloc.height)
cr.fill()
widget.draw(cr)
return surface
class HyprlandWindowButton(Button):
def __init__(
self,
window: Box,
title: str,
address: str,
app_id: str,
size,
transform: int = 0,
):
self.transform = transform % 4
self.size = size if transform in [0, 2] else (size[1], size[0])
self.address = address
self.app_id = app_id
self.title = title
self.window: Box = window
# Compute dynamic icon sizes based on the button size.
# Using the minimum dimension of the button for scaling.
icon_size_main = int(min(self.size) * 0.5) # adjust factor as needed
# Enhanced icon resolution using desktop apps
desktop_app = window.find_app(app_id)
# Get icon using improved method with fallbacks
icon_pixbuf = None
if desktop_app:
icon_pixbuf = desktop_app.get_icon_pixbuf(size=icon_size_main)
if not icon_pixbuf:
# Fallback to IconResolver
icon_pixbuf = icon_resolver.get_icon_pixbuf(app_id, icon_size_main)
if not icon_pixbuf:
# Additional fallbacks for common apps
icon_pixbuf = icon_resolver.get_icon_pixbuf("application-x-executable-symbolic", icon_size_main)
if not icon_pixbuf:
icon_pixbuf = icon_resolver.get_icon_pixbuf("image-missing", icon_size_main)
# Ensure icon is scaled to the correct size
if icon_pixbuf and (icon_pixbuf.get_width() != icon_size_main or icon_pixbuf.get_height() != icon_size_main):
icon_pixbuf = icon_pixbuf.scale_simple(
icon_size_main,
icon_size_main,
gi.repository.GdkPixbuf.InterpType.BILINEAR
)
super().__init__(
name="overview-client-box",
image=Image(pixbuf=icon_pixbuf),
tooltip_text=title,
size=size,
on_clicked=self.on_button_click,
on_button_press_event=lambda _, event: connection.send_command(
f"/dispatch closewindow address:{address}"
)
if event.button == 3
else None,
on_drag_data_get=lambda _s, _c, data, *_: data.set_text(
address, len(address)
),
on_drag_begin=lambda _, context: Gtk.drag_set_icon_surface(
context, createSurfaceFromWidget(self)
),
)
# Store the desktop_app for later use
self.desktop_app = desktop_app
self.drag_source_set(
start_button_mask=Gdk.ModifierType.BUTTON1_MASK,
targets=TARGET,
actions=Gdk.DragAction.COPY,
)
self.connect("key_press_event", self.on_key_press_event)
def on_key_press_event(self, widget, event):
if event.get_state() & Gdk.ModifierType.SHIFT_MASK:
if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_space):
connection.send_command(f"/dispatch closewindow address:{self.address}")
return True
return False
def update_image(self, image):
# Compute overlay icon size dynamically.
icon_size_overlay = int(min(self.size) * 0.5) # adjust factor as needed
# Enhanced icon resolution for overlay
icon_pixbuf = None
if hasattr(self, 'desktop_app') and self.desktop_app:
icon_pixbuf = self.desktop_app.get_icon_pixbuf(size=icon_size_overlay)
if not icon_pixbuf:
icon_pixbuf = icon_resolver.get_icon_pixbuf(self.app_id, icon_size_overlay)
if not icon_pixbuf:
icon_pixbuf = icon_resolver.get_icon_pixbuf("application-x-executable-symbolic", icon_size_overlay)
if not icon_pixbuf:
icon_pixbuf = icon_resolver.get_icon_pixbuf("image-missing", icon_size_overlay)
# Ensure icon is scaled to the correct size
if icon_pixbuf and (icon_pixbuf.get_width() != icon_size_overlay or icon_pixbuf.get_height() != icon_size_overlay):
icon_pixbuf = icon_pixbuf.scale_simple(
icon_size_overlay,
icon_size_overlay,
gi.repository.GdkPixbuf.InterpType.BILINEAR
)
self.set_image(
Overlay(
child=image,
overlays=Image(
name="overview-icon",
pixbuf=icon_pixbuf,
h_align="center",
v_align="end",
tooltip_text=self.title,
),
)
)
def on_button_click(self, *_):
connection.send_command(f"/dispatch focuswindow address:{self.address}")
class WorkspaceEventBox(EventBox):
def __init__(self, workspace_id: int, fixed: Gtk.Fixed | None = None, monitor_width: int = None, monitor_height: int = None, monitor_scale: float = 1.0):
self.fixed = fixed
# Use provided monitor dimensions or fallback to current screen
width = monitor_width or CURRENT_WIDTH
height = monitor_height or CURRENT_HEIGHT
# Workspace containers should maintain consistent size across monitors
# Only use BASE_SCALE, don't multiply by monitor_scale for the container
container_scale = BASE_SCALE
super().__init__(
name="overview-workspace-bg",
h_expand=True,
v_expand=True,
size=(int(width * container_scale), int(height * container_scale)),
child=fixed
if fixed
else Label(
name="overview-add-label",
h_expand=True,
v_expand=True,
markup=icons.circle_plus,
),
on_drag_data_received=lambda _w, _c, _x, _y, data, *_: connection.send_command(
f"/dispatch movetoworkspacesilent {workspace_id},address:{data.get_data().decode()}"
),
)
self.drag_dest_set(
Gtk.DestDefaults.ALL,
TARGET,
Gdk.DragAction.COPY,
)
if fixed:
fixed.show_all()
class Overview(Box):
def __init__(self, monitor_id: int = 0, **kwargs):
self.monitor_id = monitor_id
self.monitor_manager = None
self.workspace_start = 1
self.workspace_end = 10
# Get monitor manager and workspace range
try:
from utils.monitor_manager import get_monitor_manager
self.monitor_manager = get_monitor_manager()
self.workspace_start, self.workspace_end = self.monitor_manager.get_workspace_range_for_monitor(monitor_id)
except ImportError:
# Fallback if monitor manager not available
pass
# Get monitor dimensions
monitor_width = CURRENT_WIDTH
monitor_height = CURRENT_HEIGHT
if self.monitor_manager:
monitor_info = self.monitor_manager.get_monitor_by_id(monitor_id)
if monitor_info:
monitor_width = monitor_info['width']
monitor_height = monitor_info['height']
# Initialize as a Box instead of a PopupWindow.
super().__init__(name="overview", orientation="v", spacing=8, **kwargs)
self.workspace_boxes: dict[int, Box] = {}
self.clients: dict[str, HyprlandWindowButton] = {}
# Initialize app registry for better icon resolution
self._all_apps = get_desktop_applications()
self.app_identifiers = self._build_app_identifiers_map()
# Remove the window_class_aliases dictionary completely
connection.connect("event::openwindow", self.do_update)
connection.connect("event::closewindow", self.do_update)
connection.connect("event::movewindow", self.do_update)
self.update()
def _normalize_window_class(self, class_name):
"""Normalize window class by removing common suffixes and lowercase."""
if not class_name:
return ""
normalized = class_name.lower()
# Remove common suffixes
suffixes = [".bin", ".exe", ".so", "-bin", "-gtk"]
for suffix in suffixes:
if normalized.endswith(suffix):
normalized = normalized[:-len(suffix)]
return normalized
def _classes_match(self, class1, class2):
"""Check if two window class names match with stricter comparison."""
if not class1 or not class2:
return False
# Normalize both classes
norm1 = self._normalize_window_class(class1)
norm2 = self._normalize_window_class(class2)
# Direct match after normalization
if norm1 == norm2:
return True
# Don't do substring matching as it's too error-prone
# This avoids incorrectly matching flatpak apps and others
return False
def _build_app_identifiers_map(self):
"""Build a mapping of app identifiers (class names, executables, names) to DesktopApp objects"""
identifiers = {}
for app in self._all_apps:
# Map by name (lowercase)
if app.name:
identifiers[app.name.lower()] = app
# Map by display name
if app.display_name:
identifiers[app.display_name.lower()] = app
# Map by window class if available
if app.window_class:
identifiers[app.window_class.lower()] = app
# Map by executable name if available
if app.executable:
exe_basename = app.executable.split('/')[-1].lower()
identifiers[exe_basename] = app
# Map by command line if available (without parameters)
if app.command_line:
cmd_base = app.command_line.split()[0].split('/')[-1].lower()
identifiers[cmd_base] = app
return identifiers
def find_app(self, app_identifier):
"""Return the DesktopApp object by matching any app identifier."""
if not app_identifier:
return None
# Try direct lookup in our identifiers map
normalized_id = str(app_identifier).lower()
if normalized_id in self.app_identifiers:
return self.app_identifiers[normalized_id]
# Try with normalized class name
norm_id = self._normalize_window_class(normalized_id)
if norm_id in self.app_identifiers:
return self.app_identifiers[norm_id]
# More targeted matching with exact names only
for app in self._all_apps:
if app.name and app.name.lower() == normalized_id:
return app
if app.window_class and app.window_class.lower() == normalized_id:
return app
if app.display_name and app.display_name.lower() == normalized_id:
return app
# Try with executable basename
if app.executable:
exe_base = app.executable.split('/')[-1].lower()
if exe_base == normalized_id:
return app
# Try with command basename
if app.command_line:
cmd_base = app.command_line.split()[0].split('/')[-1].lower()
if cmd_base == normalized_id:
return app
return None
def update(self, signal_update=False):
self._all_apps = get_desktop_applications()
self.app_identifiers = self._build_app_identifiers_map()
for client in self.clients.values():
client.destroy()
self.clients.clear()
for workspace in self.workspace_boxes.values():
workspace.destroy()
self.workspace_boxes.clear()
if data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Left", "Right"]:
rows = 5
cols = 2
else:
rows = 2
cols = 5
self.children = [Box(spacing=8) for _ in range(rows)]
# Get monitor dimensions and scale for scaling
monitor_width = CURRENT_WIDTH
monitor_height = CURRENT_HEIGHT
monitor_scale = 1.0
if self.monitor_manager:
monitor_info = self.monitor_manager.get_monitor_by_id(self.monitor_id)
if monitor_info:
monitor_width = monitor_info['width']
monitor_height = monitor_info['height']
monitor_scale = monitor_info.get('scale', 1.0)
# Calculate effective scale for this monitor
# Higher scale monitors need larger overview elements to appear the same physical size
effective_scale = BASE_SCALE * monitor_scale
monitors = {
monitor["id"]: (monitor["x"], monitor["y"], monitor["transform"])
for monitor in json.loads(connection.send_command("j/monitors").reply.decode())
}
# Filter clients to only show those in this monitor's workspace range
for client in json.loads(connection.send_command("j/clients").reply.decode()):
workspace_id = client["workspace"]["id"]
if workspace_id > 0 and self.workspace_start <= workspace_id <= self.workspace_end:
btn = HyprlandWindowButton(
window=self,
title=client["title"],
address=client["address"],
app_id=client["initialClass"],
size=(client["size"][0] * effective_scale, client["size"][1] * effective_scale),
transform=monitors[client["monitor"]][2],
)
self.clients[client["address"]] = btn
w_id = workspace_id
if w_id not in self.workspace_boxes:
self.workspace_boxes[w_id] = Gtk.Fixed.new()
self.workspace_boxes[w_id].put(
btn,
abs(client["at"][0] - monitors[client["monitor"]][0]) * effective_scale,
abs(client["at"][1] - monitors[client["monitor"]][1]) * effective_scale,
)
# Generate workspaces only for this monitor's range
for w_id in range(self.workspace_start, self.workspace_end + 1):
idx = w_id - self.workspace_start
if rows == 2:
row = 0 if idx < cols else 1
else:
row = idx // cols
overview_row = self.children[row]
overview_row.add(
Box(
name="overview-workspace-box",
orientation="vertical",
children=[
Label(name="overview-workspace-label", label=f"Workspace {w_id}"),
WorkspaceEventBox(
w_id,
self.workspace_boxes.get(w_id),
monitor_width=monitor_width,
monitor_height=monitor_height,
monitor_scale=monitor_scale
),
],
)
)
def do_update(self, *_):
logger.info(f"[Overview] Updating for :{_[1].name}")
self.update(signal_update=True)
+498
View File
@@ -0,0 +1,498 @@
import gi
import config.data as data
gi.require_version('Gtk', '3.0')
import json
import os
import re
import subprocess
import tempfile
import urllib.parse
import urllib.request
from pathlib import Path
import cairo
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
import modules.icons as icons
SAVE_FILE = os.path.expanduser("~/.pins.json")
icon_size = 80
if data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]:
icon_size = 36
def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface:
alloc = widget.get_allocation()
surface = cairo.ImageSurface(cairo.Format.ARGB32, alloc.width, alloc.height)
cr = cairo.Context(surface)
cr.set_source_rgba(1, 1, 1, 0)
cr.rectangle(0, 0, alloc.width, alloc.height)
cr.fill()
widget.draw(cr)
return surface
def open_file(filepath):
try:
subprocess.Popen(["xdg-open", filepath])
except Exception as e:
print("Error opening file:", e)
def open_url(url):
try:
subprocess.Popen(["xdg-open", url])
except Exception as e:
print("Error opening URL:", e)
def is_url(text):
url_pattern = re.compile(
r'^(https?|ftp)://'
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|'
r'localhost|'
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
r'(?::\d+)?'
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
return bool(url_pattern.match(text))
def get_favicon_url(url):
"""Extract the base domain from a URL and construct a favicon URL."""
parsed_url = urllib.parse.urlparse(url)
base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
return f"{base_url}/favicon.ico"
def download_favicon(url, callback):
"""Download a favicon asynchronously and call the callback with the result."""
favicon_url = get_favicon_url(url)
def do_download():
temp_file = None
try:
temp_fd, temp_path = tempfile.mkstemp(suffix='.ico')
os.close(temp_fd)
temp_file = temp_path
urllib.request.urlretrieve(favicon_url, temp_path)
GLib.idle_add(callback, temp_path)
except Exception as e:
print(f"Error downloading favicon: {e}")
if temp_file and os.path.exists(temp_file):
try:
os.remove(temp_file)
except:
pass
GLib.idle_add(callback, None)
GLib.Thread.new("favicon-download", do_download, None)
class FileChangeHandler(FileSystemEventHandler):
def __init__(self, app):
self.app = app
def on_any_event(self, event):
if event.is_directory:
return
for cell in self.app.cells:
if cell.content_type == 'file' and cell.content:
try:
cell_real = os.path.realpath(cell.content)
src_real = os.path.realpath(event.src_path)
dest_real = os.path.realpath(getattr(event, 'dest_path', ''))
if cell_real == src_real or (dest_real and cell_real == dest_real):
GLib.idle_add(self.handle_file_event, cell, event)
except Exception:
pass
def handle_file_event(self, cell, event):
if event.event_type == 'deleted':
cell.clear_cell()
self.app.save_state()
elif event.event_type == 'moved':
if hasattr(event, 'dest_path') and os.path.exists(event.dest_path):
cell.content = event.dest_path
cell.update_display()
self.app.save_state()
self.app.add_monitor_for_path(os.path.dirname(event.dest_path))
class Cell(Gtk.EventBox):
def __init__(self, app, content=None, content_type=None):
super().__init__(name="pin-cell")
self.app = app
self.content = content
self.content_type = content_type
self.box = Box(name="pin-cell-box", orientation="v", spacing=4)
self.add(self.box)
self.favicon_temp_path = None
target_dest = Gtk.TargetEntry.new("text/uri-list", 0, 0)
self.drag_dest_set(Gtk.DestDefaults.ALL, [target_dest], Gdk.DragAction.COPY)
self.connect("drag-data-received", self.on_drag_data_received)
targets = [
Gtk.TargetEntry.new("text/uri-list", 0, 0),
Gtk.TargetEntry.new("text/plain", 0, 1)
]
self.drag_source_set(Gdk.ModifierType.BUTTON1_MASK, targets, Gdk.DragAction.COPY)
self.connect("drag-data-get", self.on_drag_data_get)
self.connect("button-press-event", self.on_button_press)
self.connect("drag-begin", self.on_drag_begin)
self.update_display()
def update_display(self):
if self.favicon_temp_path and os.path.exists(self.favicon_temp_path):
try:
os.remove(self.favicon_temp_path)
self.favicon_temp_path = None
except Exception as e:
print(f"Error removing temp favicon: {e}")
for child in self.box.get_children():
self.box.remove(child)
if self.content is None:
label = Label(name="pin-add", markup=icons.paperclip)
self.box.pack_start(label, True, True, 0)
else:
if self.content_type == 'file':
widget = self.get_file_preview(self.content)
self.box.pack_start(widget, True, True, 0)
label = Label(name="pin-file", label=os.path.basename(self.content), justification="center", ellipsization="middle")
self.box.pack_start(label, False, False, 0)
elif self.content_type == 'text':
if is_url(self.content):
icon_container = Box(name="pin-icon-container", orientation="v")
self.box.pack_start(icon_container, True, True, 0)
url_icon = Label(name="pin-url-icon", markup=icons.world, style=f"font-size: {icon_size}px;")
icon_container.pack_start(url_icon, True, True, 0)
domain = re.sub(r'^https?://', '', self.content)
domain = domain.split('/')[0]
label = Label(name="pin-url", label=domain, justification="center", ellipsization="end")
self.box.pack_start(label, False, False, 0)
download_favicon(
self.content,
lambda path: self.update_favicon(icon_container, url_icon, path)
)
else:
label = Label(name="pin-text", label=self.content.split('\n')[0], justification="center", ellipsization="end", line_wrap="word-char")
self.box.pack_start(label, True, True, 0)
self.box.show_all()
if not self.app.loading_state:
self.app.save_state()
def update_favicon(self, container, icon_widget, favicon_path):
"""Update the icon with the downloaded favicon or keep the default."""
if not favicon_path or not os.path.exists(favicon_path):
return
try:
self.favicon_temp_path = favicon_path
if data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Left", "Right"]:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
favicon_path, width=36, height=36, preserve_aspect_ratio=True)
else:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
favicon_path, width=48, height=48, preserve_aspect_ratio=True)
container.remove(icon_widget)
img = Gtk.Image.new_from_pixbuf(pixbuf)
img.set_name("pin-favicon")
container.pack_start(img, True, True, 0)
container.show_all()
except Exception as e:
print(f"Error setting favicon: {e}")
def get_file_preview(self, filepath):
try:
file = Gio.File.new_for_path(filepath)
info = file.query_info("standard::content-type", Gio.FileQueryInfoFlags.NONE, None)
content_type = info.get_content_type()
except Exception:
content_type = None
icon_theme = Gtk.IconTheme.get_default()
if content_type == "inode/directory":
try:
pixbuf = icon_theme.load_icon("default-folder", icon_size, 0)
return Gtk.Image.new_from_pixbuf(pixbuf)
except Exception:
print("Error loading folder icon")
return Gtk.Image.new_from_icon_name("default-folder", Gtk.IconSize.DIALOG)
if content_type and content_type.startswith("image/"):
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
filepath, width=icon_size, height=icon_size, preserve_aspect_ratio=True)
return Gtk.Image.new_from_pixbuf(pixbuf)
except Exception as e:
print("Error loading image preview:", e)
elif content_type and content_type.startswith("video/"):
try:
pixbuf = icon_theme.load_icon("video-x-generic", icon_size, 0)
return Gtk.Image.new_from_pixbuf(pixbuf)
except Exception:
print("Error loading video icon")
return Gtk.Image.new_from_icon_name("video-x-generic", Gtk.IconSize.DIALOG)
else:
icon_name = "text-x-generic"
if content_type:
themed_icon = Gio.content_type_get_icon(content_type)
if hasattr(themed_icon, 'get_names'):
names = themed_icon.get_names()
if names:
icon_name = names[0]
try:
pixbuf = icon_theme.load_icon(icon_name, icon_size, 0)
return Gtk.Image.new_from_pixbuf(pixbuf)
except Exception:
print("Error loading icon", icon_name)
return Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG)
def on_drag_data_received(self, widget, drag_context, x, y, data, info, time):
if self.content is None and data.get_length() >= 0:
uris = data.get_uris()
if uris:
try:
filepath, _ = GLib.filename_from_uri(uris[0])
self.content = filepath
self.content_type = 'file'
self.update_display()
except Exception as e:
print("Error getting file from URI:", e)
drag_context.finish(True, False, time)
def on_drag_data_get(self, widget, drag_context, data, info, time):
if self.content is None:
return
if info == 0 and self.content_type == 'file':
uri = GLib.filename_to_uri(self.content)
data.set_uris([uri])
elif info == 1 and self.content_type == 'text':
data.set_text(self.content, -1)
def on_drag_begin(self, widget, context):
if self.content_type == 'file':
surface = createSurfaceFromWidget(self)
Gtk.drag_set_icon_surface(context, surface)
def on_button_press(self, widget, event):
if self.content is None:
if event.button == 1:
self.select_file()
elif event.button == 2:
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
text = clipboard.wait_for_text()
if text:
self.content = text
self.content_type = 'text'
self.update_display()
else:
if self.content_type == 'file':
if event.button == 1 and event.type == Gdk.EventType._2BUTTON_PRESS:
open_file(self.content)
elif event.button == 3:
self.clear_cell()
elif self.content_type == 'text':
if event.button == 1:
if is_url(self.content):
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.set_text(self.content, -1)
if not (event.state & Gdk.ModifierType.CONTROL_MASK):
open_url(self.content)
else:
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard.set_text(self.content, -1)
elif event.button == 3:
self.clear_cell()
return True
def select_file(self):
dialog = Gtk.FileChooserDialog(
title="Select File",
parent=self.get_toplevel(),
action=Gtk.FileChooserAction.OPEN
)
dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
if dialog.run() == Gtk.ResponseType.OK:
filepath = dialog.get_filename()
self.content = filepath
self.content_type = 'file'
self.update_display()
dialog.destroy()
def clear_cell(self):
if self.favicon_temp_path and os.path.exists(self.favicon_temp_path):
try:
os.remove(self.favicon_temp_path)
self.favicon_temp_path = None
except Exception as e:
print(f"Error removing temp favicon: {e}")
self.content = None
self.content_type = None
self.update_display()
class Pins(Gtk.Box):
def __init__(self, **kwargs):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self.loading_state = True
self.monitored_paths = set()
self.observer = Observer()
self.event_handler = FileChangeHandler(self)
self.cells = []
grid = Gtk.Grid(row_spacing=8, column_spacing=8, name="pin-grid")
grid.set_column_homogeneous(True)
grid.set_row_homogeneous(True)
scrolled_window = ScrolledWindow(child=grid, name="scrolled-window", style_classes="pins", propagate_width=False, propagate_height=False)
scrolled_window.set_hexpand(True)
scrolled_window.set_vexpand(True)
scrolled_window.set_halign(Gtk.Align.FILL)
scrolled_window.set_valign(Gtk.Align.FILL)
self.pack_start(scrolled_window, True, True, 0)
if data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]):
for row in range(10):
for col in range(3):
cell = Cell(self)
self.cells.append(cell)
grid.attach(cell, col, row, 1, 1)
else:
for row in range(6):
for col in range(5):
cell = Cell(self)
self.cells.append(cell)
grid.attach(cell, col, row, 1, 1)
self.load_state()
self.loading_state = False
self.start_file_monitoring()
self.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY)
self.connect("drag-data-received", self.on_drag_data_received)
def start_file_monitoring(self):
for cell in self.cells:
if cell.content_type == 'file' and cell.content:
dir_path = os.path.dirname(cell.content)
if os.path.exists(dir_path) and dir_path not in self.monitored_paths:
self.observer.schedule(self.event_handler, dir_path, recursive=False)
self.monitored_paths.add(dir_path)
self.observer.start()
def add_monitor_for_path(self, path):
if path not in self.monitored_paths and os.path.exists(path):
self.observer.schedule(self.event_handler, path, recursive=False)
self.monitored_paths.add(path)
def save_state(self):
state = []
for cell in self.cells:
state.append({
'content_type': cell.content_type,
'content': cell.content
})
try:
with open(SAVE_FILE, 'w') as f:
json.dump(state, f)
except Exception as e:
print("Error saving state:", e)
def load_state(self):
if not os.path.exists(SAVE_FILE):
return
try:
with open(SAVE_FILE, 'r') as f:
state = json.load(f)
for i, cell_data in enumerate(state):
if i < len(self.cells):
content = cell_data.get('content')
content_type = cell_data.get('content_type')
self.cells[i].content = content
self.cells[i].content_type = content_type
self.cells[i].update_display()
except Exception as e:
print("Error loading state:", e)
def on_drag_data_received(self, widget, drag_context, x, y, data, info, time):
if data.get_length() >= 0:
uris = data.get_uris()
for uri in uris:
try:
filepath, _ = GLib.filename_from_uri(uri)
for cell in self.cells:
if cell.content is None:
cell.content = filepath
cell.content_type = 'file'
cell.update_display()
break
except Exception as e:
print("Error getting file from URI:", e)
drag_context.finish(True, False, time)
def stop_monitoring(self):
for cell in self.cells:
if hasattr(cell, 'favicon_temp_path') and cell.favicon_temp_path and os.path.exists(cell.favicon_temp_path):
try:
os.remove(cell.favicon_temp_path)
except Exception:
pass
self.observer.stop()
self.observer.join()
+706
View File
@@ -0,0 +1,706 @@
import os
import tempfile
import urllib.parse
import urllib.request
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.circularprogressbar import CircularProgressBar
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.stack import Stack
from gi.repository import Gdk, Gio, GLib, Gtk
import config.data as data
import modules.icons as icons
from modules.cavalcade import SpectrumRender
from services.mpris import MprisPlayer, MprisPlayerManager
from widgets.circle_image import CircleImage
vertical_mode = False
if data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]):
vertical_mode = True
def get_player_icon_markup_by_name(player_name):
if player_name:
pn = player_name.lower()
if pn == "firefox":
return icons.firefox
elif pn == "spotify":
return icons.spotify
elif pn in ("chromium", "brave"):
return icons.chromium
return icons.disc
def add_hover_cursor(widget):
widget.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK)
widget.connect("enter-notify-event", lambda w, event: w.get_window().set_cursor(
Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "pointer")))
widget.connect("leave-notify-event", lambda w, event: w.get_window().set_cursor(None))
class PlayerBox(Box):
def __init__(self, mpris_player=None):
super().__init__(orientation="v", h_align="fill", spacing=0, h_expand=False, v_expand=not vertical_mode)
self.mpris_player = mpris_player
self._progress_timer_id = None
self.cover = CircleImage(
name="player-cover",
image_file=os.path.expanduser("~/.current.wall"),
size=162 if not vertical_mode else 96,
h_align="center",
v_align="center",
)
self.cover_placerholder = CircleImage(
name="player-cover",
size=198 if not vertical_mode else 132,
h_align="center",
v_align="center",
)
self.title = Label(name="player-title", h_expand=True, h_align="fill", ellipsization="end", max_chars_width=1, style_classes=["vertical"] if vertical_mode else [])
self.album = Label(name="player-album", h_expand=True, h_align="fill", ellipsization="end", max_chars_width=1)
self.artist = Label(name="player-artist", h_expand=True, h_align="fill", ellipsization="end", max_chars_width=1)
self.progressbar = CircularProgressBar(
name="player-progress",
size=198 if not vertical_mode else 132,
h_align="center",
v_align="center",
start_angle=180,
end_angle=360,
)
self.time = Label(name="player-time", label="--:-- / --:--")
self.overlay = Overlay(
child=self.cover_placerholder,
overlays=[self.progressbar, self.cover],
)
self.overlay_container = CenterBox(name="player-overlay", center_children=[self.overlay])
self.title.set_label("Nothing Playing")
self.album.set_label("Enjoy the silence")
self.artist.set_label("¯\\_(ツ)_/¯")
self.progressbar.set_value(0.0)
self.prev = Button(
name="player-btn",
child=Label(name="player-btn-label", markup=icons.prev),
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.backward = Button(
name="player-btn",
child=Label(name="player-btn-label", markup=icons.skip_back),
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.play_pause = Button(
name="player-btn",
child=Label(name="player-btn-label", markup=icons.play, style_classes=["play-pause"]),
style_classes=["play-pause"],
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.forward = Button(
name="player-btn",
child=Label(name="player-btn-label", markup=icons.skip_forward),
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.next = Button(
name="player-btn",
child=Label(name="player-btn-label", markup=icons.next),
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
add_hover_cursor(self.prev)
add_hover_cursor(self.backward)
add_hover_cursor(self.play_pause)
add_hover_cursor(self.forward)
add_hover_cursor(self.next)
self.btn_box = CenterBox(
name="player-btn-box",
orientation="h",
center_children=[
Box(
orientation="h",
spacing=8,
h_expand=True,
h_align="fill",
children=[
self.prev,
self.backward,
self.play_pause,
self.forward,
self.next,
]
)
]
)
self.p_children=[
self.overlay_container,
self.title,
self.album,
self.artist,
self.btn_box,
self.time,
] if not vertical_mode else [
self.overlay_container,
Box(
orientation="v",
spacing=4,
h_expand=True,
h_align="fill",
v_expand=False,
v_align="center",
children=[
self.title,
self.album,
self.btn_box,
self.artist,
self.time,
]
)
]
self.player_box = Box(
name="player-box",
orientation="v" if not vertical_mode else "h",
v_align="center",
spacing=4,
children=self.p_children,
)
self.add(self.player_box)
if mpris_player:
self._apply_mpris_properties()
self.prev.connect("clicked", self._on_prev_clicked)
self.play_pause.connect("clicked", self._on_play_pause_clicked)
self.backward.connect("clicked", self._on_backward_clicked)
self.forward.connect("clicked", self._on_forward_clicked)
self.next.connect("clicked", self._on_next_clicked)
self.mpris_player.connect("changed", self._on_mpris_changed)
else:
self.play_pause.get_child().set_markup(icons.stop)
self.play_pause.add_style_class("stop")
self.backward.add_style_class("disabled")
self.forward.add_style_class("disabled")
self.prev.add_style_class("disabled")
self.next.add_style_class("disabled")
self.progressbar.set_value(0.0)
self.time.set_text("--:-- / --:--")
def _apply_mpris_properties(self):
mp = self.mpris_player
self.title.set_visible(bool(mp.title and mp.title.strip()))
if mp.title and mp.title.strip():
self.title.set_text(mp.title)
self.album.set_visible(bool(mp.album and mp.album.strip()))
if mp.album and mp.album.strip():
self.album.set_text(mp.album)
self.artist.set_visible(bool(mp.artist and mp.artist.strip()))
if mp.artist and mp.artist.strip():
self.artist.set_text(mp.artist)
if mp.arturl:
parsed = urllib.parse.urlparse(mp.arturl)
if parsed.scheme == "file":
local_arturl = urllib.parse.unquote(parsed.path)
self._set_cover_image(local_arturl)
elif parsed.scheme in ("http", "https"):
GLib.Thread.new("download-artwork", self._download_and_set_artwork, mp.arturl)
else:
self._set_cover_image(mp.arturl)
else:
fallback = os.path.expanduser("~/.current.wall")
self._set_cover_image(fallback)
file_obj = Gio.File.new_for_path(fallback)
monitor = file_obj.monitor_file(Gio.FileMonitorFlags.NONE, None)
monitor.connect("changed", self.on_wallpaper_changed)
self._wallpaper_monitor = monitor
self.update_play_pause_icon()
self.progressbar.set_visible(True)
self.time.set_visible(True)
player_name = mp.player_name.lower() if hasattr(mp, "player_name") and mp.player_name else ""
can_seek = hasattr(mp, "can_seek") and mp.can_seek
if player_name == "firefox" or not can_seek:
# Firefox and non-seekable players don't support progress tracking
self.backward.add_style_class("disabled")
self.forward.add_style_class("disabled")
self.progressbar.set_value(0.0)
self.time.set_text("--:-- / --:--")
# Stop the timer since we can't track progress
if self._progress_timer_id:
GLib.source_remove(self._progress_timer_id)
self._progress_timer_id = None
else:
# Enable seeking controls
self.backward.remove_style_class("disabled")
self.forward.remove_style_class("disabled")
# Use adaptive timer based on playback status instead of fixed 1-second polling
self._start_adaptive_progress_timer()
if hasattr(mp, "can_go_previous") and mp.can_go_previous:
self.prev.remove_style_class("disabled")
else:
self.prev.add_style_class("disabled")
if hasattr(mp, "can_go_next") and mp.can_go_next:
self.next.remove_style_class("disabled")
else:
self.next.add_style_class("disabled")
def _start_adaptive_progress_timer(self):
"""Start progress timer with adaptive interval based on playback status"""
if self._progress_timer_id:
GLib.source_remove(self._progress_timer_id)
# Use longer intervals when paused to reduce CPU usage
if hasattr(self.mpris_player, 'playback_status') and self.mpris_player.playback_status == "playing":
interval = 1000 # 1 second when playing
else:
interval = 5000 # 5 seconds when paused/stopped
self._progress_timer_id = GLib.timeout_add(interval, self._update_progress)
self._update_progress() # Update immediately
def _set_cover_image(self, image_path):
if image_path and os.path.isfile(image_path):
self.cover.set_image_from_file(image_path)
else:
fallback = os.path.expanduser("~/.current.wall")
self.cover.set_image_from_file(fallback)
file_obj = Gio.File.new_for_path(fallback)
monitor = file_obj.monitor_file(Gio.FileMonitorFlags.NONE, None)
monitor.connect("changed", self.on_wallpaper_changed)
self._wallpaper_monitor = monitor
def _download_and_set_artwork(self, arturl):
"""
Download the artwork from the given URL asynchronously and update the cover image
using GLib.idle_add to ensure UI updates occur on the main thread.
"""
try:
parsed = urllib.parse.urlparse(arturl)
suffix = os.path.splitext(parsed.path)[1] or ".png"
with urllib.request.urlopen(arturl) as response:
data = response.read()
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
temp_file.write(data)
temp_file.close()
local_arturl = temp_file.name
except Exception:
local_arturl = os.path.expanduser("~/.current.wall")
GLib.idle_add(self._set_cover_image, local_arturl)
return None
def update_play_pause_icon(self):
if self.mpris_player.playback_status == "playing":
self.play_pause.get_child().set_markup(icons.pause)
self.play_pause.add_style_class("playing")
else:
self.play_pause.get_child().set_markup(icons.play)
self.play_pause.remove_style_class("playing")
def on_wallpaper_changed(self, monitor, file, other_file, event):
self.cover.set_image_from_file(os.path.expanduser("~/.current.wall"))
def _on_prev_clicked(self, button):
if self.mpris_player:
self.mpris_player.previous()
def _on_play_pause_clicked(self, button):
if self.mpris_player:
self.mpris_player.play_pause()
self.update_play_pause_icon()
def _on_backward_clicked(self, button):
if self.mpris_player and self.mpris_player.can_seek and "disabled" not in self.backward.get_style_context().list_classes():
new_pos = max(0, self.mpris_player.position - 5000000)
self.mpris_player.position = new_pos
def _on_forward_clicked(self, button):
if self.mpris_player and self.mpris_player.can_seek and "disabled" not in self.forward.get_style_context().list_classes():
new_pos = self.mpris_player.position + 5000000
self.mpris_player.position = new_pos
def _on_next_clicked(self, button):
if self.mpris_player:
self.mpris_player.next()
def _update_progress(self):
if not self.mpris_player:
if self._progress_timer_id:
GLib.source_remove(self._progress_timer_id)
self._progress_timer_id = None
return False
try:
current = self.mpris_player.position
except Exception:
current = 0
try:
total = int(self.mpris_player.length or 0)
except Exception:
total = 0
if total <= 0:
progress = 0.0
self.time.set_text("--:-- / --:--")
else:
progress = (current / total)
self.time.set_text(f"{self._format_time(current)} / {self._format_time(total)}")
self.progressbar.set_value(progress)
return True
def _format_time(self, us):
seconds = int(us / 1000000)
minutes = seconds // 60
seconds = seconds % 60
return f"{minutes}:{seconds:02}"
def _update_metadata(self):
if not self.mpris_player:
return False
self._apply_mpris_properties()
return True
def _on_mpris_changed(self, *args):
if not hasattr(self, "_update_pending") or not self._update_pending:
self._update_pending = True
GLib.idle_add(self._apply_mpris_properties_debounced)
def _apply_mpris_properties_debounced(self):
"""Apply MPRIS properties with debouncing and restart adaptive timer"""
if self.mpris_player:
self._apply_mpris_properties()
else:
# Clean up timer when player is removed
if self._progress_timer_id:
GLib.source_remove(self._progress_timer_id)
self._progress_timer_id = None
self._update_pending = False
return False
class Player(Box):
def __init__(self):
super().__init__(name="player", orientation="v", h_align="fill", spacing=0, h_expand=False, v_expand=not vertical_mode)
self.player_stack = Stack(
name="player-stack",
transition_type="slide-left-right",
transition_duration=500,
v_align="center",
v_expand=not vertical_mode,
)
self.switcher = Gtk.StackSwitcher(
name="player-switcher" if not vertical_mode else "player-switcher-vertical",
spacing=8,
)
self.switcher.set_stack(self.player_stack)
self.switcher.set_halign(Gtk.Align.CENTER)
self.mpris_manager = MprisPlayerManager()
players = self.mpris_manager.players
if players:
for p in players:
mp = MprisPlayer(p)
pb = PlayerBox(mpris_player=mp)
self.player_stack.add_titled(pb, mp.player_name, mp.player_name)
else:
pb = PlayerBox(mpris_player=None)
self.player_stack.add_titled(pb, "nothing", "Nothing Playing")
self.mpris_manager.connect("player-appeared", self.on_player_appeared)
self.mpris_manager.connect("player-vanished", self.on_player_vanished)
self.switcher.set_visible(True)
self.add(self.player_stack)
self.add(self.switcher)
GLib.idle_add(self._replace_switcher_labels)
def on_player_appeared(self, manager, player):
children = self.player_stack.get_children()
if len(children) == 1 and not getattr(children[0], "mpris_player", None):
self.player_stack.remove(children[0])
mp = MprisPlayer(player)
pb = PlayerBox(mpris_player=mp)
self.player_stack.add_titled(pb, mp.player_name, mp.player_name)
self.switcher.set_visible(True)
GLib.idle_add(lambda: self._update_switcher_for_player(mp.player_name))
GLib.idle_add(self._replace_switcher_labels)
def on_player_vanished(self, manager, player_name):
for child in self.player_stack.get_children():
if hasattr(child, "mpris_player") and child.mpris_player and child.mpris_player.player_name == player_name:
self.player_stack.remove(child)
break
if not any(getattr(child, "mpris_player", None) for child in self.player_stack.get_children()):
pb = PlayerBox(mpris_player=None)
self.player_stack.add_titled(pb, "nothing", "Nothing Playing")
self.switcher.set_visible(True)
GLib.idle_add(self._replace_switcher_labels)
def _replace_switcher_labels(self):
buttons = self.switcher.get_children()
for btn in buttons:
if isinstance(btn, Gtk.ToggleButton):
default_label = None
for child in btn.get_children():
if isinstance(child, Gtk.Label):
default_label = child
break
if default_label:
label_player_name = getattr(default_label, "player_name", default_label.get_text().lower())
icon_markup = get_player_icon_markup_by_name(label_player_name)
btn.remove(default_label)
new_label = Label(name="player-label", markup=icon_markup)
new_label.player_name = label_player_name
btn.add(new_label)
new_label.show_all()
return False
def _update_switcher_for_player(self, player_name):
for btn in self.switcher.get_children():
if isinstance(btn, Gtk.ToggleButton):
default_label = None
for child in btn.get_children():
if isinstance(child, Gtk.Label):
default_label = child
break
if default_label:
label_player_name = getattr(default_label, "player_name", default_label.get_text().lower())
if label_player_name == player_name.lower():
icon_markup = get_player_icon_markup_by_name(player_name)
btn.remove(default_label)
new_label = Label(name="player-label", markup=icon_markup)
new_label.player_name = player_name.lower()
btn.add(new_label)
new_label.show_all()
return False
class PlayerSmall(CenterBox):
def __init__(self):
super().__init__(name="player-small", orientation="h", h_align="fill", v_align="center")
self._show_artist = False
self._display_options = ["cavalcade", "title", "artist"]
self._display_index = 0
self._current_display = "cavalcade"
self.mpris_icon = Button(
name="compact-mpris-icon",
h_align="center",
v_align="center",
child=Label(name="compact-mpris-icon-label", markup=icons.disc)
)
self.mpris_icon.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
self.mpris_icon.connect("button-press-event", self._on_icon_button_press)
child = self.mpris_icon.get_child()
child.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
child.connect("button-press-event", lambda widget, event: True)
add_hover_cursor(self.mpris_icon)
self.mpris_label = Label(
name="compact-mpris-label",
label="Nothing Playing",
ellipsization="end",
max_chars_width=26,
h_align="center",
)
self.mpris_button = Button(
name="compact-mpris-button",
h_align="center",
v_align="center",
child=Label(name="compact-mpris-button-label", markup=icons.play)
)
self.mpris_button.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
self.mpris_button.connect("button-press-event", self._on_play_pause_button_press)
add_hover_cursor(self.mpris_button)
self.cavalcade = SpectrumRender()
self.cavalcade_box = self.cavalcade.get_spectrum_box()
self.center_stack = Stack(
name="compact-mpris",
transition_type="crossfade",
transition_duration=100,
v_align="center",
v_expand=False,
children=[
self.cavalcade_box,
self.mpris_label,
]
)
self.center_stack.set_visible_child(self.cavalcade_box)
self.mpris_small = CenterBox(
name="compact-mpris",
orientation="h",
h_expand=True,
h_align="fill",
v_align="center",
v_expand=False,
start_children=self.mpris_icon,
center_children=self.center_stack,
end_children=self.mpris_button,
)
self.add(self.mpris_small)
self.mpris_manager = MprisPlayerManager()
self.mpris_player = None
self.current_index = 0
players = self.mpris_manager.players
if players:
mp = MprisPlayer(players[self.current_index])
self.mpris_player = mp
self._apply_mpris_properties()
self.mpris_player.connect("changed", self._on_mpris_changed)
else:
self._apply_mpris_properties()
self.mpris_manager.connect("player-appeared", self.on_player_appeared)
self.mpris_manager.connect("player-vanished", self.on_player_vanished)
self.mpris_button.connect("clicked", self._on_play_pause_clicked)
def _apply_mpris_properties(self):
if not self.mpris_player:
self.mpris_label.set_text("Nothing Playing")
self.mpris_button.get_child().set_markup(icons.stop)
self.mpris_icon.get_child().set_markup(icons.disc)
if self._current_display != "cavalcade":
self.center_stack.set_visible_child(self.mpris_label)
else:
self.center_stack.set_visible_child(self.cavalcade_box)
return
mp = self.mpris_player
player_name = mp.player_name.lower() if hasattr(mp, "player_name") and mp.player_name else ""
icon_markup = get_player_icon_markup_by_name(player_name)
self.mpris_icon.get_child().set_markup(icon_markup)
self.update_play_pause_icon()
if self._current_display == "title":
text = (mp.title if mp.title and mp.title.strip() else "Nothing Playing")
self.mpris_label.set_text(text)
self.center_stack.set_visible_child(self.mpris_label)
elif self._current_display == "artist":
text = (mp.artist if mp.artist else "Nothing Playing")
self.mpris_label.set_text(text)
self.center_stack.set_visible_child(self.mpris_label)
else:
self.center_stack.set_visible_child(self.cavalcade_box)
def _on_icon_button_press(self, widget, event):
from gi.repository import Gdk
if event.type == Gdk.EventType.BUTTON_PRESS:
players = self.mpris_manager.players
if not players:
return True
if event.button == 2:
self._display_index = (self._display_index + 1) % len(self._display_options)
self._current_display = self._display_options[self._display_index]
self._apply_mpris_properties()
return True
if event.button == 1:
self.current_index = (self.current_index + 1) % len(players)
elif event.button == 3:
self.current_index = (self.current_index - 1) % len(players)
if self.current_index < 0:
self.current_index = len(players) - 1
mp_new = MprisPlayer(players[self.current_index])
self.mpris_player = mp_new
self.mpris_player.connect("changed", self._on_mpris_changed)
self._apply_mpris_properties()
return True
return True
def _on_play_pause_button_press(self, widget, event):
if event.type == Gdk.EventType.BUTTON_PRESS:
if event.button == 1:
if self.mpris_player:
self.mpris_player.previous()
self.mpris_button.get_child().set_markup(icons.prev)
GLib.timeout_add(500, self._restore_play_pause_icon)
elif event.button == 3:
if self.mpris_player:
self.mpris_player.next()
self.mpris_button.get_child().set_markup(icons.next)
GLib.timeout_add(500, self._restore_play_pause_icon)
elif event.button == 2:
if self.mpris_player:
self.mpris_player.play_pause()
self.update_play_pause_icon()
return True
return True
def _restore_play_pause_icon(self):
self.update_play_pause_icon()
return False
def _on_icon_clicked(self, widget):
pass
def update_play_pause_icon(self):
if self.mpris_player and self.mpris_player.playback_status == "playing":
self.mpris_button.get_child().set_markup(icons.pause)
else:
self.mpris_button.get_child().set_markup(icons.play)
def _on_play_pause_clicked(self, button):
if self.mpris_player:
self.mpris_player.play_pause()
self.update_play_pause_icon()
def _on_mpris_changed(self, *args):
self._apply_mpris_properties()
def on_player_appeared(self, manager, player):
if not self.mpris_player:
mp = MprisPlayer(player)
self.mpris_player = mp
self._apply_mpris_properties()
self.mpris_player.connect("changed", self._on_mpris_changed)
def on_player_vanished(self, manager, player_name):
players = self.mpris_manager.players
if players and self.mpris_player and self.mpris_player.player_name == player_name:
if players:
self.current_index = self.current_index % len(players)
new_player = MprisPlayer(players[self.current_index])
self.mpris_player = new_player
self.mpris_player.connect("changed", self._on_mpris_changed)
else:
self.mpris_player = None
elif not players:
self.mpris_player = None
self._apply_mpris_properties()
+131
View File
@@ -0,0 +1,131 @@
from fabric.utils.helpers import exec_shell_command_async
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
import config.data as data
import modules.icons as icons
tooltip_lock = "Lock"
tooltip_suspend = "Suspend"
tooltip_logout = "Logout"
tooltip_reboot = "Reboot"
tooltip_shutdown = "Shutdown"
class PowerMenu(Box):
def __init__(self, **kwargs):
orientation = "h"
if data.PANEL_THEME == "Panel" and (
data.BAR_POSITION in ["Left", "Right"]
or data.PANEL_POSITION in ["Start", "End"]
):
orientation = "v"
super().__init__(
name="power-menu",
orientation=orientation,
spacing=4,
v_align="center",
h_align="center",
visible=True,
**kwargs,
)
self.notch = kwargs["notch"]
self.btn_lock = Button(
name="power-menu-button",
tooltip_markup=tooltip_lock,
child=Label(name="button-label", markup=icons.lock),
on_clicked=self.lock,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.btn_suspend = Button(
name="power-menu-button",
tooltip_markup=tooltip_suspend,
child=Label(name="button-label", markup=icons.suspend),
on_clicked=self.suspend,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.btn_logout = Button(
name="power-menu-button",
tooltip_markup=tooltip_logout,
child=Label(name="button-label", markup=icons.logout),
on_clicked=self.logout,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.btn_reboot = Button(
name="power-menu-button",
tooltip_markup=tooltip_reboot,
child=Label(name="button-label", markup=icons.reboot),
on_clicked=self.reboot,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.btn_shutdown = Button(
name="power-menu-button",
tooltip_markup=tooltip_shutdown,
child=Label(name="button-label", markup=icons.shutdown),
on_clicked=self.poweroff,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.buttons = [
self.btn_lock,
self.btn_suspend,
self.btn_logout,
self.btn_reboot,
self.btn_shutdown,
]
for button in self.buttons:
self.add(button)
self.show_all()
def close_menu(self):
self.notch.close_notch()
def lock(self, *args):
print("Locking screen...")
exec_shell_command_async("loginctl lock-session")
self.close_menu()
def suspend(self, *args):
print("Suspending system...")
exec_shell_command_async("systemctl suspend")
self.close_menu()
def logout(self, *args):
print("Logging out...")
exec_shell_command_async("hyprctl dispatch exit")
self.close_menu()
def reboot(self, *args):
print("Rebooting system...")
exec_shell_command_async("systemctl reboot")
self.close_menu()
def poweroff(self, *args):
print("Powering off...")
exec_shell_command_async("systemctl poweroff")
self.close_menu()
+346
View File
@@ -0,0 +1,346 @@
from collections.abc import Iterable
from enum import Enum
from typing import Literal, cast, overload
import gi
import OpenGL.GL as GL
from fabric import Property, Signal
from fabric.widgets.widget import Widget
from OpenGL.GL.shaders import compileProgram, compileShader
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk
class ShadertoyUniformType(Enum):
FLOAT = 1
INTEGER = 2
VECTOR = 3
TEXTURE = 4
class ShadertoyCompileError(Exception): ...
class Shadertoy(Gtk.GLArea, Widget):
@Signal
def ready(self) -> None: ...
@Property(str, "read-write")
def shader_buffer(self) -> str:
return self._shader_buffer
@shader_buffer.setter
def shader_buffer(self, shader_buffer: str) -> None:
self._shader_buffer = shader_buffer
if not self._ready:
return
self._shader_uniforms.clear()
self.do_realize()
self.queue_draw()
return
DEFAULT_VERTEX_SHADER = """
in vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
"""
DEFAULT_FRAGMENT_UNIFORMS = """
uniform vec3 iResolution; // viewport resolution (in pixels)
uniform float iTime; // shader playback time (in seconds)
uniform float iTimeDelta; // render time (in seconds)
uniform float iFrameRate; // shader frame rate
uniform int iFrame; // shader playback frame
uniform float iChannelTime[4]; // channel playback time (in seconds)
uniform vec3 iChannelResolution[4]; // channel resolution (in pixels)
uniform vec4 iMouse; // mouse pixel coords. xy: current (if MLB down), zw: click
uniform sampler2D iChannel0; // input channel. XX = 2D/Cube
uniform sampler2D iChannel1;
uniform sampler2D iChannel2;
uniform sampler2D iChannel3;
uniform vec4 iDate; // (year, month, day, time in seconds)
uniform float iSampleRate; // sound sample rate (i.e., 44100)
"""
FRAGMENT_MAIN_FUNCTION = """
void main() {
mainImage(gl_FragColor, gl_FragCoord.xy);
}
"""
def __init__(
self,
shader_buffer: str,
shader_uniforms: list[
tuple[
str,
ShadertoyUniformType,
bool | float | int | tuple[float, ...] | GdkPixbuf.Pixbuf,
]
]
| None = None,
name: str | None = None,
visible: bool = True,
all_visible: bool = False,
style: str | None = None,
style_classes: Iterable[str] | str | None = None,
tooltip_text: str | None = None,
tooltip_markup: str | None = None,
h_align: Literal["fill", "start", "end", "center", "baseline"]
| Gtk.Align
| None = None,
v_align: Literal["fill", "start", "end", "center", "baseline"]
| Gtk.Align
| None = None,
h_expand: bool = False,
v_expand: bool = False,
size: Iterable[int] | int | None = None,
**kwargs,
):
Gtk.GLArea.__init__(
self
)
Widget.__init__(
self,
name,
visible,
all_visible,
style,
style_classes,
tooltip_text,
tooltip_markup,
h_align,
v_align,
h_expand,
v_expand,
size,
**kwargs,
)
self._shader_buffer = shader_buffer
self._shader_uniforms = shader_uniforms or []
self.set_required_version(3, 3)
self.set_has_depth_buffer(False)
self.set_has_stencil_buffer(False)
self._ready = False
self._program = None
self._vao = None
self._quad_vbo = None
self._texture_units = {}
self._start_time = GLib.get_monotonic_time() / 1e6
self._frame_time = self._start_time
self._frame_count = 0
self._tick_id = self.add_tick_callback(lambda *_: (self.queue_draw(), True)[1])
def do_bake_program(self):
try:
vertex_shader = compileShader(
self.DEFAULT_VERTEX_SHADER, GL.GL_VERTEX_SHADER
)
fragment_shader = compileShader(
self.DEFAULT_FRAGMENT_UNIFORMS
+ self._shader_buffer
+ self.FRAGMENT_MAIN_FUNCTION,
GL.GL_FRAGMENT_SHADER,
)
except Exception as e:
raise ShadertoyCompileError(
f"couldn't compile the provided shader, OpenGL error:\n {e}"
)
return compileProgram(vertex_shader, fragment_shader)
def do_realize(self, *_):
Gtk.GLArea.do_realize(self)
if not self._ready:
ctx = self.get_context()
if (err := self.get_error()) or not ctx:
raise RuntimeError(
f"couldn't initialize the drawing context, error: {err or 'context is None'}"
)
ctx.make_current()
if self._program:
GL.glDeleteProgram(self._program)
self._program = None
self._program = self.do_bake_program()
GL.glEnable(GL.GL_BLEND)
GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA)
self._quad_vbo = GL.glGenBuffers(1)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self._quad_vbo)
quad_verts = (-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0)
array_type = GL.GLfloat * len(quad_verts)
GL.glBufferData(
GL.GL_ARRAY_BUFFER,
len(quad_verts) * 4,
array_type(*quad_verts),
GL.GL_STATIC_DRAW,
)
self._vao = GL.glGenVertexArrays(1)
GL.glBindVertexArray(self._vao)
position = GL.glGetAttribLocation(self._program, "position")
GL.glEnableVertexAttribArray(position)
GL.glVertexAttribPointer(position, 2, GL.GL_FLOAT, GL.GL_FALSE, 0, None)
for uname, utype, uvalue in self._shader_uniforms:
self.set_uniform(uname, utype, uvalue)
self._ready = True
self.ready()
return
def do_get_timing(self) -> tuple[float, float, float]:
current_time = GLib.get_monotonic_time() / 1e6
delta_time = current_time - self._frame_time
return current_time, delta_time, (1.0 / delta_time) if delta_time > 0 else 0.0
def do_post_render(self, time: float):
self._frame_time = time
self._frame_count += 1
return
def do_render(self, ctx: Gdk.GLContext):
if not self._program:
if self._tick_id:
self.remove_tick_callback(self._tick_id)
self._tick_id = 0
return False
GL.glUseProgram(self._program)
GL.glClear(GL.GL_COLOR_BUFFER_BIT)
alloc = self.get_allocation()
width: int = alloc.width
height: int = alloc.height
mouse_pos = cast(tuple[int, int], self.get_pointer())
current_time, delta_time, frame_rate = self.do_get_timing()
self.set_uniform(
"iTime", ShadertoyUniformType.FLOAT, current_time - self._start_time
)
self.set_uniform("iFrame", ShadertoyUniformType.INTEGER, self._frame_count)
self.set_uniform("iTimeDelta", ShadertoyUniformType.FLOAT, delta_time)
self.set_uniform("iFrameRate", ShadertoyUniformType.FLOAT, frame_rate)
self.set_uniform(
"iResolution", ShadertoyUniformType.VECTOR, (width, height, 1.0)
)
self.set_uniform(
"iMouse",
ShadertoyUniformType.VECTOR,
(mouse_pos[0], height - mouse_pos[1], 0, 0),
)
GL.glBindVertexArray(self._vao)
GL.glDrawArrays(GL.GL_TRIANGLE_STRIP, 0, 4)
self.do_post_render(current_time)
return True
def do_resize(self, width: int, height: int):
Gtk.GLArea.do_resize(self, width, height)
GL.glViewport(0, 0, width, height)
return
@overload
def set_uniform(
self, name: str, type: Literal[ShadertoyUniformType.FLOAT], value: float
): ...
@overload
def set_uniform(
self, name: str, type: Literal[ShadertoyUniformType.INTEGER], value: int
): ...
@overload
def set_uniform(
self,
name: str,
type: Literal[ShadertoyUniformType.VECTOR],
value: tuple[float, ...],
): ...
@overload
def set_uniform(
self,
name: str,
type: Literal[ShadertoyUniformType.TEXTURE],
value: GdkPixbuf.Pixbuf,
): ...
def set_uniform(
self,
name: str,
type: ShadertoyUniformType,
value: bool | float | int | tuple[float, ...] | GdkPixbuf.Pixbuf,
):
if not self._program:
raise RuntimeError("the shader program is not initialized")
GL.glUseProgram(self._program)
location = GL.glGetUniformLocation(self._program, name)
match type:
case ShadertoyUniformType.VECTOR:
value = cast(tuple[float, ...], value)
(
GL.glUniform2f
if (vlen := len(value)) == 2
else GL.glUniform3f
if vlen == 3
else GL.glUniform4f
)(location, *value)
case ShadertoyUniformType.FLOAT:
GL.glUniform1f(location, value)
case ShadertoyUniformType.INTEGER:
GL.glUniform1i(location, value)
case ShadertoyUniformType.TEXTURE:
value = cast(GdkPixbuf.Pixbuf, value).flip(False)
format = GL.GL_RGBA if value.get_has_alpha() else GL.GL_RGB
if name not in self._texture_units:
texture = GL.glGenTextures(1)
self._texture_units[name] = (len(self._texture_units), texture)
else:
texture_unit, texture = self._texture_units[name]
texture_unit = self._texture_units[name][0]
GL.glActiveTexture(GL.GL_TEXTURE0 + texture_unit)
GL.glBindTexture(GL.GL_TEXTURE_2D, texture)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_REPEAT)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_REPEAT)
GL.glTexParameteri(
GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR
)
GL.glTexParameteri(
GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR
)
GL.glTexImage2D(
GL.GL_TEXTURE_2D,
0,
format,
value.get_width(),
value.get_height(),
0,
format,
GL.GL_UNSIGNED_BYTE,
value.get_pixels(),
)
GL.glGenerateMipmap(GL.GL_TEXTURE_2D)
GL.glUniform1i(location, texture_unit)
+154
View File
@@ -0,0 +1,154 @@
import subprocess
from fabric.utils.helpers import exec_shell_command_async
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
import config.data as data
import modules.icons as icons
class Systemprofiles(Box):
def __init__(self, **kwargs):
super().__init__(
name="systemprofiles",
orientation="h" if not data.VERTICAL else "v",
spacing=3,
)
if data.BAR_THEME == "Dense" or data.BAR_THEME == "Edge":
self.add_style_class("invert")
self.bat_save = None
self.bat_balanced = None
self.bat_perf = None
children = []
try:
result = subprocess.run(
["powerprofilesctl", "list"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True,
)
available_profiles = result.stdout
except (subprocess.CalledProcessError, FileNotFoundError):
available_profiles = ""
if "power-saver" in available_profiles:
self.bat_save = Button(
name="battery-save",
child=Label(name="battery-save-label", markup=icons.power_saving),
on_clicked=lambda *_: self.set_power_mode("power-saver"),
tooltip_text="Power saving mode",
)
children.append(self.bat_save)
if "balanced" in available_profiles:
self.bat_balanced = Button(
name="battery-balanced",
child=Label(name="battery-balanced-label", markup=icons.power_balanced),
on_clicked=lambda *_: self.set_power_mode("balanced"),
tooltip_text="Balanced mode",
)
children.append(self.bat_balanced)
if "performance" in available_profiles:
self.bat_perf = Button(
name="battery-performance",
child=Label(
name="battery-performance-label", markup=icons.power_performance
),
on_clicked=lambda *_: self.set_power_mode("performance"),
tooltip_text="Performance mode",
)
children.append(self.bat_perf)
# Group the mode buttons into a container.
if children:
self.add(
Box(
name="power-mode-switcher",
orientation="h" if not data.VERTICAL else "v",
spacing=4,
children=children,
)
)
if data.BAR_COMPONENTS_VISIBILITY.get("sysprofiles", False):
self.get_current_power_mode()
self.hide_timer = None
self.hover_counter = 0
# self.set_power_mode("balanced")
def get_current_power_mode(self):
try:
# Run the command to get the current power mode
result = subprocess.run(
["powerprofilesctl", "get"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True,
)
# Get the output and strip unnecessary whitespace
output = result.stdout.strip()
# Validate the output
if output in ["power-saver", "balanced", "performance"]:
self.current_mode = output
else:
self.current_mode = "balanced"
# Update button styles based on the current mode
self.update_button_styles()
except subprocess.CalledProcessError as err:
print(f"Command failed: {err}")
self.current_mode = "balanced"
except Exception as err:
print(f"Error retrieving current power mode: {err}")
self.current_mode = "balanced"
def set_power_mode(self, mode):
"""
Switches power mode by running the corresponding auto-cpufreq command.
mode: one of 'powersave', 'balanced', or 'performance'
"""
commands = {
"power-saver": "powerprofilesctl set power-saver",
"balanced": "powerprofilesctl set balanced",
"performance": "powerprofilesctl set performance",
}
if mode in commands:
try:
exec_shell_command_async(commands[mode])
self.current_mode = mode
self.update_button_styles()
except Exception as err:
# Optionally, handle errors or display a notification.
print(f"Error setting power mode: {err}")
def update_button_styles(self):
"""
Optionally updates button styles to reflect the current mode.
Adjust the styling method based on your toolkit's capabilities.
"""
if self.bat_save:
self.bat_save.remove_style_class("active")
if self.bat_balanced:
self.bat_balanced.remove_style_class("active")
if self.bat_perf:
self.bat_perf.remove_style_class("active")
if self.current_mode == "power-saver" and self.bat_save:
self.bat_save.add_style_class("active")
elif self.current_mode == "balanced" and self.bat_balanced:
self.bat_balanced.add_style_class("active")
elif self.current_mode == "performance" and self.bat_perf:
self.bat_perf.add_style_class("active")
+154
View File
@@ -0,0 +1,154 @@
import gi
gi.require_version("Gray", "0.1")
import logging
import os
from fabric.widgets.box import Box
from gi.repository import Gdk, GdkPixbuf, GLib, Gray, Gtk
import config.data as data
logger = logging.getLogger(__name__)
class SystemTray(Box):
def __init__(self, pixel_size: int = 20, **kwargs) -> None:
orientation = Gtk.Orientation.HORIZONTAL if not data.VERTICAL else Gtk.Orientation.VERTICAL
super().__init__(
name="systray",
orientation=orientation,
spacing=8,
**kwargs
)
self.enabled = True
super().set_visible(False)
self.pixel_size = pixel_size
self.buttons_by_id = {}
self.items_by_id = {}
self.watcher = Gray.Watcher()
self.watcher.connect("item-added", self.on_watcher_item_added)
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()
# If IconName is a file path, prioritize loading directly from the file
if name and os.path.exists(name):
try:
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
name, self.pixel_size, self.pixel_size, True
)
except Exception as e:
# The file path exists but loading fails, falling back to theme search
logger.debug(
f"Load icon from file failed: {e}; fallback to theme for '{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, 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 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(itm, btn))
item.connect("notify::icon-name",
lambda itm, pspec: self._refresh_item_ui(itm, btn))
try:
item.connect("icon-changed", lambda itm: self._refresh_item_ui(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}")
+553
View File
@@ -0,0 +1,553 @@
import os
import subprocess
from fabric.utils import exec_shell_command_async, idle_add, remove_handler
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.entry import Entry
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from gi.repository import Gdk, GLib, Gtk
import config.data as data
import modules.icons as icons
class TmuxManager(Box):
def __init__(self, **kwargs):
super().__init__(
name="tmux-manager",
visible=False,
all_visible=False,
**kwargs,
)
self.notch = kwargs["notch"]
self.selected_index = -1 # Track the selected item index
self._arranger_handler: int = 0
self.viewport = Box(name="viewport", spacing=4, orientation="v")
self.session_name_entry = Entry(
name="session-name-entry",
placeholder="Create Tmux Session...",
h_expand=True,
h_align="fill",
on_activate=lambda entry, *_: self.create_session(entry.get_text()),
on_key_press_event=self.on_entry_key_press,
)
self.session_name_entry.props.xalign = 0.5
self.scrolled_window = ScrolledWindow(
name="scrolled-window",
spacing=10,
h_expand=True,
v_expand=True,
h_align="fill",
v_align="fill",
child=self.viewport,
propagate_width=False,
propagate_height=False,
)
self.header_box = Box(
name="header_box",
spacing=10,
orientation="h",
children=[
Button(
name="new-session-button",
child=Label(name="new-session-label", markup=icons.add),
tooltip_text="Create New Session",
on_clicked=lambda *_: self.create_session(self.session_name_entry.get_text()),
),
self.session_name_entry,
Button(
name="close-button",
child=Label(name="close-label", markup=icons.cancel),
tooltip_text="Exit",
on_clicked=lambda *_: self.close_manager()
),
],
)
self.tmux_box = Box(
name="tmux-box",
spacing=10,
h_expand=True,
orientation="v",
children=[
self.header_box,
self.scrolled_window,
],
)
self.add(self.tmux_box)
self.show_all()
def close_manager(self):
"""Close the tmux manager"""
self.viewport.children = []
self.selected_index = -1 # Reset selection
self.notch.close_notch()
def open_manager(self):
"""Open the tmux manager and refresh sessions"""
self.refresh_sessions()
self.session_name_entry.set_text("")
self.session_name_entry.grab_focus()
def refresh_sessions(self):
"""Get tmux sessions and populate the viewport"""
remove_handler(self._arranger_handler) if self._arranger_handler else None
self.viewport.children = []
self.selected_index = -1 # Clear selection when viewport changes
# Get tmux sessions
sessions = self.get_tmux_sessions()
if not sessions:
# Create a container box to better center the message
container = Box(
name="no-tmux-container",
orientation="v",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True
)
# Show a message if no sessions
label = Label(
name="no-tmux",
markup=icons.terminal,
h_align="center",
v_align="center",
)
container.add(label)
self.viewport.add(container)
return
# Add session slots to viewport
for session in sessions:
self.viewport.add(self.create_session_slot(session))
def get_tmux_sessions(self):
"""Get list of tmux sessions"""
try:
result = subprocess.run(
["tmux", "list-sessions", "-F", "#{session_name}"],
capture_output=True,
text=True
)
if result.returncode == 0:
return [s.strip() for s in result.stdout.strip().split('\n') if s.strip()]
return []
except Exception as e:
print(f"Error getting tmux sessions: {e}")
return []
def create_session_slot(self, session_name):
"""Create a button for a tmux session"""
# Create an entry for inline editing (initially hidden)
name_entry = Entry(
name="session-name-entry",
text=session_name,
visible=False,
on_activate=lambda entry, *_: self.finish_rename(button, session_name, entry),
on_key_press_event=self.on_rename_key_press,
)
# Create the label showing the session name
name_label = Label(
name="app-label",
label=session_name,
ellipsization="end",
v_align="center",
h_align="center",
)
# Session slot content box
slot_box = Box(
name="slot-box",
orientation="h",
spacing=10,
children=[
Label(
name="tmux-icon",
markup=icons.terminal, # Use existing terminal icon
),
name_label,
name_entry,
],
)
button = Button(
name="slot-button", # reuse existing CSS styling
child=slot_box,
tooltip_text=f"Attach to session: {session_name}",
on_clicked=lambda *_: self.attach_to_session(session_name),
can_focus=True, # Ensure the button can receive focus
)
# Add double-click handler to start renaming
button.connect("button-press-event", self.on_session_click, session_name, name_label, name_entry)
# Add key press handler for 'r' to rename
button.connect("key-press-event", self.on_slot_key_press, session_name, name_label, name_entry)
# Store reference to entry and label in button for later access
button.name_entry = name_entry
button.name_label = name_label
button.session_name = session_name
return button
def on_session_click(self, button, event, session_name, label, entry):
"""Handle clicks on session buttons"""
# Handle double-click to rename
if event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS and event.button == 1:
self.start_rename(button, session_name, label, entry)
return True
# Handle right click for context menu
elif event.button == 3:
menu = Gtk.Menu()
# Rename option
rename_item = Gtk.MenuItem(label="Rename")
rename_item.connect("activate", lambda _: self.start_rename(button, session_name, label, entry))
menu.append(rename_item)
# Kill option
kill_item = Gtk.MenuItem(label="Kill Session")
kill_item.connect("activate", lambda _: self.kill_session(session_name))
menu.append(kill_item)
menu.show_all()
menu.popup_at_pointer(event)
return True
return False
def start_rename(self, button, session_name, label, entry):
"""Start inline renaming of a session"""
# Hide label, show entry
label.set_visible(False)
entry.set_visible(True)
# Focus entry and select all text
entry.grab_focus()
entry.select_region(0, -1)
# Mark button as being edited
button.get_style_context().add_class("editing")
def finish_rename(self, button, old_name, entry):
"""Finish renaming a session"""
new_name = entry.get_text().strip()
# Only rename if the name changed and isn't empty
if new_name and new_name != old_name:
self.rename_session(old_name, new_name)
# Reset UI state
self.cancel_rename(button)
def cancel_rename(self, button):
"""Cancel renaming operation"""
# Restore original view
button.name_entry.set_visible(False)
button.name_label.set_visible(True)
# Remove editing style
button.get_style_context().remove_class("editing")
# Return focus to session name entry
self.session_name_entry.grab_focus()
def on_rename_key_press(self, entry, event):
"""Handle key presses in the rename entry"""
if event.keyval == Gdk.KEY_Escape:
# Find the parent button
parent = entry.get_parent()
while parent and not isinstance(parent, Button):
parent = parent.get_parent()
if parent:
self.cancel_rename(parent)
return True
return False
def on_session_right_click(self, button, event, session_name):
"""Handle right-click on a session button to show context menu"""
if event.button == 3: # Right click
menu = Gtk.Menu()
# Rename option
rename_item = Gtk.MenuItem(label="Rename")
rename_item.connect("activate", lambda _: self.start_rename(
button,
session_name,
button.name_label,
button.name_entry
))
menu.append(rename_item)
# Kill option
kill_item = Gtk.MenuItem(label="Kill Session")
kill_item.connect("activate", lambda _: self.kill_session(session_name))
menu.append(kill_item)
menu.show_all()
menu.popup_at_pointer(event)
return True
return False
def on_entry_key_press(self, widget, event):
"""Handle key press events in the entry"""
if event.keyval == Gdk.KEY_Escape:
self.close_manager()
return True
# Custom navigation with UP/DOWN keys removed
return False
def scroll_to_selected(self, button):
"""Scroll to ensure the selected button is visible"""
def scroll():
adj = self.scrolled_window.get_vadjustment()
alloc = button.get_allocation()
if alloc.height == 0:
return False # Retry if allocation isn't ready
y = alloc.y
height = alloc.height
page_size = adj.get_page_size()
current_value = adj.get_value()
# Calculate visible boundaries
visible_top = current_value
visible_bottom = current_value + page_size
if y < visible_top:
# Item above viewport - align to top
adj.set_value(y)
elif y + height > visible_bottom:
# Item below viewport - align to bottom
new_value = y + height - page_size
adj.set_value(new_value)
# No action if already fully visible
return False
GLib.idle_add(scroll)
def create_session(self, session_name):
"""Create a new tmux session"""
if not session_name:
# Get existing session names
existing_sessions = self.get_tmux_sessions()
# Find the next available number
counter = 0
while str(counter) in existing_sessions:
counter += 1
session_name = str(counter)
try:
# Clean the session name (replace spaces with underscores)
clean_name = session_name.strip().replace(" ", "_")
# Create session
subprocess.run(
["tmux", "new-session", "-d", "-s", clean_name],
check=True
)
# Refresh the session list
self.refresh_sessions()
# Clear entry
self.session_name_entry.set_text("")
# Launch a terminal and attach to this session
terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {clean_name}")
exec_shell_command_async(terminal_cmd)
# Close manager
self.close_manager()
except Exception as e:
print(f"Error creating tmux session: {e}")
def attach_to_session(self, session_name):
"""Attach to an existing tmux session"""
try:
# Launch a terminal and attach to this session
terminal_cmd = self.get_terminal_command(f"tmux attach-session -t {session_name}")
exec_shell_command_async(terminal_cmd)
self.close_manager()
except Exception as e:
print(f"Error attaching to tmux session: {e}")
def get_terminal_command(self, cmd):
"""Get terminal command based on configured terminal or available terminals"""
# First try to use the configured terminal command
if hasattr(data, 'TERMINAL_COMMAND') and data.TERMINAL_COMMAND:
parts = data.TERMINAL_COMMAND.split()
terminal = parts[0]
try:
# Check if the configured terminal is available
subprocess.run(["which", terminal], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return f"{data.TERMINAL_COMMAND} {cmd}"
except subprocess.CalledProcessError:
# If configured terminal is not available, fall back to defaults
pass
# Fallback to checking available terminals
terminals = [
("kitty", f"kitty -e {cmd}"),
("alacritty", f"alacritty -e {cmd}"),
("foot", f"foot {cmd}"),
("gnome-terminal", f"gnome-terminal -- {cmd}"),
("konsole", f"konsole -e {cmd}"),
("xfce4-terminal", f"xfce4-terminal -e '{cmd}'"),
]
for term, term_cmd in terminals:
try:
# Check if terminal is available
subprocess.run(["which", term], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return term_cmd
except subprocess.CalledProcessError:
continue
# Default fallback
return f"kitty -e {cmd}"
def rename_session_dialog(self, old_name):
"""Show dialog to rename a session"""
dialog = Gtk.Dialog(
title="Rename Session",
transient_for=None,
flags=0
)
dialog.add_buttons(
Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
Gtk.STOCK_OK, Gtk.ResponseType.OK
)
content_area = dialog.get_content_area()
entry = Gtk.Entry()
entry.set_text(old_name)
entry.set_activates_default(True)
content_area.add(entry)
dialog.set_default_response(Gtk.ResponseType.OK)
dialog.show_all()
response = dialog.run()
if response == Gtk.ResponseType.OK:
new_name = entry.get_text()
if new_name and new_name != old_name:
self.rename_session(old_name, new_name)
dialog.destroy()
def rename_session(self, old_name, new_name):
"""Rename a tmux session"""
try:
# Clean the session name (replace spaces with underscores)
clean_name = new_name.strip().replace(" ", "_")
# Rename session
subprocess.run(
["tmux", "rename-session", "-t", old_name, clean_name],
check=True
)
# Refresh the session list
self.refresh_sessions()
except Exception as e:
print(f"Error renaming tmux session: {e}")
def kill_session(self, session_name):
"""Kill a tmux session"""
try:
# Kill session
subprocess.run(
["tmux", "kill-session", "-t", session_name],
check=True
)
# Refresh the session list
self.refresh_sessions()
# Close the notch after killing session
self.close_manager()
except Exception as e:
print(f"Error killing tmux session: {e}")
# Add new method to handle key presses on session slots
def on_slot_key_press(self, button, event, session_name, label, entry):
"""Handle key presses on session buttons"""
# Print debugging info
print(f"Key pressed: {event.keyval}, State: {event.state}")
# Check if 'r' key was pressed for renaming
if event.keyval == Gdk.KEY_r:
self.start_rename(button, session_name, label, entry)
return True
# Check for 'K' (capital K) which indicates Shift is pressed
elif event.keyval == Gdk.KEY_K:
print("Shift+K detected - killing session without confirmation")
self.kill_session(session_name)
return True
# Check for lowercase 'k'
elif event.keyval == Gdk.KEY_k:
print("Regular k detected - showing confirmation")
self.show_kill_confirmation_menu(button, session_name)
return True
# Check if Delete key was pressed for killing session
elif event.keyval == Gdk.KEY_Delete:
self.show_kill_confirmation_menu(button, session_name)
return True
return False
def show_kill_confirmation_menu(self, button, session_name):
"""Show a confirmation menu for killing a session"""
menu = Gtk.Menu()
# Confirmation message as a disabled menu item
msg_item = Gtk.MenuItem(label=f"Kill session '{session_name}'?")
msg_item.set_sensitive(False)
menu.append(msg_item)
# Separator
menu.append(Gtk.SeparatorMenuItem())
# Confirm option
confirm_item = Gtk.MenuItem(label="Confirm")
confirm_item.connect("activate", lambda _: self.kill_session(session_name))
menu.append(confirm_item)
# Cancel option
cancel_item = Gtk.MenuItem(label="Cancel")
# Close notch on cancel
cancel_item.connect("activate", lambda _: self.close_manager())
menu.append(cancel_item)
menu.show_all()
# Show the menu positioned at the button
menu.popup_at_widget(
button,
Gdk.Gravity.SOUTH_WEST,
Gdk.Gravity.NORTH_WEST,
None
)
+456
View File
@@ -0,0 +1,456 @@
import os
import subprocess
from fabric.utils.helpers import exec_shell_command_async, get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from gi.repository import Gdk, GLib
from loguru import logger
import config.data as data
import modules.icons as icons
SCREENSHOT_SCRIPT = get_relative_path("../scripts/screenshot.sh")
POMODORO_SCRIPT = get_relative_path("../scripts/pomodoro.sh")
OCR_SCRIPT = get_relative_path("../scripts/ocr.sh")
GAMEMODE_SCRIPT = get_relative_path("../scripts/gamemode.sh")
SCREENRECORD_SCRIPT = get_relative_path("../scripts/screenrecord.sh")
# Tooltips
## Screenshot
tooltip_ssregion = """<b><u>Region Screenshot</u></b>
<b>Left Click:</b> Take a screenshot of a selected region.
<b>Right Click:</b> Take a mockup screenshot of a selected region."""
tooltip_ssfull = """<b><u>Screenshot</u></b>
<b>Left Click:</b> Take a fullscreen screenshot.
<b>Right Click:</b> Take a mockup fullscreen screenshot."""
tooltip_sswindow = """<b><u>Window Screenshot</u></b>
<b>Left Click:</b> Take a screenshot of the active window.
<b>Right Click:</b> Take a mockup screenshot of the active window."""
tooltip_screenshots = "<b>Screenshots Directory</b>"
tooltip_screenrecord = "<b>Screen Recorder</b>"
tooltip_recordings = "<b>Recordings Directory</b>"
tooltip_ocr = "<b>OCR</b>"
tooltip_colorpicker = """<b><u>Color Picker</u></b>
<b>Mouse:</b>
Left Click: HEX
Middle Click: HSV
Right Click: RGB
<b>Keyboard:</b>
Enter: HEX
Shift+Enter: RGB
Ctrl+Enter: HSV"""
tooltip_gamemode = "<b>Game Mode</b>\nDisables effects and window animations for better performance."
tooltip_pomodoro = "<b>Pomodoro Timer</b>"
tooltip_emoji = "<b>Emoji Picker</b>"
class Toolbox(Box):
def __init__(self, **kwargs):
orientation = "h"
if data.PANEL_THEME == "Panel" and (data.BAR_POSITION in ["Left", "Right"] or data.PANEL_POSITION in ["Start", "End"]):
orientation = "v"
super().__init__(
name="toolbox",
orientation=orientation,
spacing=4,
v_align="center",
h_align="center",
visible=True,
**kwargs,
)
self.notch = kwargs["notch"]
self.btn_ssregion = Button(
name="toolbox-button",
tooltip_markup=tooltip_ssregion,
child=Label(name="button-label", markup=icons.ssregion),
on_clicked=self.ssregion,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.btn_ssregion.set_can_focus(True)
self.btn_ssregion.connect("button-press-event", self.on_ssregion_click)
self.btn_ssregion.connect("key-press-event", self.on_ssregion_key)
self.btn_ssfull = Button(
name="toolbox-button",
tooltip_markup=tooltip_ssfull,
child=Label(name="button-label", markup=icons.ssfull),
on_clicked=self.ssfull,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.btn_ssfull.set_can_focus(True)
self.btn_ssfull.connect("button-press-event", self.on_ssfull_click)
self.btn_ssfull.connect("key-press-event", self.on_ssfull_key)
self.btn_sswindow = Button(
name="toolbox-button",
tooltip_markup=tooltip_sswindow,
child=Label(name="button-label", markup=icons.sswindow),
on_clicked=self.sswindow,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.btn_sswindow.set_can_focus(True)
self.btn_sswindow.connect("button-press-event", self.on_sswindow_click)
self.btn_sswindow.connect("key-press-event", self.on_sswindow_key)
self.btn_screenrecord = Button(
name="toolbox-button",
tooltip_markup=tooltip_screenrecord,
child=Label(name="button-label", markup=icons.screenrecord),
on_clicked=self.screenrecord,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.btn_ocr = Button(
name="toolbox-button",
tooltip_markup=tooltip_ocr,
child=Label(name="button-label", markup=icons.ocr),
on_clicked=self.ocr,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.btn_color = Button(
name="toolbox-button",
tooltip_markup=tooltip_colorpicker,
child=Label(
name="button-bar-label",
markup=icons.colorpicker
),
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.btn_gamemode = Button(
name="toolbox-button",
tooltip_markup=tooltip_gamemode,
child=Label(name="button-label", markup=icons.gamemode),
on_clicked=self.gamemode,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.btn_pomodoro = Button(
name="toolbox-button",
tooltip_markup=tooltip_pomodoro,
child=Label(name="button-label", markup=icons.timer_off),
on_clicked=self.pomodoro,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.btn_color.set_can_focus(True)
self.btn_color.connect("button-press-event", self.colorpicker)
self.btn_color.connect("key_press_event", self.colorpicker_key)
self.btn_emoji = Button(
name="toolbox-button",
tooltip_markup=tooltip_emoji,
child=Label(name="button-label", markup=icons.emoji),
on_clicked=self.emoji,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.btn_screenshots_folder = Button(
name="toolbox-button",
tooltip_markup=tooltip_screenshots,
child=Label(name="button-label", markup=icons.screenshots),
on_clicked=self.open_screenshots_folder,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.btn_recordings_folder = Button(
name="toolbox-button",
tooltip_markup=tooltip_recordings,
child=Label(name="button-label", markup=icons.recordings),
on_clicked=self.open_recordings_folder,
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
)
self.buttons = [
self.btn_ssregion,
self.btn_sswindow,
self.btn_ssfull,
self.btn_screenshots_folder,
Box(name="tool-sep", h_expand=False, v_expand=False, h_align="center", v_align="center"),
self.btn_screenrecord,
self.btn_recordings_folder,
Box(name="tool-sep", h_expand=False, v_expand=False, h_align="center", v_align="center"),
self.btn_ocr,
self.btn_color,
Box(name="tool-sep", h_expand=False, v_expand=False, h_align="center", v_align="center"),
self.btn_gamemode,
self.btn_pomodoro,
self.btn_emoji,
]
for button in self.buttons:
self.add(button)
self.show_all()
self.recorder_timer_id = GLib.timeout_add_seconds(2, self.update_screenrecord_state)
self.gamemode_updater = GLib.timeout_add_seconds(2, self.gamemode_check)
self.pomodoro_updater = GLib.timeout_add_seconds(2, self.pomodoro_check)
def close_menu(self):
self.notch.close_notch()
def ssfull(self, *args, mockup=False):
cmd = f"bash {SCREENSHOT_SCRIPT} p"
if mockup:
cmd += " mockup"
exec_shell_command_async(cmd)
self.close_menu()
def on_ssfull_click(self, button, event):
if event.type == Gdk.EventType.BUTTON_PRESS:
if event.button == 1:
self.ssfull()
elif event.button == 3:
self.ssfull(mockup=True)
return True
return False
def on_ssfull_key(self, widget, event):
if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}:
modifiers = event.get_state()
if modifiers & Gdk.ModifierType.SHIFT_MASK:
self.ssfull(mockup=True)
else:
self.ssfull()
return True
return False
def ssregion(self, *args):
exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} s")
self.close_menu()
def on_ssregion_click(self, button, event):
if event.type == Gdk.EventType.BUTTON_PRESS:
if event.button == 1:
self.ssregion()
elif event.button == 3:
exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} s mockup")
self.close_menu()
return True
return False
def on_ssregion_key(self, widget, event):
if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}:
modifiers = event.get_state()
if modifiers & Gdk.ModifierType.SHIFT_MASK:
exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} s mockup")
self.close_menu()
else:
self.ssregion()
return True
return False
def sswindow(self, *args):
exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} w")
self.close_menu()
def on_sswindow_click(self, button, event):
if event.type == Gdk.EventType.BUTTON_PRESS:
if event.button == 1:
self.sswindow()
elif event.button == 3:
exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} w mockup")
self.close_menu()
return True
return False
def on_sswindow_key(self, widget, event):
if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}:
modifiers = event.get_state()
if modifiers & Gdk.ModifierType.SHIFT_MASK:
exec_shell_command_async(f"bash {SCREENSHOT_SCRIPT} w mockup")
self.close_menu()
else:
self.sswindow()
return True
return False
def screenrecord(self, *args):
exec_shell_command_async(f"bash -c 'nohup bash {SCREENRECORD_SCRIPT} > /dev/null 2>&1 & disown'")
self.close_menu()
def pomodoro(self, *args):
exec_shell_command_async(f"bash -c 'nohup bash {POMODORO_SCRIPT} > /dev/null 2>&1 & disown'")
self.close_menu()
def pomodoro_check(self):
"""Check pomodoro status using proper background threading"""
GLib.Thread.new("pomodoro-check", self._pomodoro_check_thread, None)
return True
def _pomodoro_check_thread(self, user_data):
"""Background thread to check pomodoro status"""
try:
result = subprocess.run("pgrep -f pomodoro.sh", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
running = result.returncode == 0
except Exception:
running = False
GLib.idle_add(self._update_pomodoro_ui, running)
def _update_pomodoro_ui(self, running):
"""Update pomodoro UI from main thread"""
if running:
self.btn_pomodoro.get_child().set_markup(icons.timer_on)
self.btn_pomodoro.add_style_class("pomodoro")
else:
self.btn_pomodoro.get_child().set_markup(icons.timer_off)
self.btn_pomodoro.remove_style_class("pomodoro")
return False
def ocr(self, *args):
exec_shell_command_async(f"bash {OCR_SCRIPT} s")
self.close_menu()
def gamemode(self, *args):
exec_shell_command_async(f"bash {GAMEMODE_SCRIPT}")
self.gamemode_check()
self.close_menu()
def gamemode_check(self):
"""Check gamemode status using proper background threading"""
GLib.Thread.new("gamemode-check", self._gamemode_check_thread, None)
return True
def _gamemode_check_thread(self, user_data):
"""Background thread to check gamemode status"""
try:
result = subprocess.run(f"bash {GAMEMODE_SCRIPT} check", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
enabled = result.stdout == b't\n'
except Exception:
enabled = False
GLib.idle_add(self._update_gamemode_ui, enabled)
def _update_gamemode_ui(self, enabled):
"""Update gamemode UI from main thread"""
if enabled:
self.btn_gamemode.get_child().set_markup(icons.gamemode_off)
else:
self.btn_gamemode.get_child().set_markup(icons.gamemode)
return False
def colorpicker(self, button, event):
if event.type == Gdk.EventType.BUTTON_PRESS:
cmd = {
1: "-hex",
2: "-hsv",
3: "-rgb"
}.get(event.button)
if cmd:
exec_shell_command_async(f"bash {get_relative_path('../scripts/hyprpicker.sh')} {cmd}")
self.close_menu()
def colorpicker_key(self, widget, event):
if event.keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter}:
modifiers = event.get_state()
cmd = "-hex"
match modifiers & (Gdk.ModifierType.SHIFT_MASK | Gdk.ModifierType.CONTROL_MASK):
case Gdk.ModifierType.SHIFT_MASK:
cmd = "-rgb"
case Gdk.ModifierType.CONTROL_MASK:
cmd = "-hsv"
exec_shell_command_async(f"bash {get_relative_path('../scripts/hyprpicker.sh')} {cmd}")
self.close_menu()
return True
return False
def update_screenrecord_state(self):
"""Check screen recording status using proper background threading"""
GLib.Thread.new("screenrecord-check", self._screenrecord_check_thread, None)
return True
def _screenrecord_check_thread(self, user_data):
"""Background thread to check screen recording status"""
try:
result = subprocess.run("pgrep -f gpu-screen-recorder", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
running = result.returncode == 0
except Exception:
running = False
GLib.idle_add(self._update_screenrecord_ui, running)
def _update_screenrecord_ui(self, running):
"""Update screen recording UI from main thread"""
if running:
self.btn_screenrecord.get_child().set_markup(icons.stop)
self.btn_screenrecord.add_style_class("recording")
else:
self.btn_screenrecord.get_child().set_markup(icons.screenrecord)
self.btn_screenrecord.remove_style_class("recording")
return False
def open_screenshots_folder(self, *args):
screenshots_dir = os.path.join(os.environ.get('XDG_PICTURES_DIR',
os.path.expanduser('~/Pictures')),
'Screenshots')
os.makedirs(screenshots_dir, exist_ok=True)
exec_shell_command_async(f"xdg-open {screenshots_dir}")
self.close_menu()
def open_recordings_folder(self, *args):
recordings_dir = os.path.join(os.environ.get('XDG_VIDEOS_DIR',
os.path.expanduser('~/Videos')),
'Recordings')
os.makedirs(recordings_dir, exist_ok=True)
exec_shell_command_async(f"xdg-open {recordings_dir}")
self.close_menu()
def emoji(self, *args):
self.notch.open_notch("emoji")
+571
View File
@@ -0,0 +1,571 @@
import json
import os
import shutil
import socket
import subprocess
import sys
import time
from pathlib import Path
import gi
# Insertion for embedded VTE terminal
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
gi.require_version("Vte", "2.91")
from gi.repository import Gdk, GLib, Gtk, Vte
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from fabric.utils.helpers import get_relative_path
import config.data as data
# File locations
VERSION_FILE = get_relative_path("../version.json")
REMOTE_VERSION_FILE = "/tmp/remote_version.json"
REMOTE_URL = "https://raw.githubusercontent.com/Axenide/Ax-Shell/refs/heads/main/version.json"
REPO_DIR = get_relative_path("../")
SNOOZE_FILE_NAME = "updater_snooze.txt"
UPDATER_DISABLE_FILE_NAME = "updater_disabled.flag"
SNOOZE_DURATION_SECONDS = 8 * 60 * 60 # 8 hours
# --- Global state for standalone execution control ---
_QUIT_GTK_IF_NO_WINDOW_STANDALONE = False
def get_cache_dir():
"""Returns the cache directory path, creating it if necessary."""
cache_dir_base = data.CACHE_DIR or os.path.expanduser(f"~/.cache/{data.APP_NAME}")
try:
os.makedirs(cache_dir_base, exist_ok=True)
except Exception as e:
print(f"Error creating cache directory {cache_dir_base}: {e}")
return cache_dir_base
def get_snooze_file_path():
"""
Returns the path to the 'snooze' file inside ~/.cache/APP_NAME.
"""
return os.path.join(get_cache_dir(), SNOOZE_FILE_NAME)
def get_disable_file_path():
"""
Returns the path to the 'updater_disabled.flag' file inside ~/.cache/APP_NAME.
"""
return os.path.join(get_cache_dir(), UPDATER_DISABLE_FILE_NAME)
def fetch_remote_version():
"""
Downloads the remote version JSON using curl, with timeout and error handling.
"""
try:
subprocess.run(
["curl", "-sL", "--connect-timeout", "10", REMOTE_URL, "-o", REMOTE_VERSION_FILE],
check=False,
timeout=15
)
except subprocess.TimeoutExpired:
print("Error: curl timed out while fetching the remote version.")
except FileNotFoundError:
print("Error: curl not found. Please install curl.")
except Exception as e:
print(f"Error fetching remote version: {e}")
def get_local_version():
"""
Reads the local version file and returns (version, changelog).
"""
if os.path.exists(VERSION_FILE):
try:
with open(VERSION_FILE, "r") as f:
data_content = json.load(f)
return data_content.get("version", "0.0.0"), data_content.get("changelog", [])
except json.JSONDecodeError:
print(f"Error: Invalid JSON in local file: {VERSION_FILE}")
return "0.0.0", []
except Exception as e:
print(f"Error reading local version file {VERSION_FILE}: {e}")
return "0.0.0", []
return "0.0.0", []
def get_remote_version():
"""
Reads the downloaded remote file and returns (version, changelog, download_url, pkg_update).
"""
if os.path.exists(REMOTE_VERSION_FILE):
try:
with open(REMOTE_VERSION_FILE, "r") as f:
data_content = json.load(f)
return (
data_content.get("version", "0.0.0"),
data_content.get("changelog", []),
data_content.get("download_url", "#"),
data_content.get("pkg_update", True), # Default to True if missing
)
except json.JSONDecodeError:
print(f"Error: Invalid JSON in remote file: {REMOTE_VERSION_FILE}")
return "0.0.0", [], "#", True
except Exception as e:
print(f"Error reading remote version file {REMOTE_VERSION_FILE}: {e}")
return "0.0.0", [], "#", True
return "0.0.0", [], "#", True
def update_local_version_file():
"""
Replaces the local version with the remote one by moving the downloaded JSON to the local version file.
"""
if os.path.exists(REMOTE_VERSION_FILE):
try:
shutil.move(REMOTE_VERSION_FILE, VERSION_FILE)
except Exception as e:
print(f"Error updating local version file: {e}")
raise
def is_connected():
"""
Checks basic connectivity by attempting to connect to www.google.com:80.
"""
try:
socket.create_connection(("www.google.com", 80), timeout=5)
return True
except OSError:
return False
class UpdateWindow(Gtk.Window):
def __init__(self, latest_version, changelog, pkg_update, is_standalone_mode=False):
super().__init__(name="update-window", title=f"{data.APP_NAME_CAP} Updater")
self.set_default_size(500, 480)
self.set_border_width(16)
self.set_resizable(False)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_keep_above(True)
self.set_type_hint(Gdk.WindowTypeHint.DIALOG)
self.is_standalone_mode = is_standalone_mode
self.quit_gtk_main_on_destroy = False
self.pkg_update = pkg_update # Store pkg_update
# Main vertical container
self.main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=15)
self.add(self.main_vbox)
# Title
title_label = Gtk.Label(name="update-title")
title_label.set_markup("<span size='xx-large' weight='bold'>📦 Update Available ✨</span>")
title_label.get_style_context().add_class("title-1")
self.main_vbox.pack_start(title_label, False, False, 10)
# Version info text
info_label = Gtk.Label(
label=f"A new version ({latest_version}) of {data.APP_NAME_CAP} is available."
)
info_label.set_xalign(0)
info_label.set_line_wrap(True)
self.main_vbox.pack_start(info_label, False, False, 0)
# Changelog header
changelog_header_label = Gtk.Label()
changelog_header_label.set_markup("<b>Changelog:</b>")
changelog_header_label.set_xalign(0)
self.main_vbox.pack_start(changelog_header_label, False, False, 5)
# — Scrollable window for the changelog (using Gtk.Label with markup) —
scrolled_window = Gtk.ScrolledWindow()
scrolled_window.set_hexpand(True)
scrolled_window.set_vexpand(True)
scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
if changelog:
# Each entry may already contain Pango tags (<b>, <i>, etc.)
joined = "\n".join(f"{change}" for change in changelog)
else:
joined = "No specific changes listed for this version."
self.changelog_label = Gtk.Label()
self.changelog_label.set_xalign(0)
self.changelog_label.set_yalign(0)
self.changelog_label.set_line_wrap(Gtk.WrapMode.WORD_CHAR) # Gtk.WrapMode instead of just True
self.changelog_label.set_selectable(False)
self.changelog_label.set_markup(joined)
scrolled_window.add(self.changelog_label)
self.main_vbox.pack_start(scrolled_window, True, True, 0)
# ProgressBar (will be shown if we need to indicate status, although with VTE it remains unused)
self.progress_bar = Gtk.ProgressBar()
self.progress_bar.set_no_show_all(True)
self.progress_bar.set_visible(False)
self.main_vbox.pack_start(self.progress_bar, False, False, 5)
# Button container
action_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.main_vbox.pack_start(action_box, False, False, 10)
# "Disable/Enable Updater" Button (aligned left)
self.toggle_updater_button = Gtk.Button(name="toggle-updater-button")
self.toggle_updater_button.connect("clicked", self.on_toggle_updater_clicked)
self._update_toggle_updater_button_label() # Set initial label
action_box.pack_start(self.toggle_updater_button, False, False, 0)
# Box for right-aligned buttons
right_aligned_buttons_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
right_aligned_buttons_box.set_halign(Gtk.Align.END)
action_box.pack_end(right_aligned_buttons_box, True, True, 0) # This box expands
# Update button (will now show embedded VTE terminal)
self.update_button = Gtk.Button(name="update-button", label="Update")
self.update_button.get_style_context().add_class("suggested-action")
self.update_button.connect("clicked", self.on_update_clicked)
right_aligned_buttons_box.pack_end(self.update_button, False, False, 0)
# 'Later' button
self.close_button = Gtk.Button(name="later-button", label="Later")
self.close_button.connect("clicked", self.on_later_clicked)
right_aligned_buttons_box.pack_end(self.close_button, False, False, 0)
self.connect("destroy", self.on_window_destroyed)
# Placeholder for embedded terminal
self.terminal_container = None
self.vte_terminal = None
def _update_toggle_updater_button_label(self):
disable_file = get_disable_file_path()
if os.path.exists(disable_file):
self.toggle_updater_button.set_label("Enable Updater")
else:
self.toggle_updater_button.set_label("Disable Updater")
def on_toggle_updater_clicked(self, _widget):
disable_file = get_disable_file_path()
try:
if os.path.exists(disable_file):
os.remove(disable_file)
print("Updater enabled.")
else:
with open(disable_file, "w") as f:
pass # File content doesn't matter, its existence is the flag
print("Updater disabled.")
self._update_toggle_updater_button_label()
except Exception as e:
print(f"Error toggling updater state: {e}")
error_dialog = Gtk.MessageDialog(
transient_for=self,
flags=0,
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK,
text="Error Changing Updater Setting",
)
error_dialog.format_secondary_text(f"Could not change the updater setting: {e}")
error_dialog.run()
error_dialog.destroy()
def on_later_clicked(self, _widget):
"""
When 'Later' is clicked, create/update the snooze file and close the window.
"""
snooze_file_path = get_snooze_file_path()
try:
with open(snooze_file_path, "w") as f:
f.write(str(time.time()))
print(f"Update snoozed. Snooze file at: {snooze_file_path}")
except Exception as e:
print(f"Error creating snooze file {snooze_file_path}: {e}")
self.destroy()
def on_update_clicked(self, _widget):
"""
When 'Update' is pressed, disable buttons, hide the progress bar,
and create a VTE terminal to run the update command.
"""
# Disable the buttons so they can't be clicked again
self.update_button.set_sensitive(False)
self.close_button.set_sensitive(False)
self.toggle_updater_button.set_sensitive(False) # Disable toggle button during update
# Hide the progress bar (we don't need it now)
self.progress_bar.set_visible(False)
# If there's no container for the terminal, create it
if self.terminal_container is None:
# Scrollable container so the terminal can scroll
self.terminal_container = Gtk.ScrolledWindow()
self.terminal_container.set_hexpand(True)
self.terminal_container.set_vexpand(True)
self.terminal_container.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
# Create the VTE terminal
self.vte_terminal = Vte.Terminal()
self.vte_terminal.set_size(120, 48)
# Make update window larger
self.set_default_size(720, 540)
self.terminal_container.add(self.vte_terminal)
# Insert the terminal at the end of main_vbox
self.main_vbox.pack_start(self.terminal_container, True, True, 0)
# Show everything
self.show_all()
# Command to run in the terminal
if self.pkg_update:
update_command = "curl -fsSL https://raw.githubusercontent.com/Axenide/Ax-Shell/main/install.sh | bash"
else:
# Ensure REPO_DIR is correctly defined at the top of the file.
update_command = f"git -C \"{REPO_DIR}\" pull && echo 'Reloading in 3...' && sleep 1 && echo '2...' && sleep 1 && echo '1...' && sleep 1 && killall {data.APP_NAME} && setsid python \"{REPO_DIR}main.py\""
# Spawn the process asynchronously inside the terminal
self.vte_terminal.spawn_async(
Vte.PtyFlags.DEFAULT,
os.environ.get("HOME", "/"), # CWD for the command
["/bin/bash", "-lc", update_command], # Command and args
[], # envv
GLib.SpawnFlags.DO_NOT_REAP_CHILD, # spawn_flags
None, # child_setup
None, # child_setup_data
-1, # timeout
None, # cancellable
None, # callback_data for Vte.Terminal.spawn_async_wait_finish
self.on_curl_script_exit, # callback for when process finishes
None # user_data for callback
)
def on_curl_script_exit(self, terminal, exit_status, user_data):
"""
Callback when the script running in the VTE terminal finishes.
Depending on exit_status, success or failure is considered.
"""
# exit_status is encoded: 0 means success
if exit_status == 0:
# Call the success routine, which restarts the app
GLib.idle_add(self.handle_update_success)
else:
# If there was an error, read the last part of the buffer to display it
end_iter = self.vte_terminal.get_end_iter()
start_iter = self.vte_terminal.get_iter_at_line(max(0, self.vte_terminal.get_line_count() - 5))
error_excerpt = self.vte_terminal.get_text_range(start_iter, end_iter, False)
GLib.idle_add(self.handle_update_failure, f"Script exited with status {exit_status}. Last lines:\n{error_excerpt}")
def handle_update_success(self):
"""
Shows a success message, updates local version.json, and restarts the application.
"""
# Update the local version.json with the fetched remote one
try:
update_local_version_file()
print("Local version.json updated successfully.")
except Exception as e:
print(f"Failed to update local version.json: {e}")
# Optionally, you could show an error message to the user here
# For now, we'll proceed with the restart if the script itself was successful.
# If there was any progress bar timeout, remove it
if hasattr(self, "pulse_timeout_id"):
GLib.source_remove(self.pulse_timeout_id)
delattr(self, "pulse_timeout_id")
# Replace the terminal (or other widget) with a brief message
# First remove the terminal to show the progress bar and text
if self.terminal_container:
self.main_vbox.remove(self.terminal_container)
# Prepare the progress bar to indicate success
self.progress_bar.set_visible(True)
self.progress_bar.set_fraction(1.0)
self.progress_bar.set_text("Update Complete. Restarting application...")
self.progress_bar.set_show_text(True)
# Force it to show
self.show_all()
# After 2 seconds, close and restart
GLib.timeout_add_seconds(2, self.trigger_restart_and_close)
def trigger_restart_and_close(self):
"""
Closes the window and relaunches the application.
"""
self.destroy()
try:
print("Restarting application...")
# Relaunch the application
os.execv(sys.executable, [sys.executable] + sys.argv)
except Exception as e:
print(f"Error during application restart: {e}")
# Fallback or error message if execv fails
# For instance, you might want to just quit GTK if restart fails in standalone mode.
if self.is_standalone_mode and self.quit_gtk_main_on_destroy:
Gtk.main_quit()
return False # So the timeout runs only once
def handle_update_failure(self, error_message):
"""
Shows an error dialog if the script execution fails.
"""
# If there was any progress bar timeout, remove it
if hasattr(self, "pulse_timeout_id"):
GLib.source_remove(self.pulse_timeout_id)
delattr(self, "pulse_timeout_id")
# Indicate failure in the progress bar
self.progress_bar.set_visible(True)
self.progress_bar.set_fraction(0.0)
self.progress_bar.set_text("Update Failed.")
self.progress_bar.set_show_text(True)
# Buttons are re-enabled to retry or close
self.update_button.set_sensitive(True)
self.close_button.set_sensitive(True)
self.toggle_updater_button.set_sensitive(True) # Re-enable toggle button
# Error dialog
error_dialog = Gtk.MessageDialog(
transient_for=self,
flags=0,
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK,
text="Update Failed",
)
error_dialog.format_secondary_text(error_message)
error_dialog.run()
error_dialog.destroy()
def on_window_destroyed(self, _widget):
"""
If the window is destroyed and we're in standalone mode, quit Gtk.main().
"""
if hasattr(self, "pulse_timeout_id"):
GLib.source_remove(self.pulse_timeout_id)
delattr(self, "pulse_timeout_id")
if self.quit_gtk_main_on_destroy:
Gtk.main_quit()
def _initiate_update_check_flow(is_standalone_mode, force=False): # Added force argument with default
"""
Logic that checks connection, snooze, and downloads the remote version.
If there's a new version or force is True, launches the update window.
"""
global _QUIT_GTK_IF_NO_WINDOW_STANDALONE
# --- Check if updater is permanently disabled ---
disable_file_path = get_disable_file_path()
if os.path.exists(disable_file_path) and not force:
print(f"Updater is disabled via {UPDATER_DISABLE_FILE_NAME}. Skipping update check.")
if is_standalone_mode and _QUIT_GTK_IF_NO_WINDOW_STANDALONE:
GLib.idle_add(Gtk.main_quit)
return
if not is_connected():
print("No internet connection. Skipping update check.")
if is_standalone_mode and _QUIT_GTK_IF_NO_WINDOW_STANDALONE:
GLib.idle_add(Gtk.main_quit)
return
fetch_remote_version()
latest_version, changelog, _, pkg_update = get_remote_version() # Unpack pkg_update
if force:
print(f"Force update mode enabled. Opening updater for version {latest_version}.")
if latest_version == "0.0.0" and not changelog: # And pkg_update will be True (default)
print(f"Warning: Could not fetch remote version details for {data.APP_NAME_CAP}. Updater will show default/empty info.")
GLib.idle_add(launch_update_window, latest_version, changelog, pkg_update, is_standalone_mode) # Pass pkg_update
return # Exit after launching in force mode
# --- Regular update check flow (if not forced) ---
snooze_file_path = get_snooze_file_path()
if os.path.exists(snooze_file_path):
try:
with open(snooze_file_path, "r") as f:
snooze_timestamp_str = f.read().strip()
snooze_timestamp = float(snooze_timestamp_str)
current_time = time.time()
if current_time - snooze_timestamp < SNOOZE_DURATION_SECONDS:
snooze_until_time = snooze_timestamp + SNOOZE_DURATION_SECONDS
snooze_until_time_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(snooze_until_time))
print(f"Check postponed. It will resume after {snooze_until_time_str}.")
if is_standalone_mode and _QUIT_GTK_IF_NO_WINDOW_STANDALONE:
GLib.idle_add(Gtk.main_quit)
return
else:
print("Snooze period expired. Removing file and checking for updates.")
os.remove(snooze_file_path)
except ValueError:
print(f"Error: invalid content in snooze file. Removing: {snooze_file_path}")
try:
os.remove(snooze_file_path)
except OSError as e_remove:
print(f"Error removing corrupt snooze file: {e_remove}")
except Exception as e_snooze:
print(f"Error processing snooze file {snooze_file_path}: {e_snooze}. Proceeding with check.")
try:
os.remove(snooze_file_path) # Attempt to remove problematic snooze file
except OSError as e_remove_generic:
print(f"Error removing problematic snooze file: {e_remove_generic}")
current_version, _ = get_local_version()
# Basic version comparison (not strict semver)
if latest_version > current_version and latest_version != "0.0.0":
GLib.idle_add(launch_update_window, latest_version, changelog, pkg_update, is_standalone_mode) # Pass pkg_update
else:
print(f"{data.APP_NAME_CAP} is up to date or the remote version is invalid.")
if is_standalone_mode and _QUIT_GTK_IF_NO_WINDOW_STANDALONE:
GLib.idle_add(Gtk.main_quit)
def launch_update_window(latest_version, changelog, pkg_update, is_standalone_mode):
"""
Creates and shows the update window.
"""
win = UpdateWindow(latest_version, changelog, pkg_update, is_standalone_mode) # Pass pkg_update
if is_standalone_mode:
win.quit_gtk_main_on_destroy = True
win.show_all()
def check_for_updates():
"""
Entry point when called from the main application.
Initiates an update check in a background thread without force.
"""
# Create wrapper function for GLib.Thread compatibility
def _update_check_wrapper(user_data):
_initiate_update_check_flow(False, False)
GLib.Thread.new("update-check", _update_check_wrapper, None)
def run_updater(force=False): # Modified to accept force argument
"""
Standalone entry point: starts Gtk.main and the update check.
Args:
force (bool): If True, opens the updater even if the version isn't outdated or snoozed.
Defaults to False.
"""
global _QUIT_GTK_IF_NO_WINDOW_STANDALONE
_QUIT_GTK_IF_NO_WINDOW_STANDALONE = True
# Create wrapper function for GLib.Thread compatibility
def _standalone_update_wrapper(user_data):
_initiate_update_check_flow(True, force)
GLib.Thread.new("standalone-update-check", _standalone_update_wrapper, None)
Gtk.main()
if __name__ == "__main__":
# Example of how to run with force=True:
# run_updater(force=True)
# By default, runs with force=False:
run_updater()
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Oscar Svensson (wogscpar)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5
View File
@@ -0,0 +1,5 @@
"""
UPower wrapper using DBus
Copyright (c) 2017 Oscar Svensson (wogscpar)
https://github.com/wogscpar/upower_python
"""
+152
View File
@@ -0,0 +1,152 @@
import dbus
class UPowerManager():
def __init__(self):
self.UPOWER_NAME = "org.freedesktop.UPower"
self.UPOWER_PATH = "/org/freedesktop/UPower"
self.DBUS_PROPERTIES = "org.freedesktop.DBus.Properties"
self.bus = dbus.SystemBus()
def detect_devices(self):
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH)
upower_interface = dbus.Interface(upower_proxy, self.UPOWER_NAME)
devices = upower_interface.EnumerateDevices()
return devices
def get_display_device(self):
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH)
upower_interface = dbus.Interface(upower_proxy, self.UPOWER_NAME)
dispdev = upower_interface.GetDisplayDevice()
return dispdev
def get_critical_action(self):
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH)
upower_interface = dbus.Interface(upower_proxy, self.UPOWER_NAME)
critical_action = upower_interface.GetCriticalAction()
return critical_action
def get_device_percentage(self, battery):
battery_proxy = self.bus.get_object(self.UPOWER_NAME, battery)
battery_proxy_interface = dbus.Interface(battery_proxy, self.DBUS_PROPERTIES)
return battery_proxy_interface.Get(self.UPOWER_NAME + ".Device", "Percentage")
def get_full_device_information(self, battery):
battery_proxy = self.bus.get_object(self.UPOWER_NAME, battery)
battery_proxy_interface = dbus.Interface(battery_proxy, self.DBUS_PROPERTIES)
# Use GetAll to retrieve all properties in a single DBus call
all_properties = battery_proxy_interface.GetAll(self.UPOWER_NAME + ".Device")
# Extract properties with default values for missing keys
information_table = {
'HasHistory': all_properties.get('HasHistory', False),
'HasStatistics': all_properties.get('HasStatistics', False),
'IsPresent': all_properties.get('IsPresent', False),
'IsRechargeable': all_properties.get('IsRechargeable', False),
'Online': all_properties.get('Online', False),
'PowerSupply': all_properties.get('PowerSupply', False),
'Capacity': all_properties.get('Capacity', 0.0),
'Energy': all_properties.get('Energy', 0.0),
'EnergyEmpty': all_properties.get('EnergyEmpty', 0.0),
'EnergyFull': all_properties.get('EnergyFull', 0.0),
'EnergyFullDesign': all_properties.get('EnergyFullDesign', 0.0),
'EnergyRate': all_properties.get('EnergyRate', 0.0),
'Luminosity': all_properties.get('Luminosity', 0.0),
'Percentage': all_properties.get('Percentage', 0.0),
'Temperature': all_properties.get('Temperature', 0.0),
'Voltage': all_properties.get('Voltage', 0.0),
'TimeToEmpty': all_properties.get('TimeToEmpty', 0),
'TimeToFull': all_properties.get('TimeToFull', 0),
'IconName': all_properties.get('IconName', ''),
'Model': all_properties.get('Model', ''),
'NativePath': all_properties.get('NativePath', ''),
'Serial': all_properties.get('Serial', ''),
'Vendor': all_properties.get('Vendor', ''),
'State': all_properties.get('State', 0),
'Technology': all_properties.get('Technology', 0),
'Type': all_properties.get('Type', 0),
'WarningLevel': all_properties.get('WarningLevel', 0),
'UpdateTime': all_properties.get('UpdateTime', 0)
}
return information_table
def is_lid_present(self):
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH)
upower_interface = dbus.Interface(upower_proxy, self.DBUS_PROPERTIES)
is_lid_present = bool(upower_interface.Get(self.UPOWER_NAME, 'LidIsPresent'))
return is_lid_present
def is_lid_closed(self):
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH)
upower_interface = dbus.Interface(upower_proxy, self.DBUS_PROPERTIES)
is_lid_closed = bool(upower_interface.Get(self.UPOWER_NAME, 'LidIsClosed'))
return is_lid_closed
def on_battery(self):
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH)
upower_interface = dbus.Interface(upower_proxy, self.DBUS_PROPERTIES)
on_battery = bool(upower_interface.Get(self.UPOWER_NAME, 'OnBattery'))
return on_battery
def has_wakeup_capabilities(self):
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH + "/Wakeups")
upower_interface = dbus.Interface(upower_proxy, self.DBUS_PROPERTIES)
has_wakeup_capabilities = bool(upower_interface.Get(self.UPOWER_NAME+ '.Wakeups', 'HasCapability'))
return has_wakeup_capabilities
def get_wakeups_data(self):
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH + "/Wakeups")
upower_interface = dbus.Interface(upower_proxy, self.UPOWER_NAME + '.Wakeups')
data = upower_interface.GetData()
return data
def get_wakeups_total(self):
upower_proxy = self.bus.get_object(self.UPOWER_NAME, self.UPOWER_PATH + "/Wakeups")
upower_interface = dbus.Interface(upower_proxy, self.UPOWER_NAME + '.Wakeups')
data = upower_interface.GetTotal()
return data
def is_loading(self, battery):
battery_proxy = self.bus.get_object(self.UPOWER_NAME, battery)
battery_proxy_interface = dbus.Interface(battery_proxy, self.DBUS_PROPERTIES)
state = int(battery_proxy_interface.Get(self.UPOWER_NAME + ".Device", "State"))
if (state == 1):
return True
else:
return False
def get_state(self, battery):
battery_proxy = self.bus.get_object(self.UPOWER_NAME, battery)
battery_proxy_interface = dbus.Interface(battery_proxy, self.DBUS_PROPERTIES)
state = int(battery_proxy_interface.Get(self.UPOWER_NAME + ".Device", "State"))
if (state == 0):
return "Unknown"
elif (state == 1):
return "Loading"
elif (state == 2):
return "Discharging"
elif (state == 3):
return "Empty"
elif (state == 4):
return "Fully charged"
elif (state == 5):
return "Pending charge"
elif (state == 6):
return "Pending discharge"
+629
View File
@@ -0,0 +1,629 @@
import colorsys
import concurrent.futures
import hashlib
import os
import random # <--- AÑADIDO
import shutil
from concurrent.futures import ThreadPoolExecutor
from fabric.utils.helpers import exec_shell_command_async
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.entry import Entry
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk, Pango
from PIL import Image
import config.config
import config.data as data
import modules.icons as icons
class WallpaperSelector(Box):
CACHE_DIR = f"{data.CACHE_DIR}/thumbs" # Changed from wallpapers to thumbs
def __init__(self, **kwargs):
# Delete the old cache directory if it exists
old_cache_dir = f"{data.CACHE_DIR}/wallpapers"
if os.path.exists(old_cache_dir):
shutil.rmtree(old_cache_dir)
super().__init__(
name="wallpapers",
spacing=4,
orientation="v",
h_expand=False,
v_expand=False,
**kwargs,
)
os.makedirs(self.CACHE_DIR, exist_ok=True)
self.files = []
GLib.idle_add(self._load_wallpapers_async().__next__)
self.thumbnails = []
self.thumbnail_queue = []
self.executor = ThreadPoolExecutor(max_workers=4) # Shared executor
# Variable to control the selection (similar to AppLauncher)
self.selected_index = -1
# Initialize UI components
self.viewport = Gtk.IconView(name="wallpaper-icons")
self.viewport.set_model(Gtk.ListStore(GdkPixbuf.Pixbuf, str))
self.viewport.set_pixbuf_column(0)
# Hide text column so only the image is shown
self.viewport.set_text_column(-1)
self.viewport.set_item_width(0)
self.viewport.connect("item-activated", self.on_wallpaper_selected)
# self.viewport.connect("selection-changed", self._on_selection_changed) # Removed connection
self.scrolled_window = ScrolledWindow(
name="scrolled-window",
spacing=10,
h_expand=True,
v_expand=True,
h_align="fill",
v_align="fill",
child=self.viewport,
propagate_width=False,
propagate_height=False,
)
self.search_entry = Entry(
name="search-entry-walls",
placeholder="Search Wallpapers...",
h_expand=True,
h_align="fill",
notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()),
on_key_press_event=self.on_search_entry_key_press,
)
self.search_entry.props.xalign = 0.5
self.search_entry.connect("focus-out-event", self.on_search_entry_focus_out)
self.schemes = {
"scheme-tonal-spot": "Tonal Spot",
"scheme-content": "Content",
"scheme-expressive": "Expressive",
"scheme-fidelity": "Fidelity",
"scheme-fruit-salad": "Fruit Salad",
"scheme-monochrome": "Monochrome",
"scheme-neutral": "Neutral",
"scheme-rainbow": "Rainbow",
}
self.scheme_dropdown = Gtk.ComboBoxText()
self.scheme_dropdown.set_name("scheme-dropdown")
self.scheme_dropdown.set_tooltip_text("Select color scheme")
for key, display_name in self.schemes.items():
self.scheme_dropdown.append(key, display_name)
self.scheme_dropdown.set_active_id("scheme-tonal-spot")
self.scheme_dropdown.connect("changed", self.on_scheme_changed)
# Load matugen state from the dedicated file
self.matugen_enabled = True # Default to True
try:
with open(data.MATUGEN_STATE_FILE, "r") as f:
content = f.read().strip().lower()
if content == "false":
self.matugen_enabled = False
elif content == "true":
self.matugen_enabled = True
# Any other content defaults to True
except FileNotFoundError:
# File doesn't exist, keep default True and create it on first toggle
pass
except Exception as e:
print(f"Error reading matugen state file: {e}")
# Keep default True on error
# Create a switcher to enable/disable Matugen (enabled by default)
self.matugen_switcher = Gtk.Switch(name="matugen-switcher")
self.matugen_switcher.set_tooltip_text("Toggle dynamic colors")
self.matugen_switcher.set_vexpand(False)
self.matugen_switcher.set_hexpand(False)
self.matugen_switcher.set_valign(Gtk.Align.CENTER)
self.matugen_switcher.set_halign(Gtk.Align.CENTER)
self.matugen_switcher.set_active(self.matugen_enabled)
self.matugen_switcher.connect("notify::active", self.on_switch_toggled)
self.mat_icon = Label(name="mat-label", markup=icons.palette)
self.random_wall = Button(
name="random-wall-button",
child=Label(name="random-wall-label", markup=icons.dice_1),
tooltip_text="Random Wallpaper",
)
self.random_wall.connect("clicked", self.set_random_wallpaper) # <--- AÑADIDO
# Add the switcher to the header_box's start_children
self.header_box = Box(
name="header-box",
spacing=8,
orientation="h",
children=[
self.random_wall,
self.search_entry,
self.scheme_dropdown,
self.matugen_switcher,
],
)
self.add(self.header_box)
# Create the custom color selector components
self.hue_slider = Gtk.Scale(
orientation=Gtk.Orientation.HORIZONTAL, # Changed from VERTICAL
adjustment=Gtk.Adjustment(
value=0, lower=0, upper=360, step_increment=1, page_increment=10
),
draw_value=False, # Hide the default value text
digits=0,
# inverted=True, # Removed inverted for horizontal
name="hue-slider", # For CSS styling
)
# Changed expand/align for horizontal orientation
self.hue_slider.set_hexpand(True)
self.hue_slider.set_halign(Gtk.Align.FILL)
self.hue_slider.set_vexpand(False) # Ensure it doesn't expand vertically
self.hue_slider.set_valign(Gtk.Align.CENTER) # Center vertically within its box
self.apply_color_button = Button(
name="apply-color-button",
child=Label(name="apply-color-label", markup=icons.accept),
)
self.apply_color_button.connect("clicked", self.on_apply_color_clicked)
self.apply_color_button.set_vexpand(
False
) # Ensure button doesn't expand vertically
self.apply_color_button.set_valign(Gtk.Align.CENTER) # Center button vertically
self.custom_color_selector_box = Box(
orientation="h",
spacing=5,
name="custom-color-selector-box", # Changed orientation to horizontal
h_align="center", # Center the horizontal box
)
self.custom_color_selector_box.add(self.hue_slider)
self.custom_color_selector_box.add(self.apply_color_button)
self.custom_color_selector_box.set_halign(Gtk.Align.FILL)
# Add the scrolled window (grid) and the custom color selector box directly
# to the main WallpaperSelector box (which is already vertical)
self.pack_start(self.scrolled_window, True, True, 0) # Add grid, expand
self.pack_start(
self.custom_color_selector_box, False, False, 0
) # Add custom selector, don't expand
# Removed the old main_content_box and its add
self._start_thumbnail_thread()
self.connect("map", self.on_map)
self.setup_file_monitor()
self.show_all()
self.randomize_dice_icon()
# Ensure the search entry gets focus when starting
self.search_entry.grab_focus()
def _load_wallpapers_async(self):
"""Non-blocking wallpaper processing."""
# Process old wallpapers: use os.scandir for efficiency and only loop
# over image files that actually need renaming (they're not already lowercase
# and with hyphens instead of spaces)
with os.scandir(data.WALLPAPERS_DIR) as entries:
for entry in entries:
if entry.is_file() and self._is_image(entry.name):
# Check if the file needs renaming: file should be lowercase and have hyphens instead of spaces
if entry.name != entry.name.lower() or " " in entry.name:
new_name = entry.name.lower().replace(" ", "-")
full_path = os.path.join(data.WALLPAPERS_DIR, entry.name)
new_full_path = os.path.join(data.WALLPAPERS_DIR, new_name)
try:
os.rename(full_path, new_full_path)
print(
f"Renamed old wallpaper '{full_path}' to '{new_full_path}'"
)
except Exception as e:
print(f"Error renaming file {full_path}: {e}")
yield
# Process files in small batches to keep UI responsive
file_list = os.listdir(data.WALLPAPERS_DIR)
batch_size = 20
# Process files in batches
for i in range(0, len(file_list), batch_size):
batch = file_list[i : i + batch_size]
for filename in batch:
if self._is_image(filename):
self.files.append(filename)
# Sort the current batch to maintain order
self.files.sort()
# Yield to let the main loop process events
yield True
# Final sort of the complete list
self.files.sort()
# Start thumbnail loading after files are processed
self._start_thumbnail_thread()
# Return False to stop the idle callback
yield False
def randomize_dice_icon(self):
dice_icons = [
icons.dice_1,
icons.dice_2,
icons.dice_3,
icons.dice_4,
icons.dice_5,
icons.dice_6,
]
chosen_icon = random.choice(dice_icons)
label = self.random_wall.get_child()
if isinstance(label, Label):
label.set_markup(chosen_icon)
def set_random_wallpaper(self, widget, external=False):
if not self.files:
print("No wallpapers available to set a random one.")
return
file_name = random.choice(self.files)
full_path = os.path.join(data.WALLPAPERS_DIR, file_name)
selected_scheme = self.scheme_dropdown.get_active_id()
current_wall = os.path.expanduser(f"~/.current.wall")
if os.path.isfile(current_wall) or os.path.islink(
current_wall
): # Check for link too
os.remove(current_wall)
os.symlink(full_path, current_wall)
if self.matugen_switcher.get_active():
exec_shell_command_async(
f'matugen image "{full_path}" -t {selected_scheme}'
)
else:
exec_shell_command_async(
f'awww img "{full_path}" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest'
)
print(f"Set random wallpaper: {file_name}")
if external:
exec_shell_command_async(
f"notify-send '🎲 Wallpaper' 'Setting a random wallpaper 🎨' -a '{data.APP_NAME_CAP}' -i '{full_path}' -e"
)
self.randomize_dice_icon()
def setup_file_monitor(self):
gfile = Gio.File.new_for_path(data.WALLPAPERS_DIR)
self.file_monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None)
self.file_monitor.connect("changed", self.on_directory_changed)
def on_directory_changed(self, monitor, file, other_file, event_type):
file_name = file.get_basename()
if event_type == Gio.FileMonitorEvent.DELETED:
if file_name in self.files:
self.files.remove(file_name)
cache_path = self._get_cache_path(file_name)
if os.path.exists(cache_path):
try:
os.remove(cache_path)
except Exception as e:
print(f"Error deleting cache {cache_path}: {e}")
self.thumbnails = [(p, n) for p, n in self.thumbnails if n != file_name]
GLib.idle_add(self.arrange_viewport, self.search_entry.get_text())
elif event_type == Gio.FileMonitorEvent.CREATED:
if self._is_image(file_name):
# Convert filename to lowercase and replace spaces with "-"
new_name = file_name.lower().replace(" ", "-")
full_path = os.path.join(data.WALLPAPERS_DIR, file_name)
new_full_path = os.path.join(data.WALLPAPERS_DIR, new_name)
if new_name != file_name:
try:
os.rename(full_path, new_full_path)
file_name = new_name
print(f"Renamed file '{full_path}' to '{new_full_path}')")
except Exception as e:
print(f"Error renaming file {full_path}: {e}")
if file_name not in self.files:
self.files.append(file_name)
self.files.sort()
self.executor.submit(self._process_file, file_name)
elif event_type == Gio.FileMonitorEvent.CHANGED:
if self._is_image(file_name) and file_name in self.files:
cache_path = self._get_cache_path(file_name)
if os.path.exists(cache_path):
try:
os.remove(cache_path)
except Exception as e:
print(f"Error deleting cache for changed file {file_name}: {e}")
self.executor.submit(self._process_file, file_name)
def arrange_viewport(self, query: str = ""):
model = self.viewport.get_model()
model.clear()
filtered_thumbnails = [
(thumb, name)
for thumb, name in self.thumbnails
if query.casefold() in name.casefold()
]
filtered_thumbnails.sort(key=lambda x: x[1].lower())
for pixbuf, file_name in filtered_thumbnails:
model.append([pixbuf, file_name])
# If the search entry is empty, no icon is selected; otherwise, select the first one.
if query.strip() == "":
self.viewport.unselect_all()
self.selected_index = -1
elif len(model) > 0:
self.update_selection(0)
def on_wallpaper_selected(self, iconview, path):
model = iconview.get_model()
file_name = model[path][1]
full_path = os.path.join(data.WALLPAPERS_DIR, file_name)
selected_scheme = self.scheme_dropdown.get_active_id()
current_wall = os.path.expanduser(f"~/.current.wall")
if os.path.isfile(current_wall) or os.path.islink(current_wall):
os.remove(current_wall)
os.symlink(full_path, current_wall)
if self.matugen_switcher.get_active():
# Matugen is enabled: run the normal command.
exec_shell_command_async(
f'matugen image "{full_path}" -t {selected_scheme}'
)
else:
# Matugen is disabled: run the alternative awww command.
exec_shell_command_async(
f'awww img "{full_path}" -t outer --transition-duration 1.5 --transition-step 255 --transition-fps 60 -f Nearest'
)
def on_scheme_changed(self, combo):
selected_scheme = combo.get_active_id()
print(f"Color scheme selected: {selected_scheme}")
def on_search_entry_key_press(self, widget, event):
if event.state & Gdk.ModifierType.SHIFT_MASK:
if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down):
schemes_list = list(self.schemes.keys())
current_id = self.scheme_dropdown.get_active_id()
current_index = (
schemes_list.index(current_id) if current_id in schemes_list else 0
)
new_index = (
(current_index - 1) % len(schemes_list)
if event.keyval == Gdk.KEY_Up
else (current_index + 1) % len(schemes_list)
)
self.scheme_dropdown.set_active(new_index)
return True
elif event.keyval == Gdk.KEY_Right:
self.scheme_dropdown.popup()
return True
if event.keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Left, Gdk.KEY_Right):
self.move_selection_2d(event.keyval)
return True
elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
if self.selected_index != -1:
path = Gtk.TreePath.new_from_indices([self.selected_index])
self.on_wallpaper_selected(self.viewport, path)
return True
return False
# Removed _on_selection_changed method
def move_selection_2d(self, keyval):
model = self.viewport.get_model()
total_items = len(model)
if total_items == 0:
return
# --- Determine Column Count ---
columns = self.viewport.get_columns()
# If get_columns returns 0 or -1 (auto), try to estimate by checking item rows
if columns <= 0 and total_items > 0:
estimated_cols = 0
try:
# Check the row of the first item (should be 0)
first_item_path = Gtk.TreePath.new_from_indices([0])
base_row = self.viewport.get_item_row(first_item_path)
# Find the index of the first item in the *next* row
for i in range(1, total_items):
path = Gtk.TreePath.new_from_indices([i])
row = self.viewport.get_item_row(path)
if row > base_row:
estimated_cols = i # The number of items in the first row
break
# If loop finished without finding a new row, all items are in one row
if estimated_cols == 0:
estimated_cols = total_items
columns = max(1, estimated_cols)
except Exception:
# Fallback if get_item_row fails (e.g., widget not realized)
columns = 1
elif columns <= 0 and total_items == 0:
columns = 1 # Should not happen due to early return, but safe
# Ensure columns is at least 1 after all checks
columns = max(1, columns)
# --- Navigation Logic ---
current_index = self.selected_index
new_index = current_index
if current_index == -1:
# If nothing is selected, select the first or last item based on direction
if keyval in (Gdk.KEY_Down, Gdk.KEY_Right):
new_index = 0
elif keyval in (Gdk.KEY_Up, Gdk.KEY_Left):
new_index = total_items - 1
if total_items == 0:
new_index = -1 # Handle edge case
else:
# Calculate potential new index based on key press
if keyval == Gdk.KEY_Up:
potential_new_index = current_index - columns
# Only update if the new index is valid (>= 0)
if potential_new_index >= 0:
new_index = potential_new_index
elif keyval == Gdk.KEY_Down:
potential_new_index = current_index + columns
# Only update if the new index is valid (< total_items)
if potential_new_index < total_items:
new_index = potential_new_index
elif keyval == Gdk.KEY_Left:
# Only update if not already in the first column (index % columns != 0)
# and the index is greater than 0
if current_index > 0 and current_index % columns != 0:
new_index = current_index - 1
elif keyval == Gdk.KEY_Right:
# Only update if not in the last column ((index + 1) % columns != 0)
# and not the very last item (index < total_items - 1)
if (
current_index < total_items - 1
and (current_index + 1) % columns != 0
):
new_index = current_index + 1
# Only update if the index actually changed and is valid
if new_index != self.selected_index and 0 <= new_index < total_items:
self.update_selection(new_index)
elif (
total_items > 0
and self.selected_index == -1
and 0 <= new_index < total_items
):
# Handle selecting the first item when starting from -1
self.update_selection(new_index)
def update_selection(self, new_index: int):
self.viewport.unselect_all()
path = Gtk.TreePath.new_from_indices([new_index])
self.viewport.select_path(path)
self.viewport.scroll_to_path(
path, False, 0.5, 0.5
) # Ensure the selected icon is visible
self.selected_index = new_index
def _start_thumbnail_thread(self):
thread = GLib.Thread.new("thumbnail-loader", self._preload_thumbnails, None)
def _preload_thumbnails(self, _data):
futures = [
self.executor.submit(self._process_file, file_name)
for file_name in self.files
]
concurrent.futures.wait(futures)
GLib.idle_add(self._process_batch)
def _process_file(self, file_name):
full_path = os.path.join(data.WALLPAPERS_DIR, file_name)
cache_path = self._get_cache_path(file_name)
if not os.path.exists(cache_path):
try:
with Image.open(full_path) as img:
width, height = img.size
side = min(width, height)
left = (img.width - side) // 2
top = (height - side) // 2
right = left + side
bottom = top + side
img_cropped = img.crop((left, top, right, bottom))
img_cropped.thumbnail((96, 96), Image.Resampling.LANCZOS)
img_cropped.save(cache_path, "PNG")
except Exception as e:
print(f"Error processing {file_name}: {e}")
return
self.thumbnail_queue.append((cache_path, file_name))
GLib.idle_add(self._process_batch)
def _process_batch(self):
batch = self.thumbnail_queue[:10]
del self.thumbnail_queue[:10]
for cache_path, file_name in batch:
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)
self.thumbnails.append((pixbuf, file_name))
self.viewport.get_model().append([pixbuf, file_name])
except Exception as e:
print(f"Error loading thumbnail {cache_path}: {e}")
if self.thumbnail_queue:
GLib.idle_add(self._process_batch)
return False
def _get_cache_path(self, file_name: str) -> str:
file_hash = hashlib.md5(file_name.encode("utf-8")).hexdigest()
return os.path.join(self.CACHE_DIR, f"{file_hash}.png")
@staticmethod
def _is_image(file_name: str) -> bool:
return file_name.lower().endswith(
(".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
)
def on_search_entry_focus_out(self, widget, event):
if self.get_mapped():
widget.grab_focus()
return False
def on_map(self, widget):
"""Handles the map signal to set initial visibility of the color selector."""
# Set visibility based on the loaded state when the widget becomes visible
self.custom_color_selector_box.set_visible(not self.matugen_enabled)
def hsl_to_rgb_hex(self, h: float, s: float = 1.0, l: float = 0.5) -> str:
"""Converts HSL color value to RGB HEX string."""
# colorsys uses HLS, not HSL, and expects values between 0.0 and 1.0
hue = h / 360.0
r, g, b = colorsys.hls_to_rgb(hue, l, s) # Note the order: H, L, S
r_int, g_int, b_int = int(r * 255), int(g * 255), int(b * 255)
return f"#{r_int:02X}{g_int:02X}{b_int:02X}"
def rgba_to_hex(self, rgba: Gdk.RGBA) -> str:
"""Converts Gdk.RGBA to a HEX color string."""
r = int(rgba.red * 255)
g = int(rgba.green * 255)
b = int(rgba.blue * 255)
return f"#{r:02X}{g:02X}{b:02X}"
def on_switch_toggled(self, switch, gparam):
"""Handles the toggling of the Matugen switch."""
is_active = switch.get_active()
self.matugen_enabled = is_active
# self.scheme_dropdown.set_sensitive(is_active)
self.custom_color_selector_box.set_visible(not is_active) # Toggle visibility
# Save the state to the dedicated file
try:
with open(data.MATUGEN_STATE_FILE, "w") as f:
f.write(str(is_active))
except Exception as e:
print(f"Error writing matugen state file: {e}")
def on_apply_color_clicked(self, button):
"""Applies the color selected by the hue slider via matugen."""
hue_value = self.hue_slider.get_value() # Get value from 0-360
hex_color = self.hsl_to_rgb_hex(hue_value) # Convert HSL(hue, 1.0, 0.5) to HEX
print(f"Applying color from slider: H={hue_value}, HEX={hex_color}")
selected_scheme = self.scheme_dropdown.get_active_id()
# Run matugen with the chosen hex color and selected scheme
exec_shell_command_async(
f'matugen color hex "{hex_color}" -t {selected_scheme}'
)
# Optionally save the chosen color to config if needed later
# config.config.bind_vars["matugen_hex_color"] = hex_color
# config.config.save_config() # Removed as save_config doesn't exist
+106
View File
@@ -0,0 +1,106 @@
import subprocess
import urllib.parse
import gi
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from gi.repository import GLib
gi.require_version("Gtk", "3.0")
import config.data as data
import modules.icons as icons
class Weather(Button):
def __init__(self, **kwargs) -> None:
super().__init__(name="weather", orientation="h", spacing=8, **kwargs)
self.label = Label(name="weather-label", markup=icons.loader)
self.add(self.label)
self.show_all()
self.enabled = False # Will be set by apply_component_props
self.has_weather_data = False
self.fetching = False # Prevent concurrent fetches
# Fetch weather every 10 minutes (600 seconds)
GLib.timeout_add_seconds(600, self.fetch_weather)
# Delay initial fetch to allow visibility config to be applied first (runs only once)
GLib.timeout_add(100, self._initial_fetch)
def set_visible(self, visible):
"""Override to track external visibility setting"""
self.enabled = visible
# If being disabled, always hide
if not visible:
super().set_visible(False)
return
# If being enabled, only show if we have weather data
if hasattr(self, "has_weather_data") and self.has_weather_data:
super().set_visible(True)
# If no weather data yet, remain hidden until fetch completes
def _initial_fetch(self):
"""Initial fetch that runs only once"""
self.fetch_weather()
return False # Don't repeat this timeout
def fetch_weather(self):
# Prevent concurrent fetches
if self.fetching:
return True
self.fetching = True
GLib.Thread.new("weather-fetch", self._fetch_weather_thread, None)
return True
def _fetch_weather_thread(self, user_data):
url = (
"https://wttr.in/?format=%c+%t"
if not data.VERTICAL
else "https://wttr.in/?format=%c"
)
tooltip_url = "https://wttr.in/?format=%l:+%C,+%t+(%f),+Humidity:+%h,+Wind:+%w"
try:
# Use curl to fetch weather data
result = subprocess.run(
["curl", "-sf", "--max-time", "5", url],
capture_output=True,
text=True,
timeout=6,
)
if result.returncode == 0 and result.stdout:
weather_data = result.stdout.strip()
if "Unknown" in weather_data:
self.has_weather_data = False
GLib.idle_add(self.set_visible, False)
else:
self.has_weather_data = True
# Fetch tooltip data
tooltip_result = subprocess.run(
["curl", "-sf", "--max-time", "5", tooltip_url],
capture_output=True,
text=True,
timeout=6,
)
if tooltip_result.returncode == 0 and tooltip_result.stdout:
tooltip_text = tooltip_result.stdout.strip()
GLib.idle_add(self.set_tooltip_text, tooltip_text)
GLib.idle_add(self.set_visible, self.enabled)
GLib.idle_add(self.label.set_label, weather_data.replace(" ", ""))
else:
self.has_weather_data = False
GLib.idle_add(self.label.set_markup, f"{icons.cloud_off} Unavailable")
GLib.idle_add(self.set_visible, False)
except Exception as e:
self.has_weather_data = False
print(f"Error fetching weather: {e}")
GLib.idle_add(self.label.set_markup, f"{icons.cloud_off} Error")
GLib.idle_add(self.set_visible, False)
finally:
# Always reset fetching flag when done
self.fetching = False
+166
View File
@@ -0,0 +1,166 @@
import gi
gi.require_version("Gtk", "3.0")
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.stack import Stack
import config.data as data
from modules.bluetooth import BluetoothConnections
from modules.buttons import Buttons
from modules.calendar import Calendar
from modules.controls import ControlSliders
from modules.metrics import Metrics
from modules.network import NetworkConnections
from modules.notifications import NotificationHistory
from modules.player import Player
class Widgets(Box):
def __init__(self, **kwargs):
super().__init__(
name="dash-widgets",
h_align="fill",
v_align="fill",
h_expand=True,
v_expand=True,
visible=True,
all_visible=True,
)
vertical_layout = False
if data.PANEL_THEME == "Panel" and (
data.BAR_POSITION in ["Left", "Right"]
or data.PANEL_POSITION in ["Start", "End"]
):
vertical_layout = True
calendar_view_mode = "week" if vertical_layout else "month"
self.calendar = Calendar(view_mode=calendar_view_mode)
self.notch = kwargs["notch"]
self.buttons = Buttons(widgets=self)
self.bluetooth = BluetoothConnections(widgets=self)
self.box_1 = Box(
name="box-1",
h_expand=True,
v_expand=True,
)
self.box_2 = Box(
name="box-2",
h_expand=True,
v_expand=True,
)
self.box_3 = Box(
name="box-3",
v_expand=True,
)
self.controls = ControlSliders()
self.player = Player()
self.metrics = Metrics()
self.notification_history = NotificationHistory()
self.network_connections = NetworkConnections(widgets=self)
self.applet_stack = Stack(
h_expand=True,
v_expand=True,
transition_type="slide-left-right",
children=[
self.notification_history,
self.network_connections,
self.bluetooth,
],
)
self.applet_stack_box = Box(
name="applet-stack",
h_expand=True,
v_expand=True,
h_align="fill",
children=[
self.applet_stack,
],
)
if not vertical_layout:
self.children_1 = [
Box(
name="container-sub-1",
h_expand=True,
v_expand=True,
spacing=8,
children=[
self.calendar,
self.applet_stack_box,
],
),
self.metrics,
]
else:
self.children_1 = [
self.applet_stack_box,
self.calendar, # Weekly view when vertical
self.player,
]
self.container_1 = Box(
name="container-1",
h_expand=True,
v_expand=True,
orientation="h" if not vertical_layout else "v",
spacing=8,
children=self.children_1,
)
self.container_2 = Box(
name="container-2",
h_expand=True,
v_expand=True,
orientation="v",
spacing=8,
children=[
self.buttons,
self.controls,
self.container_1,
],
)
if not vertical_layout:
self.children_3 = [
self.player,
self.container_2,
]
else: # vertical_layout
self.children_3 = [
self.container_2,
]
self.container_3 = Box(
name="container-3",
h_expand=True,
v_expand=True,
orientation="h",
spacing=8,
children=self.children_3,
)
self.add(self.container_3)
def show_bt(self):
self.applet_stack.set_visible_child(self.bluetooth)
def show_notif(self):
self.applet_stack.set_visible_child(self.notification_history)
def show_network_applet(self):
self.notch.open_notch("network_applet")