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_01XUWCXM4KhjRkwheaA3X7bPmain
parent
fa097da436
commit
4196b84ad3
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue