diff --git a/src/window/panel/notch/__init__.py b/src/window/panel/notch/__init__.py index faaa1a2..d333c11 100644 --- a/src/window/panel/notch/__init__.py +++ b/src/window/panel/notch/__init__.py @@ -1,3 +1,4 @@ from window.panel.notch.notch import Notch +from window.panel.notch.indicators import CapsLockIndicator, KeyboardLayoutIndicator -__all__ = ["Notch"] +__all__ = ["Notch", "CapsLockIndicator", "KeyboardLayoutIndicator"] diff --git a/src/window/panel/notch/indicators.py b/src/window/panel/notch/indicators.py new file mode 100644 index 0000000..4514051 --- /dev/null +++ b/src/window/panel/notch/indicators.py @@ -0,0 +1,141 @@ +from fabric.utils import GLib, logger +from fabric.widgets.box import Box +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label + +from services.capslock import CapsLock +from services.keyboard_layout import KeyboardLayout +from utils.utils import svg_file + +DISPLAY_MS = 2000 # how long each indicator stays visible + + +class KeyboardLayoutIndicator(Box): + def __init__(self, show_cb, hide_cb, **kwargs): + super().__init__( + name="notch-kbd-layout", + orientation="h", + h_expand=True, + v_align="center", + **kwargs, + ) + + self._show_cb = show_cb + self._hide_cb = hide_cb + self._hide_timer_id = 0 + + self._kbd = KeyboardLayout.get_initial() + + self._icon = svg_file( + "misc/keyboard-layout.svg", + size=16, + name="notch-indicator-icon", + v_align="center", + ) + self._label = Label( + name="notch-indicator-label", + label=self._kbd.current_layout.upper(), + v_align="center", + ) + + self.add( + CenterBox( + orientation="h", + h_expand=True, + start_children=self._icon, + end_children=self._label, + ) + ) + + self._kbd.connect("layout_changed", self._on_layout_changed) + + def _on_layout_changed(self, _service, layout: str): + self._label.set_label(layout.upper()) + self._show_cb(self) + self._schedule_hide() + + def _schedule_hide(self): + if self._hide_timer_id: + GLib.source_remove(self._hide_timer_id) + self._hide_timer_id = GLib.timeout_add(DISPLAY_MS, self._do_hide) + + def _do_hide(self): + self._hide_timer_id = 0 + self._hide_cb() + return False + + def destroy(self): + if self._hide_timer_id: + GLib.source_remove(self._hide_timer_id) + self._hide_timer_id = 0 + try: + self._kbd.disconnect_by_func(self._on_layout_changed) + except Exception as e: + logger.warning(f"[NotchIndicators] kbd disconnect: {e}") + super().destroy() + + +class CapsLockIndicator(Box): + def __init__(self, show_cb, hide_cb, **kwargs): + super().__init__( + name="notch-capslock", + orientation="h", + h_expand=True, + v_align="center", + **kwargs, + ) + + self._show_cb = show_cb + self._hide_cb = hide_cb + self._hide_timer_id = 0 + + self._caps = CapsLock.get_initial() + + self._icon = svg_file( + "misc/caps-lock.svg", + size=16, + name="notch-indicator-icon", + v_align="center", + ) + self._label = Label( + name="notch-indicator-label", + label="On", + v_align="center", + ) + + self.add( + CenterBox( + orientation="h", + h_expand=True, + start_children=self._icon, + end_children=self._label, + ) + ) + + self._caps.connect("state_changed", self._on_state_changed) + + def _on_state_changed(self, _service, is_on: bool): + if not is_on: + return + self._show_cb(self) + self._schedule_hide() + + def _schedule_hide(self): + if self._hide_timer_id: + GLib.source_remove(self._hide_timer_id) + self._hide_timer_id = GLib.timeout_add(DISPLAY_MS, self._do_hide) + + def _do_hide(self): + self._hide_timer_id = 0 + self._hide_cb() + return False + + def destroy(self): + if self._hide_timer_id: + GLib.source_remove(self._hide_timer_id) + self._hide_timer_id = 0 + try: + self._caps.disconnect_by_func(self._on_state_changed) + except Exception as e: + logger.warning(f"[NotchIndicators] caps disconnect: {e}") + super().destroy() diff --git a/src/window/panel/notch/notch.py b/src/window/panel/notch/notch.py index 45fb9e3..d956570 100644 --- a/src/window/panel/notch/notch.py +++ b/src/window/panel/notch/notch.py @@ -6,6 +6,7 @@ from fabric.widgets.stack import Stack from services.screencapture import screen_capture_service from window.panel.notch.recording import RecordingIndicator from window.panel.notch.player import NotchPlayer +from window.panel.notch.indicators import CapsLockIndicator, KeyboardLayoutIndicator from window.controlcenter.player import get_shared_mpris_manager @@ -34,6 +35,16 @@ class Notch(Box): h_expand=True, ) + # Transient indicator pages — shown on change, hidden after 2 s + self.kbd_indicator = KeyboardLayoutIndicator( + show_cb=self._show_transient, + hide_cb=self._hide_transient, + ) + self.caps_indicator = CapsLockIndicator( + show_cb=self._show_transient, + hide_cb=self._hide_transient, + ) + self._mpris = get_shared_mpris_manager() self._mpris.connect("new-player", self._on_new_player) self._mpris.connect("player-vanish", self._on_player_vanish) @@ -46,13 +57,21 @@ class Notch(Box): self._last_recording_check = 0 self._is_active_recording = False self._music_services: list[str] = [] + # Track if a transient indicator is currently shown + self._transient_active = False self.notch_stack = Stack( name="panel-notch-stack", v_expand=True, h_expand=True, transition_type="none", - children=[self.idle_widget, self.recording_indicator, self.player_widget], + children=[ + self.idle_widget, + self.recording_indicator, + self.player_widget, + self.kbd_indicator, + self.caps_indicator, + ], ) self._last_stack_page = 0 @@ -98,6 +117,21 @@ class Notch(Box): self.notch_stack.connect("scroll-event", self._on_scroll) + # ── Transient indicator show / hide ────────────────────────────────────── + + def _show_transient(self, widget): + """Make a transient indicator the visible notch page.""" + self._transient_active = True + if self.notch_stack.get_visible_child() != widget: + self.notch_stack.set_visible_child(widget) + + def _hide_transient(self): + """Return to the normal notch state after the indicator timer expires.""" + self._transient_active = False + self._apply_stack_state() + + # ── Init / MPRIS / recording ────────────────────────────────────────────── + def _init_state(self): self._is_active_recording = screen_capture_service.is_recording if self._is_active_recording: @@ -133,6 +167,8 @@ class Notch(Box): self._music_services.remove(name) self._apply_stack_state() + # ── Scroll ──────────────────────────────────────────────────────────────── + def _on_scroll(self, widget, event): now = GLib.get_monotonic_time() if now - self._last_scroll_time < 100_000: @@ -158,7 +194,13 @@ class Notch(Box): self._apply_stack_state() return True + # ── Stack state ─────────────────────────────────────────────────────────── + def _apply_stack_state(self): + # Don't override a transient indicator that is still counting down + if self._transient_active: + return + is_recording = self._is_active_recording now = GLib.get_monotonic_time() @@ -204,4 +246,6 @@ class Notch(Box): def destroy(self): self.recording_indicator.destroy() self.player_widget.destroy() + self.kbd_indicator.destroy() + self.caps_indicator.destroy() super().destroy()