feat(astal-menu): tabbed workspace-layout selector

Replace the layout dropdown with a tab bar (one tab per layout — Dwindle · Master ·
Monocle · Scrolling, from the manifest) over a Gtk.Stack of per-layout options that
switches with the tab. Selecting a tab applies that layout to the currently focused
workspace; the scrolling page carries its options — a "direction" dropdown and a
"center-focused" switch (focus_fit_method). The active tab reflects the workspace's
real (per-ws) layout via the layouts state file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUWCXM4KhjRkwheaA3X7bP
main
Amir Alexander Abdelbaki 2026-07-06 11:59:47 +02:00
parent fa097da436
commit 4196b84ad3
2 changed files with 87 additions and 71 deletions

View File

@ -182,6 +182,7 @@ button:checked.quad-action { background: @accent; color: @bg; border-color: @acc
/* pop-open panel: layout controls + per-window rows */ /* pop-open panel: layout controls + per-window rows */
.task-panel { padding: 4px 2px; } .task-panel { padding: 4px 2px; }
.layout-page { padding: 6px 4px 2px 4px; }
.task-row { padding: 2px 4px; border-radius: 10px; min-height: 30px; } .task-row { padding: 2px 4px; border-radius: 10px; min-height: 30px; }
.task-row:hover { background: alpha(@violet, 0.12); } .task-row:hover { background: alpha(@violet, 0.12); }
.task-name { background: transparent; border: none; padding: 2px 6px; min-height: 26px; } .task-name { background: transparent; border: none; padding: 2px 6px; min-height: 26px; }

View File

@ -166,27 +166,24 @@ class Taskbar(Gtk.Box):
def _rebuild_panel(self) -> None: def _rebuild_panel(self) -> None:
self._clear(self._panel) self._clear(self._panel)
# layout controls for the active workspace # tabbed layout selector for the active workspace: a tab per layout, and a
ctrl = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) # per-layout options page underneath that switches with the tab. Selecting a
# tab applies that layout to the currently focused workspace.
ws = self._active_ws ws = self._active_ws
ctrl.append(Gtk.Label(label=f"Workspace {ws if ws is not None else '?'}", xalign=0.0)) hdr = Gtk.Label(label=f"Workspace {ws if ws is not None else '?'} layout", xalign=0.0)
names = [ly["label"] for ly in self._layouts] hdr.add_css_class("section-title")
self._layout_dd = Gtk.DropDown.new_from_strings(names or [""]) self._panel.append(hdr)
self._layout_dd.set_tooltip_text("Layout")
self._layout_dd.connect("notify::selected", self._on_layout_pick) self._dir_dd = None
ctrl.append(self._layout_dd) self._fit_sw = None
self._dir_dd = Gtk.DropDown.new_from_strings([""]) self._layout_stack = Gtk.Stack()
self._dir_dd.set_tooltip_text("Direction") for ly in self._layouts:
self._dir_dd.connect("notify::selected", self._on_dir_pick) self._layout_stack.add_titled(self._layout_page(ly), ly["name"], ly["label"])
ctrl.append(self._dir_dd) switcher = Gtk.StackSwitcher(stack=self._layout_stack)
self._fit_btn = Gtk.ToggleButton(label="Centered") switcher.add_css_class("net-switcher") # reuse the tab pill styling
self._fit_btn.add_css_class("quad-action") self._panel.append(switcher)
self._fit_btn.set_tooltip_text( self._panel.append(self._layout_stack)
"Centered follow: keep the focused column centred so the prev/next columns " self._layout_stack.connect("notify::visible-child-name", self._on_tab_switch)
"stay on-screen and tappable (scrolling focus_fit_method)")
self._fit_btn.connect("toggled", self._on_fit_toggle)
ctrl.append(self._fit_btn)
self._panel.append(ctrl)
self._sync_layout_controls() self._sync_layout_controls()
self._panel.append(Gtk.Separator()) self._panel.append(Gtk.Separator())
@ -231,79 +228,97 @@ class Taskbar(Gtk.Box):
row.append(pull) row.append(pull)
return row return row
# -- layout control sync + handlers ------------------------------------ # -- per-layout options page -------------------------------------------
def _layout_page(self, ly: dict) -> Gtk.Widget:
page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
page.add_css_class("layout-page")
if ly.get("dirs"):
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.append(Gtk.Label(label="direction", xalign=0.0, hexpand=True))
dd = Gtk.DropDown.new_from_strings(ly["dirs"])
dd.connect("notify::selected", self._on_opt_change)
row.append(dd)
page.append(row)
self._dir_dd = dd # only scrolling declares dirs
if ly.get("fit_method"):
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.append(Gtk.Label(label="center-focused", xalign=0.0, hexpand=True))
sw = Gtk.Switch(valign=Gtk.Align.CENTER)
sw.set_tooltip_text("Keep the focused column centred so the prev/next "
"columns stay on-screen and tappable")
sw.connect("state-set", self._on_fit_toggle)
row.append(sw)
page.append(row)
self._fit_sw = sw
if not ly.get("dirs") and not ly.get("fit_method"):
page.append(Gtk.Label(label="No adjustable options", xalign=0.0,
css_classes=["net-ip"]))
return page
# -- sync + handlers ---------------------------------------------------
def _sync_layout_controls(self) -> None: def _sync_layout_controls(self) -> None:
# reflect the current general:layout / scrolling:direction / focus_fit in the UI # reflect the current per-ws layout / direction / focus_fit in the tabs+options
self._syncing = True self._syncing = True
run_json(["hyprctl", "getoption", "general:layout", "-j"], self._apply_layout_sel) run_json(["hyprctl", "getoption", "general:layout", "-j"], self._apply_tab_sel)
run_json(["hyprctl", "getoption", "scrolling:direction", "-j"], self._apply_dir_sel) run_json(["hyprctl", "getoption", "scrolling:direction", "-j"], self._apply_dir_sel)
run_json(["hyprctl", "getoption", "scrolling:focus_fit_method", "-j"], self._apply_fit_sel) run_json(["hyprctl", "getoption", "scrolling:focus_fit_method", "-j"], self._apply_fit_sel)
def _apply_fit_sel(self, ok, data) -> None: def _apply_tab_sel(self, ok, data) -> None:
val = data.get("int") if ok and isinstance(data, dict) else 0
self._syncing = True
self._fit_btn.set_active(val == 1)
self._syncing = False
def _apply_layout_sel(self, ok, data) -> None:
cur = data.get("str") if ok and isinstance(data, dict) else None cur = data.get("str") if ok and isinstance(data, dict) else None
# prefer the per-workspace layout recorded by hypr/layouts (layouts.set), so # prefer the per-workspace layout recorded by hypr/layouts (layouts.set)
# the dropdown reflects THIS workspace, not just the global default.
try: try:
state = json.loads(_LAYOUTS_STATE.read_text()) state = json.loads(_LAYOUTS_STATE.read_text())
cur = state.get(str(self._active_ws), cur) cur = state.get(str(self._active_ws), cur)
except (FileNotFoundError, json.JSONDecodeError): except (FileNotFoundError, json.JSONDecodeError):
pass pass
idx = next((i for i, ly in enumerate(self._layouts) if ly["name"] == cur), 0) if cur and any(ly["name"] == cur for ly in self._layouts):
self._layout_dd.set_selected(idx) self._layout_stack.set_visible_child_name(cur)
self._refresh_dir_dd(idx)
self._syncing = False self._syncing = False
def _refresh_dir_dd(self, layout_idx: int) -> None:
ly = self._layouts[layout_idx] if layout_idx < len(self._layouts) else {}
dirs = ly.get("dirs") or []
self._dir_dd.set_model(Gtk.StringList.new(dirs or [""]))
self._dir_dd.set_sensitive(bool(dirs))
self._fit_btn.set_visible(bool(ly.get("fit_method")))
def _apply_dir_sel(self, ok, data) -> None: def _apply_dir_sel(self, ok, data) -> None:
if self._dir_dd is None:
return
cur = data.get("str") if ok and isinstance(data, dict) else None cur = data.get("str") if ok and isinstance(data, dict) else None
model = self._dir_dd.get_model() model = self._dir_dd.get_model()
if model is not None: for i in range(model.get_n_items() if model else 0):
for i in range(model.get_n_items()): if model.get_string(i) == cur:
if model.get_string(i) == cur: self._syncing = True
self._dir_dd.set_selected(i) self._dir_dd.set_selected(i)
break self._syncing = False
break
def _selected_layout(self) -> dict | None: def _apply_fit_sel(self, ok, data) -> None:
i = self._layout_dd.get_selected() if self._fit_sw is None:
return self._layouts[i] if 0 <= i < len(self._layouts) else None return
val = data.get("int") if ok and isinstance(data, dict) else 0
self._syncing = True
self._fit_sw.set_active(val == 1)
self._syncing = False
def _on_layout_pick(self, *_a) -> None: def _cur_dir(self) -> str:
if getattr(self, "_syncing", False): if self._dir_dd is None:
return return ""
ly = self._selected_layout() m = self._dir_dd.get_model()
if ly is None or self._active_ws is None: return m.get_string(self._dir_dd.get_selected()) if m else ""
return
self._refresh_dir_dd(self._layout_dd.get_selected())
dm = self._dir_dd.get_model()
d = dm.get_string(self._dir_dd.get_selected()) if (dm and ly.get("dirs")) else ""
_eval(f'layouts.set("{self._active_ws}", "{ly["name"]}", "{d}")')
def _on_fit_toggle(self, btn) -> None: def _on_tab_switch(self, *_a) -> None:
if getattr(self, "_syncing", False): if getattr(self, "_syncing", False) or self._active_ws is None:
return return
_eval(f"layouts.set_fit({1 if btn.get_active() else 0})") name = self._layout_stack.get_visible_child_name()
if name:
_eval(f'layouts.set("{self._active_ws}", "{name}", "{self._cur_dir()}")')
def _on_dir_pick(self, *_a) -> None: def _on_opt_change(self, *_a) -> None:
if getattr(self, "_syncing", False): if getattr(self, "_syncing", False) or self._active_ws is None:
return return
ly = self._selected_layout() name = self._layout_stack.get_visible_child_name()
dm = self._dir_dd.get_model() if name:
if ly is None or not ly.get("dirs") or dm is None or self._active_ws is None: _eval(f'layouts.set("{self._active_ws}", "{name}", "{self._cur_dir()}")')
return
d = dm.get_string(self._dir_dd.get_selected()) def _on_fit_toggle(self, _sw, state) -> bool:
_eval(f'layouts.set("{self._active_ws}", "{ly["name"]}", "{d}")') if not getattr(self, "_syncing", False):
_eval(f"layouts.set_fit({1 if state else 0})")
return False
# -- window actions ---------------------------------------------------- # -- window actions ----------------------------------------------------
def _focus(self, w) -> None: def _focus(self, w) -> None: