From fa097da4363ca1ea8ac1a7c75aa71dd1ef053b3d Mon Sep 17 00:00:00 2001 From: The_miro Date: Mon, 6 Jul 2026 11:52:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(hyprlua):=20per-file=20layout=20registry?= =?UTF-8?q?=20(default=20scrolling=E2=86=93);=20menu=20layout+window=20con?= =?UTF-8?q?trols?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hypr/layouts/: drop-in layout registry. init.lua auto-discovers layouts/.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 Claude-Session: https://claude.ai/code/session_01XUWCXM4KhjRkwheaA3X7bP --- .../hyprlua/astal-menu/style/style.css | 8 + desktopenvs/hyprlua/astal-menu/ui/taskbar.py | 256 ++++++++++++++++-- desktopenvs/hyprlua/eww-nobattery/eww.yuck | 4 +- desktopenvs/hyprlua/eww-touch/eww.yuck | 2 +- desktopenvs/hyprlua/eww/eww.yuck | 4 +- desktopenvs/hyprlua/hypr/hyprland.lua | 34 +-- desktopenvs/hyprlua/hypr/layouts/dwindle.lua | 10 + desktopenvs/hyprlua/hypr/layouts/init.lua | 101 +++++++ desktopenvs/hyprlua/hypr/layouts/master.lua | 10 + desktopenvs/hyprlua/hypr/layouts/monocle.lua | 8 + .../hyprlua/hypr/layouts/scrolling.lua | 27 ++ 11 files changed, 415 insertions(+), 49 deletions(-) create mode 100644 desktopenvs/hyprlua/hypr/layouts/dwindle.lua create mode 100644 desktopenvs/hyprlua/hypr/layouts/init.lua create mode 100644 desktopenvs/hyprlua/hypr/layouts/master.lua create mode 100644 desktopenvs/hyprlua/hypr/layouts/monocle.lua create mode 100644 desktopenvs/hyprlua/hypr/layouts/scrolling.lua diff --git a/desktopenvs/hyprlua/astal-menu/style/style.css b/desktopenvs/hyprlua/astal-menu/style/style.css index 1555709..2223922 100644 --- a/desktopenvs/hyprlua/astal-menu/style/style.css +++ b/desktopenvs/hyprlua/astal-menu/style/style.css @@ -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; } diff --git a/desktopenvs/hyprlua/astal-menu/ui/taskbar.py b/desktopenvs/hyprlua/astal-menu/ui/taskbar.py index 9836df7..881a8dc 100644 --- a/desktopenvs/hyprlua/astal-menu/ui/taskbar.py +++ b/desktopenvs/hyprlua/astal-menu/ui/taskbar.py @@ -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() diff --git a/desktopenvs/hyprlua/eww-nobattery/eww.yuck b/desktopenvs/hyprlua/eww-nobattery/eww.yuck index 2008b49..83a80e5 100644 --- a/desktopenvs/hyprlua/eww-nobattery/eww.yuck +++ b/desktopenvs/hyprlua/eww-nobattery/eww.yuck @@ -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}"}) ) ) diff --git a/desktopenvs/hyprlua/eww-touch/eww.yuck b/desktopenvs/hyprlua/eww-touch/eww.yuck index 5865e28..c853fd3 100644 --- a/desktopenvs/hyprlua/eww-touch/eww.yuck +++ b/desktopenvs/hyprlua/eww-touch/eww.yuck @@ -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@ {}%" diff --git a/desktopenvs/hyprlua/eww/eww.yuck b/desktopenvs/hyprlua/eww/eww.yuck index 7895881..e5a7ffc 100644 --- a/desktopenvs/hyprlua/eww/eww.yuck +++ b/desktopenvs/hyprlua/eww/eww.yuck @@ -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}"}) ) ) diff --git a/desktopenvs/hyprlua/hypr/hyprland.lua b/desktopenvs/hyprlua/hypr/hyprland.lua index 3a68b9b..4e0331b 100644 --- a/desktopenvs/hyprlua/hypr/hyprland.lua +++ b/desktopenvs/hyprlua/hypr/hyprland.lua @@ -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") diff --git a/desktopenvs/hyprlua/hypr/layouts/dwindle.lua b/desktopenvs/hyprlua/hypr/layouts/dwindle.lua new file mode 100644 index 0000000..b27324f --- /dev/null +++ b/desktopenvs/hyprlua/hypr/layouts/dwindle.lua @@ -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 }, + }, +} diff --git a/desktopenvs/hyprlua/hypr/layouts/init.lua b/desktopenvs/hyprlua/hypr/layouts/init.lua new file mode 100644 index 0000000..7647b45 --- /dev/null +++ b/desktopenvs/hyprlua/hypr/layouts/init.lua @@ -0,0 +1,101 @@ +-- Layout registry for the hyprlua session. +-- +-- Adding a layout is a one-file drop-in: create `layouts/.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 diff --git a/desktopenvs/hyprlua/hypr/layouts/master.lua b/desktopenvs/hyprlua/hypr/layouts/master.lua new file mode 100644 index 0000000..e907a32 --- /dev/null +++ b/desktopenvs/hyprlua/hypr/layouts/master.lua @@ -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 }, + }, +} diff --git a/desktopenvs/hyprlua/hypr/layouts/monocle.lua b/desktopenvs/hyprlua/hypr/layouts/monocle.lua new file mode 100644 index 0000000..058ae59 --- /dev/null +++ b/desktopenvs/hyprlua/hypr/layouts/monocle.lua @@ -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 = {}, +} diff --git a/desktopenvs/hyprlua/hypr/layouts/scrolling.lua b/desktopenvs/hyprlua/hypr/layouts/scrolling.lua new file mode 100644 index 0000000..adfed80 --- /dev/null +++ b/desktopenvs/hyprlua/hypr/layouts/scrolling.lua @@ -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, + }, + }, +}