feat(hyprlua): per-file layout registry (default scrolling↓); menu layout+window controls
- hypr/layouts/: drop-in layout registry. init.lua auto-discovers layouts/<name>.lua (each self-describing) and exposes the global `layouts` table: set_default (picked in hyprland.lua — now scrolling, direction down), set(ws,name,dir) for live per-workspace switching (verified it re-tiles the active ws), set_fit(0/1). Writes a manifest + per-ws state for the menu. Ships scrolling/dwindle/master/monocle. - Scrolling default: column_width 0.5 (active column < viewport so prev/next stay clickable), focus_fit_method 1 (centered follow), fullscreen_on_one_column. - astal-menu taskbar pop-open: workspace layout selector + direction + a "Centered" toggle (focus_fit_method), and a per-window list — [icon+title → jump][⇤ here → pull to current workspace], keeping jump-to-window intact. - eww bar menu buttons open the menu explicitly from the top (menu-toggle.sh toggle top). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XUWCXM4KhjRkwheaA3X7bPmain
parent
2ea9c664a5
commit
fa097da436
|
|
@ -180,6 +180,14 @@ button:checked.quad-action { background: @accent; color: @bg; border-color: @acc
|
|||
.task-window { color: @text; background: transparent; border-radius: 10px; padding: 6px 12px; }
|
||||
.task-window:hover { color: @accent; }
|
||||
|
||||
/* pop-open panel: layout controls + per-window rows */
|
||||
.task-panel { padding: 4px 2px; }
|
||||
.task-row { padding: 2px 4px; border-radius: 10px; min-height: 30px; }
|
||||
.task-row:hover { background: alpha(@violet, 0.12); }
|
||||
.task-name { background: transparent; border: none; padding: 2px 6px; min-height: 26px; }
|
||||
.task-name:hover { color: @accent; }
|
||||
.task-name label { color: @text; }
|
||||
|
||||
/* ---- location map --------------------------------------------------- */
|
||||
.map-view { border-radius: 14px; }
|
||||
.map-info { color: @text; padding: 6px 2px 0 2px; font-size: 11pt; }
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
"""Full-width taskbar strip: jump to any open window.
|
||||
"""Full-width taskbar strip: jump to any open window, plus a pop-open panel.
|
||||
|
||||
Reads open windows from `hyprctl clients -j`, groups them by app (class). A group
|
||||
with a single window focuses it directly; a group with several windows opens a
|
||||
pop-out list so you can pick a specific instance. The strip scrolls sideways when
|
||||
many apps are open. Focusing a window closes the menu.
|
||||
Compact: app-grouped icons; click focuses the window (single) or opens a pop-out of
|
||||
instances (grouped). Pop-open (the ⤢ button) reveals:
|
||||
* workspace/layout controls — pick the current workspace's layout (scrolling /
|
||||
dwindle / master / monocle, enumerated from ~/.cache/astal-menu/layouts.json,
|
||||
written by hypr/layouts) and, for directional layouts, its direction. Applied
|
||||
live via `hyprctl eval 'layouts.set(ws, name, dir)'`.
|
||||
* a per-window row list: [icon + title → focus/jump] [⇤ pull to this workspace].
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
|
|
@ -15,20 +20,44 @@ gi.require_version("AstalApps", "0.1")
|
|||
from gi.repository import AstalApps, Gtk # noqa: E402
|
||||
|
||||
from lib.proc import run_json, run_text
|
||||
from paths import CACHE_DIR
|
||||
|
||||
_LAYOUTS_MANIFEST = CACHE_DIR / "layouts.json"
|
||||
_LAYOUTS_STATE = CACHE_DIR / "layouts-state.json"
|
||||
|
||||
|
||||
def _eval(lua: str) -> None:
|
||||
run_text(["hyprctl", "eval", lua], lambda *_a: None)
|
||||
|
||||
|
||||
def _dispatch(lua: str) -> None:
|
||||
# In hyprlua, `hyprctl dispatch` evaluates its argument as Lua (the hl.dsp.* API).
|
||||
run_text(["hyprctl", "dispatch", lua], lambda *_a: None)
|
||||
|
||||
|
||||
class Taskbar(Gtk.Box):
|
||||
def __init__(self, on_activate):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||
self.add_css_class("taskbar")
|
||||
self._on_activate = on_activate
|
||||
self._apps = AstalApps.Apps()
|
||||
self._wm_index = self._build_wm_index()
|
||||
self._layouts = self._load_layouts()
|
||||
self._active_ws = None
|
||||
self._clients: list = []
|
||||
|
||||
header = Gtk.Label(label="Open windows", xalign=0.0)
|
||||
header.add_css_class("section-title")
|
||||
header = Gtk.CenterBox()
|
||||
title = Gtk.Label(label="Open windows", xalign=0.0)
|
||||
title.add_css_class("section-title")
|
||||
header.set_start_widget(title)
|
||||
self._expand_btn = Gtk.ToggleButton(label="") # nf-fa-expand
|
||||
self._expand_btn.add_css_class("quad-action")
|
||||
self._expand_btn.set_tooltip_text("Workspace & window controls")
|
||||
self._expand_btn.connect("toggled", lambda b: self._reveal.set_reveal_child(b.get_active()))
|
||||
header.set_end_widget(self._expand_btn)
|
||||
self.append(header)
|
||||
|
||||
# compact icon strip
|
||||
self._row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
self._row.add_css_class("taskbar-row")
|
||||
scroller = Gtk.ScrolledWindow(
|
||||
|
|
@ -37,6 +66,16 @@ class Taskbar(Gtk.Box):
|
|||
scroller.set_child(self._row)
|
||||
self.append(scroller)
|
||||
|
||||
# pop-open panel: layout controls + detailed window list
|
||||
self._reveal = Gtk.Revealer(
|
||||
transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN,
|
||||
transition_duration=180, reveal_child=False)
|
||||
self._panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
self._panel.add_css_class("task-panel")
|
||||
self._reveal.set_child(self._panel)
|
||||
self.append(self._reveal)
|
||||
|
||||
# -- setup helpers -----------------------------------------------------
|
||||
def _build_wm_index(self) -> dict:
|
||||
idx = {}
|
||||
for app in self._apps.get_list():
|
||||
|
|
@ -51,20 +90,38 @@ class Taskbar(Gtk.Box):
|
|||
return app.get_icon_name()
|
||||
return (cls or "application-x-executable").lower()
|
||||
|
||||
@staticmethod
|
||||
def _load_layouts() -> list:
|
||||
try:
|
||||
return json.loads(_LAYOUTS_MANIFEST.read_text())
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
# fallback if hypr/layouts hasn't written the manifest yet
|
||||
return [{"name": "scrolling", "label": "Scrolling", "directional": True,
|
||||
"dirs": ["down", "up", "right", "left"], "default_dir": "down"},
|
||||
{"name": "dwindle", "label": "Dwindle", "directional": False, "dirs": []},
|
||||
{"name": "master", "label": "Master", "directional": False, "dirs": []},
|
||||
{"name": "monocle", "label": "Monocle", "directional": False, "dirs": []}]
|
||||
|
||||
# -- populate ----------------------------------------------------------
|
||||
def refresh(self) -> None:
|
||||
run_json(["hyprctl", "clients", "-j"], self._on_clients)
|
||||
run_json(["hyprctl", "activeworkspace", "-j"], self._on_ws)
|
||||
|
||||
def _on_ws(self, ok: bool, data) -> None:
|
||||
if ok and isinstance(data, dict):
|
||||
self._active_ws = data.get("id")
|
||||
self._rebuild_panel()
|
||||
|
||||
def _on_clients(self, ok: bool, data) -> None:
|
||||
child = self._row.get_first_child()
|
||||
while child:
|
||||
self._row.remove(child)
|
||||
child = self._row.get_first_child()
|
||||
if not ok or not isinstance(data, list):
|
||||
self._row.append(Gtk.Label(label="no window data"))
|
||||
return
|
||||
self._clients = data if ok and isinstance(data, list) else []
|
||||
self._rebuild_strip()
|
||||
self._rebuild_panel()
|
||||
|
||||
# -- compact strip -----------------------------------------------------
|
||||
def _rebuild_strip(self) -> None:
|
||||
self._clear(self._row)
|
||||
groups: dict[str, list] = {}
|
||||
for w in data:
|
||||
for w in self._clients:
|
||||
if not w.get("mapped", True) or not w.get("class"):
|
||||
continue
|
||||
groups.setdefault(w["class"], []).append(w)
|
||||
|
|
@ -84,7 +141,6 @@ class Taskbar(Gtk.Box):
|
|||
btn.set_tooltip_text(wins[0].get("title") or cls)
|
||||
btn.connect("clicked", lambda *_a, w=wins[0]: self._focus(w))
|
||||
return btn
|
||||
# multiple windows -> pop-out list of specific instances
|
||||
btn = Gtk.MenuButton()
|
||||
btn.add_css_class("task-tile")
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
|
|
@ -106,14 +162,166 @@ class Taskbar(Gtk.Box):
|
|||
btn.set_popover(pop)
|
||||
return btn
|
||||
|
||||
# -- pop-open panel: layout controls + window list ---------------------
|
||||
def _rebuild_panel(self) -> None:
|
||||
self._clear(self._panel)
|
||||
|
||||
# layout controls for the active workspace
|
||||
ctrl = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
ws = self._active_ws
|
||||
ctrl.append(Gtk.Label(label=f"Workspace {ws if ws is not None else '?'}", xalign=0.0))
|
||||
names = [ly["label"] for ly in self._layouts]
|
||||
self._layout_dd = Gtk.DropDown.new_from_strings(names or ["…"])
|
||||
self._layout_dd.set_tooltip_text("Layout")
|
||||
self._layout_dd.connect("notify::selected", self._on_layout_pick)
|
||||
ctrl.append(self._layout_dd)
|
||||
self._dir_dd = Gtk.DropDown.new_from_strings(["—"])
|
||||
self._dir_dd.set_tooltip_text("Direction")
|
||||
self._dir_dd.connect("notify::selected", self._on_dir_pick)
|
||||
ctrl.append(self._dir_dd)
|
||||
self._fit_btn = Gtk.ToggleButton(label="Centered")
|
||||
self._fit_btn.add_css_class("quad-action")
|
||||
self._fit_btn.set_tooltip_text(
|
||||
"Centered follow: keep the focused column centred so the prev/next columns "
|
||||
"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._panel.append(Gtk.Separator())
|
||||
|
||||
# per-window rows: [focus/jump] [pull here]
|
||||
wins = [w for w in self._clients if w.get("mapped", True) and w.get("class")]
|
||||
wins.sort(key=lambda w: (w.get("workspace", {}).get("id", 0),
|
||||
(w.get("title") or w.get("class") or "").lower()))
|
||||
if not wins:
|
||||
self._panel.append(Gtk.Label(label="No open windows", xalign=0.0))
|
||||
return
|
||||
listing = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
scroller.set_child(listing)
|
||||
for w in wins:
|
||||
listing.append(self._window_row(w))
|
||||
self._panel.append(scroller)
|
||||
|
||||
def _window_row(self, w) -> Gtk.Widget:
|
||||
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
row.add_css_class("task-row")
|
||||
icon = Gtk.Image.new_from_icon_name(self._icon_for(w.get("class", "")))
|
||||
icon.set_pixel_size(22)
|
||||
wsid = w.get("workspace", {}).get("id")
|
||||
title = (w.get("title") or w.get("class") or "?")
|
||||
name = Gtk.Button(hexpand=True)
|
||||
name.add_css_class("task-name")
|
||||
lbl = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
lbl.append(icon)
|
||||
wl = Gtk.Label(label=f"{title}", xalign=0.0, hexpand=True, ellipsize=3, max_width_chars=32)
|
||||
lbl.append(wl)
|
||||
lbl.append(Gtk.Label(label=f"ws {wsid}", xalign=1.0))
|
||||
name.set_child(lbl)
|
||||
name.set_tooltip_text("Jump to window")
|
||||
name.connect("clicked", lambda *_a: self._focus(w))
|
||||
row.append(name)
|
||||
|
||||
pull = Gtk.Button(label="⇤ here")
|
||||
pull.add_css_class("quad-action")
|
||||
pull.set_tooltip_text("Pull this window to the current workspace")
|
||||
pull.connect("clicked", lambda *_a: self._pull(w))
|
||||
row.append(pull)
|
||||
return row
|
||||
|
||||
# -- layout control sync + handlers ------------------------------------
|
||||
def _sync_layout_controls(self) -> None:
|
||||
# reflect the current general:layout / scrolling:direction / focus_fit in the UI
|
||||
self._syncing = True
|
||||
run_json(["hyprctl", "getoption", "general:layout", "-j"], self._apply_layout_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)
|
||||
|
||||
def _apply_fit_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
|
||||
# prefer the per-workspace layout recorded by hypr/layouts (layouts.set), so
|
||||
# the dropdown reflects THIS workspace, not just the global default.
|
||||
try:
|
||||
state = json.loads(_LAYOUTS_STATE.read_text())
|
||||
cur = state.get(str(self._active_ws), cur)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
pass
|
||||
idx = next((i for i, ly in enumerate(self._layouts) if ly["name"] == cur), 0)
|
||||
self._layout_dd.set_selected(idx)
|
||||
self._refresh_dir_dd(idx)
|
||||
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:
|
||||
cur = data.get("str") if ok and isinstance(data, dict) else None
|
||||
model = self._dir_dd.get_model()
|
||||
if model is not None:
|
||||
for i in range(model.get_n_items()):
|
||||
if model.get_string(i) == cur:
|
||||
self._dir_dd.set_selected(i)
|
||||
break
|
||||
|
||||
def _selected_layout(self) -> dict | None:
|
||||
i = self._layout_dd.get_selected()
|
||||
return self._layouts[i] if 0 <= i < len(self._layouts) else None
|
||||
|
||||
def _on_layout_pick(self, *_a) -> None:
|
||||
if getattr(self, "_syncing", False):
|
||||
return
|
||||
ly = self._selected_layout()
|
||||
if ly is None or self._active_ws is None:
|
||||
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:
|
||||
if getattr(self, "_syncing", False):
|
||||
return
|
||||
_eval(f"layouts.set_fit({1 if btn.get_active() else 0})")
|
||||
|
||||
def _on_dir_pick(self, *_a) -> None:
|
||||
if getattr(self, "_syncing", False):
|
||||
return
|
||||
ly = self._selected_layout()
|
||||
dm = self._dir_dd.get_model()
|
||||
if ly is None or not ly.get("dirs") or dm is None or self._active_ws is None:
|
||||
return
|
||||
d = dm.get_string(self._dir_dd.get_selected())
|
||||
_eval(f'layouts.set("{self._active_ws}", "{ly["name"]}", "{d}")')
|
||||
|
||||
# -- window actions ----------------------------------------------------
|
||||
def _focus(self, w) -> None:
|
||||
addr = w.get("address")
|
||||
if addr:
|
||||
# This is a hyprlua (Lua-configured Hyprland) setup: `hyprctl dispatch`
|
||||
# evaluates its argument as Lua, so the native `focuswindow address:…`
|
||||
# syntax is a Lua error. Use the hyprlua focus dispatcher, which also
|
||||
# switches to the window's workspace.
|
||||
run_text(["hyprctl", "dispatch",
|
||||
f'hl.dsp.focus({{ window = "address:{addr}" }})'],
|
||||
lambda *_a: None)
|
||||
_dispatch(f'hl.dsp.focus({{ window = "address:{addr}" }})')
|
||||
self._on_activate()
|
||||
|
||||
def _pull(self, w) -> None:
|
||||
addr = w.get("address")
|
||||
if addr and self._active_ws is not None:
|
||||
_dispatch(f'hl.dsp.window.move({{ window = "address:{addr}", '
|
||||
f'workspace = "{self._active_ws}" }})')
|
||||
run_json(["hyprctl", "clients", "-j"], self._on_clients) # reflect the move
|
||||
|
||||
@staticmethod
|
||||
def _clear(box: Gtk.Box) -> None:
|
||||
child = box.get_first_child()
|
||||
while child:
|
||||
box.remove(child)
|
||||
child = box.get_first_child()
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@
|
|||
(defwidget winsworks [monitor]
|
||||
(box :orientation "h" :space-evenly false :halign "start"
|
||||
; astal-menu launcher — opens the popup control centre / app drawer
|
||||
(button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh" {""})
|
||||
(button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh toggle top" {""})
|
||||
(workspaceWidget :monitor monitor)
|
||||
(button :onclick "~/.config/scripts/menu-toggle.sh" :class "music" {" ${activewindow}"})
|
||||
(button :onclick "~/.config/scripts/menu-toggle.sh toggle top" :class "music" {" ${activewindow}"})
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
(box :orientation "h" :space-evenly false :halign "start"
|
||||
(osk)
|
||||
(box :class "music" {"${battery}"})
|
||||
(button :onclick "~/.config/scripts/menu-toggle.sh" :class "icon-btn" :valign "center" :width 26 :height 26 {""})
|
||||
(button :onclick "~/.config/scripts/menu-toggle.sh toggle top" :class "icon-btn" :valign "center" :width 26 :height 26 {""})
|
||||
(metric :label " "
|
||||
:value volume
|
||||
:onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%"
|
||||
|
|
|
|||
|
|
@ -55,13 +55,13 @@
|
|||
(defwidget winsworks [monitor]
|
||||
(box :orientation "h" :space-evenly false :halign "start"
|
||||
; astal-menu launcher — opens the popup control centre / app drawer
|
||||
(button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh" {""})
|
||||
(button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh toggle top" {""})
|
||||
; Battery percentage badge — styled as a pill with class "music"
|
||||
(box :class "music" {"${battery}"})
|
||||
; Workspace dots — one button per active workspace on this monitor
|
||||
(workspaceWidget :monitor monitor)
|
||||
; Active window title — clicking opens the application drawer
|
||||
(button :onclick "~/.config/scripts/menu-toggle.sh" :class "music" {" ${activewindow}"})
|
||||
(button :onclick "~/.config/scripts/menu-toggle.sh toggle top" :class "music" {" ${activewindow}"})
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -70,10 +70,8 @@ hl.config({
|
|||
-- Tearing introduces screen-tear artefacts in exchange for lower latency.
|
||||
-- Disabled here for a clean image; enable per-window via windowrules for games.
|
||||
allow_tearing = false,
|
||||
-- Default tiling algorithm. Dwindle splits the remaining space in half each time
|
||||
-- a new window opens, producing a predictable Fibonacci spiral layout that keeps
|
||||
-- all windows visible without needing manual arrangement.
|
||||
layout = "dwindle",
|
||||
-- The default tiling algorithm is chosen at the end of this file by the layout
|
||||
-- registry (require("layouts.init") → layouts.set_default), not hard-coded here.
|
||||
},
|
||||
group = {
|
||||
-- Window groups stack multiple windows into one tiled slot with a tab bar.
|
||||
|
|
@ -147,22 +145,8 @@ hl.config({
|
|||
-- Individual animation types are configured below in the animations section.
|
||||
enabled = true,
|
||||
},
|
||||
dwindle = {
|
||||
-- preserve_split = true: when a split window is closed and then reopened,
|
||||
-- Hyprland restores the previous split direction instead of always defaulting
|
||||
-- to horizontal. This prevents the layout from constantly collapsing and re-splitting.
|
||||
preserve_split = true,
|
||||
-- Windows in the special (scratchpad) workspace are scaled down to 95% so they
|
||||
-- visually "float" above the regular workspace content when toggled in.
|
||||
special_scale_factor = 0.95,
|
||||
},
|
||||
master = {
|
||||
-- New windows claim the master (largest) pane rather than being added as a slave,
|
||||
-- so the most recently launched app always gets the most prominent position.
|
||||
new_status = "master",
|
||||
-- Same 95% scratchpad scale as dwindle, for visual consistency.
|
||||
special_scale_factor = 0.95,
|
||||
},
|
||||
-- Per-layout settings (dwindle/master/scrolling/monocle) live in hypr/layouts/*.lua
|
||||
-- and are applied by the layout registry, so they are not repeated here.
|
||||
misc = {
|
||||
-- force_default_wallpaper = 0 disables Hyprland's built-in anime wallpaper so
|
||||
-- hyprpaper (or any other wallpaper daemon) has exclusive control of the background.
|
||||
|
|
@ -226,3 +210,13 @@ hl.device({
|
|||
name = "epic-mouse-v1",
|
||||
sensitivity = -0.5,
|
||||
})
|
||||
|
||||
---------------
|
||||
---- LAYOUTS ---
|
||||
---------------
|
||||
|
||||
-- Load the per-file layout registry (hypr/layouts/*.lua) and select the session
|
||||
-- default. Switching a workspace's layout at runtime is done from the astal-menu
|
||||
-- popup via `hyprctl eval 'layouts.set(ws, name, dir)'`.
|
||||
require("layouts.init")
|
||||
layouts.set_default("scrolling")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
-- Dwindle: binary-tree split (Fibonacci spiral), all windows tiled and visible.
|
||||
return {
|
||||
name = "dwindle",
|
||||
label = "Dwindle",
|
||||
backend = "dwindle",
|
||||
directional = false,
|
||||
config = {
|
||||
dwindle = { preserve_split = true, special_scale_factor = 0.95 },
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
-- Layout registry for the hyprlua session.
|
||||
--
|
||||
-- Adding a layout is a one-file drop-in: create `layouts/<name>.lua` returning a
|
||||
-- spec table (see scrolling.lua for the shape) and it is auto-discovered here — no
|
||||
-- other wiring. Exposes the global `layouts` table used by hyprland.lua (to pick the
|
||||
-- default) and by the astal-menu popup (over `hyprctl eval`) to switch a workspace's
|
||||
-- layout live. A JSON manifest is written for the menu to enumerate the choices.
|
||||
|
||||
local M = { registry = {}, order = {}, current = {} } -- current: ws(str) -> name
|
||||
|
||||
local HOME = os.getenv("HOME")
|
||||
local DIR = HOME .. "/.config/hypr/layouts"
|
||||
local MANIFEST = HOME .. "/.cache/astal-menu/layouts.json"
|
||||
local STATE = HOME .. "/.cache/astal-menu/layouts-state.json"
|
||||
|
||||
local function scan()
|
||||
local p = io.popen("ls '" .. DIR .. "' 2>/dev/null")
|
||||
if not p then return {} end
|
||||
local names = {}
|
||||
for line in p:lines() do
|
||||
local n = line:match("^(.+)%.lua$")
|
||||
if n and n ~= "init" then names[#names + 1] = n end
|
||||
end
|
||||
p:close()
|
||||
table.sort(names)
|
||||
return names
|
||||
end
|
||||
|
||||
local function jstr(s) return '"' .. tostring(s):gsub('\\', '\\\\'):gsub('"', '\\"') .. '"' end
|
||||
|
||||
-- Write layouts.json so the menu can list {name,label,directional,dirs,default_dir}.
|
||||
function M.write_manifest()
|
||||
local items = {}
|
||||
for _, name in ipairs(M.order) do
|
||||
local s = M.registry[name]
|
||||
local dirs = {}
|
||||
for _, d in ipairs(s.dirs or {}) do dirs[#dirs + 1] = jstr(d) end
|
||||
items[#items + 1] = table.concat({
|
||||
"{", '"name":', jstr(s.name),
|
||||
',"label":', jstr(s.label or s.name),
|
||||
',"directional":', tostring(s.directional or false),
|
||||
',"dirs":[', table.concat(dirs, ","), "]",
|
||||
',"default_dir":', jstr(s.default_dir or ""),
|
||||
',"fit_method":', tostring(s.fit_method or false), "}",
|
||||
})
|
||||
end
|
||||
os.execute("mkdir -p '" .. MANIFEST:match("(.+)/[^/]+$") .. "' 2>/dev/null")
|
||||
local f = io.open(MANIFEST, "w")
|
||||
if f then f:write("[" .. table.concat(items, ",") .. "]\n"); f:close() end
|
||||
end
|
||||
|
||||
function M.load()
|
||||
for _, name in ipairs(scan()) do
|
||||
local ok, spec = pcall(require, "layouts." .. name)
|
||||
if ok and type(spec) == "table" and spec.name then
|
||||
if not M.registry[spec.name] then M.order[#M.order + 1] = spec.name end
|
||||
M.registry[spec.name] = spec
|
||||
end
|
||||
end
|
||||
M.write_manifest()
|
||||
return M
|
||||
end
|
||||
|
||||
-- Global default layout (called once from hyprland.lua).
|
||||
function M.set_default(name)
|
||||
local s = M.registry[name]; if not s then return end
|
||||
hl.config({ general = { layout = s.backend } })
|
||||
if s.config then hl.config(s.config) end
|
||||
end
|
||||
|
||||
-- Switch one workspace's layout live (called by the menu via hyprctl eval).
|
||||
-- `dir` is applied for directional layouts (scrolling.direction is a global option,
|
||||
-- so this affects every scrolling workspace — hyprland has no per-ws direction).
|
||||
local function write_state()
|
||||
local parts = {}
|
||||
for ws, name in pairs(M.current) do
|
||||
parts[#parts + 1] = jstr(ws) .. ":" .. jstr(name)
|
||||
end
|
||||
local f = io.open(STATE, "w")
|
||||
if f then f:write("{" .. table.concat(parts, ",") .. "}\n"); f:close() end
|
||||
end
|
||||
|
||||
function M.set(ws, name, dir)
|
||||
local s = M.registry[name]; if not s then return end
|
||||
ws = tostring(ws)
|
||||
hl.workspace_rule({ workspace = ws, layout = s.backend })
|
||||
if s.directional and dir and dir ~= "" then
|
||||
hl.config({ scrolling = { direction = dir } })
|
||||
end
|
||||
M.current[ws] = name -- remember so the menu can show the real per-ws layout
|
||||
write_state()
|
||||
end
|
||||
|
||||
-- Toggle the scrolling "centered follow" (focus_fit_method 0=fit / 1=centered).
|
||||
-- column_width stays < 1.0 (set in scrolling.lua) so neighbours remain clickable.
|
||||
function M.set_fit(method)
|
||||
hl.config({ scrolling = { focus_fit_method = tonumber(method) or 0 } })
|
||||
end
|
||||
|
||||
_G.layouts = M.load()
|
||||
return M
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
-- Master: one large master pane + a stack of the rest.
|
||||
return {
|
||||
name = "master",
|
||||
label = "Master",
|
||||
backend = "master",
|
||||
directional = false,
|
||||
config = {
|
||||
master = { new_status = "master", special_scale_factor = 0.95 },
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- Monocle: one full-size window at a time (fullscreen-like tiling).
|
||||
return {
|
||||
name = "monocle",
|
||||
label = "Monocle",
|
||||
backend = "monocle",
|
||||
directional = false,
|
||||
config = {},
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
-- Scrolling: an infinite row/column of windows you scroll through. The session
|
||||
-- default. Adjustable from the menu:
|
||||
-- * direction (down by default) — see M.set() in init.lua.
|
||||
-- * focus_fit_method 0/1 ("Centered follow") — see M.set_fit(); when centered the
|
||||
-- focused column is centred and the previous/next columns peek in from the sides.
|
||||
--
|
||||
-- column_width is kept < 1.0 (0.5) so the active column is narrower than the viewport
|
||||
-- and the prev/next columns stay on-screen and clickable. fullscreen_on_one_column
|
||||
-- lets a lone window still fill the screen, so the narrow width costs nothing alone.
|
||||
return {
|
||||
name = "scrolling",
|
||||
label = "Scrolling",
|
||||
backend = "scrolling", -- hyprland general.layout value
|
||||
directional = true,
|
||||
dirs = { "down", "up", "right", "left" },
|
||||
default_dir = "down",
|
||||
fit_method = true, -- exposes the "Centered follow" toggle in the menu
|
||||
config = {
|
||||
scrolling = {
|
||||
direction = "down",
|
||||
column_width = 0.5,
|
||||
focus_fit_method = 1,
|
||||
follow_focus = true,
|
||||
fullscreen_on_one_column = true,
|
||||
},
|
||||
},
|
||||
}
|
||||
Loading…
Reference in New Issue