1212from fabric .widgets .datetime import DateTime
1313from fabric .widgets .label import Label
1414from fabric .widgets .revealer import Revealer
15- from gi .repository import Gdk , Gtk
15+ from gi .repository import Gdk , Gtk , GLib
16+ import logging
1617
1718import config .data as data
1819import modules .icons as icons
2425from modules .weather import Weather
2526from widgets .wayland import WaylandWindow as Window
2627
28+ logger = logging .getLogger (__name__ )
29+
2730CHINESE_NUMERALS = ["一" , "二" , "三" , "四" , "五" , "六" , "七" , "八" , "九" , "〇" ]
2831
2932# Tooltips
3033tooltip_apps = f"""<b><u>Launcher</u></b>
3134<b>• Apps:</b> Type to search.
3235
33- <b>• Calculator [Prefix "="]:</b> Solve a math expression.
36+ <b>• Calculator [Prefix "="]:
37+ Solve a math expression.
3438 e.g. "=2+2"
3539
36- <b>• Converter [Prefix ";"]:</b> Convert between units.
40+ <b>• Converter [Prefix ";"]:
41+ Convert between units.
3742 e.g. ";100 USD to EUR", ";10 km to miles"
3843
39- <b>• Special Commands [Prefix ":"]:</b>
44+ <b>• Special Commands [Prefix ":"]:
4045 :update - Open { data .APP_NAME_CAP } 's updater.
4146 :d - Open Dashboard.
4247 :w - Open Wallpapers."""
4550tooltip_tools = """<b>Toolbox</b>"""
4651tooltip_overview = """<b>Overview</b>"""
4752
53+ def build_caption (i : int , start_workspace : int ):
54+ """Build the label for a given workspace number"""
55+ label = data .BAR_WORKSPACE_ICONS .get (str (i )) or data .BAR_WORKSPACE_ICONS .get (
56+ "default"
57+ )
58+ if label is None :
59+ return (
60+ CHINESE_NUMERALS [(i - start_workspace )]
61+ if data .BAR_WORKSPACE_USE_CHINESE_NUMERALS
62+ and 0 <= (i - start_workspace ) < len (CHINESE_NUMERALS )
63+ else str (i )
64+ )
65+ else :
66+ return label
67+
4868
4969class Bar (Window ):
5070 def __init__ (self , monitor_id : int = 0 , ** kwargs ):
@@ -145,12 +165,7 @@ def __init__(self, monitor_id: int = 0, **kwargs):
145165 h_align = "center" ,
146166 v_align = "center" ,
147167 id = i ,
148- label = (
149- CHINESE_NUMERALS [(i - start_workspace )]
150- if data .BAR_WORKSPACE_USE_CHINESE_NUMERALS
151- and 0 <= (i - start_workspace ) < len (CHINESE_NUMERALS )
152- else str (i )
153- ),
168+ label = build_caption (i , start_workspace ),
154169 )
155170 for i in workspace_range
156171 ],
@@ -161,15 +176,27 @@ def __init__(self, monitor_id: int = 0, **kwargs):
161176 ),
162177 )
163178
164- self .ws_container = Box (
165- name = "workspaces-container" ,
166- children = (
167- self .workspaces
168- if not data . BAR_WORKSPACE_SHOW_NUMBER
169- else self .workspaces_num
170- ),
179+ self .ws_rail = Box (name = "workspace-rail" , h_align = "start" , v_align = "center" )
180+ self . current_rail_pos = 0
181+ self . current_rail_size = 0
182+ self .is_animating_rail = False
183+ self . ws_rail_provider = Gtk . CssProvider ()
184+ self .ws_rail . get_style_context (). add_provider (
185+ self . ws_rail_provider , Gtk . STYLE_PROVIDER_PRIORITY_USER
171186 )
172187
188+ workspaces_widget = (
189+ self .workspaces_num
190+ if data .BAR_WORKSPACE_SHOW_NUMBER or data .BAR_WORKSPACE_ICONS
191+ else self .workspaces
192+ )
193+
194+ self .ws_container = Gtk .Grid ()
195+ self .ws_container .attach (self .ws_rail , 0 , 0 , 1 , 1 )
196+ self .ws_container .attach (workspaces_widget , 0 , 0 , 1 , 1 )
197+ self .ws_container .set_name ("workspaces-container" )
198+
199+
173200 self .button_tools = Button (
174201 name = "button-bar" ,
175202 tooltip_markup = tooltip_tools ,
@@ -180,6 +207,7 @@ def __init__(self, monitor_id: int = 0, **kwargs):
180207 self .connection = get_hyprland_connection ()
181208 self .button_tools .connect ("enter_notify_event" , self .on_button_enter )
182209 self .button_tools .connect ("leave_notify_event" , self .on_button_leave )
210+ self .connection .connect ("event::workspace" , self ._on_workspace_changed )
183211
184212 self .systray = SystemTray ()
185213
@@ -494,6 +522,215 @@ def __init__(self, monitor_id: int = 0, **kwargs):
494522
495523 self .systray ._update_visibility ()
496524 self .chinese_numbers ()
525+ self .setup_workspaces ()
526+
527+ def setup_workspaces (self ):
528+ """Set up workspace rail and initialize with current workspace"""
529+ logger .info ("Setting up workspaces" )
530+ try :
531+ active_workspace = json .loads (
532+ self .connection .send_command ("j/activeworkspace" ).reply .decode ()
533+ )["id" ]
534+ self .update_rail (active_workspace , initial_setup = True )
535+ except Exception as e :
536+ logger .error (f"Error initializing workspace rail: { e } " )
537+
538+ def _on_workspace_changed (self , _ , event ):
539+ """Handle workspace change events directly"""
540+ if event is not None and isinstance (event , HyprlandEvent ) and event .data :
541+ try :
542+ workspace_id = int (event .data [0 ])
543+ logger .info (f"Workspace changed to: { workspace_id } " )
544+ self .update_rail (workspace_id )
545+ except (ValueError , IndexError ) as e :
546+ logger .error (f"Error processing workspace event: { e } " )
547+ else :
548+ logger .warning (f"Invalid workspace event received: { event } " )
549+
550+ def update_rail (self , workspace_id , initial_setup = False ):
551+ """Update the workspace rail position based on the workspace button"""
552+ if self .is_animating_rail and not initial_setup :
553+ return
554+
555+ logger .info (f"Updating rail for workspace { workspace_id } " )
556+ workspaces = self .children_workspaces
557+ active_button = next (
558+ (
559+ b
560+ for b in workspaces
561+ if isinstance (b , WorkspaceButton ) and b .id == workspace_id
562+ ),
563+ None ,
564+ )
565+
566+ if not active_button :
567+ logger .warning (f"No button found for workspace { workspace_id } " )
568+ return
569+
570+ if initial_setup :
571+ GLib .idle_add (self ._position_rail_initially , active_button )
572+ else :
573+ self .is_animating_rail = True
574+ GLib .idle_add (self ._update_rail_with_animation , active_button )
575+
576+ def _position_rail_initially (self , active_button ):
577+ allocation = active_button .get_allocation ()
578+ if allocation .width == 0 or allocation .height == 0 :
579+ return True
580+
581+ diameter = 24
582+ if data .VERTICAL :
583+ self .current_rail_pos = (
584+ allocation .y + (allocation .height / 2 ) - (diameter / 2 )
585+ )
586+ self .current_rail_size = diameter
587+ css = f"""
588+ #workspace-rail {{
589+ transition-property: none;
590+ margin-top: { self .current_rail_pos } px;
591+ min-height: { self .current_rail_size } px;
592+ min-width: { self .current_rail_size } px;
593+ }}
594+ """
595+ else :
596+ self .current_rail_pos = (
597+ allocation .x + (allocation .width / 2 ) - (diameter / 2 )
598+ )
599+ self .current_rail_size = diameter
600+ css = f"""
601+ #workspace-rail {{
602+ transition-property: none;
603+ margin-left: { self .current_rail_pos } px;
604+ min-width: { self .current_rail_size } px;
605+ min-height: { self .current_rail_size } px;
606+ }}
607+ """
608+ self .ws_rail_provider .load_from_data (css .encode ())
609+ logger .info (
610+ f"Rail initialized at pos={ self .current_rail_pos } , size={ self .current_rail_size } "
611+ )
612+ return False
613+
614+ def _update_rail_with_animation (self , active_button ):
615+ """Position the rail at the active workspace button with a stretch animation."""
616+ target_allocation = active_button .get_allocation ()
617+
618+ if target_allocation .width == 0 or target_allocation .height == 0 :
619+ logger .info ("Button allocation not ready, retrying..." )
620+ self .is_animating_rail = False
621+ return True
622+
623+ diameter = 24
624+ if data .VERTICAL :
625+ pos_prop , size_prop = "margin-top" , "min-height"
626+ target_pos = (
627+ target_allocation .y + (target_allocation .height / 2 ) - (diameter / 2 )
628+ )
629+ else :
630+ pos_prop , size_prop = "margin-left" , "min-width"
631+ target_pos = (
632+ target_allocation .x + (target_allocation .width / 2 ) - (diameter / 2 )
633+ )
634+
635+ if target_pos == self .current_rail_pos :
636+ self .is_animating_rail = False
637+ return False
638+
639+ distance = target_pos - self .current_rail_pos
640+ stretched_size = self .current_rail_size + abs (distance )
641+ stretch_pos = target_pos if distance < 0 else self .current_rail_pos
642+
643+ stretch_duration = 0.1
644+ shrink_duration = 0.15
645+
646+ reduced_diameter = max (2 , int (diameter - abs (distance / 10.0 )))
647+
648+ if data .VERTICAL :
649+ other_size_prop , other_size_val = "min-width" , reduced_diameter
650+ else :
651+ other_size_prop , other_size_val = "min-height" , reduced_diameter
652+
653+ stretch_css = f"""
654+ #workspace-rail {{
655+ transition-property: { pos_prop } , { size_prop } ;
656+ transition-duration: { stretch_duration } s;
657+ transition-timing-function: ease-out;
658+ { pos_prop } : { stretch_pos } px;
659+ { size_prop } : { stretched_size } px;
660+ { other_size_prop } : { other_size_val } px;
661+ }}
662+ """
663+ self .ws_rail_provider .load_from_data (stretch_css .encode ())
664+
665+ GLib .timeout_add (
666+ int (stretch_duration * 1000 ),
667+ self ._shrink_rail ,
668+ target_pos ,
669+ diameter ,
670+ shrink_duration ,
671+ )
672+ return False
673+
674+ def _shrink_rail (self , target_pos , target_size , duration ):
675+ """Shrink the rail to its final size and position."""
676+ if data .VERTICAL :
677+ pos_prop = "margin-top"
678+ size_props = "min-height, min-width"
679+ else :
680+ pos_prop = "margin-left"
681+ size_props = "min-width, min-height"
682+
683+ shrink_css = f"""
684+ #workspace-rail {{
685+ transition-property: { pos_prop } , { size_props } ;
686+ transition-duration: { duration } s;
687+ transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
688+ { pos_prop } : { target_pos } px;
689+ min-width: { target_size } px;
690+ min-height: { target_size } px;
691+ }}
692+ """
693+ self .ws_rail_provider .load_from_data (shrink_css .encode ())
694+
695+ GLib .timeout_add (
696+ int (duration * 1000 ),
697+ self ._finalize_rail_animation ,
698+ target_pos ,
699+ target_size ,
700+ )
701+ return False
702+
703+ def _finalize_rail_animation (self , final_pos , final_size ):
704+ """Finalize animation and update state."""
705+ self .current_rail_pos = final_pos
706+ self .current_rail_size = final_size
707+ self .is_animating_rail = False
708+ logger .info (
709+ f"Rail animation finished at pos={ self .current_rail_pos } , size={ self .current_rail_size } "
710+ )
711+ return False
712+
713+ @property
714+ def children_workspaces (self ):
715+ workspaces_widget = None
716+ for child in self .ws_container .get_children ():
717+ if isinstance (child , Workspaces ):
718+ workspaces_widget = child
719+ break
720+
721+ if workspaces_widget :
722+ try :
723+ # The structure is Workspaces -> internal Box -> Buttons
724+ internal_box = workspaces_widget .get_children ()[0 ]
725+ return internal_box .get_children ()
726+ except (IndexError , AttributeError ):
727+ logger .error (
728+ "Failed to get workspace buttons due to unexpected widget structure."
729+ )
730+ return []
731+
732+ logger .warning ("Could not find the Workspaces widget in the container." )
733+ return []
497734
498735 def apply_component_props (self ):
499736 components = {
@@ -614,7 +851,7 @@ def toggle_hidden(self):
614851 self .bar_inner .remove_style_class ("hidden" )
615852
616853 def chinese_numbers (self ):
617- if data .BAR_WORKSPACE_USE_CHINESE_NUMERALS :
854+ if data .BAR_WORKSPACE_USE_CHINESE_NUMERALS or data . BAR_WORKSPACE_ICONS :
618855 self .workspaces_num .add_style_class ("chinese" )
619856 else :
620857 self .workspaces_num .remove_style_class ("chinese" )
0 commit comments