Skip to content

Commit 64ad424

Browse files
committed
fancier workspaces
1 parent 25b00bd commit 64ad424

File tree

3 files changed

+268
-21
lines changed

3 files changed

+268
-21
lines changed

config/data.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def load_config():
8383
BAR_WORKSPACE_SHOW_NUMBER = config.get("bar_workspace_show_number", DEFAULTS["bar_workspace_show_number"])
8484
BAR_WORKSPACE_USE_CHINESE_NUMERALS = config.get("bar_workspace_use_chinese_numerals", DEFAULTS["bar_workspace_use_chinese_numerals"])
8585
BAR_HIDE_SPECIAL_WORKSPACE = config.get("bar_hide_special_workspace", DEFAULTS["bar_hide_special_workspace"])
86+
BAR_WORKSPACE_ICONS = config.get("bar_workspace_icons", {})
8687
BAR_THEME = config.get("bar_theme", DEFAULTS["bar_theme"])
8788
DOCK_THEME = config.get("dock_theme", DEFAULTS["dock_theme"])
8889
PANEL_THEME = config.get("panel_theme", DEFAULTS["panel_theme"])

modules/bar.py

Lines changed: 255 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
from fabric.widgets.datetime import DateTime
1313
from fabric.widgets.label import Label
1414
from fabric.widgets.revealer import Revealer
15-
from gi.repository import Gdk, Gtk
15+
from gi.repository import Gdk, Gtk, GLib
16+
import logging
1617

1718
import config.data as data
1819
import modules.icons as icons
@@ -24,19 +25,23 @@
2425
from modules.weather import Weather
2526
from widgets.wayland import WaylandWindow as Window
2627

28+
logger = logging.getLogger(__name__)
29+
2730
CHINESE_NUMERALS = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "〇"]
2831

2932
# Tooltips
3033
tooltip_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."""
@@ -45,6 +50,21 @@
4550
tooltip_tools = """<b>Toolbox</b>"""
4651
tooltip_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

4969
class 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")

styles/workspaces.css

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@
88

99
#workspaces-container {
1010
background-color: var(--shadow);
11+
padding: 0;
12+
border-radius: 16px;
13+
}
14+
15+
#workspace-rail {
16+
background-color: var(--primary);
17+
border-radius: 16px;
18+
transition: transform 0.5s cubic-bezier(0.15, 1, 0.3, 1), min-width 0.5s cubic-bezier(0.15, 1, 0.3, 1);
19+
min-height: 34px;
1120
}
1221

1322
#workspaces-container.invert {
@@ -38,13 +47,13 @@
3847
#workspaces > button.active {
3948
min-width: 48px;
4049
min-height: 8px;
41-
background-color: var(--primary);
50+
background-color: transparent;
4251
}
4352

4453
#workspaces > button.active.vertical {
4554
min-width: 8px;
4655
min-height: 48px;
47-
background-color: var(--primary);
56+
background-color: transparent;
4857
}
4958

5059
#workspaces > button.empty {
@@ -77,7 +86,7 @@
7786
}
7887

7988
#workspaces-num > button.active {
80-
background-color: var(--primary);
89+
background-color: transparent;
8190
border-radius: 8px;
8291
}
8392

0 commit comments

Comments
 (0)