Files
SDG-DRIFT/Ax_Shell/modules/notch.py
T
2026-06-03 21:26:54 +02:00

1447 lines
52 KiB
Python

from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow
from fabric.utils.helpers import FormattedString, get_desktop_applications
from fabric.widgets.box import Box
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.revealer import Revealer
from fabric.widgets.stack import Stack
from fabric.audio.service import Audio
from gi.repository import Gdk, GLib, Gtk, Pango
import config.data as data
from modules.cliphist import ClipHistory
from modules.corners import MyCorner
from modules.dashboard import Dashboard
from modules.emoji import EmojiPicker
from modules.launcher import AppLauncher
from modules.overview import Overview
from modules.player import PlayerSmall
from modules.power import PowerMenu
from modules.tmux import TmuxManager
from modules.tools import Toolbox
from utils.icon_resolver import IconResolver
from utils.occlusion import check_occlusion
from widgets.wayland import WaylandWindow as Window
class Notch(Window):
def __init__(self, monitor_id: int = 0, **kwargs):
self.monitor_id = monitor_id
self.monitor_manager = None
# Get monitor manager
try:
from utils.monitor_manager import get_monitor_manager
self.monitor_manager = get_monitor_manager()
except ImportError:
pass
is_panel_vertical = False
if data.PANEL_THEME == "Panel":
is_panel_vertical = data.VERTICAL
anchor_val = "top"
revealer_transition_type = "slide-down"
if data.PANEL_THEME == "Notch":
anchor_val = "top"
revealer_transition_type = "slide-down"
elif data.PANEL_THEME == "Panel":
if is_panel_vertical:
if data.BAR_POSITION == "Left":
match data.PANEL_POSITION:
case "Start":
anchor_val = "left top"
revealer_transition_type = "slide-right"
case "Center":
anchor_val = "left"
revealer_transition_type = "slide-right"
case "End":
anchor_val = "left bottom"
revealer_transition_type = "slide-right"
case _:
anchor_val = "left"
revealer_transition_type = "slide-right"
elif data.BAR_POSITION == "Right":
match data.PANEL_POSITION:
case "Start":
anchor_val = "right top"
revealer_transition_type = "slide-left"
case "Center":
anchor_val = "right"
revealer_transition_type = "slide-left"
case "End":
anchor_val = "right bottom"
revealer_transition_type = "slide-left"
case _:
anchor_val = "right"
revealer_transition_type = "slide-left"
else:
if data.BAR_POSITION == "Top":
match data.PANEL_POSITION:
case "Start":
anchor_val = "top left"
revealer_transition_type = "slide-down"
case "Center":
anchor_val = "top"
revealer_transition_type = "slide-down"
case "End":
anchor_val = "top right"
revealer_transition_type = "slide-down"
case _:
anchor_val = "top"
revealer_transition_type = "slide-down"
elif data.BAR_POSITION == "Bottom":
match data.PANEL_POSITION:
case "Start":
anchor_val = "bottom left"
revealer_transition_type = "slide-up"
case "Center":
anchor_val = "bottom"
revealer_transition_type = "slide-up"
case "End":
anchor_val = "bottom right"
revealer_transition_type = "slide-up"
case _:
anchor_val = "bottom"
revealer_transition_type = "slide-up"
default_top_anchor_margin_str = "-40px 8px 8px 8px"
pills_margin_top_str = "-40px 0px 0px 0px"
dense_edge_margin_top_str = "-46px 0px 0px 0px"
current_margin_str = ""
if data.PANEL_THEME == "Panel":
current_margin_str = "0px 0px 0px 0px"
else:
if data.VERTICAL:
current_margin_str = "0px 0px 0px 0px"
else:
if data.BAR_POSITION == "Bottom":
current_margin_str = "0px 0px 0px 0px"
else:
match data.BAR_THEME:
case "Pills":
current_margin_str = pills_margin_top_str
case "Dense" | "Edge":
current_margin_str = dense_edge_margin_top_str
case _:
current_margin_str = default_top_anchor_margin_str
super().__init__(
name="notch",
layer="overlay",
anchor=anchor_val,
margin=current_margin_str,
keyboard_mode="none",
exclusivity="none" if data.PANEL_THEME == "Notch" else "normal",
visible=True,
all_visible=True,
monitor=monitor_id,
)
# Audio display variables
self.VOLUME_DISPLAY_DURATION = 2000
self._current_display_timeout_id = None
self._suppress_first_audio_display = True
self._typed_chars_buffer = ""
self._launcher_transitioning = False
self._launcher_transition_timeout = None
self.bar = kwargs.get("bar", None)
self.is_hovered = False
self.connect("realize", self._on_realize)
self._prevent_occlusion = False
self._occlusion_timer_id = None
self._forced_occlusion = False
self.icon_resolver = IconResolver()
self._all_apps = get_desktop_applications()
self.app_identifiers = self._build_app_identifiers_map()
self.dashboard = Dashboard(notch=self)
self.nhistory = self.dashboard.widgets.notification_history
self.applet_stack = self.dashboard.widgets.applet_stack
self.btdevices = self.dashboard.widgets.bluetooth
self.nwconnections = self.dashboard.widgets.network_connections
self.btdevices.set_visible(False)
self.nwconnections.set_visible(False)
self.launcher = AppLauncher(notch=self)
self.overview = Overview(monitor_id=monitor_id)
self.emoji = EmojiPicker(notch=self)
self.power = PowerMenu(notch=self)
self.tmux = TmuxManager(notch=self)
self.cliphist = ClipHistory(notch=self)
# Audio service initialization
self.audio = Audio()
# Volume display widgets
self.volume_icon = Image(
name="volume-display-icon",
icon_name="audio-volume-high-symbolic",
icon_size=16
)
self.volume_icon.set_valign(Gtk.Align.CENTER)
self.volume_label = Label(
name="volume-display-label",
label="..."
)
self.volume_label.set_valign(Gtk.Align.CENTER)
self.volume_bar = Gtk.ProgressBar(
name="volume-display-bar"
)
self.volume_bar.set_fraction(1.0)
self.volume_bar.set_show_text(False)
self.volume_bar.set_hexpand(False)
self.volume_bar.set_valign(Gtk.Align.CENTER)
self.volume_box = Box(
name="volume-display-box",
orientation="h",
spacing=8,
h_align="center",
v_align="center",
children=[
self.volume_icon,
self.volume_bar,
self.volume_label
]
)
# Microphone display widgets
self.mic_icon = Image(
name="mic-display-icon",
icon_name="microphone-sensitivity-high-symbolic",
icon_size=16
)
self.mic_icon.set_valign(Gtk.Align.CENTER)
self.mic_label = Label(
name="mic-display-label",
label="..."
)
self.mic_label.set_valign(Gtk.Align.CENTER)
self.mic_bar = Gtk.ProgressBar(
name="mic-display-bar"
)
self.mic_bar.set_fraction(1.0)
self.mic_bar.set_show_text(False)
self.mic_bar.set_valign(Gtk.Align.CENTER)
self.mic_box = Box(
name="mic-display-box",
orientation="h",
spacing=8,
h_align="center",
v_align="center",
children=[
self.mic_icon,
self.mic_bar,
self.mic_label
]
)
self.window_label = Label(
name="notch-window-label",
h_expand=True,
h_align="fill",
)
self.window_icon = Image(
name="notch-window-icon", icon_name="application-x-executable", icon_size=20
)
self.active_window = ActiveWindow(
name="hyprland-window",
h_expand=True,
h_align="fill",
formatter=FormattedString(
f"{{'Desktop' if not win_title or win_title == 'unknown' else win_title}}",
),
)
self.active_window_box = CenterBox(
name="active-window-box",
h_expand=True,
h_align="fill",
start_children=self.window_icon,
center_children=self.active_window,
end_children=None,
)
self.active_window_box.connect(
"button-press-event",
lambda widget, event: (self.open_notch("dashboard"), False)[1],
)
self.active_window.connect("notify::label", self.update_window_icon)
if data.PANEL_THEME == "Notch":
self.active_window.connect("notify::label", self.on_active_window_changed)
self.active_window.get_children()[0].set_hexpand(True)
self.active_window.get_children()[0].set_halign(Gtk.Align.FILL)
self.active_window.get_children()[0].set_ellipsize(Pango.EllipsizeMode.END)
self.active_window.connect(
"notify::label", lambda *_: self.restore_label_properties()
)
self.player_small = PlayerSmall()
self.user_label = Label(
name="compact-user", label=f"{data.USERNAME}@{data.HOSTNAME}"
)
self.player_small.mpris_manager.connect(
"player-appeared",
lambda *_: self.compact_stack.set_visible_child(self.player_small),
)
self.player_small.mpris_manager.connect(
"player-vanished", self.on_player_vanished
)
self.compact_stack = Stack(
name="notch-compact-stack",
v_expand=True,
h_expand=True,
transition_type="slide-up-down",
transition_duration=100,
children=[
self.user_label,
self.active_window_box,
self.player_small,
# Add audio display widgets to compact stack
self.volume_box,
self.mic_box,
],
)
self.compact_stack.set_visible_child(self.active_window_box)
self.compact = Gtk.EventBox(name="notch-compact")
self.compact.set_visible(True)
self.compact.add(self.compact_stack)
self.compact.add_events(
Gdk.EventMask.SCROLL_MASK
| Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.SMOOTH_SCROLL_MASK
)
self.compact.connect("scroll-event", self._on_compact_scroll)
self.compact.connect(
"button-press-event",
lambda widget, event: (self.open_notch("dashboard"), False)[1],
)
self.compact.connect("enter-notify-event", self.on_button_enter)
self.compact.connect("leave-notify-event", self.on_button_leave)
self.tools = Toolbox(notch=self)
self.stack = Stack(
name="notch-content",
v_expand=True,
h_expand=True,
style_classes=["invert"]
if (not data.VERTICAL and data.BAR_THEME in ["Dense", "Edge"])
and data.BAR_POSITION not in ["Bottom"]
else [],
transition_type="crossfade",
transition_duration=250,
children=[
self.compact,
self.launcher,
self.dashboard,
self.overview,
self.emoji,
self.power,
self.tools,
self.tmux,
self.cliphist,
],
)
if data.PANEL_THEME == "Panel":
self.stack.add_style_class("panel")
self.stack.add_style_class(data.BAR_POSITION.lower())
self.stack.add_style_class(data.PANEL_POSITION.lower())
if is_panel_vertical or (
data.PANEL_POSITION in ["Start", "End"] and data.PANEL_THEME == "Panel"
):
self.compact.set_size_request(260, 40)
self.launcher.set_size_request(320, 635)
self.tmux.set_size_request(320, 635)
self.cliphist.set_size_request(320, 635)
self.dashboard.set_size_request(410, 900)
else:
self.compact.set_size_request(260, 40)
self.launcher.set_size_request(480, 244)
self.tmux.set_size_request(480, 244)
self.cliphist.set_size_request(480, 244)
self.dashboard.set_size_request(1093, 472)
self.stack.set_interpolate_size(True)
self.stack.set_homogeneous(False)
self.corner_left = Box(
name="notch-corner-left",
orientation="v",
h_align="start",
children=[MyCorner("top-right")],
)
self.corner_right = Box(
name="notch-corner-right",
orientation="v",
h_align="end",
children=[MyCorner("top-left")],
)
self.notch_box = CenterBox(
name="notch-box",
orientation="h",
h_align="center",
v_align="center",
start_children=self.corner_left,
center_children=self.stack,
end_children=self.corner_right,
)
self.notch_box.add_style_class(data.PANEL_THEME.lower())
self.notch_revealer = Revealer(
name="notch-revealer",
transition_type=revealer_transition_type,
transition_duration=250,
child_revealed=True,
child=self.notch_box,
)
self.notch_revealer.set_size_request(-1, 1)
self.notch_complete = Box(
name="notch-complete",
orientation="v" if is_panel_vertical else "h",
children=[self.notch_revealer],
)
self._is_notch_open = False
self._scrolling = False
if data.VERTICAL:
vert_comp_size = {
"Pills": 38,
"Dense": 50,
"Edge": 44,
}.get(data.BAR_THEME, 38)
if is_panel_vertical:
vert_comp_size = 1
self.vert_comp_left = Box(name="vert-comp")
self.vert_comp_left.set_size_request(vert_comp_size, 0)
self.vert_comp_left.set_sensitive(False)
self.vert_comp_right = Box(name="vert-comp")
self.vert_comp_right.set_size_request(vert_comp_size, 0)
self.vert_comp_right.set_sensitive(False)
self.notch_children = [
self.vert_comp_left,
self.notch_complete,
self.vert_comp_right,
]
else:
self.notch_children = [self.notch_complete]
self.notch_wrap = Box(
name="notch-wrap",
children=self.notch_children,
)
# Create top-level EventBox that wraps the entire notch for hover detection
if data.PANEL_THEME == "Notch":
self.hover_eventbox = Gtk.EventBox(name="notch-hover-eventbox")
self.hover_eventbox.add(self.notch_wrap)
self.hover_eventbox.set_visible(True)
# Set minimum size to ensure hover detection area is always available
self.hover_eventbox.set_size_request(260, 4) # Width matches compact size, min height for hover
self.hover_eventbox.add_events(
Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK
)
self.hover_eventbox.connect(
"enter-notify-event", self.on_notch_hover_area_enter
)
self.hover_eventbox.connect(
"leave-notify-event", self.on_notch_hover_area_leave
)
self.add(self.hover_eventbox)
else:
self.add(self.notch_wrap)
self.show_all()
# Connect audio signals after a short delay
GLib.timeout_add(100, self._connect_audio_signals)
self.add_keybinding("Escape", lambda *_: self.close_notch())
self.add_keybinding("Ctrl Tab", lambda *_: self.dashboard.go_to_next_child())
self.add_keybinding(
"Ctrl Shift ISO_Left_Tab", lambda *_: self.dashboard.go_to_previous_child()
)
self.update_window_icon()
self.active_window.connect(
"button-press-event",
lambda widget, event: (self.open_notch("dashboard"), False)[1],
)
if data.PANEL_THEME != "Notch":
for corner in [self.corner_left, self.corner_right]:
corner.set_visible(False)
self._current_window_class = self._get_current_window_class()
# Always enable occlusion detection for fullscreen windows
GLib.timeout_add(500, self._check_occlusion)
if data.PANEL_THEME == "Notch":
self.notch_revealer.set_reveal_child(True)
else:
self.notch_revealer.set_reveal_child(False)
self.connect("key-press-event", self.on_key_press)
# Audio-related methods
def _connect_audio_signals(self, retry_count=0):
max_retries = 5
try:
if self.audio:
self.audio.connect("notify::speaker", self._on_speaker_changed)
self.audio.connect("notify::microphone", self._on_microphone_changed)
if self.audio.speaker:
self.audio.speaker.connect("changed", self._on_speaker_changed_signal)
GLib.idle_add(self._update_volume_widgets_silently)
if self.audio.microphone:
self.audio.microphone.connect("changed", self._on_microphone_changed_signal)
GLib.idle_add(self._update_mic_widgets_silently)
GLib.timeout_add(500, self._enable_audio_display)
return False
except Exception as e:
print(f"Audio connection error (attempt {retry_count + 1}): {e}")
if retry_count < max_retries - 1:
GLib.timeout_add(1000, lambda: self._connect_audio_signals(retry_count + 1))
return False
def _on_speaker_changed(self, audio_service, speaker):
if self.audio.speaker:
try:
self.audio.speaker.disconnect_by_func(self._on_speaker_changed_signal)
except:
pass
self.audio.speaker.connect("changed", self._on_speaker_changed_signal)
self._update_volume_widgets_silently()
def _on_microphone_changed(self, audio_service, microphone):
if self.audio.microphone:
try:
self.audio.microphone.disconnect_by_func(self._on_microphone_changed_signal)
except:
pass
self.audio.microphone.connect("changed", self._on_microphone_changed_signal)
self._update_mic_widgets_silently()
def _on_speaker_changed_signal(self, speaker, *args):
self._handle_speaker_change()
def _on_microphone_changed_signal(self, microphone, *args):
self._handle_microphone_change()
def _handle_speaker_change(self):
if not self.audio or not self.audio.speaker:
return
if self._suppress_first_audio_display:
self._update_volume_widgets_silently()
return
speaker = self.audio.speaker
volume = speaker.volume
is_muted = speaker.muted
volume_int = int(round(volume))
volume_percentage = volume_int / 100.0
self.volume_bar.set_fraction(volume_percentage)
self._update_volume_appearance(volume_int, is_muted)
if is_muted:
self.volume_icon.set_from_icon_name("audio-volume-muted-symbolic", 16)
self.volume_label.set_text("Muted")
elif volume_int == 0:
self.volume_icon.set_from_icon_name("audio-volume-muted-symbolic", 16)
self.volume_label.set_text("Muted")
else:
if volume_int <= 33:
icon_name = "audio-volume-low-symbolic"
elif volume_int <= 66:
icon_name = "audio-volume-medium-symbolic"
else:
icon_name = "audio-volume-high-symbolic"
self.volume_icon.set_from_icon_name(icon_name, 16)
self.volume_label.set_text(f"{volume_int}%")
if not self._is_notch_open:
self.show_volume_display()
def _handle_microphone_change(self):
if not self.audio or not self.audio.microphone:
return
if self._suppress_first_audio_display:
self._update_mic_widgets_silently()
return
microphone = self.audio.microphone
volume = microphone.volume
is_muted = microphone.muted
volume_int = int(round(volume))
volume_percentage = volume_int / 100.0
self.mic_bar.set_fraction(volume_percentage)
self._update_mic_appearance(volume_int, is_muted)
if is_muted:
self.mic_icon.set_from_icon_name("microphone-disabled-symbolic", 16)
self.mic_label.set_text("Muted")
else:
self.mic_icon.set_from_icon_name("microphone-sensitivity-high-symbolic", 16)
self.mic_label.set_text(f"{volume_int}%")
if not self._is_notch_open:
self.show_mic_display()
def _enable_audio_display(self):
self._suppress_first_audio_display = False
return False
def _update_volume_appearance(self, volume_int, is_muted):
volume_box_style = self.volume_box.get_style_context()
volume_icon_style = self.volume_icon.get_style_context()
volume_bar_style = self.volume_bar.get_style_context()
for cls in ["volume-muted", "volume-low", "volume-medium", "volume-high"]:
volume_box_style.remove_class(cls)
volume_icon_style.remove_class(cls)
volume_bar_style.remove_class(cls)
if is_muted or volume_int == 0:
volume_box_style.add_class("volume-muted")
volume_icon_style.add_class("volume-muted")
volume_bar_style.add_class("volume-muted")
elif volume_int <= 33:
volume_box_style.add_class("volume-low")
volume_icon_style.add_class("volume-low")
volume_bar_style.add_class("volume-low")
elif volume_int <= 66:
volume_box_style.add_class("volume-medium")
volume_icon_style.add_class("volume-medium")
volume_bar_style.add_class("volume-medium")
else:
volume_box_style.add_class("volume-high")
volume_icon_style.add_class("volume-high")
volume_bar_style.add_class("volume-high")
def _update_mic_appearance(self, volume_int, is_muted):
mic_box_style = self.mic_box.get_style_context()
mic_icon_style = self.mic_icon.get_style_context()
mic_bar_style = self.mic_bar.get_style_context()
for cls in ["mic-muted", "mic-low", "mic-medium", "mic-high"]:
mic_box_style.remove_class(cls)
mic_icon_style.remove_class(cls)
mic_bar_style.remove_class(cls)
if is_muted:
mic_box_style.add_class("mic-muted")
mic_icon_style.add_class("mic-muted")
mic_bar_style.add_class("mic-muted")
elif volume_int <= 33:
mic_box_style.add_class("mic-low")
mic_icon_style.add_class("mic-low")
mic_bar_style.add_class("mic-low")
elif volume_int <= 66:
mic_box_style.add_class("mic-medium")
mic_icon_style.add_class("mic-medium")
mic_bar_style.add_class("mic-medium")
else:
mic_box_style.add_class("mic-high")
mic_icon_style.add_class("mic-high")
mic_bar_style.add_class("mic-high")
def _update_volume_widgets_silently(self):
if not self.audio or not self.audio.speaker:
return
speaker = self.audio.speaker
volume = speaker.volume
is_muted = speaker.muted
volume_int = int(round(volume))
volume_percentage = volume_int / 100.0
self.volume_bar.set_fraction(volume_percentage)
self._update_volume_appearance(volume_int, is_muted)
if is_muted:
self.volume_icon.set_from_icon_name("audio-volume-muted-symbolic", 16)
self.volume_label.set_text("Muted")
elif volume_int == 0:
self.volume_icon.set_from_icon_name("audio-volume-muted-symbolic", 16)
self.volume_label.set_text("Muted")
else:
if volume_int <= 33:
icon_name = "audio-volume-low-symbolic"
elif volume_int <= 66:
icon_name = "audio-volume-medium-symbolic"
else:
icon_name = "audio-volume-high-symbolic"
self.volume_icon.set_from_icon_name(icon_name, 16)
self.volume_label.set_text(f"{volume_int}%")
def _update_mic_widgets_silently(self):
if not self.audio or not self.audio.microphone:
return
microphone = self.audio.microphone
volume = microphone.volume
is_muted = microphone.muted
volume_int = int(round(volume))
volume_percentage = volume_int / 100.0
self.mic_bar.set_fraction(volume_percentage)
self._update_mic_appearance(volume_int, is_muted)
if is_muted:
self.mic_icon.set_from_icon_name("microphone-disabled-symbolic", 16)
self.mic_label.set_text(" Muted")
else:
self.mic_icon.set_from_icon_name("microphone-sensitivity-high-symbolic", 16)
self.mic_label.set_text(f" {volume_int}%")
def show_volume_display(self):
if self._is_notch_open:
return
if self._current_display_timeout_id:
GLib.source_remove(self._current_display_timeout_id)
self.compact_stack.set_visible_child(self.volume_box)
self._current_display_timeout_id = GLib.timeout_add(
self.VOLUME_DISPLAY_DURATION,
self.return_to_normal_view
)
def show_mic_display(self):
if self._is_notch_open:
return
if self._current_display_timeout_id:
GLib.source_remove(self._current_display_timeout_id)
self.compact_stack.set_visible_child(self.mic_box)
self._current_display_timeout_id = GLib.timeout_add(
self.VOLUME_DISPLAY_DURATION,
self.return_to_normal_view
)
def return_to_normal_view(self):
self._current_display_timeout_id = None
if not self._is_notch_open:
current_child = self.compact_stack.get_visible_child()
if current_child in [self.volume_box, self.mic_box]:
self.compact_stack.set_visible_child(self.active_window_box)
return False
def on_button_enter(self, widget, event):
self.is_hovered = True
window = widget.get_window()
if window:
window.set_cursor(Gdk.Cursor(Gdk.CursorType.HAND2))
return True
def on_button_leave(self, widget, event):
if event.detail == Gdk.NotifyType.INFERIOR:
return False
self.is_hovered = False
window = widget.get_window()
if window:
window.set_cursor(None)
return True
def _on_realize(self, widget):
"""Ensure the notch window is raised above the bar."""
self.get_window().raise_()
def on_notch_hover_area_enter(self, widget, event):
"""Handle hover enter for the entire notch area"""
self.is_hovered = True
if data.PANEL_THEME == "Notch" and data.BAR_POSITION != "Top":
self.notch_revealer.set_reveal_child(True)
return False
def on_notch_hover_area_leave(self, widget, event):
"""Handle hover leave for the entire notch area"""
if event.detail == Gdk.NotifyType.INFERIOR:
return False
self.is_hovered = False
return False
def close_notch(self):
if self.monitor_manager:
self.monitor_manager.set_notch_state(self.monitor_id, False)
self.set_keyboard_mode("none")
self.notch_box.remove_style_class("open")
self.stack.remove_style_class("open")
self.bar.revealer_right.set_reveal_child(True)
self.bar.revealer_left.set_reveal_child(True)
self.applet_stack.set_visible_child(self.nhistory)
self._is_notch_open = False
self.stack.set_visible_child(self.compact)
if data.PANEL_THEME != "Notch":
self.notch_revealer.set_reveal_child(False)
if self.bar and not self.bar.get_visible() and data.BAR_POSITION == "Top":
if data.BAR_THEME == "Pills":
self.set_margin("-40px 0px 0px 0px")
elif data.BAR_THEME in ["Dense", "Edge"]:
self.set_margin("-46px 0px 0px 0px")
else:
self.set_margin("-40px 8px 8px 8px")
def open_notch(self, widget_name: str):
# Debug info for troubleshooting
if hasattr(self, '_debug_monitor_focus') and self._debug_monitor_focus:
print(f"DEBUG: open_notch called on monitor {self.monitor_id} for widget '{widget_name}'")
# Handle monitor focus switching - always check real focused monitor from Hyprland
if self.monitor_manager:
# Get real focused monitor directly from Hyprland to ensure accuracy
real_focused_monitor_id = self._get_real_focused_monitor_id()
# Update monitor manager if we got a valid result
if real_focused_monitor_id is not None:
# Update the monitor manager's focused monitor
self.monitor_manager._focused_monitor_id = real_focused_monitor_id
if hasattr(self, '_debug_monitor_focus') and self._debug_monitor_focus:
print(f"DEBUG: Real focused monitor from Hyprland: {real_focused_monitor_id}")
focused_monitor_id = self.monitor_manager.get_focused_monitor_id()
focused_notch = self.monitor_manager.get_instance(focused_monitor_id, 'notch')
# Close notches on other monitors
self.monitor_manager.close_all_notches_except(focused_monitor_id)
if focused_notch and hasattr(focused_notch, 'open_notch'):
# Open notch on focused monitor
focused_notch._open_notch_internal(widget_name)
self.monitor_manager.set_notch_state(focused_monitor_id, True, widget_name)
def _get_real_focused_monitor_id(self):
"""Get the real focused monitor ID directly from Hyprland."""
# Use thread to avoid blocking UI
self._focused_monitor_result = None
GLib.Thread.new("get-focused-monitor", self._get_focused_monitor_thread, None)
# Wait for result (not ideal, but for compatibility)
import time
start = time.time()
while self._focused_monitor_result is None and time.time() - start < 2.0:
time.sleep(0.01)
return self._focused_monitor_result
def _get_focused_monitor_thread(self, user_data):
try:
import json
import subprocess
# Get focused monitor from Hyprland
result = subprocess.run(
["hyprctl", "monitors", "-j"],
capture_output=True,
text=True,
check=True,
timeout=2.0
)
monitors = json.loads(result.stdout)
for i, monitor in enumerate(monitors):
if monitor.get('focused', False):
self._focused_monitor_result = i
return
except (subprocess.CalledProcessError, json.JSONDecodeError,
FileNotFoundError, subprocess.TimeoutExpired) as e:
print(f"Warning: Could not get focused monitor from Hyprland: {e}")
self._focused_monitor_result = None
def _open_notch_internal(self, widget_name: str):
self.notch_revealer.set_reveal_child(True)
self.notch_box.add_style_class("open")
self.stack.add_style_class("open")
current_stack_child = self.stack.get_visible_child()
is_dashboard_currently_visible = current_stack_child == self.dashboard
if widget_name == "network_applet":
if is_dashboard_currently_visible:
if (
self.dashboard.stack.get_visible_child() == self.dashboard.widgets
and self.applet_stack.get_visible_child() == self.nwconnections
):
self.close_notch()
return
self.set_keyboard_mode("exclusive")
self.dashboard.go_to_section("widgets")
self.applet_stack.set_visible_child(self.nwconnections)
return
elif widget_name == "bluetooth":
if is_dashboard_currently_visible:
if (
self.dashboard.stack.get_visible_child() == self.dashboard.widgets
and self.applet_stack.get_visible_child() == self.btdevices
):
self.close_notch()
return
self.set_keyboard_mode("exclusive")
self.dashboard.go_to_section("widgets")
self.applet_stack.set_visible_child(self.btdevices)
return
elif widget_name == "dashboard":
if is_dashboard_currently_visible:
if (
self.dashboard.stack.get_visible_child() == self.dashboard.widgets
and self.applet_stack.get_visible_child() == self.nhistory
):
self.close_notch()
return
self.set_keyboard_mode("exclusive")
self.dashboard.go_to_section("widgets")
self.applet_stack.set_visible_child(self.nhistory)
return
dashboard_sections_map = {
"pins": self.dashboard.pins,
"kanban": self.dashboard.kanban,
"wallpapers": self.dashboard.wallpapers,
"mixer": self.dashboard.mixer,
}
if widget_name in dashboard_sections_map:
section_widget_instance = dashboard_sections_map[widget_name]
if (
is_dashboard_currently_visible
and self.dashboard.stack.get_visible_child() == section_widget_instance
):
self.close_notch()
return
target_widget_on_stack = None
action_on_open = None
focus_action = None
hide_bar_revealers = False
widget_configs = {
"tmux": {"instance": self.tmux, "action": self.tmux.open_manager},
"cliphist": {
"instance": self.cliphist,
"action": lambda: GLib.idle_add(self.cliphist.open),
},
"launcher": {
"instance": self.launcher,
"action": self.launcher.open_launcher,
"focus": lambda: (
self.launcher.search_entry.set_text(""),
self.launcher.search_entry.grab_focus(),
),
},
"emoji": {
"instance": self.emoji,
"action": self.emoji.open_picker,
"focus": lambda: (
self.emoji.search_entry.set_text(""),
self.emoji.search_entry.grab_focus(),
),
},
"overview": {"instance": self.overview, "hide_revealers": True},
"power": {"instance": self.power},
"tools": {"instance": self.tools},
}
if widget_name in widget_configs:
config = widget_configs[widget_name]
target_widget_on_stack = config["instance"]
action_on_open = config.get("action")
focus_action = config.get("focus")
hide_bar_revealers = config.get("hide_revealers", False)
if current_stack_child == target_widget_on_stack:
self.close_notch()
return
else:
target_widget_on_stack = self.dashboard
hide_bar_revealers = True
self.set_keyboard_mode("exclusive")
self.stack.set_visible_child(target_widget_on_stack)
if action_on_open:
action_on_open()
if focus_action:
focus_action()
if target_widget_on_stack == self.dashboard:
if widget_name == "bluetooth":
self.dashboard.go_to_section("widgets")
self.applet_stack.set_visible_child(self.btdevices)
elif widget_name == "network_applet":
self.dashboard.go_to_section("widgets")
self.applet_stack.set_visible_child(self.nwconnections)
elif widget_name in dashboard_sections_map:
self.dashboard.go_to_section(widget_name)
elif widget_name == "dashboard":
self.dashboard.go_to_section("widgets")
self.applet_stack.set_visible_child(self.nhistory)
if (
data.BAR_POSITION in ["Top", "Bottom"]
and data.PANEL_THEME == "Panel"
or data.BAR_POSITION in ["Bottom"]
and data.PANEL_THEME == "Notch"
):
self.bar.revealer_right.set_reveal_child(True)
self.bar.revealer_left.set_reveal_child(True)
else:
self.bar.revealer_right.set_reveal_child(not hide_bar_revealers)
self.bar.revealer_left.set_reveal_child(not hide_bar_revealers)
if self.bar and not self.bar.get_visible() and data.BAR_POSITION == "Top":
self.set_margin("0px 8px 8px 8px")
self._is_notch_open = True
def toggle_hidden(self):
self.hidden = not self.hidden
self.set_visible(not self.hidden)
def _on_compact_scroll(self, widget, event):
if self._scrolling:
return True
children = self.compact_stack.get_children()
current = children.index(self.compact_stack.get_visible_child())
new_index = current
if event.direction == Gdk.ScrollDirection.SMOOTH:
if event.delta_y < -0.1:
new_index = (current - 1) % len(children)
elif event.delta_y > 0.1:
new_index = (current + 1) % len(children)
else:
return False
elif event.direction == Gdk.ScrollDirection.UP:
new_index = (current - 1) % len(children)
elif event.direction == Gdk.ScrollDirection.DOWN:
new_index = (current + 1) % len(children)
else:
return False
self.compact_stack.set_visible_child(children[new_index])
self._scrolling = True
GLib.timeout_add(500, self._reset_scrolling)
return True
def _reset_scrolling(self):
self._scrolling = False
return False
def on_player_vanished(self, *args):
if self.player_small.mpris_label.get_label() == "Nothing Playing":
self.compact_stack.set_visible_child(self.active_window_box)
def restore_label_properties(self):
label = self.active_window.get_children()[0]
if isinstance(label, Gtk.Label):
label.set_ellipsize(Pango.EllipsizeMode.END)
label.set_hexpand(True)
label.set_halign(Gtk.Align.FILL)
label.queue_resize()
self.update_window_icon()
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:
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:
exe_basename = app.executable.split("/")[-1].lower()
identifiers[exe_basename] = app
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_id: str):
"""Find a DesktopApp object by various identifiers using the pre-built map."""
normalized_id = app_id.lower()
return self.app_identifiers.get(normalized_id)
def update_window_icon(self, *args):
"""Update the window icon based on the current active window title"""
label_widget = self.active_window.get_children()[0]
if not isinstance(label_widget, Gtk.Label):
return
title = label_widget.get_text()
if title == "Desktop" or not title:
self.window_icon.set_visible(False)
return
self.window_icon.set_visible(True)
from fabric.hyprland.widgets import get_hyprland_connection
conn = get_hyprland_connection()
if conn:
try:
import json
active_window_json = conn.send_command("j/activewindow").reply.decode()
active_window_data = json.loads(active_window_json)
app_id = active_window_data.get(
"initialClass", ""
) or active_window_data.get("class", "")
icon_size = 20
desktop_app = self.find_app(app_id)
icon_pixbuf = None
if desktop_app:
icon_pixbuf = desktop_app.get_icon_pixbuf(size=icon_size)
if not icon_pixbuf:
icon_pixbuf = self.icon_resolver.get_icon_pixbuf(app_id, icon_size)
if not icon_pixbuf and "-" in app_id:
base_app_id = app_id.split("-")[0]
icon_pixbuf = self.icon_resolver.get_icon_pixbuf(
base_app_id, icon_size
)
if icon_pixbuf:
self.window_icon.set_from_pixbuf(icon_pixbuf)
else:
try:
self.window_icon.set_from_icon_name(
"application-x-executable", 20
)
except:
self.window_icon.set_from_icon_name(
"application-x-executable-symbolic", 20
)
except Exception as e:
print(f"Error updating window icon: {e}")
try:
self.window_icon.set_from_icon_name("application-x-executable", 20)
except:
self.window_icon.set_from_icon_name(
"application-x-executable-symbolic", 20
)
else:
try:
self.window_icon.set_from_icon_name("application-x-executable", 20)
except:
self.window_icon.set_from_icon_name(
"application-x-executable-symbolic", 20
)
def _check_occlusion(self):
"""
Check if top 40px of the screen is occluded by any window
and update the notch_revealer accordingly.
"""
occlusion_edge = "top"
occlusion_size = 40
if self._forced_occlusion:
# When forced occlusion is active, show only on hover
self.notch_revealer.set_reveal_child(self.is_hovered)
elif not (self.is_hovered or self._is_notch_open or self._prevent_occlusion):
is_occluded = check_occlusion((occlusion_edge, occlusion_size))
self.notch_revealer.set_reveal_child(not is_occluded)
return True
def force_occlusion(self):
"""Force notch to occlusion mode (hidden)."""
self._forced_occlusion = True
self._prevent_occlusion = False
self.notch_revealer.set_reveal_child(False)
# Start occlusion check timer if in vertical mode (left/right)
if data.BAR_POSITION in ["Left", "Right"]:
GLib.timeout_add(100, self._check_occlusion)
def restore_from_occlusion(self):
"""Restore notch from occlusion mode."""
import config.data as data
self._forced_occlusion = False
if data.PANEL_THEME == "Notch":
if data.BAR_POSITION == "Top":
self.notch_revealer.set_reveal_child(True)
else:
self._prevent_occlusion = False
def _get_current_window_class(self):
"""Get the class of the currently active window"""
try:
from fabric.hyprland.widgets import get_hyprland_connection
conn = get_hyprland_connection()
if conn:
import json
active_window_json = conn.send_command("j/activewindow").reply.decode()
active_window_data = json.loads(active_window_json)
return active_window_data.get(
"initialClass", ""
) or active_window_data.get("class", "")
except Exception as e:
print(f"Error getting window class: {e}")
return ""
def on_active_window_changed(self, *args):
"""
Temporarily remove the 'occluded' class when active window class changes
to make the notch visible momentarily.
"""
if data.PANEL_THEME != "Notch":
return
new_window_class = self._get_current_window_class()
if new_window_class != self._current_window_class:
self._current_window_class = new_window_class
if self._occlusion_timer_id is not None:
GLib.source_remove(self._occlusion_timer_id)
self._occlusion_timer_id = None
self._prevent_occlusion = True
self.notch_revealer.set_reveal_child(True)
self._occlusion_timer_id = GLib.timeout_add(
500, self._restore_occlusion_check
)
def _restore_occlusion_check(self):
"""Re-enable occlusion checking after temporary visibility"""
self._prevent_occlusion = False
self._occlusion_timer_id = None
return False
def open_launcher_with_text(self, initial_text):
"""Open the launcher with initial text in the search field."""
self._launcher_transitioning = True
if initial_text:
self._typed_chars_buffer = initial_text
if self.stack.get_visible_child() == self.launcher:
current_text = self.launcher.search_entry.get_text()
self.launcher.search_entry.set_text(current_text + initial_text)
self.launcher.search_entry.set_position(-1)
self.launcher.search_entry.select_region(-1, -1)
self.launcher.search_entry.grab_focus()
return
self.set_keyboard_mode("exclusive")
for style in [
"launcher",
"dashboard",
"notification",
"overview",
"emoji",
"power",
"tools",
"tmux",
]:
self.stack.remove_style_class(style)
for w in [
self.launcher,
self.dashboard,
self.overview,
self.emoji,
self.power,
self.tools,
self.tmux,
self.cliphist,
]:
w.remove_style_class("open")
self.stack.add_style_class("launcher")
self.stack.set_visible_child(self.launcher)
self.launcher.add_style_class("open")
self.launcher.ensure_initialized()
self.launcher.open_launcher()
if self._launcher_transition_timeout:
GLib.source_remove(self._launcher_transition_timeout)
self._launcher_transition_timeout = GLib.timeout_add(
150, self._finalize_launcher_transition
)
self.bar.revealer_right.set_reveal_child(True)
self.bar.revealer_left.set_reveal_child(True)
self._is_notch_open = True
def _finalize_launcher_transition(self):
"""Apply buffered text and finalize launcher transition"""
if self._typed_chars_buffer:
entry = self.launcher.search_entry
entry.set_text(self._typed_chars_buffer)
entry.grab_focus()
GLib.timeout_add(10, self._ensure_no_text_selection)
GLib.timeout_add(50, self._ensure_no_text_selection)
GLib.timeout_add(100, self._ensure_no_text_selection)
print(f"Applied buffered text: '{self._typed_chars_buffer}'")
self._typed_chars_buffer = ""
self._launcher_transitioning = False
self._launcher_transition_timeout = None
return False
def _ensure_no_text_selection(self):
"""Make absolutely sure no text is selected in the search entry"""
entry = self.launcher.search_entry
text_len = len(entry.get_text())
entry.set_position(text_len)
entry.select_region(text_len, text_len)
if not entry.has_focus():
entry.grab_focus()
GLib.idle_add(lambda: entry.select_region(text_len, text_len))
return False
def on_key_press(self, widget, event):
"""Handle key presses at the notch level"""
if self._launcher_transitioning:
keyval = event.keyval
keychar = chr(keyval) if 32 <= keyval <= 126 else None
is_valid_char = (
(keyval >= Gdk.KEY_a and keyval <= Gdk.KEY_z)
or (keyval >= Gdk.KEY_A and keyval <= Gdk.KEY_Z)
or (keyval >= Gdk.KEY_0 and keyval <= Gdk.KEY_9)
or keyval
in (Gdk.KEY_space, Gdk.KEY_underscore, Gdk.KEY_minus, Gdk.KEY_period)
)
if is_valid_char and keychar:
self._typed_chars_buffer += keychar
print(
f"Buffered character: {keychar}, buffer now: '{self._typed_chars_buffer}'"
)
return True
if (
self.stack.get_visible_child() == self.dashboard
and self.dashboard.stack.get_visible_child() == self.dashboard.widgets
):
if self.stack.get_visible_child() == self.launcher:
return False
keyval = event.keyval
keychar = chr(keyval) if 32 <= keyval <= 126 else None
is_valid_char = (
(keyval >= Gdk.KEY_a and keyval <= Gdk.KEY_z)
or (keyval >= Gdk.KEY_A and keyval <= Gdk.KEY_Z)
or (keyval >= Gdk.KEY_0 and keyval <= Gdk.KEY_9)
or keyval
in (Gdk.KEY_space, Gdk.KEY_underscore, Gdk.KEY_minus, Gdk.KEY_period)
)
if is_valid_char and keychar:
print(f"Notch received keypress: {keychar}")
self.open_launcher_with_text(keychar)
return True
return False