update
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Ax-Shell modules package.
|
||||
Contains UI components and functionality modules.
|
||||
"""
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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 = ""
|
||||
dashboard: str = ""
|
||||
chat: str = ""
|
||||
windows: str = ""
|
||||
|
||||
# Bar
|
||||
colorpicker: str = ""
|
||||
media: str = ""
|
||||
|
||||
# Toolbox
|
||||
|
||||
toolbox: str = ""
|
||||
ssfull: str = ""
|
||||
ssregion: str = ""
|
||||
sswindow: str = ""
|
||||
screenshots: str = ""
|
||||
screenrecord: str = ""
|
||||
recordings: str = ""
|
||||
ocr: str = "ﳃ"
|
||||
gamemode: str = ""
|
||||
gamemode_off: str = ""
|
||||
close: str = ""
|
||||
|
||||
# Circles
|
||||
temp: str = ""
|
||||
disk: str = ""
|
||||
battery: str = ""
|
||||
memory: str = "流"
|
||||
cpu: str = ""
|
||||
gpu: str = ""
|
||||
|
||||
# AIchat
|
||||
reload: str = ""
|
||||
detach: str = ""
|
||||
|
||||
# Wallpapers
|
||||
add: str = ""
|
||||
sort: str = ""
|
||||
circle: str = ""
|
||||
|
||||
# Chevrons
|
||||
chevron_up: str = ""
|
||||
chevron_down: str = ""
|
||||
chevron_left: str = ""
|
||||
chevron_right: str = ""
|
||||
|
||||
# Power
|
||||
lock: str = ""
|
||||
suspend: str = ""
|
||||
logout: str = ""
|
||||
reboot: str = ""
|
||||
shutdown: str = ""
|
||||
|
||||
# Power Manager
|
||||
power_saving: str = ""
|
||||
power_balanced: str = "勺"
|
||||
power_performance: str = ""
|
||||
charging: str = ""
|
||||
discharging: str = ""
|
||||
alert: str = ""
|
||||
bat_charging: str = ""
|
||||
bat_discharging: str = ""
|
||||
bat_low: str = "="
|
||||
bat_full: str = ""
|
||||
|
||||
|
||||
# Applets
|
||||
wifi_0: str = ""
|
||||
wifi_1: str = ""
|
||||
wifi_2: str = ""
|
||||
wifi_3: str = ""
|
||||
world: str = ""
|
||||
world_off: str = ""
|
||||
bluetooth: str = ""
|
||||
night: str = ""
|
||||
coffee: str = ""
|
||||
notifications: str = ""
|
||||
|
||||
wifi_off: str = ""
|
||||
bluetooth_off: str = ""
|
||||
night_off: str = ""
|
||||
notifications_off: str = ""
|
||||
|
||||
notifications_clear: str = ""
|
||||
|
||||
download: str = ""
|
||||
upload: str = ""
|
||||
|
||||
# Bluetooth
|
||||
bluetooth_connected: str = ""
|
||||
bluetooth_disconnected: str = ""
|
||||
|
||||
# Player
|
||||
pause: str = ""
|
||||
play: str = ""
|
||||
stop: str = ""
|
||||
skip_back: str = ""
|
||||
skip_forward: str = ""
|
||||
prev: str = ""
|
||||
next: str = ""
|
||||
shuffle: str = ""
|
||||
repeat: str = ""
|
||||
music: str = ""
|
||||
rewind_backward_5: str = "謹"
|
||||
rewind_forward_5: str = "難"
|
||||
|
||||
# Volume
|
||||
vol_off: str = ""
|
||||
vol_mute: str = ""
|
||||
vol_medium: str = ""
|
||||
vol_high: str = ""
|
||||
|
||||
mic: str = ""
|
||||
mic_mute: str = ""
|
||||
|
||||
speaker: str = "𐁅"
|
||||
headphones: str = "屮"
|
||||
mic_filled: str = "️"
|
||||
|
||||
# Overview
|
||||
circle_plus: str = ""
|
||||
|
||||
# Pins
|
||||
paperclip: str = ""
|
||||
|
||||
# Clipboard Manager
|
||||
clipboard: str = ""
|
||||
clip_text: str = ""
|
||||
|
||||
# Confirm
|
||||
accept: str = ""
|
||||
cancel: str = ""
|
||||
trash: str = ""
|
||||
|
||||
# Config
|
||||
config: str = ""
|
||||
|
||||
# Icons
|
||||
firefox: str = ""
|
||||
chromium: str = ""
|
||||
spotify: str = "ﺆ"
|
||||
disc: str = "𐀾"
|
||||
disc_off: str = ""
|
||||
|
||||
# Brightness
|
||||
brightness_low: str = ""
|
||||
brightness_medium: str = ""
|
||||
brightness_high: str = ""
|
||||
|
||||
brightness: str = ""
|
||||
|
||||
# Dashboard
|
||||
widgets: str = ""
|
||||
pins: str = ""
|
||||
kanban: str = ""
|
||||
wallpapers: str = ""
|
||||
sparkles: str = ""
|
||||
|
||||
# Misc
|
||||
dot: str = ""
|
||||
palette: str = ""
|
||||
cloud_off: str = ""
|
||||
loader: str = ""
|
||||
radar: str = ""
|
||||
emoji: str = ""
|
||||
keyboard: str = ""
|
||||
terminal: str = ""
|
||||
timer_off: str = ""
|
||||
timer_on: str = ""
|
||||
spy: str = ""
|
||||
|
||||
# Dice
|
||||
dice_1: str = ""
|
||||
dice_2: str = ""
|
||||
dice_3: str = ""
|
||||
dice_4: str = ""
|
||||
dice_5: str = ""
|
||||
dice_6: str = ""
|
||||
|
||||
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()
|
||||
@@ -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}")
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
UPower wrapper using DBus
|
||||
Copyright (c) 2017 Oscar Svensson (wogscpar)
|
||||
https://github.com/wogscpar/upower_python
|
||||
"""
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user