diff --git a/src/mewline/constants.py b/src/mewline/constants.py index 2dcc712..f307b4b 100644 --- a/src/mewline/constants.py +++ b/src/mewline/constants.py @@ -115,7 +115,11 @@ "10": "10", }, }, - "system_tray": {"icon_size": 16, "ignore": []}, + "system_tray": { + "icon_size": 16, + "ignore": [], + "pinned": ["Telegram"] + }, "power": {"icon": "", "icon_size": "16px", "tooltip": True}, "datetime": {"format": "%d-%m-%y %H:%M"}, "battery": { diff --git a/src/mewline/shared/popover.py b/src/mewline/shared/popover.py index 4f9787d..2fc2455 100644 --- a/src/mewline/shared/popover.py +++ b/src/mewline/shared/popover.py @@ -26,6 +26,7 @@ def __init__(self): name="popover-overlay", style_classes="popover-overlay", anchor="left top right bottom", + margin="-50px 0px 0px 0px", exclusivity="auto", layer="overlay", type="top-level", @@ -87,14 +88,14 @@ def popover_closed(widget: Widget): ... @GObject.type_register class Popover(Widget): - """Memory-efficient popover implementation (ported from Tsumiki style).""" + """Memory-efficient popover implementation""" __gsignals__: ClassVar = { "popover-opened": (GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, ()), "popover-closed": (GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, ()), } - def __init__(self, content=None, point_to=None, gap: int = 2): + def __init__(self, content=None, point_to=None, gap: int = 4): super().__init__() self._content_factory = None self._point_to = point_to @@ -122,22 +123,30 @@ def open(self, *_): def _calculate_margins(self): widget_allocation = self._point_to.get_allocation() popover_size = self._content_window.get_size() + display = Gdk.Display.get_default() screen = display.get_default() - monitor_at_window = screen.get_monitor_at_window(self._point_to.get_window()) + monitor_at_window = screen.get_monitor_at_window( + self._point_to.get_window() + ) monitor_geometry = monitor_at_window.get_geometry() - # Center under widget horizontally + + # Center horizontally under the widget x = ( widget_allocation.x - + (widget_allocation.width / 2) - - (popover_size.width / 2) + + widget_allocation.width / 2 + - popover_size.width / 2 ) - y = widget_allocation.y + widget_allocation.height + self._gap + + y = widget_allocation.y + self._gap + + # Horizontal bounds check if x <= 0: x = widget_allocation.x elif x + popover_size.width >= monitor_geometry.width: x = widget_allocation.x - popover_size.width + widget_allocation.width - return [y, 0, 0, int(x)] + + return [int(y), 0, 0, int(x)] def set_position(self, position: tuple[int, int, int, int] | None = None): if position is None: @@ -157,11 +166,8 @@ def _create_popover(self): self._content_window.add( Box(style_classes="popover-content", children=self._content) ) - # Key and focus handling try: self._content_window.connect("key-press-event", self._on_key_press) - # Do not auto-close on focus-out to avoid closing - # while interacting with sliders self._content_window.set_can_focus(True) except Exception: ... @@ -173,8 +179,6 @@ def _create_popover(self): self._content_window.grab_focus() self._visible = True - self._content_window.show() - self._visible = True def hide_popover(self): if not self._visible or not self._content_window: @@ -195,7 +199,6 @@ def _on_key_press(self, widget, event): return False - # Compatibility helpers def close(self): return self.hide_popover() diff --git a/src/mewline/styles/system_tray.scss b/src/mewline/styles/system_tray.scss index d1bfa08..5b33501 100644 --- a/src/mewline/styles/system_tray.scss +++ b/src/mewline/styles/system_tray.scss @@ -2,34 +2,81 @@ @use "variable.scss"; @use "common/functions.scss"; +// ── Status-bar toggle capsule ────────────────────────────────────────────── #system-tray { - padding: 0.125em 0.7em; - color: theme.$text-color; + padding: 0.2em 1em; + border-radius: variable.$radius-large; + + #system-tray-pinned { + // Pinned icons sit flush next to the chevron + } - // Recolor tray icons (symbolic via CSS, raster are tinted in code) - image, - .tray-icon { + #nerd-icon.chevron-icon { + font-size: 0.75em; color: theme.$text-color; + opacity: 0.8; } - & > button { - margin: 0 0.1em; - padding: 0 0.15em; - border-radius: variable.$radius-large; + &.active, + &:active { + background-color: theme.$background-highlight; + + #nerd-icon.chevron-icon { + color: theme.$accent-color; + opacity: 1; + } + } + + &:hover { + background-color: theme.$background-highlight; + } + + // Pinned icon buttons + .tray-item-btn { + padding: 0 0.1em; + border-radius: variable.$radius; + background: transparent; + min-width: 0; + min-height: 0; + + &:hover { + background-color: theme.$background-highlight; + } + } +} + +// ── Popup grid ──────────────────────────────────────────────────────────── +#system-tray-popup { + background-color: theme.$background-base; + border-radius: variable.$radius; + padding: 0; + + .tray-item-btn { + padding: 0.35em; + border-radius: variable.$radius; + background: transparent; + min-width: 32px; + min-height: 32px; + &:hover { background-color: theme.$background-highlight; } &:active { - background-color: theme.$text-color; + opacity: 0.7; + } + + image { + color: theme.$text-color; } } } +// ── GTK context menus (right-click on a tray icon) ──────────────────────── #system-tray-menu > menuitem menu, #system-tray-menu { background-color: theme.$background-highlight; - border-radius: 1em; + border-radius: variable.$radius; padding: functions.toEm(10); min-width: 100px; font-weight: bold; @@ -37,7 +84,7 @@ #system-tray-menu > menuitem menu > menuitem, #system-tray-menu > menuitem { - margin: 5px 0px; + margin: 5px 0; background-color: theme.$background-highlight; padding: 0.3em 0.5em; border-radius: variable.$radius; @@ -50,13 +97,11 @@ } #system-tray-menu > menuitem menu > menuitem:hover, -#system-tray-menu > menuitem:hover, -#submenu-button:hover { +#system-tray-menu > menuitem:hover { background-color: theme.$background-highlight; color: theme.$accent-color; } -// Ensure symbolic icons inside tray menus also get the accent color #system-tray-menu image { color: theme.$text-color; } diff --git a/src/mewline/utils/config_structure.py b/src/mewline/utils/config_structure.py index 0c9179e..e030e5e 100644 --- a/src/mewline/utils/config_structure.py +++ b/src/mewline/utils/config_structure.py @@ -63,6 +63,7 @@ class WorkspacesModule(BaseModel): class TrayModule(BaseModel): icon_size: int ignore: list[str] + pinned: list[str] class PowerModule(BaseModel): diff --git a/src/mewline/widgets/system_tray.py b/src/mewline/widgets/system_tray.py index 275bf3a..3887338 100644 --- a/src/mewline/widgets/system_tray.py +++ b/src/mewline/widgets/system_tray.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import gi +from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.image import Image from gi.repository import Gdk @@ -8,153 +11,286 @@ from gi.repository import Gtk from mewline.config import cfg -from mewline.shared.widget_container import BoxWidget +from mewline.shared.popover import Popover +from mewline.shared.widget_container import ButtonWidget +from mewline.utils.widget_utils import text_icon gi.require_version("Gray", "0.1") gi.require_version("Gtk", "3.0") +MAX_COLUMNS = 4 + + +class SystemTrayGrid(Box): + """Popup menu that lays out tray items in a fixed-column grid.""" + + def __init__(self, **kwargs): + super().__init__( + name="system-tray-popup", + orientation="v", + all_visible=True, + **kwargs, + ) + + self.grid = Gtk.Grid( + row_spacing=8, + column_spacing=12, + margin_top=6, + margin_bottom=6, + margin_start=12, + margin_end=12, + visible=True, + ) + self.add(self.grid) -class SystemTray(BoxWidget): - """A widget to display the system tray items. + self._row = 0 + self._col = 0 - Multiple instances (one per monitor) share a single ``Gray.Watcher`` - because only one process can claim the StatusNotifierWatcher D-Bus name. - All instances are connected to that shared watcher and therefore each - renders the same set of tray items independently. + def add_item(self, button: Button) -> None: + self.grid.attach(button, self._col, self._row, 1, 1) + button.show_all() + self._col += 1 + if self._col >= MAX_COLUMNS: + self._col = 0 + self._row += 1 + + def remove_item(self, button: Button) -> None: + self.grid.remove(button) + children = self.grid.get_children() + for child in children: + self.grid.remove(child) + self._row = 0 + self._col = 0 + for child in reversed(children): + self.grid.attach(child, self._col, self._row, 1, 1) + self._col += 1 + if self._col >= MAX_COLUMNS: + self._col = 0 + self._row += 1 + + def has_items(self) -> bool: + return bool(self.grid.get_children()) + + +class SystemTray(ButtonWidget): + """System tray widget. + + The widget is a clickable capsule on the status bar. + Items whose title matches an entry in `config.pinned` are shown + directly on the bar; all remaining items land in a popup grid + that opens when the chevron is clicked. """ - # Class-level singleton – created once, shared across all monitors. - _shared_watcher: "Gray.Watcher | None" = None + _shared_watcher: Gray.Watcher | None = None def __init__(self, **kwargs) -> None: super().__init__(name="system-tray", **kwargs) self.config = cfg.modules.system_tray - # Lazily create the shared watcher on first use. if SystemTray._shared_watcher is None: SystemTray._shared_watcher = Gray.Watcher() self.watcher = SystemTray._shared_watcher - self.watcher.connect("item-added", self.on_item_added) - self.count_items = 0 + # Pinned icons live here — always visible on the bar + self.tray_box = Box( + name="system-tray-pinned", + orientation="h", + spacing=8, + ) + self.tray_box.set_no_show_all(True) + self.tray_box.hide() + + # Chevron: shows open/closed state of the popup + self.chevron = text_icon("", size="13px") + self.chevron.add_style_class("chevron-icon") + + self.children = Box( + orientation="h", + spacing=12, + halign="center", + valign="center", + children=[self.chevron, self.tray_box], + ) + + self.popup_menu = SystemTrayGrid() + self.popup: Popover | None = None + self._fake_open = False + + # identifier -> (button, is_pinned) + self._item_buttons: dict[str, tuple[Button, bool]] = {} + + self.watcher.connect("item-added", self._on_item_added) + self.connect("clicked", self._on_clicked) + self.hide() - def on_item_added(self, _, identifier: str): - item = self.watcher.get_item_for_identifier(identifier) + # ------------------------------------------------------------------ + # Popup toggle + # ------------------------------------------------------------------ + def _on_clicked(self, *_) -> None: + has_items = self.popup_menu.has_items() - if ( - self.config.ignore - and item.get_property("title") in self.config.ignore - ): + # If empty, just toggle visual state (feedback) + if not has_items: + self._fake_open = not self._fake_open + self._sync_chevron() + # Reset after a short delay if it's a "fake" open + if self._fake_open: + GLib.timeout_add(800, self._reset_fake_open) return - if not item.get_property("title"): + if self.popup is None: + self.popup = Popover( + content=self.popup_menu, + point_to=self, + gap=0, + ) + self.popup.connect("popover-closed", lambda *_: self._sync_chevron()) + + if self.popup.get_visible(): + self.popup.close() + else: + self.popup.open() + + self._sync_chevron() + + def _reset_fake_open(self) -> bool: + if self._fake_open: + self._fake_open = False + self._sync_chevron() + return False + + def _sync_chevron(self) -> None: + # Check either real popover visibility or our fake toggle state + is_open = (self.popup is not None and self.popup.get_visible()) or self._fake_open + + self.chevron.set_label("" if is_open else "") + if is_open: + self.add_style_class("active") + else: + self.remove_style_class("active") + + # ------------------------------------------------------------------ + # Item lifecycle + # ------------------------------------------------------------------ + def _on_item_added(self, _, identifier: str) -> None: + item = self.watcher.get_item_for_identifier(identifier) + + if item is None or str(item.get_property("title")) == "None": return - item_button = self.do_bake_item_button(item) - item.connect( - "removed", lambda *_: self.destroy_btn(item_button) - ) - item.connect( - "icon-changed", - lambda icon_item: self.do_update_item_button( - icon_item, item_button - ), - ) - item_button.show_all() - self.add(item_button) + title = item.get_property("title") or "" + + if any(t.lower() in title.lower() for t in self.config.ignore): + return + + button = self._bake_button(item) - # Show the tray if it was hidden before - if self.count_items < 1: - self.show() + is_pinned = any(t.lower() in title.lower() for t in self.config.pinned) + self._item_buttons[identifier] = (button, is_pinned) - self.count_items += 1 + if is_pinned: + self.tray_box.pack_start(button, False, False, 0) + button.show_all() + self.tray_box.show() + else: + self.popup_menu.add_item(button) + if self._fake_open: + self._fake_open = False + self._sync_chevron() - def destroy_btn(self, btn: Button): - btn.destroy() - self.count_items -= 1 + self.show() + self.chevron.show() - # Hide the tray if no items are left - if self.count_items == 0: + def _on_item_removed(self, identifier: str) -> None: + entry = self._item_buttons.pop(identifier, None) + if entry is None: + return + + button, is_pinned = entry + if is_pinned: + self.tray_box.remove(button) + if not self.tray_box.get_children(): + self.tray_box.hide() + else: + self.popup_menu.remove_item(button) + button.destroy() + + has_pinned = bool(self.tray_box.get_children()) + has_popup = self.popup_menu.has_items() + if not has_pinned and not has_popup: + if self.popup: + self.popup.close() self.hide() - def do_bake_item_button(self, item: Gray.Item) -> Button: + # ------------------------------------------------------------------ + # Button factory + # ------------------------------------------------------------------ + def _bake_button(self, item: Gray.Item) -> Button: button = Button() - # context menu handler + button.add_style_class("tray-item-btn") + button.set_tooltip_text(item.get_property("title") or "") button.connect( "button-press-event", - lambda button, event: self.on_button_click( - button, item, event - ), + lambda btn, ev: self._on_icon_click(btn, item, ev), ) - button.set_tooltip_text(item.get_property("title")) - - self.do_update_item_button(item, button) - + self._update_button_icon(item, button) return button - def do_update_item_button( - self, item: Gray.Item, item_button: Button - ): - pixmap = Gray.get_pixmap_for_pixmaps( - item.get_icon_pixmaps(), 24 - ) + def _update_button_icon(self, item: Gray.Item, button: Button) -> None: + size = self.config.icon_size + theme = Gtk.IconTheme.get_default() - icon = item.get_icon_name() or "image-missing" + icon_name = item.get_icon_name() or "" + if icon_name: + for candidate in ( + f"{icon_name}-symbolic" if not icon_name.endswith("-symbolic") else None, + icon_name, + ): + if candidate and theme.has_icon(candidate): + button.set_image(Image(icon_name=candidate, icon_size=size)) + return - # Prefer a symbolic variant if available to allow CSS recoloring - theme = Gtk.IconTheme.get_default() - symbolic_icon = None - if icon: - if icon.endswith("-symbolic"): - symbolic_icon = icon - elif theme.has_icon(f"{icon}-symbolic"): - symbolic_icon = f"{icon}-symbolic" - - # If we have a symbolic icon name, render by name so GTK can tint it via CSS - if symbolic_icon is not None: - item_button.set_image( - Image(icon_name=symbolic_icon, icon_size=self.config.icon_size) - ) - return + pixmap = Gray.get_pixmap_for_pixmaps(item.get_icon_pixmaps(), size) + if pixmap is not None: + try: + pixbuf = pixmap.as_pixbuf(size, GdkPixbuf.InterpType.HYPER) + button.set_image(Image(pixbuf=pixbuf, pixel_size=size)) + return + except GLib.GError: + pass + + if icon_name: + try: + pixbuf = theme.load_icon(icon_name, size, Gtk.IconLookupFlags.FORCE_SIZE) + button.set_image(Image(pixbuf=pixbuf, pixel_size=size)) + return + except GLib.GError: + pass - # Fallback: render provided pixmap or load by name as pixbuf, then tint it try: - pixbuf: GdkPixbuf.Pixbuf = ( - pixmap.as_pixbuf( - self.config.icon_size, - GdkPixbuf.InterpType.HYPER, - ) - if pixmap is not None - else theme.load_icon( - icon, - self.config.icon_size, - Gtk.IconLookupFlags.FORCE_SIZE, - ) - ) + pixbuf = theme.load_icon("image-missing", size, Gtk.IconLookupFlags.FORCE_SIZE) + button.set_image(Image(pixbuf=pixbuf, pixel_size=size)) except GLib.GError: - pixbuf = theme.load_icon( - "image-missing", - self.config.icon_size, - Gtk.IconLookupFlags.FORCE_SIZE, - ) - - item_button.set_image( - Image(pixbuf=pixbuf, pixel_size=self.config.icon_size) - ) + pass - def on_button_click(self, button, item: Gray.Item, event): - match event.button: - case 1 | 3: - menu = item.get_property("menu") - menu.set_name("system-tray-menu") - if menu: - menu.popup_at_widget( - button, - Gdk.Gravity.SOUTH, - Gdk.Gravity.NORTH, - event, - ) - else: - item.context_menu(event.x, event.y) + def _on_icon_click(self, button: Button, item: Gray.Item, event) -> None: + if event.button not in (1, 3): + return + button.unset_state_flags(Gtk.StateFlags.PRELIGHT) + menu = item.get_property("menu") + if menu: + menu.set_name("system-tray-menu") + menu.connect( + "hide", + lambda *_: button.unset_state_flags(Gtk.StateFlags.PRELIGHT), + ) + menu.popup_at_widget( + button, + Gdk.Gravity.SOUTH, + Gdk.Gravity.NORTH, + event, + ) + else: + item.context_menu(event.x, event.y)