diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 12078b7..757610f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,12 +2,32 @@ name: tests on: push: - branches: [main] + branches: [main, develop] pull_request: +permissions: + contents: read + jobs: + windows-display-policy: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + # The implementation now belongs to Remote Desktops. Keep the required + # native policy check on its immutable, validated extraction revision. + - uses: actions/checkout@v4 + with: + repository: jdvmi00/remote-desktops + ref: 30d795c83164f812ec70a4374e68926e1cb39d9c + path: remote-desktops + persist-credentials: false + - name: Migrated Windows PowerShell display policy and journal + shell: powershell + run: .\remote-desktops\remote_desktops\windows\Test.ps1 -PolicyOnly test: runs-on: ubuntu-latest + timeout-minutes: 20 container: archlinux:latest steps: # The engine targets the Lua that Hyprland embeds (5.5); Arch tracks it. @@ -17,13 +37,19 @@ jobs: - name: Scripts run: shellcheck install.sh uninstall.sh - name: Development helper - run: python3 test/dev.py + run: python3 test/dev.py && python3 test/upgrade.py - name: Engine run: lua test/harness.lua + - name: Window navigation + run: lua test/navigation.lua - name: Bridge and CLI run: lua test/bridge.lua - name: Session recovery run: python3 test/session.py && lua test/session.lua + - name: Scenes and content + run: python3 test/scenes.py && python3 test/apps.py && lua test/scenes.lua && lua test/swap.lua && node test/content.js + - name: Managed layout browsing + run: python3 test/browse.py && node test/browse.js - name: Overlay geometry run: node test/geometry.js - name: Overlay editor diff --git a/.gitignore b/.gitignore index 689711e..6bc4536 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ __pycache__/ # Private planning notes, kept locally only docs/PLAN.md docs/REMOTE-SOURCES.md +docs/COOPERATING-AGENTS.md +docs/REMOTE-SOURCES-PROTOTYPES.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fbe9d67 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# Repository workflow + +Read [docs/RELEASING.md](docs/RELEASING.md) before changing branches, CI, +release metadata, tags, GitHub settings, or marketplace submissions. + +## Branches and preservation + +- `main` is the default, stable installation branch. It is frozen at + `1ba0f8a30526f2148ff46885d67991862ae90f00` for marketplace issue #4893. + Do not push, merge, rebase, reset remotely, or add even documentation to it. +- Work on feature branches based on `develop`; target pull requests at `develop`. + Before editing, inspect `git status` and preserve existing uncommitted work. + Stage only your changes; never discard or commit unrelated user changes. +- Never force-push `main` or `develop`, move published tags, change the default + branch, disable protection, or bypass CI as a routine fix. +- Moving `main` or removing its freeze requires an explicit release instruction + from the owner. Ordinary implementation requests authorize development work, + not a release. Keep `main` locked between releases and throughout review. + +## CI and delivery + +- `.github/workflows/test.yml` defines the test commands. Keep CI running on + pushes to `develop` and `main`, and on pull requests. +- Run the relevant suites locally. Before merging, both GitHub checks `test` + and `windows-display-policy` must pass on the current PR revision. Fix + failures without weakening or skipping checks. Do not claim an unavailable + local platform check passed; use its GitHub runner. +- Local runtime installation (`./dev apply`, `./install.sh`) is separate from CI + and release publication. Do not run these merely to test a workflow change. +- Development CI must never automatically push to `main`, publish a release, + edit a marketplace issue, or install the plugin on the user's desktop. +- For an explicitly requested release, follow docs/RELEASING.md: prepare all + content and version changes first, promote once, record the resulting full + SHA, immediately freeze `main`, and obtain marketplace reports for that SHA. + A successful GitHub test run is not marketplace approval. +- After approval, keep developing on `develop`. A new `main` commit needs the + marketplace update process; an existing approval does not cover it. + +This file lives on `develop` because adding it to the currently frozen release +would change the exact commit the owner selected. GitHub protection enforces +the freeze independently of this file. diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb62de..d0fb6eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,36 @@ # Changelog -## Unreleased +## 1.1.0 (2026-09-06) + +- Scenes: save a workspace's layout together with what each zone holds: an + installed app, one open window, local windows in fill order, or Empty. + Applying a scene launches or reuses each app and places it once; you can + then move or close it freely. Scenes live in `~/.config/hypertile/scenes/` + and are managed from the overlay's Scenes tab or `hypertile-ctl scene`; an + independent `hypertile-scenes` service owns placement. See + [scenes and content](docs/SCENES.md). + +- Scenes tab redesign. The header names the workspace until a scene is + applied, then the scene with how many apps are placed. Saved scenes are + cards like the layout list, with the apps drawn in their zones; the row + applies. One zone list replaces CONTENT, ZONE and CHANGE TO; the picker + under the selected zone filters as you type, shows app icons, and groups + open windows (with titles), remote desktops, and installed apps. Zone + cards show what they hold with icon, name, and state, plus Change… and + Clear; a card that needs attention is outlined and offers Retry. Digits + select zones by fill number, hovering a match previews it in the card, and + zones are listed by position ("Top left"). The catalog lists each app's + icon and each saved scene's sources, and skips placeholder window classes. + +- Remote desktops: each computer's launcher from + [Remote Desktops](https://github.com/jdvmi00/remote-desktops) is an ordinary + app for Scenes and session recovery. Hypertile does not manage connections + or host displays. Upgrade checks preserve unresolved host recovery and user + configuration. + +- `SUPER+SHIFT+arrows` move a window into an empty slot of the layout as well + as swapping with a neighbour, so a lone app can travel around a sparse + layout. Explicit moves reveal collapsed slots until the layout is reset. - Session recovery: batched automatic checkpoints with durable publication and previous generations; named sessions; protected partial restores; supported diff --git a/README.md b/README.md index ed9715d..792ff94 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ everything else in place, and is the one to run again after every update: - the engine and bridge into `~/.config/hypr/`, and the two shipped layouts into `~/.config/hypr/layouts/` (a layout that already exists is left alone) - `hypertile-ctl` into `~/.local/bin/` -- the session recovery service, started by the layout loader +- the session recovery and independent Scenes services, started by the layout loader - a `require("hypr.hypertile-layouts")` line in `hyprland.lua` - the bar widget after the workspaces (skipped when it is already on the bar) - a **Layouts** entry in the `SUPER+SPACE` menu (`--no-menu` skips it) @@ -47,7 +47,11 @@ everything else in place, and is the one to run again after every update: overlay; `SUPER+L` cycles the workspace through your layouts and then dwindle, replacing Omarchy's dwindle/scrolling toggle, which cannot return to a Lua layout; `SUPER+SHIFT+L` cycles the other way -- `SUPER+Arrow` focuses and `SUPER+SHIFT+Arrow` swaps with the nearest window in that direction, +- `SUPER+Arrow` focuses the nearest window in that direction. `SUPER+SHIFT+Arrow` + moves to the next layout slot: an empty slot receives the active window, and an + occupied slot swaps windows. Other apps stay in their slots; spacers and scene + slots marked Empty are skipped. Moving into a collapsed slot reveals the full + layout on that workspace until the layout is reset. Every config file it edits is first copied to `.hypertile.bak`. It then reloads Hyprland and checks `hyprctl configerrors`. Update with: @@ -302,16 +306,20 @@ Two layouts ship: manifest.json the Omarchy plugin manifest (kinds: overlay, bar-widget) plugin/ the shell plugin: Overlay.qml, Rail.qml (inspector), ZoneItem.qml, Divider.qml, Thumb.qml, Card.qml, Chip.qml, Geometry.js (drawing), - Editor.js (edits); LayoutWidget.qml (bar widget) + Editor.js (edits); ContentPane.qml and Content.js (the Scenes tab); + LayoutWidget.qml (bar widget) hypertile.lua engine: spec -> layout provider (hot-swappable) hypertile-bridge.lua bridge: load/serialize/JSON/save/preview/apply hypertile-json.lua JSON encode/decode (pure Lua) hypertile-layouts.lua loader: requires every ~/.config/hypr/layouts/*.lua +hypertile-navigation.lua gap-aware focus and swap for SUPER+arrows and SUPER+SHIFT+arrows hypertile-session.lua compositor adapter: capture and restore window placement session/service.py session watcher, durable snapshots, app launch and matching +scenes/*.py scenes service: saved scenes, the app catalog, one-shot placement layouts/*.lua shipped layouts: ultrawide, quad bin/hypertile-ctl CLI over the bridge bin/hypertile-session session service entry point (also via hypertile-ctl session) +bin/hypertile-scenes scenes service entry point (also via hypertile-ctl scene) install.sh puts the engine, CLI, layouts, keybinds, and menu entry in place uninstall.sh takes them out again probe.lua live probe (logs everything the API hands a layout) @@ -367,6 +375,21 @@ put them (within 1px on stacked heights, where hypertile rounds edges instead of sizes to avoid seams). The CLI runs from a checkout without installing: `HYPERTILE_SRC=$PWD bin/hypertile-ctl list`. +## Remote desktops + +Scenes can launch or reuse installed apps in named zones, including each +computer's Remote Desktops launcher. Placement happens once; subsequent window +moves and closes stay under your control. The independent Scenes service does +not own remote connections or host display settings. + +Use the overlay’s **Scenes** tab or `hypertile-ctl scene` to assign apps, local +windows, or Empty, then save the arrangement. See [scenes and content](docs/SCENES.md) +for setup, migration from legacy stream sources, and recovery behavior. +Remote connections and host recovery now belong to +[Remote Desktops](https://github.com/jdvmi00/remote-desktops). Upgrade checks +require legacy connections to be disconnected and restored before removing +their old runtime files; saved configuration and journals are preserved. + ## License MIT. See [LICENSE](LICENSE). diff --git a/bin/hypertile-ctl b/bin/hypertile-ctl index e56f578..df77132 100755 --- a/bin/hypertile-ctl +++ b/bin/hypertile-ctl @@ -64,6 +64,8 @@ JSON exchanged with editors: {"name": "...", "spec": {...}} monitor size, reserved edges, gaps, and layout area workspaces [--json] every workspace with its monitor and layout windows [--json] open windows (class, title, workspace) + scene [...] list, save, apply, current, restore, retry, content; + see docs/SCENES.md session [name] status, save , restore [name], freeze, resume, stop, logout, reboot, shutdown; see docs/SESSIONS.md default [name|BUILTINS] [--no-reload] @@ -442,6 +444,19 @@ end local argv = { ... } local cmd = table.remove(argv, 1) +if cmd == "scene" then + local entry = "hypertile-scenes" + local command = src and src ~= "" and (src .. "/bin/" .. entry) + or ((os.getenv("HOME") or "") .. "/.local/bin/" .. entry) + local words = { command } + if cmd == "scene" then + if #argv == 0 then argv[1] = "current" end + end + for _, value in ipairs(argv) do words[#words + 1] = value end + for i, value in ipairs(words) do words[i] = "'" .. value:gsub("'", "'\\''") .. "'" end + local ok, _, code = os.execute(table.concat(words, " ")) + os.exit(ok and 0 or (code or 1)) +end if not cmd or cmd == "-h" or cmd == "--help" then cmd = "help" end diff --git a/bin/hypertile-scenes b/bin/hypertile-scenes new file mode 100755 index 0000000..07cf8b8 --- /dev/null +++ b/bin/hypertile-scenes @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +"""Entry point for the installed scene service (or HYPERTILE_SRC).""" +import os +from pathlib import Path +import runpy +import sys + +root = Path(os.environ["HYPERTILE_SRC"]) if os.environ.get("HYPERTILE_SRC") else Path( + os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share") / "hypertile" +sys.path.insert(0, str(root / "session")) +sys.path.insert(0, str(root / "scenes")) +runpy.run_path(str(root / "scenes/scene_service.py"), run_name="__main__") diff --git a/bin/hypertile-session b/bin/hypertile-session index fcb6b52..8163197 100755 --- a/bin/hypertile-session +++ b/bin/hypertile-session @@ -3,7 +3,9 @@ import os from pathlib import Path import runpy +import sys source = os.environ.get("HYPERTILE_SRC") root = Path(source) if source else Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share") / "hypertile" +sys.path.insert(0, str(root / "session")) runpy.run_path(str(root / "session/service.py"), run_name="__main__") diff --git a/dev b/dev index 278b763..7c4d44d 100755 --- a/dev +++ b/dev @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Link and apply a Hypertile checkout to an existing Omarchy installation.""" import argparse -from contextlib import contextmanager, nullcontext +from contextlib import contextmanager, nullcontext, ExitStack import fcntl import hashlib import json @@ -15,6 +15,8 @@ import tempfile import time ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT / "session")) +from upgrade import check_legacy, obsolete, cleanup CONFIG = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") DATA = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share") STATE = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "hypertile" @@ -56,7 +58,8 @@ def groups(): return { "lua": sorted(ROOT.glob("hypertile*.lua")), "cli": [ROOT / "bin/hypertile-ctl"], - "session": [ROOT / "bin/hypertile-session", *sorted((ROOT / "session").glob("*.py"))], + "session": [ROOT / "bin/hypertile-session", *sorted((ROOT / "bin").glob("hypertile-scenes")), + *sorted((ROOT / "session").glob("*.py")), *sorted((ROOT / "scenes").glob("*.py"))], "shell": [ROOT / "manifest.json", *sorted(p for p in (ROOT / "plugin").rglob("*") if p.is_file())], } @@ -71,7 +74,7 @@ def destination(path): relative = path.relative_to(ROOT) if relative.parts[0] == "bin": return BIN / path.name - if relative.parts[0] == "session": + if relative.parts[0] in ("session", "scenes"): return DATA / "hypertile" / relative return CONFIG / "hypr" / path.name @@ -163,26 +166,63 @@ def wait_for(probe, description, seconds=10): @contextmanager def stopped_session(): - path = STATE / "sessions/writer.lock" - path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - with path.open("a") as lock: - def available(): - try: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - return True - except BlockingIOError: - return False - if not available(): - status = session_status() - if not status: - raise RuntimeError("session writer is busy but not responding; no files were changed") - if status.get("instance") != ENV.get("HYPRLAND_INSTANCE_SIGNATURE"): - raise RuntimeError("session service belongs to another compositor; use its terminal or --instance") - run(BIN / "hypertile-session", "stop") - wait_for(available, "session service to stop") - # Hold the writer lock across installation/reload. The loader cannot - # start a watcher against a half-updated set of runtime files. - yield + with ExitStack() as stack: + scene_lock = None + if (BIN / "hypertile-scenes").exists(): + path = STATE / "scenes/writer.lock" + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + scene_lock = stack.enter_context(path.open("a")) + def scene_available(): + try: + fcntl.flock(scene_lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + return True + except BlockingIOError: + return False + if not scene_available(): + run(BIN / "hypertile-scenes", "stop", timeout=10) + wait_for(scene_available, "scene service to stop") + stream_lock = None + if (BIN / "hypertile-stream").exists() or (STATE / "streams/state.json").exists(): + path = STATE / "streams/writer.lock" + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + stream_lock = stack.enter_context(path.open("a")) + def stream_available(): + try: + # Exclude the legacy writer while permitting Remote Desktops + # to keep its shared migration guard throughout deployment. + fcntl.flock(stream_lock, fcntl.LOCK_SH | fcntl.LOCK_NB) + return True + except BlockingIOError: + return False + if not stream_available(): + status = json.loads(run(BIN / "hypertile-stream", "status", "--json", timeout=3).stdout) + if status.get("instance") != ENV.get("HYPRLAND_INSTANCE_SIGNATURE"): + raise RuntimeError("legacy stream controller belongs to another compositor") + if any(r.get("desired") or r.get("journal") for r in status.get("computers", [])): + raise RuntimeError("disconnect/restore legacy Hypertile streams before applying runtime changes") + run(BIN / "hypertile-stream", "stop", timeout=55) + wait_for(stream_available, "stream controller to stop") + check_legacy(STATE) + path = STATE / "sessions/writer.lock" + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + with path.open("a") as lock: + def available(): + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + return True + except BlockingIOError: + return False + if not available(): + status = session_status() + if not status: + raise RuntimeError("session writer is busy but not responding; no files were changed") + if status.get("instance") != ENV.get("HYPRLAND_INSTANCE_SIGNATURE"): + raise RuntimeError("session service belongs to another compositor; use its terminal or --instance") + run(BIN / "hypertile-session", "stop") + wait_for(available, "session service to stop") + # Hold the writer lock across installation/reload. The loader cannot + # start a watcher against a half-updated set of runtime files. + yield def lua_string(value): @@ -202,6 +242,9 @@ def reload_lua(): def start_session(): + if (BIN / "hypertile-scenes").exists(): + command = shlex.join(["env", "-u", "HYPERTILE_SRC", str(BIN / "hypertile-scenes"), "daemon"]) + run("hyprctl", "eval", f"hl.exec_cmd({lua_string(command)})") settings = read_json(CONFIG / "hypertile/session.json", {}) if settings.get("enabled", True) is False: print("Session service is disabled in session.json.") @@ -256,6 +299,13 @@ def apply(force): saved.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(target, saved) atomic_write(target, content, 0o755 if path.relative_to(ROOT).parts[0] == "bin" else 0o644) + if "session" in changed: + for target in obsolete(BIN, DATA): + if target.exists(): + saved = backup / "retired" / (Path("bin") / target.name if target.parent == BIN else target.relative_to(DATA)) + saved.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(target, saved) + cleanup(BIN, DATA) if "lua" in changed: reload_lua() finally: diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..fcc62ce --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,88 @@ +# Releases and marketplace approval + +## Current candidate + +- Repository/default branch: `jdvmi00/hypertile`, `main`. +- Candidate: the `1.1.0` release, the merge of its release PR into `main`, + tagged `v1.1.0`. `main` is locked at that commit; its full SHA is recorded + here once the promotion has been read back. +- Submission: https://github.com/omacom/omarchy-plugin-marketplace/issues/4893 + (initial listing, pending). It supersedes the earlier candidate + `1ba0f8a30526f2148ff46885d67991862ae90f00` (manifest 1.0.1, marker tag + `marketplace-1ba0f8a`); leave that tag and the older `v1.0.1` tag alone. + +`main` is locked, including for administrators. It remains GitHub's default +branch because marketplace validation and upstream installation resolve it. +Ongoing development belongs on `develop` and feature branches. Scenes and +session recovery are part of this candidate. + +## Development + +1. Create a feature branch from current `develop`, preserving existing work. +2. Implement and run relevant tests from `.github/workflows/test.yml`. +3. Push the feature branch and open a PR targeting `develop`. +4. Wait for `test` and `windows-display-policy` to pass, then merge the PR. + +The `windows-display-policy` job runs the extracted policy suite from an immutable +Remote Desktops commit on Windows; host code is no longer duplicated here. + +Both integration branches require passing checks on an up-to-date PR; no +additional reviewer is required for this solo-maintainer repository. Force +pushes and deletion are disabled. The extra lock on `main` prevents even a +passing development PR from changing the candidate accidentally. + +## Finish the current submission + +1. Verify remote `main` still equals the candidate above and both its + checks passed. +2. Update the existing submission's maintainer notes to describe this exact + tree, including session recovery, background service, application launch, + and menu/config changes. Remove stale claims such as no background service. + Preserve the issue form headings and checklist. Editing the issue triggers + marketplace validation; do not open a duplicate initial submission. +3. Wait for both the marketplace compatibility report and automated security + baseline to identify the full candidate SHA (the visible report may + abbreviate it). Resolve reported blockers before requesting re-review. +4. Ask a marketplace maintainer to review the current evidence and apply + `approved-and-verified`. Installer capabilities may require manual review. +5. Verify publication completed and the listing snapshot equals the candidate. + A green local CI run, candidate tag, or retained approval label alone does + not demonstrate publication. Leave `main` locked after publication. + +Any code fix would create a different candidate. Obtain the owner's explicit +instruction before replacing this specifically selected SHA. + +## Subsequent releases + +1. Obtain an explicit release instruction. Prepare the complete release on + a branch from `develop`: bump the manifest version beyond `1.0.1`, finish + changelog/README/dependency and installer disclosures, and pass all CI. +2. Merge the preparation into `develop`; open the release PR into `main`. + Review the complete diff and wait for both required checks on the current + revision. Do not include unfinished development features. +3. For the authorized promotion only, unlock `main` through branch protection, + retaining required PRs, status checks, admin enforcement, and force-push/ + deletion restrictions. Merge the release PR, immediately lock `main` + again, and read back its full resulting SHA and protection settings. + If promotion fails, restore the lock before doing anything else. +4. Confirm CI on the resulting `main` SHA passes. Tag that exact SHA with the + matching new version and publish matching GitHub release notes. Never move + an existing release tag. Do not call the release marketplace-verified yet. +5. For an already listed plugin, use the marketplace's Plugin verification + form, action **Verify and publish a newer upstream commit**, plugin ID + `jmartin.hypertile`, repository root URL, and full current `main` SHA. + For an initial listing still pending, update #4893 instead. +6. Wait for fresh compatibility and baseline reports, then maintainer approval + and successful publication, all covering that same SHA. Keep `main` frozen + throughout; continue development elsewhere. + +Moving `main` can show `Update unverified` until the new snapshot is approved. +Omarchy's mutable upstream installation command is not bound to marketplace +verification. Tags do not change which commit that command installs. + +## References + +- https://github.com/omacom/omarchy-plugin-marketplace/blob/main/SUBMISSION.md +- https://github.com/omacom/omarchy-plugin-marketplace/blob/main/VERIFICATION.md + +Recheck these policies before each release; the marketplace workflow can change. diff --git a/docs/SCENES.md b/docs/SCENES.md new file mode 100644 index 0000000..0ada38c --- /dev/null +++ b/docs/SCENES.md @@ -0,0 +1,192 @@ +# Scenes and content + +A scene saves a workspace's layout and the apps assigned to its zones. Choose +an installed app, an already open local window, normal fill order, or Empty. +Scenes launches or reuses an app window and places it once. You can then move, +resize, float, fullscreen, or close it without Scenes pulling it back or +opening another copy. + +Remote Desktops is an ordinary app in this model. It owns Moonlight/Sunshine, +connection profiles, reconnects, and host display restoration. Hypertile owns +layout and initial placement. No stream controller or computers.json is needed +for app scenes. + +## Overlay + +Open **Super+Alt+L** and switch to **Scenes**. Each zone card on the screen +shows what it holds: the app's icon and name with its state, or *Local +windows · fill order*. Click a card (or a row under **Zones**, or press its +fill number) and the picker below takes the keys: type to search, ↑ ↓ pick a +match, Enter assigns it, Esc clears the search. Hovering a match previews it +in the selected card. Zones are listed by where they sit ("Top left"), with +the layout's own name where positions would collide. **Local windows** and **Empty** are the two chips above the +list. **Open here** lists the windows already on this workspace with their +titles; choosing one pins that window without launching anything. **Remote +desktops** lists each computer's Remote Desktops launcher (install it there +first; it uses that computer's default profile). **Apps** lists installed +desktop entries with a known window identity; entries whose window class is +a packaging placeholder are left out. A card's **Change…** and **Clear** do +the same from the screen, and a card that needs attention is outlined and +offers **Retry**. + +Applications declaring `StartupWMClass` are available immediately. For other +apps, an open window whose class equals the desktop ID without `.desktop` +provides the identity. Apps without either can be configured through the CLI +with an explicit class and optional exact title. + +The header names the workspace until a scene is applied, then the scene, with +the layout, the workspace, and how many apps are placed. **Scenes** lists the +saved scenes as cards: the layout with the apps it places drawn in their +zones, and what it holds. Click a card to apply it; the delete appears on hover +and confirms inline. + +**Save as scene…** stores the current definition. **Apply** requests that saved +arrangement again, including apps you moved or closed. **Retry** explicitly +rechecks placement and may retry a failed/timed-out launch. Closing an app or +moving it yourself leaves its source marked *Closed* or *Moved*. Changes to the +saved definition happen only when you save. + +**Restore previous** restores the prior layout/content and eligible app pins. +It leaves apps open and does not move departed windows back to their original +workspace. Changing scenes or cancelling an in-progress scene stops pending +placement; an app already launched may still open normally. Empty/fill behavior +and the layout's application rules continue to apply to other windows. + +Layout browsing previews the geometry and restores the committed layout when +you leave the preview. The lease expires after ten seconds without a heartbeat. +Session capture waits until the preview ends. Choosing a different layout +replaces the assignments with local content; open apps keep running. + +## CLI + +```bash +hypertile-ctl scene catalog --json +hypertile-ctl scene content --zone right --type app --desktop-id remote-desktops-macbook.desktop +hypertile-ctl scene content --zone left --type empty +hypertile-ctl scene content --zone center --type local --app-class org.example.Editor +hypertile-ctl scene save work +hypertile-ctl scene apply work --workspace 1 +hypertile-ctl scene current --workspace 1 --json +hypertile-ctl scene retry --workspace 1 +hypertile-ctl scene restore --workspace 1 +``` + +For an app without declared identity, add `--app-class org.example.App` and, +when its class is shared, `--app-title 'Exact window title'`. Classes and titles +are literal strings, not regexes. Use `hypertile-ctl windows --json` to inspect +windows. Desktop IDs resolve through the XDG application directories, with +user entries taking precedence. Arbitrary desktop paths and stored shell +commands are not accepted. `gio launch` handles the installed desktop file. + +Scenes uses existing numbered workspaces. Omit `--workspace` for the current +one. An explicit assignment elsewhere supersedes an older pending assignment +of the same app. Applying a scene accepts work asynchronously; inspect `current` +for progress. A missing app window times out after 45 seconds. Ambiguous matches +require closing extras or narrowing the title; no arbitrary window is selected. +An interrupted launch is not automatically submitted again after service restart. + +`cancel` aliases `restore`. `remove NAME` deletes only the saved definition. +`layout NAME` applies a layout with normal local fill. Placement itself does not +change focus; the app's own launcher may activate its window. + +## Format and stable references + +Definitions live in `~/.config/hypertile/scenes/NAME.json`, mode 0600. Save this +input using `scene save work --file scene.json`, substituting your layout/zones: + +```json +{ + "version": 1, + "layout": "my-layout", + "sources": { + "right": { + "type": "app", + "desktop_id": "remote-desktops-macbook.desktop", + "app_class": "com.moonlight_stream.Moonlight", + "app_title": "MacBook - Moonlight" + }, + "left": { "type": "empty" }, + "center": { "type": "local", "app_class": "org.example.Editor" } + } +} +``` + +Remote Desktops publishes `X-RemoteDesktops-WindowClass` and +`X-RemoteDesktops-WindowTitle` in each launcher. Scenes reads those optional +metadata fields and preserves their exact match. This distinguishes computers +whose Moonlight windows share a class. Ordinary apps use the same placement +path. Overlapping app matches within a scene are rejected. + +On first save, Hypertile adds a persistent `layout_id` and leaf `id` fields to +the layout without changing geometry. Saved sources use leaf IDs, with `zone` +as a readable hint. `scene show NAME` shows the normalized document; +`scene validate --file FILE` checks it without applying changes. Name-based +imports are allowed only when the input omits `layout_id`. + +Renaming/reordering zones preserves IDs. Splitting retains the original ID on +one half and gives the other a new ID. Copied layouts get new IDs. Deleted or +ambiguous identities require choosing replacements; reusing a name does not +revive a deleted zone. Keep one fill/cycle zone available for local overflow. +No monitor-input source is enabled without a validated hardware profile. + +## Service and recovery + +The independent `hypertile-scenes` service owns +`~/.local/state/hypertile/scenes/state.json` and +`$XDG_RUNTIME_DIR/hypertile-scenes/control.sock`. It takes its own writer lock, +so it can run alongside Remote Desktops. It does not read legacy computer +configuration or modify host display journals. The loader starts it; CLI scene +commands also start it on demand. Launch/placement intent is persisted before +effects. Work advances at 200 ms while connecting; settled scenes are checked +at 30-second intervals or when the overlay/CLI requests state. There is no +per-frame scripting in this path. + +A restart in the same compositor preserves consumed launches and placements. +A new compositor waits for session recovery's checkpoint before applying scene +references. Scene-managed app windows are excluded from ordinary app recovery +so there is one launch owner. Checkpoints omit app assignments that the user +moved/closed; moved windows use normal session recovery, including the exact +computer launcher where available. Saved scene files retain their defaults. +Missing scene service/invalid legacy references produce recovery warnings. + +Pins are restored only when the same window still has the scene-owned pin on +the same workspace. Identity checks include compositor address, stable ID, and +PID, and happen again immediately before placement. A lost placement reply +requires explicit reapplication instead of risking an automatic second move. + +## Migrating legacy remote scenes + +Existing `type: "stream"` scene files and legacy host recovery journals are +preserved. They are not automatically converted: install each computer's +Remote Desktops launcher, disconnect/restore legacy Hypertile streams, and +replace those sources with `type: "app"` entries. Legacy stream scenes appear +invalid until migrated. Upgrade and uninstall refuse to remove recovery tools +while any legacy connection is desired or has a pending host journal, including +when its controller is stopped. Finish recovery with the previously installed +version before upgrading. Configuration and state files are kept. + +Hypertile no longer installs `hypertile-stream`, host adapters, Windows display +scripts, connection controls, or quality measurements. The standalone Scenes +service lives under `scenes/`; it only manages layouts and app placement. +Connection, input capture, audio, and host display controls belong to +[Remote Desktops](https://github.com/jdvmi00/remote-desktops). + +## Validation + +On 2026-09-05, a live MacBook check used the installed desktop launcher with the +new scene service and Lua adapter on temporary workspaces. Initial zone +placement passed; a move to another workspace survived a scene-service restart; +explicit reapplication reused the same client PID and connection generation; +restoring the scene left the app running. Cleanup disconnected the test session +and completed host display restoration. The regular session watcher was paused +during the check and resumed afterward. The installed plugin was not replaced. + +Automated tests cover exact/ambiguous matches, interrupted launches and placement +replies, app-only session recovery, cancelled/superseded operations, XDG launcher +precedence, socket/lock isolation, and preservation of manual moves/closes. +Both MacBook and the Windows work laptop were subsequently validated live with +Remote Desktops launchers and the saved two-app scene. Workspace movement, +launcher reuse, explicit scene reapplication, and Windows reconnection passed; +the owner confirmed video, mouse, and keyboard on both computers. These checks +preceded removal of the legacy code; removal is covered by the generic app, +scene, session, swap, preview, and upgrade regression suites. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index aeb3aa3..58b45dc 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -178,3 +178,11 @@ intent before spawning apps, and persists successful matches. Restarting the service during recovery in the same compositor does not relaunch already attempted apps. Automatic saving resumes only after all saved windows have matched and settled, or after an explicit `resume`. + +Workspace [scenes](SCENES.md) are checkpointed as versioned definitions. The +independent scene service places their apps, including on an otherwise empty +workspace. Moved or closed app assignments are omitted from recovery without +changing saved scene defaults. Normal app recovery handles moved windows using +their exact launcher identity. Old remote assignments generate a migration +message and are never sent to a connection controller. Scene definitions do not +include transient compositor window IDs. diff --git a/hypertile-layouts.lua b/hypertile-layouts.lua index 2d53a42..2b39be1 100644 --- a/hypertile-layouts.lua +++ b/hypertile-layouts.lua @@ -53,6 +53,14 @@ end -- The service holds an exclusive writer lock, so reloads cannot create a -- second watcher or trigger a second restore. Start after workspace rules. if hl.timer then + hl.timer(function() + local command = (os.getenv("HOME") or "") .. "/.local/bin/hypertile-scenes" + local f = io.open(command, "r") + if f then + f:close() + hl.exec_cmd("'" .. command:gsub("'", "'\\''") .. "' daemon") + end + end, { timeout = 500, type = "oneshot" }) hl.timer(function() local command = (os.getenv("HOME") or "") .. "/.local/bin/hypertile-session" local f = io.open(command, "r") diff --git a/hypertile-navigation.lua b/hypertile-navigation.lua index 66dac48..2376d25 100644 --- a/hypertile-navigation.lua +++ b/hypertile-navigation.lua @@ -46,10 +46,37 @@ local function navigate(direction, swap) return hl.dispatch(dispatcher({ direction = direction })) end if active.fullscreen ~= 0 then return end + if swap then + local session = require("hypr.hypertile-session") + local source, slots = session.navigation_slots(active) + if source then + -- Retain movement within a stacked slot before leaving that slot. + local stacked = M.neighbor(active, source.windows, direction) + if stacked then + if not session.swap(active, stacked) then + hl.dispatch(hl.dsp.window.swap({ target = "address:" .. stacked.address })) + end + return + end + local destination = M.neighbor(source, slots, direction) + if not destination then return end + if #destination.windows == 0 then + return session.move_to_empty(active, destination.zone) + end + -- Use an actual occupant for swaps, including slots with a stack. + local target = destination.windows[1] + if not session.swap(active, target) then + hl.dispatch(hl.dsp.window.swap({ target = "address:" .. target.address })) + end + return + end + end local target = M.neighbor(active, hl.get_windows({ workspace = active.workspace, floating = false }), direction) if target then if swap then - hl.dispatch(hl.dsp.window.swap({ target = "address:" .. target.address })) + if not require("hypr.hypertile-session").swap(active, target) then + hl.dispatch(hl.dsp.window.swap({ target = "address:" .. target.address })) + end else hl.dispatch(hl.dsp.focus({ window = "address:" .. target.address })) end diff --git a/hypertile-session.lua b/hypertile-session.lua index cad22b2..8d6f761 100644 --- a/hypertile-session.lua +++ b/hypertile-session.lua @@ -6,6 +6,7 @@ local prefix = modname:match("^(.-)hypertile%-session$") or "" local engine = require(prefix .. "hypertile") local json = require(prefix .. "hypertile-json") local M = {} +local scene_content = {} local function selector(ws) if ws.special then return ws.name end @@ -15,6 +16,7 @@ end function M.snapshot() local out = { windows = json.array(), workspaces = json.array(), layouts = {}, monitors = json.array() } + out.scene_content = scene_content for _, mon in ipairs(hl.get_monitors()) do out.monitors[#out.monitors + 1] = { name = mon.name, x = mon.x, y = mon.y } end @@ -27,6 +29,7 @@ function M.snapshot() id = ws.id, selector = selector(ws), layout = ws.tiled_layout, monitor = ws.monitor and ws.monitor.name, visible = ws.visible, special = ws.special, order = json.array(live and live.orders[tostring(ws.id)] or {}), + navigation_keep = live and live.state.navigation_keep and live.state.navigation_keep[tostring(ws.id)] or nil, } if live then out.layouts[name] = { spec = live.spec, sizes = live.state.sizes } @@ -36,13 +39,26 @@ function M.snapshot() -- go away; this query runs every few seconds, so prune here. for _, live in pairs(engine.live) do for id in pairs(live.orders or {}) do - if not existing[id] then live.orders[id] = nil end + if not existing[id] then + live.orders[id] = nil + if live.boxes then live.boxes[id] = nil end + if live.state.navigation_keep then live.state.navigation_keep[id] = nil end + end end end for _, win in ipairs(hl.get_windows()) do if win.mapped and win.workspace then local name = win.workspace.tiled_layout:match("^lua:(.+)$") local live = name and engine.live[name] + local scene_app + for workspace, scene in pairs(scene_content) do + if workspace == selector(win.workspace) then + for _, ref in pairs(scene.app_placements or {}) do + if ref.address == win.address and ref.pid == win.pid and ref.stable_id == win.stable_id + and live and live.state.pins[win.address] == ref.zone then scene_app = true end + end + end + end out.windows[#out.windows + 1] = { address = win.address, stable_id = win.stable_id, pid = win.pid, class = win.class, title = win.title, initial_class = win.initial_class, initial_title = win.initial_title, @@ -50,6 +66,8 @@ function M.snapshot() at = win.at, size = win.size, floating = win.floating, pinned = win.pinned, fullscreen = win.fullscreen, fullscreen_client = win.fullscreen_client, pin = live and live.state.pins[win.address], grouped = win.group ~= nil, + pin_exclusive = live and live.state.exclusive_pins and live.state.exclusive_pins[win.address] or nil, + scene_app = scene_app, } end end @@ -81,6 +99,330 @@ local function dispatch(fn, args) if type(result) == "table" and result.error then error(result.error) end end +local function refresh_workspace(ws) + -- Target a window explicitly: neither this nor session.place changes focus. + for _, w in ipairs(hl.get_windows()) do + if w.mapped and w.workspace and w.workspace.id == ws.id and not w.floating and (w.fullscreen or 0) == 0 then + dispatch(hl.dsp.window.resize, { window = "address:" .. w.address, x = 0, y = 0, relative = true }) + break + end + end +end + +function M.scene_layout(request) + local bridge = require(prefix .. "hypertile-bridge") + if request.spec then engine.layout(request.layout:match("^lua:(.+)$"), request.spec) end + local rule = bridge.rule_source(request.workspace, request.layout, request.spec) + assert(load(rule, "=scene-workspace", "t"))() + return rule -- Persistence belongs to the external writer, never nested hyprctl. +end + +function M.scene_clear(request) + local old = scene_content[request.workspace] + if not old then return true end + local windows = {} + for _, w in ipairs(hl.get_windows()) do windows[w.address] = w end + local live = engine.live[old.layout:match("^lua:(.+)$")] + if live then + if live.state.scene_empty then live.state.scene_empty[tostring(old.workspace_id)] = nil end + for _, p in ipairs(old.pins or {}) do + local w = windows[p.address] + if w and w.stable_id == p.stable_id and w.pid == p.pid and w.workspace + and selector(w.workspace) == request.workspace and live.state.pins[p.address] == p.zone then + live.state.pins[p.address] = p.before + if live.state.exclusive_pins then live.state.exclusive_pins[p.address] = p.exclusive end + end + end + end + scene_content[request.workspace] = nil + for _, ws in ipairs(hl.get_workspaces()) do if selector(ws) == request.workspace then refresh_workspace(ws) end end + return true +end + +function M.scene_content_apply(request) + local previous = scene_content[request.workspace] + if request.operation and previous and previous.operation == request.operation then return previous end + local ws, live + for _, w in ipairs(hl.get_workspaces()) do + if selector(w) == request.workspace and w.tiled_layout == request.layout then ws = w end + end + if not ws and request.allow_missing_workspace and request.operation then + for _, w in ipairs(hl.get_workspaces()) do + assert(selector(w) ~= request.workspace, "scene workspace layout changed") + end + assert(request.workspace:match("^[1-9][0-9]*$"), "invalid scene workspace") + ws = { id = tonumber(request.workspace) } + end + assert(ws, "scene workspace or layout changed") + live = engine.live[request.layout:match("^lua:(.+)$")] + assert(live, "scene requires a Hypertile layout") + local empty, blocked = {}, {} + for _, source in ipairs(request.sources) do + local zone = source.zone_id and live.compiled.zone_ids[source.zone_id] or source.zone + assert(zone and live.compiled.leaf_set[zone], "scene zone no longer exists") + source.zone = zone + if source.type == "empty" then empty[zone], blocked[zone] = true, true end + assert(source.type == "local" or source.type == "app" or source.type == "empty", "unsupported scene source type") + end + local available = false + for _, zone in ipairs(live.compiled.cycle) do if not blocked[zone] then available = true end end + assert(available, "leave one fill zone for local windows") + M.scene_clear(request) + live.state.scene_empty = live.state.scene_empty or {} + live.state.scene_empty[tostring(ws.id)] = empty + live.state.exclusive_pins = live.state.exclusive_pins or {} + local record = { workspace_id = ws.id, layout = request.layout, operation = request.operation, + app_placements = {}, pins = json.array(), results = json.array() } + scene_content[request.workspace] = record + for _, source in ipairs(request.sources) do + if source.type == "local" and source.app_class then + local matches = {} + for _, w in ipairs(hl.get_windows()) do + if w.mapped and w.workspace and w.workspace.id == ws.id and not w.floating + and w.class == source.app_class then matches[#matches + 1] = w end + end + local result = { zone = source.zone, status = "needs-attention", error = #matches == 0 and "Open this app on the workspace" or "More than one matching app window is open" } + if #matches == 1 then + local w = matches[1] + record.pins[#record.pins + 1] = { address = w.address, pid = w.pid, stable_id = w.stable_id, + zone = source.zone, before = live.state.pins[w.address], exclusive = live.state.exclusive_pins[w.address] } + live.state.pins[w.address], live.state.exclusive_pins[w.address] = source.zone, true + result.status, result.error = "ready", nil + end + record.results[#record.results + 1] = result + end + end + refresh_workspace(ws) + return { results = record.results, pins = record.pins } +end + +-- Called once per scene operation, with a live identity rather than a class +-- dispatcher. Validation and placement happen together on the compositor thread. +function M.scene_app_place(request) + local record = assert(scene_content[request.workspace], "Scene was superseded") + assert(request.operation and record.operation == request.operation, "Scene was superseded") + assert(record.layout == request.layout, "Scene layout changed") + local live = assert(engine.live[request.layout:match("^lua:(.+)$")], "Scene layout is unavailable") + request.zone = live.compiled.zone_ids[request.zone_id] + assert(request.zone and not live.compiled.leaf_opts[request.zone].spacer, "Scene zone is unavailable") + local ws + for _, candidate in ipairs(hl.get_workspaces()) do + if selector(candidate) == request.workspace then + assert(candidate.tiled_layout == request.layout, "Scene workspace layout changed") + ws = candidate + end + end + local previous = record.app_placements[request.zone_id] + if previous then return previous end + local matches = {} + for _, w in ipairs(hl.get_windows()) do + if w.mapped and w.workspace and w.class == request.app_class + and (not request.app_title or w.title == request.app_title) then matches[#matches + 1] = w end + end + assert(#matches == 1, "App window match changed or is ambiguous") + local w = matches[1] + assert(w.address == request.address and w.stable_id == request.stable_id and w.pid == request.pid, + "App window identity changed") + local pin = { address = w.address, stable_id = w.stable_id, pid = w.pid, zone = request.zone, + before = live.state.pins[w.address], exclusive = (live.state.exclusive_pins or {})[w.address] } + -- Mark consumed before dispatching. A lost IPC reply or later manual move + -- must not turn the next call into a second placement. + record.app_placements[request.zone_id] = pin + record.pins[#record.pins + 1] = pin + M.place({ address = w.address, layout = request.layout, + saved = { workspace = request.workspace, pin = request.zone, pin_exclusive = true, floating = false } }) + if ws then refresh_workspace(ws) end + return pin +end + +function M.scene_restore_pins(request) + for _, saved in ipairs(request.windows or {}) do + for _, w in ipairs(hl.get_windows()) do + if w.address == saved.address and w.stable_id == saved.stable_id and w.pid == saved.pid + and w.workspace and selector(w.workspace) == request.workspace then + local live = engine.live[w.workspace.tiled_layout:match("^lua:(.+)$")] + if live and live.state.pins[w.address] == saved.zone then + live.state.pins[w.address] = saved.before + live.state.exclusive_pins = live.state.exclusive_pins or {} + live.state.exclusive_pins[w.address] = saved.exclusive + end + end + end + end + for _, ws in ipairs(hl.get_workspaces()) do if selector(ws) == request.workspace then refresh_workspace(ws) end end + return true +end + +local function swap_windows(request) + local found = {} + for i, ref in ipairs(request.windows) do + for _, w in ipairs(hl.get_windows()) do + if w.address == ref.address and w.stable_id == ref.stable_id then found[i] = w end + end + local w = found[i] + assert(w and w.mapped and w.workspace and not w.floating and not w.hidden and (w.fullscreen or 0) == 0, + "swap unavailable: window closed, moved or became fullscreen") + end + assert(#found == 2 and found[1].address ~= found[2].address, "swap needs two different windows") + local ws = found[1].workspace + assert(ws.id == found[2].workspace.id, "swap unavailable: windows must share a workspace") + local live = engine.live[ws.tiled_layout:match("^lua:(.+)$")] + assert(live, "swap unavailable: workspace must use Hypertile") + return found, ws, live +end + +local function workspace_buckets(ws, live) + local by_address, targets = {}, {} + for _, w in ipairs(hl.get_windows()) do + if w.mapped and w.workspace and w.workspace.id == ws.id and not w.floating then by_address[w.address] = w end + end + for _, address in ipairs(live.orders[tostring(ws.id)] or {}) do + if by_address[address] then targets[#targets + 1] = { window = by_address[address] }; by_address[address] = nil end + end + assert(next(by_address) == nil, "swap unavailable: waiting for layout order") + local reserved = {} + for name in pairs((live.state.scene_empty or {})[tostring(ws.id)] or {}) do reserved[name] = true end + return engine.assign(live.compiled, targets, { + pins = live.state.pins, exclusive_pins = live.state.exclusive_pins, + reserved = reserved, + }) +end + +-- Resolve actual engine assignments, including rules, pins and reservations. +-- Planning validates both windows without changing either pin. +function M.swap_plan(request) + local windows, ws, live = swap_windows(request) + local buckets = workspace_buckets(ws, live) + local plan = { workspace = selector(ws), layout = ws.tiled_layout, windows = json.array() } + for i, w in ipairs(windows) do + local zone + for name, bucket in pairs(buckets) do + for _, t in ipairs(bucket) do + if t.window.address == w.address then + assert(#bucket == 1, "swap unavailable: use a zone containing one window") + zone = name + end + end + end + assert(zone, "swap unavailable: window has no zone") + plan.windows[i] = { address = w.address, stable_id = w.stable_id, pid = w.pid, + before = zone, before_id = live.compiled.leaf_opts[zone].id, + pin = live.state.pins[w.address], + exclusive = live.state.exclusive_pins and live.state.exclusive_pins[w.address] or nil } + end + assert(plan.windows[1].before ~= plan.windows[2].before, "swap unavailable: windows share a zone") + for i, ref in ipairs(plan.windows) do + ref.zone = plan.windows[3 - i].before + ref.zone_id = live.compiled.leaf_opts[ref.zone].id + end + return plan +end + +-- Navigation uses layout boxes rather than window edges, so empty slots and +-- aspect-constrained windows have the same directional destinations. +function M.navigation_slots(active) + local ws = active.workspace + local live = engine.live[ws.tiled_layout:match("^lua:(.+)$")] + local boxes = live and live.boxes and live.boxes[tostring(ws.id)] + if not boxes then return end + local ok, buckets = pcall(workspace_buckets, ws, live) + if not ok then return end -- Wait for the compositor's next layout order. + local slots, source = {}, nil + for index, name in ipairs(live.compiled.leaves) do + local box, bucket = boxes[name], buckets[name] + if box and not live.compiled.leaf_opts[name].spacer and not bucket.reserved then + local slot = { address = string.format("%08d", index), zone = name, workspace = ws, + at = { x = box.x, y = box.y }, size = { x = box.w, y = box.h }, windows = {} } + local unavailable = false + for _, target in ipairs(bucket) do + slot.windows[#slot.windows + 1] = target.window + if target.window.hidden or (target.window.fullscreen or 0) ~= 0 then unavailable = true end + if target.window.address == active.address then source = slot end + end + if not unavailable then slots[#slots + 1] = slot end + end + end + return source, slots +end + +function M.move_to_empty(active, zone) + local ws = active.workspace + local live = engine.live[ws.tiled_layout:match("^lua:(.+)$")] + local buckets = workspace_buckets(ws, live) + assert(live.compiled.leaf_set[zone] and not live.compiled.leaf_opts[zone].spacer, + "move unavailable: zone changed") + assert(#buckets[zone] == 0 and not buckets[zone].reserved, "move unavailable: zone is not empty") + local found = false + for _, bucket in pairs(buckets) do + for _, target in ipairs(bucket) do + local w = target.window + if w.address == active.address and w.stable_id == active.stable_id and w.pid == active.pid + and not w.hidden and (w.fullscreen or 0) == 0 then found = true end + end + end + assert(found, "move unavailable: window changed") + -- Explicit placement reveals configured slots even in collapsing layouts. + live.state.navigation_keep = live.state.navigation_keep or {} + live.state.navigation_keep[tostring(ws.id)] = true + -- Preserve the other windows' assignments before moving the active one; + -- otherwise fill order would pull them into the newly vacated slot. + live.state.exclusive_pins = live.state.exclusive_pins or {} + for name, bucket in pairs(buckets) do + for _, target in ipairs(bucket) do + local address = target.window.address + live.state.pins[address] = address == active.address and zone or name + live.state.exclusive_pins[address] = true + end + end + refresh_workspace(ws) + return true +end + +-- Absolute assignments make retry after a lost IPC reply safe. Validate the +-- entire exchange before changing either pin; never focus or relaunch. +function M.swap_apply(plan) + local windows, ws, live = swap_windows(plan) + assert(selector(ws) == plan.workspace and ws.tiled_layout == plan.layout, "swap unavailable: layout changed") + for i, w in ipairs(windows) do + local ref = plan.windows[i] + assert(w.pid == ref.pid, "swap unavailable: window identity changed") + assert(live.compiled.leaf_set[ref.zone] and not live.compiled.leaf_opts[ref.zone].spacer, + "swap unavailable: zone changed") + assert((not ref.zone_id or live.compiled.leaf_opts[ref.zone].id == ref.zone_id) + and (not ref.before_id or (live.compiled.leaf_opts[ref.before] or {}).id == ref.before_id), + "swap unavailable: zone identity changed") + local pin = live.state.pins[w.address] + assert(pin == ref.pin or pin == ref.zone, "swap unavailable: pin changed") + end + local zones = {} + for name in pairs((live.state.scene_empty or {})[tostring(ws.id)] or {}) do + zones[name] = true + for _, ref in ipairs(plan.windows) do assert(ref.zone ~= name, "swap unavailable: zone is intentionally empty") end + end + local available = false + for _, zone in ipairs(live.compiled.cycle) do if not zones[zone] then available = true end end + assert(available, "swap unavailable: leave one fill zone for local windows") + live.state.exclusive_pins = live.state.exclusive_pins or {} + for _, ref in ipairs(plan.windows) do + live.state.pins[ref.address] = ref.zone + live.state.exclusive_pins[ref.address] = true + end + refresh_workspace(ws) + return true +end + +function M.swap(active, target) + local live = engine.live[active.workspace.tiled_layout:match("^lua:(.+)$")] + if not live.state.pins[active.address] and not live.state.pins[target.address] then return false end + local request = { windows = { active, target } } + local ok, err = pcall(function() M.swap_apply(M.swap_plan(request)) end) + if not ok then + local function quote(v) return "'" .. tostring(v):gsub("'", "'\\''") .. "'" end + hl.exec_cmd("notify-send 'Hypertile swap' " .. quote(err)) + end + return true +end + -- A layout that still exists keeps its current definition: the user may have -- edited it since the snapshot, and silently reverting to the saved spec -- until the next reload would be surprising. Only a layout that no longer @@ -102,7 +444,7 @@ function M.prepare(snapshot) for _, ws in ipairs(snapshot.workspaces) do local name = ws.layout:match("^lua:(.+)$") local spec = name and current_spec(name, snapshot.layouts[name]) - assert(load(bridge.rule_source(ws.layout, ws.selector, spec), "=session-workspace", "t"))() + assert(load(bridge.rule_source(ws.selector, ws.layout, spec), "=session-workspace", "t"))() end return true end @@ -113,17 +455,25 @@ function M.place(request) local saved, address = request.saved, request.address local window = "address:" .. address dispatch(hl.dsp.window.fullscreen_state, { window = window, internal = 0, client = 0, action = "set" }) - dispatch(hl.dsp.window.move, { window = window, workspace = saved.workspace, silent = true }) + dispatch(hl.dsp.window.move, { window = window, workspace = saved.workspace, follow = false }) dispatch(hl.dsp.window.float, { window = window, action = saved.floating and "on" or "off" }) if saved.floating then dispatch(hl.dsp.window.resize, { window = window, x = saved.size.x, y = saved.size.y }) dispatch(hl.dsp.window.move, { window = window, x = saved.at.x, y = saved.at.y }) dispatch(hl.dsp.window.pin, { window = window, action = saved.pinned and "on" or "off" }) end - for _, live in pairs(engine.live) do live.state.pins[address] = nil end + for _, live in pairs(engine.live) do + live.state.pins[address] = nil + if live.state.exclusive_pins then live.state.exclusive_pins[address] = nil end + end if saved.pin and request.layout then local name = request.layout:match("^lua:(.+)$") - if name and engine.state[name] then engine.state[name].pins[address] = saved.pin end + if name and engine.state[name] then + local state = engine.state[name] + state.pins[address] = saved.pin + state.exclusive_pins = state.exclusive_pins or {} + state.exclusive_pins[address] = saved.pin_exclusive or nil + end end return true end @@ -172,6 +522,12 @@ function M.finish(request) if matches[old] then wanted[#wanted + 1] = matches[old] end end if live and wanted[1] then + for _, current in ipairs(hl.get_workspaces()) do + if selector(current) == ws.selector then + live.state.navigation_keep = live.state.navigation_keep or {} + live.state.navigation_keep[tostring(current.id)] = ws.navigation_keep or nil + end + end reorder(live, wanted, warnings, ws.selector) end local populated = false diff --git a/hypertile.lua b/hypertile.lua index 09165c6..45815d1 100644 --- a/hypertile.lua +++ b/hypertile.lua @@ -86,6 +86,7 @@ local function normalize_node(node, path) return { kind = "leaf", name = node.name, + id = node.id, size = size, stack = node.stack, spacer = node.spacer == true, @@ -119,6 +120,16 @@ function M.compile(spec) leaves[#leaves + 1] = name end local leaf_set = seen + assert(spec.layout_id == nil or (type(spec.layout_id) == "string" and spec.layout_id:match("^[%w_%-]+$") and #spec.layout_id <= 128), "hypertile: invalid layout id") + local zone_ids = {} + for _, name in ipairs(leaves) do + local id = leaf_opts[name].id + if id ~= nil then + assert(type(id) == "string" and id:match("^[%w_%-]+$") and #id <= 128, "hypertile: invalid zone id") + assert(not zone_ids[id], "hypertile: duplicate zone id " .. id) + zone_ids[id] = name + end + end local fillable = {} for _, name in ipairs(leaves) do local o = leaf_opts[name] @@ -177,6 +188,7 @@ function M.compile(spec) leaves = leaves, fillable = fillable, leaf_set = leaf_set, + zone_ids = zone_ids, leaf_opts = leaf_opts, fill = fill, fill_pos = fill_pos, @@ -240,6 +252,17 @@ end -- inside a slot regardless of whether they arrived by pin, rule, or fill. function M.assign(compiled, targets, state) local pins = state and state.pins or {} + local reserved = state and state.reserved or {} + -- A local window exchanged with a reserved source takes one whole zone. + -- Ordinary pins retain their existing stacking behavior. Only live swap + -- pins exclude ordinary fill; overflow may still use these local zones. + local occupied = {} + for _, target in ipairs(targets) do + local key = window_key(target.window) + if key and state and state.exclusive_pins and state.exclusive_pins[key] and pins[key] then + occupied[pins[key]] = true + end + end local slot_of = {} local count = {} for _, name in ipairs(compiled.leaves) do @@ -252,6 +275,7 @@ function M.assign(compiled, targets, state) end local function has_room(name) + if reserved[name] or occupied[name] then return false end if compiled.leaf_opts[name].never_split then return count[name] < 1 end @@ -265,7 +289,11 @@ function M.assign(compiled, targets, state) local win = target.window local key = window_key(win) local slot = key and pins[key] - if slot and compiled.leaf_set[slot] and not compiled.leaf_opts[slot].spacer then + for name, owner in pairs(reserved) do + if key == owner then slot = name end + end + if slot and compiled.leaf_set[slot] and not compiled.leaf_opts[slot].spacer + and (not reserved[slot] or reserved[slot] == key) then take(slot, i) else -- Among every rule this window matches, take the slot with the lowest @@ -317,18 +345,25 @@ function M.assign(compiled, targets, state) local fallback for _ = 1, #compiled.cycle do local name = next_slot() - if not compiled.leaf_opts[name].never_split then + if not reserved[name] and not compiled.leaf_opts[name].never_split then fallback = name break end end - take(fallback or next_slot(), i) + if not fallback then + for _, name in ipairs(compiled.cycle) do + if not reserved[name] then fallback = name; break end + end + end + assert(fallback, "empty zones must leave a window overflow zone") + take(fallback, i) end end local buckets = {} for _, name in ipairs(compiled.leaves) do buckets[name] = {} + buckets[name].reserved = reserved[name] ~= nil end for i, target in ipairs(targets) do table.insert(buckets[slot_of[i]], target) @@ -343,7 +378,7 @@ end local function subtree_has_windows(node, buckets) if node.kind == "leaf" then -- A spacer is a fixed hole: it is never collapsed away. - return node.spacer or #buckets[node.name] > 0 + return node.spacer or buckets[node.name].reserved or #buckets[node.name] > 0 end for _, child in ipairs(node.children) do if subtree_has_windows(child, buckets) then @@ -402,6 +437,15 @@ local function walk(node, box, compiled, buckets, overrides, out, empty) end end +-- The configured slots, including ones currently collapsed out of view. +function M.slot_boxes(compiled, area, sizes) + local buckets, boxes = {}, {} + for _, name in ipairs(compiled.leaves) do buckets[name] = { reserved = true } end + walk(compiled.tree, { x = area.x, y = area.y, w = area.w, h = area.h }, + compiled, buckets, sizes, boxes, "keep") + return boxes +end + -- Shrink `box` to `aspect` (w/h) and/or `scale`, centered. Returns the -- original box when neither is set. local function fit_box(box, opts) @@ -453,6 +497,14 @@ function M.recalculate(compiled, ctx, state) return {} end local area = ctx.area + -- Reservations are workspace-specific and contain only placement data. + -- The external controller owns every process and network operation. + local win = targets[1].window + local workspace = win and win.workspace and tostring(win.workspace.id) + local keep_slots = state and state.navigation_keep and state.navigation_keep[workspace] + local reserved = {} + for name in pairs(state and state.scene_empty and state.scene_empty[workspace] or {}) do reserved[name] = true end + state = setmetatable({ reserved = reserved }, { __index = state or {} }) local buckets = M.assign(compiled, targets, state) -- state.jiggle: true for every workspace on this layout, or a workspace -- id to jiggle only that workspace (looked up from the first window). @@ -464,7 +516,7 @@ function M.recalculate(compiled, ctx, state) end state = state or { pins = {}, sizes = {} } local jstate = { pins = state.pins, sizes = state.sizes, jiggle = jiggle and true or false } - if n == 1 and compiled.single == "collapse" then + if n == 1 and compiled.single == "collapse" and next(reserved) == nil and not keep_slots then -- The lone window takes the whole area, but keeps its slot's shape. local slot for _, name in ipairs(compiled.leaves) do @@ -479,7 +531,11 @@ function M.recalculate(compiled, ctx, state) return { ["*"] = full }, buckets end local boxes = {} - walk(compiled.tree, { x = area.x, y = area.y, w = area.w, h = area.h }, compiled, buckets, jstate.sizes, boxes, compiled.empty) + if keep_slots then + boxes = M.slot_boxes(compiled, area, jstate.sizes) + else + walk(compiled.tree, { x = area.x, y = area.y, w = area.w, h = area.h }, compiled, buckets, jstate.sizes, boxes, compiled.empty) + end for _, name in ipairs(compiled.leaves) do local box = boxes[name] if box and #buckets[name] > 0 then @@ -535,11 +591,13 @@ function M.handle_msg(compiled, state, msg, active_window) return "no active window" end state.pins[key] = slot + if state.exclusive_pins then state.exclusive_pins[key] = nil end return true elseif cmd == "unpin" then local key = window_key(active_window) if key then state.pins[key] = nil + if state.exclusive_pins then state.exclusive_pins[key] = nil end end return true elseif cmd == "size" or cmd == "grow" then @@ -555,6 +613,8 @@ function M.handle_msg(compiled, state, msg, active_window) return true elseif cmd == "reset" then state.pins = {} + state.exclusive_pins = {} + state.navigation_keep = {} state.sizes = {} return true elseif cmd == "relayout" then @@ -582,6 +642,7 @@ function M.provider(name, spec) live.spec = spec live.state = state live.orders = live.orders or {} + live.boxes = {} -- Plain geometry only; never retain compositor targets. M.live[name] = live return { recalculate = function(ctx) @@ -597,6 +658,9 @@ function M.provider(name, spec) end if workspace then live.orders[workspace] = order end local ok, err = pcall(M.recalculate, live.compiled, ctx, live.state) + if workspace then + live.boxes[workspace] = ok and M.slot_boxes(live.compiled, ctx.area, live.state.sizes) or nil + end if not ok then print("hypertile[" .. name .. "]: " .. tostring(err)) end diff --git a/install.sh b/install.sh index 522d310..a434bb4 100755 --- a/install.sh +++ b/install.sh @@ -63,11 +63,56 @@ if [[ -d "$plugin_dst/.git" && "$(cd "$plugin_dst" && pwd -P)" != "$src" ]]; the exit 1 fi -for tool in lua jq python3; do +for tool in lua jq python3 flock; do command -v "$tool" >/dev/null 2>&1 || { echo "install.sh: $tool is required" >&2; exit 1; } done [[ -e "$hypr/hyprland.lua" ]] || { echo "install.sh: $hypr/hyprland.lua not found; is this an Omarchy 4 (Lua config) system?" >&2; exit 1; } +PYTHONPATH="$src/session" python3 - "$state" <<'PY_PREFLIGHT' +from pathlib import Path +from upgrade import check_legacy +import sys +check_legacy(Path(sys.argv[1])) +PY_PREFLIGHT + +# Retire old in-memory scene code before installing the independent writer. +# Keep active legacy connection recovery running until the owner migrates it. +python3 - "$bin" <<'PY_SERVICES' +import json +import os +from pathlib import Path +import subprocess +import sys +bin_dir = Path(sys.argv[1]) +env = dict(os.environ) +env.pop("HYPERTILE_SRC", None) +legacy = bin_dir / "hypertile-stream" +if legacy.exists(): + status = subprocess.run([str(legacy), "status", "--json"], env=env, capture_output=True, text=True, timeout=5) + if status.returncode == 0: + if any(r.get("desired") or r.get("journal") for r in json.loads(status.stdout).get("computers", [])): + sys.exit("install.sh: disconnect/restore legacy Hypertile streams before installing this update") + subprocess.run([str(legacy), "stop"], env=env, stdout=subprocess.DEVNULL, check=True, timeout=10) +for name in ("hypertile-scenes", "hypertile-session"): + entry = bin_dir / name + if entry.exists(): + status = subprocess.run([str(entry), "status"], env=env, capture_output=True, timeout=5) + if status.returncode == 0: + subprocess.run([str(entry), "stop"], env=env, stdout=subprocess.DEVNULL, check=True, timeout=10) +PY_SERVICES + +# Hold the migration lock through all runtime edits. Shared Remote Desktops +# guards coexist; a legacy writer cannot start while its files are retired. +mkdir -p "$state/streams" +exec 9>"$state/streams/writer.lock" +flock -sn 9 || { echo "install.sh: legacy controller is still running" >&2; exit 1; } +PYTHONPATH="$src/session" python3 - "$state" <<'PY_CHECK' +from pathlib import Path +from upgrade import check_legacy +import sys +check_legacy(Path(sys.argv[1])) +PY_CHECK + mkdir -p "$hypr/layouts" "$bin" "$state" # One backup per edited config file, overwritten on each edit. @@ -80,9 +125,20 @@ for f in hypertile.lua hypertile-json.lua hypertile-bridge.lua hypertile-layouts done install -m 0755 "$src/bin/hypertile-ctl" "$bin/hypertile-ctl" install -m 0755 "$src/bin/hypertile-session" "$bin/hypertile-session" +install -m 0755 "$src/bin/hypertile-scenes" "$bin/hypertile-scenes" session_data="${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/session" mkdir -p "$session_data" -install -m 0644 "$src/session/service.py" "$session_data/service.py" +for f in "$src"/session/*.py; do install -m 0644 "$f" "$session_data/$(basename "$f")"; done +scene_data="${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/scenes" +mkdir -p "$scene_data" +for f in "$src"/scenes/*.py; do install -m 0644 "$f" "$scene_data/$(basename "$f")"; done + +PYTHONPATH="$src/session" python3 - "$bin" "${XDG_DATA_HOME:-$HOME/.local/share}" <<'PY_CLEANUP' +from pathlib import Path +from upgrade import cleanup +import sys +cleanup(Path(sys.argv[1]), Path(sys.argv[2])) +PY_CLEANUP for f in "$src"/layouts/*.lua; do name="$(basename "$f")" diff --git a/manifest.json b/manifest.json index 0b7d193..367f739 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "jmartin.hypertile", "name": "Hypertile", - "version": "1.0.1", + "version": "1.1.0", "author": "Jim Martin", "description": "Zone-based tiling layouts for Hyprland's Lua layout API: browse and edit layouts at true scale in a fullscreen overlay, with a bar widget showing the current layout. Run install.sh after adding the plugin.", "kinds": [ diff --git a/plugin/Content.js b/plugin/Content.js new file mode 100644 index 0000000..6566502 --- /dev/null +++ b/plugin/Content.js @@ -0,0 +1,200 @@ +// Presentation helpers shared by the overlay and its tests. No side effects. + +// One short phrase for an app's state or a scene's phase. The +// empty string means there is nothing to say (no scene, no state). +var STATUS = { + "waiting-session": "Waiting for session recovery", "waiting-window": "Opening app…", + moved: "Moved", closed: "Closed", ready: "Ready", restored: "Previous arrangement restored", + partial: "Some content needs attention", stopping: "Clearing previous placement…", + layout: "Applying layout…", connecting: "Placing apps…", "needs-attention": "Needs attention", + pending: "Pending", "waiting-workspace": "Waiting for the workspace", "restore-builtin": "Restoring…" +} + +function status(value) { + if (!value || value === "none") return "" + return STATUS[value] || value +} + +// States that want the user's attention (drawn in the urgent color). +function troubled(value) { + return ["partial", "needs-attention"].indexOf(value) !== -1 +} + +// The content assigned to a zone from the active scene. Null means the zone holds local windows by fill order. +function source(catalog, workspace, zone, active) { + if (!active || !catalog) return null + var sources = (catalog.current || {}).sources || [] + if ((catalog.current || {}).phase === "restored") return null + for (var j = 0; j < sources.length; j++) if (sources[j].zone === zone) { + var item = sources[j] + return item + } + return null +} + +// What the zone holds, as a name: "Local windows", "Empty", an app class, +// or an installed app name. +function label(source) { + if (!source) return "Local windows" + if (source.type === "empty") return "Empty" + if (source.type === "local") return source.app_class || "Local windows" + if (source.type === "app") return displayName(source.app_name || source.desktop_id) + return "Unknown source" +} + +// Remote Desktops installs one launcher per computer, named "X (Remote +// Desktop)"; the picker groups them apart and drops the suffix. +function isRemoteDesktop(app) { + return !!app && (String(app.desktop_id || "").indexOf("remote-desktops-") === 0 || !!app.app_title) +} + +function displayName(name) { + return String(name || "").replace(/\s*\(Remote Desktop\)\s*$/, "") +} + +// The zone card's chip shows its assigned content. +function chip(source) { + if (!source) return "" + return label(source) +} + +// The state of a zone's content in a few words, and whether it is a problem. +function state(source) { + if (!source) return { text: "", urgent: false } + if (source.type === "empty") return { text: "", urgent: false } + if (source.type === "local") return source.status === "needs-attention" + ? { text: "Pending", urgent: true } : { text: "", urgent: false } + var s = source.status + return { text: status(s) || "Pending", urgent: troubled(s) } +} + +// A sentence under the zone's title. +function detail(source) { + if (!source) return "Windows open here in fill order" + if (source.type === "empty") return "Nothing opens here; the zone stays empty" + if (source.type === "local") return source.status === "needs-attention" + ? (source.error || "No matching window on this workspace yet") + : "One matching window is pinned here" + if (source.type === "app") return source.error || (source.status === "moved" + ? "Moved by you; apply the scene again to place it here" + : source.status === "closed" ? "Closed by you; apply the scene again to open it" + : source.status === "waiting-window" ? "Waiting for the app window" + : "Placed here once; you can move it to any workspace") + return source.error || "Unknown source" +} + +// The header for the workspace's scene: what it is called, what state it +// is in, and whether the saved definition is behind. Without a scene the +// header is about the workspace itself. +function sceneTitle(scene, workspace) { + if (!scene || !scene.phase || scene.phase === "none" || scene.phase === "restored") return workspace ? "Workspace " + workspace : "No scene" + return (scene.document && scene.document.name) || "Unsaved scene" +} + +function sceneModified(scene) { + return !!(scene && scene.document && scene.document.name && scene.modified && scene.phase !== "restored") +} + +// How far the scene has come: "2 of 3 placed" while apps are placed, then +// the phase in words. +function sceneProgress(scene) { + var phase = scene ? scene.phase : "" + var sources = (scene && scene.sources) || [] + var n = 0, placed = 0, trouble = 0 + for (var i = 0; i < sources.length; i++) { + var s = sources[i] + if (s.type !== "app" && !(s.type === "local" && s.app_class)) continue + n++ + if (s.status === "ready") placed++ + else if (troubled(s.status) || s.error) trouble++ + } + if (n > 0 && ["connecting", "partial", "ready"].indexOf(phase) !== -1) { + var t = placed + " of " + n + " placed" + if (trouble > 0) t += " · " + trouble + (trouble === 1 ? " needs attention" : " need attention") + return t + } + return status(phase) +} + +function sceneMeta(scene, layout, workspace) { + var bits = [] + if (layout) bits.push(layout) + var active = !!(scene && scene.phase && scene.phase !== "none" && scene.phase !== "restored") + if (!active) { + if (scene && scene.phase === "restored") bits.push(status("restored")) + else if (layout) bits.push("local windows in every zone") + return bits.join(" · ") + } + if (workspace) bits.push("workspace " + workspace) + var p = sceneProgress(scene) + if (p !== "") bits.push(p) + return bits.join(" · ") +} + +// The apps a scene places, by name, and a short form for a card's meta +// line: "Chrome, Cursor +2". +function appNames(sources) { + var out = [] + for (var i = 0; i < (sources || []).length; i++) { + var s = sources[i] + if (s.type === "app") out.push(displayName(s.app_name || s.desktop_id)) + else if (s.type === "local" && s.app_class) out.push(s.app_class) + } + return out +} + +function summary(names, max) { + max = max || 2 + if (names.length <= max) return names.join(", ") + return names.slice(0, max).join(", ") + " +" + (names.length - max) +} + +// Case-insensitive substring match of a search query against any field. +function matches(query, fields) { + var q = String(query || "").trim().toLowerCase() + if (q === "") return true + for (var i = 0; i < fields.length; i++) if (String(fields[i] || "").toLowerCase().indexOf(q) !== -1) return true + return false +} + +// The windows open on a workspace, one entry per class: the one title +// when there is one window, else how many there are. +function openApps(windows, workspace) { + var by = {}, out = [] + for (var i = 0; i < (windows || []).length; i++) { + var w = windows[i] + if (String(w.workspace) !== String(workspace) || !w.class) continue + if (!by[w.class]) { by[w.class] = { app_class: w.class, count: 0, title: "" }; out.push(by[w.class]) } + by[w.class].count++ + by[w.class].title = w.title || "" + } + out.sort(function(a, b) { return a.app_class < b.app_class ? -1 : a.app_class > b.app_class ? 1 : 0 }) + return out +} + +// Where a zone sits, in words: "Top left", "Right", "Full screen". A word +// is used only when it tells zones apart, so a column that fills the +// height is just "Left". Zones whose words would collide get "" and are +// shown by their layout name instead. +function positionLabel(zone, area) { + var tol = Math.max(2, Math.min(area.w, area.h) * 0.02) + var left = zone.x <= area.x + tol, right = zone.x + zone.w >= area.x + area.w - tol + var top = zone.y <= area.y + tol, bottom = zone.y + zone.h >= area.y + area.h - tol + var h = (left && right) ? "" : left ? "left" : right ? "right" : "center" + var v = (top && bottom) ? "" : top ? "top" : bottom ? "bottom" : "middle" + var words = (v + " " + h).trim() + if (words === "") return "Full screen" + return words.charAt(0).toUpperCase() + words.slice(1) +} + +function positionLabels(zones, area) { + var labels = {}, counts = {} + if (!area) return labels + for (var i = 0; i < zones.length; i++) { + var l = positionLabel(zones[i], area) + labels[zones[i].name] = l + counts[l] = (counts[l] || 0) + 1 + } + for (var name in labels) if (counts[labels[name]] > 1) labels[name] = "" + return labels +} diff --git a/plugin/ContentPane.qml b/plugin/ContentPane.qml new file mode 100644 index 0000000..9c6c8f6 --- /dev/null +++ b/plugin/ContentPane.qml @@ -0,0 +1,705 @@ +import QtQuick +import qs.Commons +import qs.Ui +import "Content.js" as Content + +// The body of the rail's Scenes tab: the saved scenes as cards, what each +// zone of the workspace's layout holds, and a picker for the selected zone +// that filters as you type. The header above it (the scene's name and +// state, Save and Restore) is the rail's own. Every change goes through +// hypertile-ctl scene; the catalog is re-read every couple of seconds while +// the overlay is open, so the states here follow the controller. +Column { + id: pane + required property var overlay + readonly property var catalog: overlay.contentCatalog || ({}) + readonly property var scene: catalog.current || ({}) + // The catalog is re-read every couple of seconds; these keep their + // identity until their content changes, so the rows (and what the + // pointer is over) survive a poll. + property var scenes: [] + property var apps: [] + onCatalogChanged: syncLists() + Component.onCompleted: syncLists() + function syncLists() { + syncOpenRows() + var nextScenes = catalog.scenes || [] + if (JSON.stringify(nextScenes) !== JSON.stringify(scenes)) scenes = nextScenes + var nextApps = catalog.apps || [] + if (JSON.stringify(nextApps) !== JSON.stringify(apps)) apps = nextApps + } + readonly property bool ready: overlay.contentCatalog !== null && !overlay.catalogFailed + readonly property bool usable: ready && overlay.viewedIsActive + // The zones in fill order, as the numerals on the screen read them. + readonly property var zoneRows: { + if (!usable) return [] + var rows = [] + for (var i = 0; i < overlay.zones.length; i++) { + var z = overlay.zones[i] + rows.push({ name: z.name, first: z.numbers.length > 0 ? z.numbers[0] : 999, badge: z.spacer ? "∅" : (z.numbers.length > 0 ? z.numbers.join(" · ") : "—"), spacer: z.spacer === true }) + } + rows.sort(function(a, b) { return a.first - b.first || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) }) + return rows + } + readonly property var sel: overlay.selectedZone + readonly property var source: overlay.contentFor(overlay.selected) + readonly property string appliedScene: (scene.document && scene.document.name && ["none", "restored"].indexOf(scene.phase) === -1) ? scene.document.name : "" + readonly property color fg: overlay.foreground + readonly property color accent: overlay.accent + readonly property string family: overlay.fontFamily + readonly property int iconSize: Math.round(overlay.uiFontSmall * 1.4) + property string deleting: "" // the saved scene a delete is being confirmed for + + // ---- the picker: what is typed, and the rows that match it, in the + // order they are listed (Enter takes the hot one). + readonly property string query: searchField.text + readonly property bool searching: query.trim() !== "" + property int hot: 0 + onQueryChanged: hot = 0 + property var openRows: [] + Connections { + target: pane.overlay + function onWindowsChanged() { pane.syncOpenRows() } + function onWorkspaceIdChanged() { pane.syncOpenRows() } + } + function syncOpenRows() { + var next = Content.openApps(overlay.windows, overlay.workspaceId) + if (JSON.stringify(next) !== JSON.stringify(openRows)) openRows = next + } + readonly property var builtinMatches: { + if (!searching) return [] + var out = [] + if (Content.matches(query, ["Local windows", "fill order"])) out.push({ kind: "local", name: "Local windows", trait: "by fill order", icon: "" }) + if (Content.matches(query, ["Empty", "nothing opens here"])) out.push({ kind: "empty", name: "Empty", trait: "nothing opens here", icon: "" }) + return out + } + readonly property var openMatches: { + var out = [] + for (var i = 0; i < openRows.length; i++) { + var r = openRows[i] + var name = overlay.nameForClass(r.app_class, apps) + if (!Content.matches(query, [name, r.app_class, r.title])) continue + out.push({ kind: "open", name: name, app_class: r.app_class, trait: r.count === 1 ? r.title : r.count + " windows", icon: overlay.iconForClass(r.app_class, apps) }) + } + return out + } + readonly property var remoteMatches: pane.appRows(pane.query, pane.apps, true) + readonly property var appMatches: pane.appRows(pane.query, pane.apps, false) + readonly property var matches: builtinMatches.concat(openMatches, remoteMatches, appMatches) + readonly property int matchCount: matches.length + + function appRows(query, apps, remote) { + var out = [] + for (var i = 0; i < apps.length; i++) { + var a = apps[i] + if (Content.isRemoteDesktop(a) !== remote) continue + var name = remote ? Content.displayName(a.name) : a.name + if (!Content.matches(query, [name, a.app_class, a.desktop_id])) continue + out.push({ kind: "app", name: name, desktop_id: a.desktop_id, app: a, trait: "", icon: pane.overlay.resolveIcon(a.icon) }) + } + return out + } + + function focusSearch() { + Qt.callLater(function() { if (searchField.visible) { searchField.forceActiveFocus(); searchField.selectAll() } }) + } + function setQuery(text) { searchField.text = String(text || ""); pane.focusSearch() } + // A key typed while the overlay's key handler had the focus: the search + // takes it and the ones after it. + function typeSearch(text) { + searchField.text = searchField.text + String(text || "") + Qt.callLater(function() { if (searchField.visible) { searchField.forceActiveFocus(); searchField.cursorPosition = searchField.text.length } }) + } + function pickMatch() { + if (!pane.searching || pane.matches.length === 0) return + pane.choose(pane.matches[Math.max(0, Math.min(pane.hot, pane.matches.length - 1))]) + } + function choose(m) { + pane.overlay.hoverMatch = null + if (m.kind === "local") pane.overlay.assignContent("local") + else if (m.kind === "empty") pane.overlay.assignContent("empty") + else if (m.kind === "open") pane.overlay.assignContent("local", m.app_class) + else pane.overlay.assignApp(m.app) + searchField.text = "" + } + // Preview a match by its place in the list (-1 clears), as hovering does. + function hoverMatch(index) { + if (index < 0 || index >= pane.matches.length) { pane.overlay.hoverMatch = null; return } + pane.overlay.ghost(pane.ghostFor(pane.matches[index])) + } + function ghostFor(m) { + var key = m.kind + ":" + (m.desktop_id || m.app_class || m.kind) + return { key: key, kind: m.kind, name: m.name || (m.kind === "empty" ? "Empty" : "Local windows"), icon: m.icon || "" } + } + function isCurrent(m) { + var s = pane.source + if (m.kind === "local") return s === null || (s.type === "local" && !s.app_class) + if (m.kind === "empty") return s !== null && s.type === "empty" + if (m.kind === "open") return s !== null && s.type === "local" && s.app_class === m.app_class + return s !== null && s.type === "app" && s.desktop_id === m.desktop_id + } + + spacing: Style.spacing.xl + + // Another zone: the picker starts clean and takes the keys. + Connections { + target: pane.overlay + function onSelectedChanged() { + searchField.text = "" + pane.overlay.hoverMatch = null + if (pane.overlay.contentMode && pane.overlay.selected !== "" && pane.usable) pane.focusSearch() + } + } + + // ---------------------------------------------------------- pieces + + component Label: Text { + textFormat: Text.PlainText + color: Util.alpha(pane.fg, 0.7) + font.family: pane.family + font.pixelSize: pane.overlay.uiCaption + font.bold: true + } + + component Muted: Text { + property bool urgent: false + textFormat: Text.PlainText + width: pane.width + wrapMode: Text.WordWrap + color: urgent ? Color.urgent : Util.alpha(pane.fg, 0.62) + font.family: pane.family + font.pixelSize: pane.overlay.uiCaption + } + + component Action: Button { + bordered: true + radius: pane.overlay.radiusControl + foreground: pane.fg + accent: pane.accent + fontFamily: pane.family + fontSize: pane.overlay.uiFontSmall + enabled: !pane.overlay.busy + opacity: enabled ? 1 : 0.45 + } + + component Section: Column { + id: section + property string title: "" + property string detail: "" + width: pane.width + spacing: Style.spacing.lg + PanelSeparator { foreground: pane.fg; width: pane.width } + Item { + visible: section.title !== "" + width: pane.width + implicitHeight: Math.max(sectionTitle.implicitHeight, sectionDetail.implicitHeight) + PanelSectionHeader { + id: sectionTitle + text: section.title + foreground: pane.fg + fontFamily: pane.family + fontSize: pane.overlay.uiCaption + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + Text { + id: sectionDetail + textFormat: Text.PlainText + text: section.detail + color: pane.accent + font.family: pane.family + font.pixelSize: pane.overlay.uiFontSmall + font.bold: true + elide: Text.ElideRight + width: Math.min(implicitWidth, parent.width - sectionTitle.implicitWidth - Style.spacing.lg * 2) + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // A group inside the picker: a small heading over its rows. + component Group: Column { + property string title: "" + property string caption: "" + width: pane.width + spacing: Style.spacing.xxs + Label { text: parent.title; topPadding: Style.spacing.xs; bottomPadding: Style.spacing.xxs } + Muted { visible: parent.caption !== ""; text: parent.caption; bottomPadding: Style.spacing.xs } + } + + // One line in a list: an optional badge and icon, a name, a quieter + // phrase next to it, and a state on the right. The current one reads + // like the viewed layout in the LAYOUTS list; the hot one is what Enter + // takes while a search is typed. + component ListRow: Rectangle { + id: row + property string badge: "" // the fill number, as on the zone card + property bool badgeStrong: true + property string icon: "" + property string text: "" + property string sub: "" + property string trait: "" + property bool current: false + property bool hot: false + property bool urgent: false + signal clicked() + signal hovered(bool on) + width: pane.width + implicitHeight: Math.max(rowMain.implicitHeight, rowIcon.visible ? rowIcon.height : 0, rowBadge.visible ? rowBadge.implicitHeight : 0) + Style.spacing.sm * 2 + height: implicitHeight + radius: pane.overlay.radiusControl + color: current ? Util.alpha(pane.accent, 0.14) : (hot ? Util.alpha(pane.accent, 0.08) : (rowHover.containsMouse ? Util.alpha(pane.fg, 0.06) : "transparent")) + border.width: (current || hot) ? 1 : 0 + border.color: Util.alpha(pane.accent, current ? 0.6 : 0.35) + Behavior on color { ColorAnimation { duration: pane.overlay.motionFast } } + Chip { + id: rowBadge + visible: row.badge !== "" + x: Style.spacing.md + anchors.verticalCenter: parent.verticalCenter + text: row.badge + strong: row.badgeStrong + foreground: pane.fg + fontFamily: pane.family + fontSize: pane.overlay.uiCaption + } + Image { + id: rowIcon + visible: row.icon !== "" + x: rowBadge.visible ? rowBadge.x + rowBadge.width + Style.spacing.md : Style.spacing.md + anchors.verticalCenter: parent.verticalCenter + width: pane.iconSize + height: pane.iconSize + sourceSize.width: pane.iconSize + sourceSize.height: pane.iconSize + source: row.icon + smooth: true + mipmap: true + asynchronous: true + } + Text { + id: rowMain + x: rowIcon.visible ? rowIcon.x + rowIcon.width + Style.spacing.md + : (rowBadge.visible ? rowBadge.x + rowBadge.width + Style.spacing.md : Style.spacing.md) + anchors.verticalCenter: parent.verticalCenter + width: parent.width - x - Style.spacing.md - (rowTrait.visible ? rowTrait.width + Style.spacing.lg : 0) + textFormat: Text.PlainText + text: row.text + color: row.current ? pane.accent : pane.fg + font.family: pane.family + font.pixelSize: pane.overlay.uiFontSmall + font.bold: row.current + elide: Text.ElideRight + Text { + // The quieter phrase sits after the name on the same line. + visible: row.sub !== "" + x: parent.contentWidth + Style.spacing.lg + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, parent.width - x) + textFormat: Text.PlainText + text: row.sub + color: Util.alpha(pane.fg, 0.7) + font.family: pane.family + font.pixelSize: pane.overlay.uiFontSmall + elide: Text.ElideRight + } + } + Text { + id: rowTrait + visible: row.trait !== "" + anchors.right: parent.right + anchors.rightMargin: Style.spacing.md + anchors.verticalCenter: parent.verticalCenter + width: Math.min(implicitWidth, row.width * 0.45) + textFormat: Text.PlainText + text: row.trait + color: row.urgent ? Color.urgent : Util.alpha(pane.fg, 0.62) + font.family: pane.family + font.pixelSize: pane.overlay.uiCaption + elide: Text.ElideRight + } + MouseArea { + id: rowHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: row.clicked() + onEntered: row.hovered(true) + onExited: row.hovered(false) + } + } + + // A picker row for one match, by its place in the flat list of matches. + component MatchRow: ListRow { + required property var modelData + required property int index + property int offset: 0 + readonly property var match: pane.matches[offset + index] || modelData + icon: modelData.icon + text: modelData.name + trait: modelData.trait + current: pane.isCurrent(match) + hot: pane.searching && pane.hot === offset + index + onClicked: pane.choose(match) + // Hovering previews the match in the selected zone's card. + onHovered: function(on) { on ? pane.overlay.ghost(pane.ghostFor(match)) : pane.overlay.unghost(pane.ghostFor(match).key) } + } + + // A saved scene, drawn like a layout in the LAYOUTS list: a picture of + // its layout with the apps it places, its name, and what it holds. + // Clicking applies it; the delete shows on hover and confirms inline. + component SceneCard: Rectangle { + id: card + required property var modelData + required property int index + // The Repeater hands delegates a converted copy; the original entry + // keeps its plain arrays for the thumbnail. + readonly property var entry: pane.scenes[index] || modelData + readonly property bool applied: pane.appliedScene === entry.name + readonly property bool valid: entry.valid === true + readonly property bool modified: applied && pane.scene.modified === true + readonly property var spec: valid ? pane.overlay.layoutSpec(entry.layout) : null + readonly property var sources: entry.sources || [] + readonly property bool canApply: valid && !(applied && !modified) && !pane.overlay.busy + readonly property string meta: { + if (!valid) return entry.error || "This scene cannot be applied" + var bits = [] + if (applied) bits.push(modified ? "applied · modified" : "applied") + var names = Content.summary(Content.appNames(sources), 2) + bits.push(names !== "" ? names : "local windows") + bits.push(String(entry.layout || "")) + return bits.join(" · ") + } + width: pane.width + implicitHeight: cardRow.implicitHeight + Style.spacing.sm * 2 + height: implicitHeight + radius: pane.overlay.radiusControl + color: applied ? Util.alpha(pane.accent, 0.14) : (cardHover.containsMouse ? Util.alpha(pane.fg, 0.06) : "transparent") + border.width: applied ? 1 : 0 + border.color: Util.alpha(pane.accent, 0.6) + Behavior on color { ColorAnimation { duration: pane.overlay.motionFast } } + + MouseArea { + id: cardHover + anchors.fill: parent + hoverEnabled: true + cursorShape: card.canApply ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: if (card.canApply) pane.overlay.sceneAction("apply", card.entry.name) + } + Row { + id: cardRow + x: Style.spacing.sm + y: Style.spacing.sm + width: parent.width - Style.spacing.sm * 2 - deleteButton.width - Style.spacing.sm + spacing: Style.spacing.lg + Thumb { + overlay: pane.overlay + spec: card.spec + sources: card.sources + current: card.applied + width: pane.overlay.uiFont * 5 + anchors.verticalCenter: parent.verticalCenter + } + Column { + width: parent.width - pane.overlay.uiFont * 5 - Style.spacing.lg + spacing: Style.spacing.xxs + anchors.verticalCenter: parent.verticalCenter + Text { + textFormat: Text.PlainText + width: parent.width + text: card.entry.name + color: card.applied ? pane.accent : pane.fg + font.family: pane.family + font.pixelSize: pane.overlay.uiFontSmall + font.bold: card.applied + elide: Text.ElideRight + } + Text { + textFormat: Text.PlainText + width: parent.width + text: card.meta + color: card.valid ? Util.alpha(pane.fg, 0.62) : Color.urgent + font.family: pane.family + font.pixelSize: pane.overlay.uiCaption + wrapMode: card.valid ? Text.NoWrap : Text.WordWrap + elide: card.valid ? Text.ElideRight : Text.ElideNone + } + } + } + Action { + id: deleteButton + text: "✕" + bordered: false + fontSize: pane.overlay.uiCaption + anchors.right: parent.right + anchors.rightMargin: Style.spacing.xs + anchors.verticalCenter: parent.verticalCenter + opacity: (cardHover.containsMouse || hot || pane.deleting === card.entry.name) ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: pane.overlay.motionFast } } + tooltipText: "Delete this scene" + onClicked: pane.deleting = card.entry.name + } + } + + component Prompt: Rectangle { + id: prompt + property bool warning: false + default property alias content: promptColumn.data + width: pane.width + implicitHeight: promptColumn.implicitHeight + Style.spacing.xl * 2 + height: implicitHeight + radius: pane.overlay.radiusControl + color: Util.alpha(warning ? Color.urgent : pane.accent, 0.08) + border.width: 1 + border.color: Util.alpha(warning ? Color.urgent : pane.accent, 0.6) + Column { + id: promptColumn + x: Style.spacing.xl + y: Style.spacing.xl + width: parent.width - Style.spacing.xl * 2 + spacing: Style.spacing.md + } + } + + component PromptTitle: Text { + textFormat: Text.PlainText + width: parent.width + wrapMode: Text.WordWrap + color: pane.fg + font.family: pane.family + font.pixelSize: pane.overlay.uiFontSmall + font.bold: true + } + + // ------------------------------------------------- when nothing works + + Muted { + visible: pane.overlay.catalogFailed + text: "Scenes need Hypertile's scene service, which is not installed. Run install.sh from the plugin directory, then open the overlay again." + } + Muted { + visible: !pane.overlay.catalogFailed && pane.overlay.contentCatalog === null + text: "Reading the workspace…" + } + Muted { + visible: pane.ready && !pane.overlay.viewedIsActive + text: "Workspace " + pane.overlay.workspaceId + " is not on a Hypertile layout. Pick one under Layouts first; then its zones can hold content." + } + + // ------------------------------------------------------ saved scenes + + Section { + visible: pane.ready && pane.scenes.length > 0 + title: "SCENES" + + Column { + width: pane.width + spacing: Style.spacing.xs + Repeater { + model: pane.scenes + SceneCard {} + } + } + + Prompt { + visible: pane.deleting !== "" + warning: true + PromptTitle { text: "Delete scene " + pane.deleting + "?" } + Muted { width: parent.width; text: "Its file is removed. Nothing on the workspace changes and nothing closes." } + Flow { + width: parent.width + spacing: Style.spacing.sm + Action { text: "Delete"; accent: Color.urgent; selected: true; onClicked: { pane.overlay.deleteScene(pane.deleting); pane.deleting = "" } } + Action { text: "Cancel"; onClicked: pane.deleting = "" } + } + } + } + + // ---------------------------------------------------- what is where + + Section { + visible: pane.usable + title: "ZONES" + detail: pane.overlay.viewed ? pane.overlay.viewed.name : "" + + Column { + width: pane.width + spacing: Style.spacing.xxs + Repeater { + model: pane.zoneRows + ListRow { + required property var modelData + readonly property var zoneSource: pane.overlay.contentFor(modelData.name) + readonly property var zoneState: Content.state(zoneSource) + badge: modelData.badge + badgeStrong: !modelData.spacer + icon: modelData.spacer ? "" : pane.overlay.iconFor(zoneSource) + text: modelData.spacer ? "Spacer" : pane.overlay.contentName(zoneSource) + sub: pane.overlay.zoneLabel(modelData.name) + trait: zoneState.text + urgent: zoneState.urgent + current: pane.overlay.selected === modelData.name + onClicked: pane.overlay.selected = modelData.name + } + } + } + + Muted { + visible: pane.sel === null + text: "Click a zone on the screen, or in this list, to choose what opens there." + } + Muted { visible: pane.sel !== null && pane.sel.spacer === true; text: "A spacer never holds windows." } + } + + // ------------------------------------------- what the zone could hold + + Section { + visible: pane.usable && pane.sel !== null && pane.sel.spacer !== true + + Item { + width: pane.width + implicitHeight: Math.max(pickBadge.implicitHeight, pickName.implicitHeight, pickCurrent.implicitHeight) + Chip { + id: pickBadge + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: pane.sel ? (pane.sel.numbers.length > 0 ? pane.sel.numbers.join(" · ") : "—") : "" + strong: true + foreground: pane.fg + fontFamily: pane.family + fontSize: pane.overlay.uiCaption + } + Text { + id: pickName + anchors.left: pickBadge.right + anchors.leftMargin: Style.spacing.md + anchors.verticalCenter: parent.verticalCenter + width: Math.max(0, Math.min(implicitWidth, parent.width - pickBadge.width - pickCurrent.width - Style.spacing.md * 2)) + textFormat: Text.PlainText + text: pane.sel ? pane.overlay.zoneLabel(pane.sel.name) : "" + color: pane.fg + font.family: pane.family + font.pixelSize: pane.overlay.uiFontSmall + font.bold: true + elide: Text.ElideRight + Text { + // The layout's own name for the zone, when the position stands in for it. + visible: pane.sel !== null && pane.overlay.zoneLabel(pane.sel.name) !== pane.sel.name + x: parent.contentWidth + Style.spacing.md + anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText + text: pane.sel ? pane.sel.name : "" + color: Util.alpha(pane.fg, 0.62) + font.family: pane.family + font.pixelSize: pane.overlay.uiCaption + } + } + Text { + id: pickCurrent + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: Math.min(implicitWidth, parent.width * 0.55) + textFormat: Text.PlainText + text: pane.overlay.contentName(pane.source) + color: pane.accent + font.family: pane.family + font.pixelSize: pane.overlay.uiFontSmall + font.bold: true + elide: Text.ElideRight + } + } + Muted { text: Content.detail(pane.source); urgent: Content.state(pane.source).urgent } + + TextField { + id: searchField + width: pane.width + foreground: pane.fg + accent: pane.accent + font.family: pane.family + font.pixelSize: pane.overlay.uiFontSmall + placeholderText: "Search apps…" + Component.onCompleted: background.radius = pane.overlay.radiusControl + // Esc clears, Enter takes the hot match, ↑ ↓ move it; everything + // else the overlay would do with these keys still happens. + Keys.onPressed: function(event) { + var k = event.key + if (k === Qt.Key_Escape && text !== "") { text = ""; event.accepted = true; return } + if (text === "" && k >= Qt.Key_1 && k <= Qt.Key_9 && !(event.modifiers & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier))) { pane.overlay.selectContentNumber(k - Qt.Key_0); event.accepted = true; return } + if ((k === Qt.Key_Return || k === Qt.Key_Enter) && pane.searching) { pane.pickMatch(); event.accepted = true; return } + if (k === Qt.Key_Down && pane.searching) { pane.hot = Math.min(pane.hot + 1, Math.max(0, pane.matches.length - 1)); event.accepted = true; return } + if (k === Qt.Key_Up && pane.searching) { pane.hot = Math.max(pane.hot - 1, 0); event.accepted = true; return } + if ((k === Qt.Key_Left || k === Qt.Key_Right) && text !== "") return + if ([Qt.Key_Escape, Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab, Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(k) !== -1) { + if (pane.overlay.handleKey(event)) event.accepted = true + } + } + } + + Row { + visible: !pane.searching + spacing: Style.spacing.sm + Action { + text: "Local windows" + selected: pane.isCurrent({ kind: "local" }) + tooltipText: "Windows open here in fill order" + onClicked: pane.choose({ kind: "local" }) + onHotChanged: hot ? pane.overlay.ghost(pane.ghostFor({ kind: "local" })) : pane.overlay.unghost("local:local") + } + Action { + text: "Empty" + selected: pane.isCurrent({ kind: "empty" }) + tooltipText: "Nothing opens here; the zone stays empty" + onClicked: pane.choose({ kind: "empty" }) + onHotChanged: hot ? pane.overlay.ghost(pane.ghostFor({ kind: "empty" })) : pane.overlay.unghost("empty:empty") + } + } + + Column { + visible: pane.builtinMatches.length > 0 + width: pane.width + spacing: Style.spacing.xxs + Repeater { + model: pane.builtinMatches + MatchRow { offset: 0 } + } + } + + Group { + visible: pane.openMatches.length > 0 + title: "OPEN HERE" + Repeater { + model: pane.openMatches + MatchRow { offset: pane.builtinMatches.length } + } + } + + Group { + visible: pane.remoteMatches.length > 0 + title: "REMOTE DESKTOPS" + Repeater { + model: pane.remoteMatches + MatchRow { offset: pane.builtinMatches.length + pane.openMatches.length } + } + } + + Group { + visible: pane.appMatches.length > 0 + title: "APPS" + caption: pane.searching ? "" : "Launches the app, or reuses its open window" + Repeater { + model: pane.appMatches + MatchRow { offset: pane.builtinMatches.length + pane.openMatches.length + pane.remoteMatches.length } + } + } + + Muted { + visible: pane.searching && pane.matches.length === 0 + text: "Nothing matches “" + pane.query.trim() + "”" + } + Muted { + visible: !pane.searching && pane.apps.length === 0 + text: "Apps with a known window identity appear here. Open an installed app to help identify it." + } + } +} diff --git a/plugin/Editor.js b/plugin/Editor.js index 545633e..48af75c 100644 --- a/plugin/Editor.js +++ b/plugin/Editor.js @@ -10,7 +10,23 @@ function clone(v) { return JSON.parse(JSON.stringify(v)) } // The options that belong to a zone rather than to the layout. -var LEAF_KEYS = ["stack", "spacer", "never_split", "aspect", "scale"] +var LEAF_KEYS = ["id", "stack", "spacer", "never_split", "aspect", "scale"] + +function newId() { return "z-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) } + +// Names and fill numbers are editable labels; scene references use these IDs. +// A copy gets new identities. Splitting keeps the original half's identity. +function identify(input, fresh) { + var spec = clone(input) + if (fresh || !spec.layout_id) spec.layout_id = newId() + function walk(node) { + var kids = childrenOf(node) + if (kids) { for (var i = 0; i < kids.length; i++) walk(kids[i]) } + else if (fresh || !node.id) node.id = newId() + } + walk(spec) + return spec +} function copyLeafOptions(from, to) { for (var i = 0; i < LEAF_KEYS.length; i++) if (from[LEAF_KEYS[i]] !== undefined) to[LEAF_KEYS[i]] = from[LEAF_KEYS[i]] @@ -132,7 +148,7 @@ function splitZone(input, name, kind) { if (parent && kindOf(parent) === kind) { var half = sizeOf(leaf) / 2 setSize(leaf, kind, half) - var sibling = { name: newName } + var sibling = { name: newName, id: newId() } setSize(sibling, kind, half) childrenOf(parent).splice(hit.index + 1, 0, sibling) } else { @@ -140,7 +156,7 @@ function splitZone(input, name, kind) { // Only per-zone options travel to the half; when the root is the leaf, // `leaf` is the whole spec and must not leak fill/gaps/rules into a zone. copyLeafOptions(leaf, first) - var second = { name: newName } + var second = { name: newName, id: newId() } if (parent) { var container = {} setSize(container, kindOf(parent), sizeOf(leaf)) diff --git a/plugin/Overlay.qml b/plugin/Overlay.qml index de6c301..a1842f5 100644 --- a/plugin/Overlay.qml +++ b/plugin/Overlay.qml @@ -6,6 +6,7 @@ import qs.Commons import qs.Ui import "Geometry.js" as Geometry import "Editor.js" as Editor +import "Content.js" as Content // Hypertile overlay: view and edit tiling layouts at true scale. The keys // are listed in README.md and in the rail (?). @@ -47,6 +48,18 @@ Item { property var workspaces: [] // hypertile-ctl workspaces --json .workspaces property var windows: [] // hypertile-ctl windows --json .windows property string defaultLayout: "" + property bool contentMode: false // the rail's Scenes tab: zones are selected, not browsed + property var contentCatalog: null // hypertile-ctl scene catalog --json + property bool catalogFailed: false // no scene service: scenes are unavailable, browsing is not + property int catalogFailures: 0 + property string catalogError: "" + property bool namingScene: false + property var pendingSwitch: null // { workspaces, close }: a layout switch over assigned content awaiting confirmation + property bool switchConfirmed: false + readonly property bool managedContent: { + if (!contentCatalog) return false + return contentCatalog.current && ["none", "restored"].indexOf(contentCatalog.current.phase) === -1 + } property int viewIndex: 0 property string errorText: "" property string statusText: "" @@ -120,7 +133,7 @@ Item { readonly property bool viewedIsActive: viewed !== null && committedLayout !== "" && ("lua:" + viewed.name) === committedLayout readonly property string workspaceId: (current && current.workspace) ? String(current.workspace.id) : "" readonly property var selectedZone: { - if (!editing || selected === "") return null + if ((!editing && !contentMode) || selected === "") return null for (var i = 0; i < zones.length; i++) if (zones[i].name === selected) return zones[i] return null } @@ -134,6 +147,16 @@ Item { out.sort() return out } + readonly property var contentWindowClasses: { + var seen = {}, out = [] + for (var i = 0; i < windows.length; i++) { + var w = windows[i] + if (String(w.workspace) === workspaceId && w.class && !seen[w.class]) { + seen[w.class] = true; out.push(w.class) + } + } + return out.sort() + } readonly property var monitors: { var seen = {}, out = [] for (var i = 0; i < workspaces.length; i++) { @@ -165,6 +188,171 @@ Item { function focusKeys() { keys.forceActiveFocus() } + // ------------------------------------------------------------- scenes + // + // The Scenes tab: what each zone of the workspace's layout holds (local + // windows, a remote desktop, one app, or nothing) and the saved scenes. + // Assignments and scenes are the scene service's; the overlay asks + // through hypertile-ctl scene and shows the catalog it re-reads while open. + + function contentFor(zone) { return Content.source(contentCatalog, workspaceId, zone, viewedIsActive) } + function contentLabel(zone) { return Content.label(contentFor(zone)) } + function contentChip(zone) { return Content.chip(contentFor(zone)) } + + // Icons and names for what a zone holds, from the desktop entries the + // catalog lists. The shell's icon provider resolves theme names; a window + // class is matched to an installed app, then to a desktop entry heuristically. + readonly property string genericIcon: Quickshell.iconPath("application-x-executable", true) + function resolveIcon(icon) { + var v = String(icon || "") + if (v === "") return genericIcon + if (v.charAt(0) === "/") return "file://" + v + var themed = Quickshell.iconPath(v, true) + return themed !== "" ? themed : genericIcon + } + function catalogApp(desktopId, apps) { + apps = apps || (contentCatalog && contentCatalog.apps) || [] + for (var i = 0; i < apps.length; i++) if (apps[i].desktop_id === desktopId) return apps[i] + return null + } + function catalogAppForClass(cls, apps) { + var want = String(cls || "").toLowerCase() + if (want === "") return null + apps = apps || (contentCatalog && contentCatalog.apps) || [] + for (var i = 0; i < apps.length; i++) if (String(apps[i].app_class || "").toLowerCase() === want) return apps[i] + return null + } + function iconForApp(desktopId, apps) { + var app = catalogApp(desktopId, apps) + return resolveIcon(app ? app.icon : "") + } + function iconForClass(cls, apps) { + var app = catalogAppForClass(cls, apps) + if (app) return resolveIcon(app.icon) + var entry = null + try { entry = DesktopEntries.heuristicLookup(String(cls || "")) } catch (e) { entry = null } + return resolveIcon(entry ? entry.icon : "") + } + function iconFor(source) { + if (!source) return "" + if (source.type === "app") return iconForApp(source.desktop_id) + if (source.type === "local" && source.app_class) return iconForClass(source.app_class) + return "" + } + function nameForClass(cls, apps) { + var app = catalogAppForClass(cls, apps) + if (app) return Content.displayName(app.name) + var entry = null + try { entry = DesktopEntries.heuristicLookup(String(cls || "")) } catch (e) { entry = null } + return entry && entry.name ? entry.name : String(cls || "") + } + // Zones by where they sit ("Top left"), falling back to the layout's name + // where positions would collide. + readonly property var zoneLabels: Content.positionLabels(zones, area) + function zoneLabel(name) { return zoneLabels[name] || name } + // The picker row under the pointer, previewed in the selected zone's card. + property var hoverMatch: null + function ghost(match) { hoverMatch = match } + function unghost(key) { if (hoverMatch && hoverMatch.key === key) hoverMatch = null } + // The zone carrying a fill number, for the digit keys. + function selectContentNumber(n) { + for (var i = 0; i < zones.length; i++) if (zones[i].numbers.indexOf(n) !== -1) { selected = zones[i].name; return } + } + function contentName(source) { + if (source && source.type === "local" && source.app_class) return nameForClass(source.app_class) + return Content.label(source) + } + function layoutSpec(name) { + for (var i = 0; i < layouts.length; i++) if (layouts[i].name === name) return layouts[i].spec + return null + } + function focusSearch() { rail.focusSearch() } + function nextContentZone() { + var names = activeSpec ? Editor.leafNames(activeSpec) : [] + if (names.length) selected = names[(names.indexOf(selected) + 1) % names.length] + } + + function showContent(on) { + if (editing) return + contentMode = on + namingScene = false + hoverMatch = null + pendingSwitch = null + confirmingDelete = false + choosingNew = false + errorText = "" + browseTimer.stop() + revertBrowse() + selectActive() + selected = "" + pollCatalog() + } + + function pollCatalog() { if (!catalogProc.running && root.catalogFailures < 3) catalogProc.running = true } + + function sceneAction(action, name, workspace) { + var args = ["scene", action] + if (name) args.push(name) + args.push("--workspace", workspace ? String(workspace) : workspaceId, "--json") + var status = action === "apply" ? "Applying " + name + "…" + : action === "restore" ? "Restoring the previous arrangement…" + : action === "save" ? "Saving " + name + "…" + : action === "remove" ? "Deleting " + name + "…" + : action === "retry" ? "Checking the pending content…" + : "Updating the scene…" + var done = action === "save" ? "Saved " + name : action === "remove" ? "Deleted " + name : "" + if (runCtl(args, status, done)) { + browseTimer.stop() + commitOnRefresh = true + } + } + + function startSceneSave() { + if (!contentMode || busy || !viewedIsActive) return + namingScene = true + errorText = "" + var scene = contentCatalog ? contentCatalog.current : null + rail.focusName((scene && scene.document && scene.document.name && scene.phase !== "restored") ? scene.document.name : "") + } + + function confirmSceneName() { saveScene(rail.nameText) } + + function saveScene(name) { + name = String(name || "").trim() + if (!Editor.validName(name)) { errorText = "Scene name: letters, digits, _ and - only"; return } + errorText = "" + namingScene = false + focusKeys() + sceneAction("save", name) + } + + function deleteScene(name) { sceneAction("remove", name) } + + function assignContent(type, app) { + if (!selected || !viewedIsActive) { errorText = "Select a zone in the current layout"; return } + var what = type === "empty" ? "Empty" : app ? app : "Local windows" + var args = ["scene", "content", "--workspace", workspaceId, "--zone", selected, "--type", type, "--json"] + if (app) args.push("--app-class", app) + runCtl(args, "Putting " + what + " in " + selected + "…", "") + } + + function assignApp(app) { + if (!selected || !viewedIsActive) { errorText = "Select a zone in the current layout"; return } + var args = ["scene", "content", "--workspace", workspaceId, "--zone", selected, "--type", "app", + "--desktop-id", app.desktop_id, "--app-class", app.app_class, "--json"] + if (app.app_title) args.push("--app-title", app.app_title) + runCtl(args, "Opening " + app.name + " in " + selected + "…", "") + } + + function selectContentNeighbor(dir) { + if (!root.activeSpec) return + var names = Editor.leafNames(root.activeSpec) + if (names.length === 0) return + if (root.selected === "") { root.selected = names[0]; return } + var next = Editor.neighbor(root.activeSpec, root.area, root.selected, dir) + if (next !== "") root.selected = next + } + // ------------------------------------------------------------ preferences FileView { @@ -239,6 +427,7 @@ Item { workspacesProc.running = true windowsProc.running = true defaultProc.running = true + pollCatalog() } function parseJson(text, what) { @@ -281,11 +470,13 @@ Item { property string liveLayout: "" // what the compositor is showing property string browseTarget: "" // what browsing wants it to show property string browseLaunched: "" // what the running switch is going to + property string browseToken: "" // controller lease for a workspace with assigned content property bool commitOnRefresh: false property bool dismissAfterApply: false function browseTo(layout) { if (layout === "" || root.workspaceId === "") return + if (root.contentMode || (root.contentCatalog === null && !root.catalogFailed)) return root.browseTarget = layout root.liveLayout = layout runBrowse() @@ -293,6 +484,10 @@ Item { // A compositor-only switch of this workspace; nothing is persisted. function browseArgs(layout) { + if (root.managedContent || root.browseToken !== "") { + if (root.browseToken === "") root.browseToken = Editor.newId() + return [root.ctl, "scene", "browse", layout, "--workspace", root.workspaceId, "--browse-token", root.browseToken, "--json"] + } return [root.ctl, "apply", layout, "--workspace", root.workspaceId, "--no-persist", "--quiet"] } @@ -308,6 +503,15 @@ Item { // killed with the overlay. function revertBrowse() { browseTimer.stop() + if (root.browseToken !== "") { + var token = root.browseToken + root.browseToken = "" + root.liveLayout = root.committedLayout + root.browseTarget = root.committedLayout + root.browseLaunched = root.committedLayout + Quickshell.execDetached([root.ctl, "scene", "browse-end", "--workspace", root.workspaceId, "--browse-token", token, "--json"]) + return + } if (root.committedLayout === "" || root.liveLayout === "" || root.liveLayout === root.committedLayout || root.workspaceId === "") return root.liveLayout = root.committedLayout root.browseTarget = root.committedLayout @@ -349,6 +553,7 @@ Item { // at it, and the default follow the new name. function startRename() { if (!root.viewed || root.editing || root.busy) return + if (root.browseToken !== "") revertBrowse() root.confirmingDelete = false root.choosingNew = false root.renaming = true @@ -420,6 +625,7 @@ Item { // OSD is for switches made without it. function applyViewed(andClose) { if (!root.viewed) return + if (root.managedContent && !root.viewedIsActive) { askSwitch([root.workspaceId], andClose === true); return } browseTimer.stop() if (!runCtl(["apply", root.viewed.name, "--quiet"], "Using " + root.viewed.name + "…", "Now using " + root.viewed.name)) return root.commitOnRefresh = true @@ -428,7 +634,56 @@ Item { function applyTo(workspace) { if (!root.viewed) return - runCtl(["apply", root.viewed.name, "--workspace", String(workspace), "--quiet"], "Using " + root.viewed.name + " on workspace " + workspace + "…", "Workspace " + workspace + " uses " + root.viewed.name) + workspace = String(workspace) + if (contentWorkspace(workspace)) { + if (!root.switchConfirmed) { askSwitch([workspace], false); return } + // The controller replaces the content along with the layout. + if (runCtl(["scene", "layout", root.viewed.name, "--workspace", workspace, "--json"], "Switching workspace " + workspace + " to " + root.viewed.name + "…", "Workspace " + workspace + " uses " + root.viewed.name) + && workspace === root.workspaceId) root.commitOnRefresh = true + return + } + runCtl(["apply", root.viewed.name, "--workspace", workspace, "--quiet"], "Using " + root.viewed.name + " on workspace " + workspace + "…", "Workspace " + workspace + " uses " + root.viewed.name) + } + + // Whether a workspace has content assigned to zones (a scene). + function contentWorkspace(workspace) { + if (!root.contentCatalog) return false + if ((root.contentCatalog.active_workspaces || []).indexOf(workspace) !== -1) return true + return false + } + + // Using another layout on a workspace with assigned content replaces the + // content (apps stay open), so it is asked about first. + function askSwitch(workspaces, close) { + root.applyQueue = [] + root.confirmingDelete = false + root.choosingNew = false + root.pendingSwitch = { workspaces: workspaces.map(String), close: close === true } + } + + function switchSummary() { + var p = root.pendingSwitch + if (!p) return "" + var managed = p.workspaces.filter(function(w) { return contentWorkspace(w) }) + var s = managed.length === 1 ? "Workspace " + managed[0] + " has content assigned to its zones. " : "Workspaces " + managed.join(", ") + " have content assigned to their zones. " + s += "Every zone goes back to local windows; apps stay open." + return s + " Save the arrangement as a scene first to come back to it." + } + + function confirmSwitch() { + var p = root.pendingSwitch + if (!p || !root.viewed || root.busy) return + root.pendingSwitch = null + root.switchConfirmed = true + if (p.close) { + browseTimer.stop() + if (!runCtl(["scene", "layout", root.viewed.name, "--workspace", root.workspaceId, "--json"], "Switching to " + root.viewed.name + "…", "Now using " + root.viewed.name)) { root.switchConfirmed = false; return } + root.commitOnRefresh = true + root.dismissAfterApply = true + return + } + root.applyQueue = p.workspaces.slice(1) + applyTo(p.workspaces[0]) } // Every existing workspace on a monitor, one apply per workspace. @@ -438,6 +693,7 @@ Item { var ids = [] for (var i = 0; i < root.workspaces.length; i++) if (root.workspaces[i].monitor === monitor) ids.push(root.workspaces[i].id) if (ids.length === 0) return + if (!root.switchConfirmed) for (var j = 0; j < ids.length; j++) if (contentWorkspace(String(ids[j]))) { askSwitch(ids, false); return } root.applyQueue = ids.slice(1) applyTo(ids[0]) } @@ -466,7 +722,8 @@ Item { if (!root.current) return var fromBlank = blank === true || !root.viewed || !root.viewed.spec if (!asNew && fromBlank) return - root.draft = fromBlank ? ({ name: "main", fill: ["main"] }) : Editor.clone(root.viewed.spec) + if (root.browseToken !== "") revertBrowse() + root.draft = Editor.identify(fromBlank ? ({ name: "main", fill: ["main"] }) : root.viewed.spec, asNew === true) root.draftIsNew = asNew === true root.draftName = !root.draftIsNew ? root.viewed.name : (fromBlank ? uniqueLayoutName("new-layout") : uniqueLayoutName(root.viewed.name + "-copy")) root.undoStack = [] @@ -691,6 +948,7 @@ Item { function doPreview() { if (!root.editing || !root.draft || root.workspaceId === "") return + if (root.managedContent) { root.statusText = "Assigned content stays in place while editing. Changes take effect when saved."; return } if (previewProc.running) { root.previewPending = true; return } editFile.setText(docJson()) } @@ -927,6 +1185,50 @@ Item { } } + // The scene catalog: the workspace's scene, the saved scenes, the + // installed apps. Re-read every couple of seconds while open, + // so the states in the Scenes tab follow the controller. Without the + // scene service (an older install) there are no scenes; the Scenes + // tab says so, and nothing else is affected. + Process { + id: catalogProc + command: [root.ctl, "scene", "catalog", "--json"].concat(root.workspaceId ? ["--workspace", root.workspaceId] : []) + .concat(root.browseToken ? ["--browse-token", root.browseToken] : []) + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + var doc = null + try { doc = JSON.parse(String(text || "")) } catch (e) { return } + if (!doc || typeof doc !== "object") return + root.catalogFailures = 0 + root.catalogFailed = false + root.catalogError = "" + var old = root.contentCatalog ? root.contentCatalog.current : null + root.contentCatalog = doc + if (old && doc.current && (old.phase !== doc.current.phase || JSON.stringify(old.document) !== JSON.stringify(doc.current.document))) { + root.commitOnRefresh = true + if (!currentProc.running) currentProc.running = true + } + } + } + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: root.catalogError = String(text || "").trim().replace(/^hypertile-ctl: /, "") + } + onExited: function(code, status) { + if (code === 0 && status === 0) return + root.catalogFailures = (code === 126 || code === 127) ? 3 : root.catalogFailures + 1 + if (root.catalogFailures >= 3) root.catalogFailed = true + } + } + + Timer { + interval: 2000 + repeat: true + running: root.opened && root.catalogFailures < 3 + onTriggered: root.pollCatalog() + } + // One generic runner for apply / default / rename / remove / save; // refreshes when done. CtlProcess { @@ -938,6 +1240,7 @@ Item { root.commitOnRefresh = false root.dismissAfterApply = false root.applyQueue = [] + root.switchConfirmed = false root.statusText = "" return } @@ -949,6 +1252,7 @@ Item { root.applyTo(id) return } + root.switchConfirmed = false if (root.dismissAfterApply) { // The switch is persisted now: closing must not revert it. root.dismissAfterApply = false @@ -975,8 +1279,8 @@ Item { var shift = event.modifiers & Qt.ShiftModifier var k = event.key - if (root.naming || root.renaming) { - if (k === Qt.Key_Escape) { root.naming = false; root.renaming = false; root.errorText = ""; keys.forceActiveFocus(); return true } + if (root.naming || root.renaming || root.namingScene) { + if (k === Qt.Key_Escape) { root.naming = false; root.renaming = false; root.namingScene = false; root.errorText = ""; keys.forceActiveFocus(); return true } return false } @@ -988,6 +1292,7 @@ Item { if (k === Qt.Key_Question || (shift && k === Qt.Key_Slash)) { setPref("showKeys", !root.showKeys); return true } if (k === Qt.Key_Escape) { + if (root.pendingSwitch) { root.pendingSwitch = null; return true } if (root.confirmingDelete) { root.confirmingDelete = false; return true } if (root.choosingNew) { root.choosingNew = false; return true } if (root.confirmingDiscard) { root.confirmingDiscard = false; return true } @@ -1002,6 +1307,11 @@ Item { return true } + if (root.pendingSwitch) { + if (k === Qt.Key_Return || k === Qt.Key_Enter) { confirmSwitch(); return true } + return true + } + if (root.choosingNew) { if (plain && k === Qt.Key_B) { startEdit(true, true); return true } if (plain && k === Qt.Key_C && root.viewed) { startEdit(true, false); return true } @@ -1047,6 +1357,24 @@ Item { return true } + // The Scenes tab: the keys select zones; the layout keys are the + // Layouts tab's. + if (root.contentMode) { + if (k === Qt.Key_Tab) { nextContentZone(); return true } + var dir = (k === Qt.Key_Left || (plain && k === Qt.Key_H)) ? "left" + : (k === Qt.Key_Right || (plain && k === Qt.Key_L)) ? "right" + : (k === Qt.Key_Up || (plain && k === Qt.Key_K)) ? "up" + : (k === Qt.Key_Down || (plain && k === Qt.Key_J)) ? "down" : "" + if (dir !== "" && (k === Qt.Key_Left || k === Qt.Key_Right || k === Qt.Key_Up || k === Qt.Key_Down)) { selectContentNeighbor(dir); return true } + if (plain && k >= Qt.Key_1 && k <= Qt.Key_9) { selectContentNumber(k - Qt.Key_0); return true } + // A printable key with a zone selected starts a search in the + // picker, wherever the focus was (after a save, a click on chrome). + if (plain && root.selected !== "" && root.viewedIsActive && event.text.length === 1 && event.text.trim() !== "") { rail.typeSearch(event.text); return true } + if (dir !== "") { selectContentNeighbor(dir); return true } + if (k === Qt.Key_Return || k === Qt.Key_Enter) { dismiss(); return true } + if (plain && k === Qt.Key_R) { refresh(); return true } + return true + } if (k === Qt.Key_Right || k === Qt.Key_Down || (plain && (k === Qt.Key_L || k === Qt.Key_J))) { step(1); return true } if (k === Qt.Key_Left || k === Qt.Key_Up || (plain && (k === Qt.Key_H || k === Qt.Key_K))) { step(-1); return true } if (k === Qt.Key_Return || k === Qt.Key_Enter) { if (root.viewedIsActive) dismiss(); else applyViewed(true); return true } @@ -1134,7 +1462,7 @@ Item { } function zoneAt(x, y) { - return (root.editing && root.draft && inArea(x, y)) ? Editor.leafAt(root.draft, root.area, x, y) : "" + return ((root.editing || root.contentMode) && root.activeSpec && inArea(x, y)) ? Editor.leafAt(root.activeSpec, root.area, x, y) : "" } onPositionChanged: function(mouse) { @@ -1152,6 +1480,7 @@ Item { if (root.naming) root.naming = false if (root.renaming) root.renaming = false if (!root.editing) { + if (root.contentMode) { root.selected = zoneAt(mouse.x, mouse.y); return } if (mouse.button === Qt.LeftButton) root.dismiss() return } @@ -1286,6 +1615,18 @@ Item { function keysHint(on: bool): void { root.setPref("showKeys", on) } function peek(on: bool): void { root.peeking = on } function refresh(): void { root.refresh() } + function content(on: bool): void { root.showContent(on) } + function assign(kind: string): void { root.assignContent(kind) } + function assignApp(cls: string): void { root.assignContent("local", cls) } + function scene(action: string, name: string): void { root.sceneAction(action, name) } + function saveScene(name: string): void { root.saveScene(name) } + function saveSceneAs(): void { root.startSceneSave() } + function deleteScene(name: string): void { root.deleteScene(name) } + function confirmSwitch(): void { root.confirmSwitch() } + function search(text: string): void { rail.setSearch(text) } + function pick(): void { rail.pickMatch() } + function hover(index: int): void { rail.hoverMatch(index) } + function focusSearch(): void { root.focusSearch() } function viewed(): string { return root.viewed ? root.viewed.name : "" } function view(name: string): void { if (root.editing) return @@ -1334,7 +1675,9 @@ Item { undo: root.undoStack.length, status: root.statusText, error: root.errorText, workspaces: root.workspaces.length, windows: root.windows.length, defaultLayout: root.defaultLayout, committed: root.committedLayout, live: root.liveLayout, dockLeft: root.dockLeft, showKeys: root.showKeys, - area: root.area }) + area: root.area, contentMode: root.contentMode, query: rail.searchText, matches: rail.matchCount, + namingScene: root.namingScene, pendingSwitch: root.pendingSwitch, catalogFailed: root.catalogFailed, + scene: root.contentCatalog ? root.contentCatalog.current : null }) } } } diff --git a/plugin/Rail.qml b/plugin/Rail.qml index a97ef41..4f0483c 100644 --- a/plugin/Rail.qml +++ b/plugin/Rail.qml @@ -3,17 +3,29 @@ import qs.Commons import qs.Ui import "Geometry.js" as Geometry import "Editor.js" as Editor - -// The inspector rail: the layout's name and actions on top, then the -// sections for the current mode. View mode shows the layout list, the -// fill order, the workspaces to put the layout on, and the cycle switch. -// Edit mode shows the selected zone, then (collapsed by default) the apps -// pinned to it and the layout's gutters and policies. The keys are shown -// on request (?). The rail docks on either side. +import "Content.js" as Content + +// The inspector rail: the name and actions of what is being looked at on +// top, then the sections for the current mode. The Layouts tab shows the +// layout list, the fill order, the workspaces to put the layout on, and +// the cycle switch. The Scenes tab (ContentPane) shows what each zone of +// the workspace holds and the saved scenes. Edit mode shows the selected +// zone, then (collapsed by default) the apps pinned to it and the +// layout's gutters and policies. The keys are shown on request (?). The +// rail docks on either side. Card { id: rail property real maxHeight: 100000 readonly property string nameText: nameField.text + // The Scenes tab's picker: what is typed into its search, and the keys + // that drive it from the overlay and its IPC. + readonly property string searchText: contentPane.query + readonly property int matchCount: contentPane.matchCount + function focusSearch() { contentPane.focusSearch() } + function setSearch(text) { contentPane.setQuery(text) } + function typeSearch(text) { contentPane.typeSearch(text) } + function pickMatch() { contentPane.pickMatch() } + function hoverMatch(index) { contentPane.hoverMatch(index) } // Put the cursor in the layout-name field, preloaded with `initial`. function focusName(initial) { @@ -41,6 +53,12 @@ Card { readonly property bool borderSet: draft && draft.border !== undefined readonly property int borderPx: borderSet ? draft.border : globalBorder readonly property bool inspecting: overlay.editing && !overlay.numbering + // The Scenes tab's header: the workspace's scene, if the catalog is in. + readonly property var scene: (overlay.contentCatalog && overlay.contentCatalog.current) ? overlay.contentCatalog.current : null + readonly property bool sceneNamed: !!(scene && scene.document && scene.document.name && ["none", "restored"].indexOf(scene.phase) === -1) + readonly property bool sceneModified: Content.sceneModified(scene) + readonly property bool contentReady: overlay.contentCatalog !== null && !overlay.catalogFailed && overlay.viewedIsActive + readonly property bool scenesTab: overlay.contentMode && !overlay.editing readonly property string widthTarget: (sel && draft) ? Editor.extentTarget(draft, sel.name, "w") : "" readonly property string heightTarget: (sel && draft) ? Editor.extentTarget(draft, sel.name, "h") : "" @@ -52,16 +70,22 @@ Card { } readonly property var keyHints: { - if (overlay.naming) return [["Enter", "save"], ["Esc", "cancel"]] + if (overlay.naming || overlay.namingScene) return [["Enter", "save"], ["Esc", "cancel"]] if (overlay.renaming) return [["Enter", "rename"], ["Esc", "cancel"]] + if (overlay.contentMode) return [["click / ← → ↑ ↓", "select zone"], ["Tab", "next zone"], ["1 – 9", "zone by number"], ["type", "search apps"], ["↑ ↓", "pick a match"], ["Enter", "assign the match, else close"], ["Esc", "clear the search, else close"], ["?", "hide keys"]] if (overlay.numbering) return [["click", "next in order"], ["click again", "stack"], ["Backspace", "undo"], ["Enter", "done"]] if (overlay.editing) return [["click / ← → ↑ ↓", "select zone"], ["Shift + arrows", "resize 1%"], ["Tab", "next zone"], ["drag", "resize"], ["c", "split columns"], ["r", "split rows"], ["x", "delete"], ["s", "spacer"], ["f", "renumber"], ["u", "undo"], ["Space", "hold to peek"], ["w", "save"], ["Esc", "leave"], ["?", "hide keys"]] return [["← →", "browse (the windows follow)"], ["Enter", "use and close"], ["Space", "hold to peek"], ["e", "edit"], ["n", "new"], ["F2", "rename"], ["d", "delete"], ["r", "refresh"], ["Esc", "close"], ["?", "hide keys"]] } readonly property string metaText: { - if (overlay.naming || overlay.renaming) return "Letters, digits, _ and - only" + if (overlay.naming || overlay.renaming || overlay.namingScene) return "Letters, digits, _ and - only" if (overlay.numbering) return "Click zones in the order windows should fill them" + if (overlay.contentMode) { + if (overlay.catalogFailed || overlay.contentCatalog === null) return "" + if (!overlay.viewedIsActive) return (overlay.current && overlay.current.workspace) ? "Workspace " + overlay.workspaceId + " uses " + String(overlay.current.workspace.layout).replace(/^lua:/, "") : "" + return Content.sceneMeta(rail.scene, overlay.viewed ? overlay.viewed.name : "", overlay.workspaceId) + } if (overlay.editing) { var s = overlay.workspaceId !== "" ? "Previewing on workspace " + overlay.workspaceId : "Previewing" if (overlay.draftIsNew) s += " · new layout" @@ -70,6 +94,7 @@ Card { if (!overlay.viewed) return "No layouts in ~/.config/hypr/layouts yet" var m = "" if (overlay.viewedIsActive) m = "In use on workspace " + overlay.current.workspace.id + else if (overlay.managedContent && overlay.current && overlay.current.workspace) m = "Workspace " + overlay.current.workspace.id + " keeps its windows in place: it has assigned content" else if (overlay.current && overlay.current.workspace) m = "Workspace " + overlay.current.workspace.id + " uses " + String(overlay.current.workspace.layout).replace(/^lua:/, "") if (overlay.viewedIsDefault) m += (m !== "" ? " · " : "") + "default layout" if (!overlay.viewedInCycle) m += (m !== "" ? " · " : "") + "not in the SUPER+L cycle" @@ -399,32 +424,51 @@ Card { width: scroller.width spacing: Style.spacing.xl - // ---- Header: mode, name, meta, actions. + // ---- Header: tabs or mode, name, meta, actions. Column { width: column.width spacing: Style.spacing.sm Item { width: parent.width - implicitHeight: Math.max(eyebrow.implicitHeight, headerTools.implicitHeight) + implicitHeight: Math.max(eyebrow.implicitHeight, tabs.implicitHeight, headerTools.implicitHeight) Label { id: eyebrow + visible: !tabs.visible anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: overlay.naming ? "SAVE AS" : overlay.renaming ? "RENAME" + : overlay.namingScene ? "SAVE SCENE AS" : overlay.numbering ? "RENUMBERING" : overlay.editing ? (overlay.draftIsNew ? "NEW LAYOUT" : "EDITING") - : "HYPERTILE" + (overlay.layouts.length > 0 ? " " + (overlay.viewIndex + 1) + " / " + overlay.layouts.length : "") + : "HYPERTILE" + } + // The two things the rail can be about: the layouts (browsed with + // the arrows, the windows follow) and the scenes (what each zone + // of the workspace holds). + Row { + id: tabs + visible: !overlay.editing && !overlay.naming && !overlay.renaming && !overlay.namingScene + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Style.spacing.xs + Action { text: "Layouts"; bordered: false; selected: !overlay.contentMode; tooltipText: "Browse and edit the layouts"; onClicked: overlay.showContent(false) } + Action { text: "Scenes"; bordered: false; selected: overlay.contentMode; tooltipText: "What each zone holds: local windows, a remote desktop, an app; saved as scenes"; onClicked: overlay.showContent(true) } } Row { id: headerTools anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: Style.spacing.sm + Label { + visible: !overlay.editing && !overlay.contentMode && overlay.layouts.length > 0 + text: (overlay.viewIndex + 1) + " / " + overlay.layouts.length + anchors.verticalCenter: parent.verticalCenter + } Chip { - visible: overlay.editing && overlay.dirty - text: "unsaved" + visible: (overlay.editing && overlay.dirty) || (rail.scenesTab && rail.sceneModified) + text: overlay.editing ? "unsaved" : "modified" foreground: rail.fg fontFamily: rail.family fontSize: overlay.uiCaption @@ -451,10 +495,12 @@ Card { } Text { - visible: !overlay.naming && !overlay.renaming + visible: !overlay.naming && !overlay.renaming && !overlay.namingScene width: parent.width textFormat: Text.PlainText - text: overlay.editing ? overlay.draftName : (overlay.viewed ? overlay.viewed.name : "No layouts") + text: overlay.editing ? overlay.draftName + : overlay.contentMode ? ((overlay.catalogFailed || overlay.contentCatalog === null) ? "Scenes" : Content.sceneTitle(rail.scene, overlay.workspaceId)) + : (overlay.viewed ? overlay.viewed.name : "No layouts") color: rail.accent font.family: rail.family font.pixelSize: overlay.uiTitle @@ -464,33 +510,42 @@ Card { TextField { id: nameField - visible: overlay.naming || overlay.renaming + visible: overlay.naming || overlay.renaming || overlay.namingScene width: parent.width foreground: rail.fg accent: rail.accent font.family: rail.family font.pixelSize: overlay.uiFont - placeholderText: "layout name" + placeholderText: overlay.namingScene ? "scene name" : "layout name" Component.onCompleted: background.radius = overlay.radiusControl Keys.onPressed: function(event) { - if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { overlay.renaming ? overlay.confirmRename() : overlay.confirmName(); event.accepted = true } - else if (event.key === Qt.Key_Escape) { overlay.naming = false; overlay.renaming = false; overlay.errorText = ""; overlay.focusKeys(); event.accepted = true } + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { overlay.renaming ? overlay.confirmRename() : overlay.namingScene ? overlay.confirmSceneName() : overlay.confirmName(); event.accepted = true } + else if (event.key === Qt.Key_Escape) { overlay.naming = false; overlay.renaming = false; overlay.namingScene = false; overlay.errorText = ""; overlay.focusKeys(); event.accepted = true } } } Muted { text: rail.metaText; visible: text !== "" } + Muted { visible: rail.scenesTab && rail.scene !== null && !!rail.scene.error; text: rail.scene ? (rail.scene.error || "") : ""; color: Color.urgent } Flow { width: parent.width spacing: Style.spacing.sm topPadding: Style.spacing.xs - // view mode - Action { visible: !overlay.editing && !overlay.renaming; text: overlay.viewedIsActive ? "In use" : "Use"; selected: overlay.viewedIsActive; tooltipText: "Use on this workspace and close (Enter)"; enabled: overlay.viewed !== null && !overlay.viewedIsActive && !overlay.busy; onClicked: overlay.applyViewed(true) } - Action { visible: !overlay.editing && !overlay.renaming; text: "Edit"; tooltipText: "Edit this layout (e)"; enabled: overlay.viewed !== null; onClicked: overlay.startEdit(false) } - Action { visible: !overlay.editing && !overlay.renaming; text: "New"; selected: overlay.choosingNew; tooltipText: "New layout: blank, or a copy of this one (n)"; enabled: overlay.current !== null; onClicked: { overlay.confirmingDelete = false; overlay.choosingNew = !overlay.choosingNew } } - Action { visible: !overlay.editing && !overlay.renaming; text: "Rename"; tooltipText: "Rename this layout (F2)"; enabled: overlay.viewed !== null && !overlay.busy; onClicked: overlay.startRename() } - Action { visible: !overlay.editing && !overlay.renaming && !overlay.confirmingDelete; text: "Delete"; accent: Color.urgent; tooltipText: overlay.viewedIsDefault ? "The default layout cannot be deleted; make another the default first" : "Delete this layout's file (d)"; enabled: overlay.viewed !== null && !overlay.viewedIsDefault && !overlay.busy; onClicked: { overlay.choosingNew = false; overlay.confirmingDelete = true } } + // the Layouts tab + Action { visible: !overlay.editing && !overlay.renaming && !overlay.contentMode; text: overlay.viewedIsActive ? "In use" : "Use"; selected: overlay.viewedIsActive; tooltipText: "Use on this workspace and close (Enter)"; enabled: overlay.viewed !== null && !overlay.viewedIsActive && !overlay.busy; onClicked: overlay.applyViewed(true) } + Action { visible: !overlay.editing && !overlay.renaming && !overlay.contentMode; text: "Edit"; tooltipText: "Edit this layout (e)"; enabled: overlay.viewed !== null; onClicked: overlay.startEdit(false) } + Action { visible: !overlay.editing && !overlay.renaming && !overlay.contentMode; text: "New"; selected: overlay.choosingNew; tooltipText: "New layout: blank, or a copy of this one (n)"; enabled: overlay.current !== null; onClicked: { overlay.confirmingDelete = false; overlay.choosingNew = !overlay.choosingNew } } + Action { visible: !overlay.editing && !overlay.renaming && !overlay.contentMode; text: "Rename"; tooltipText: "Rename this layout (F2)"; enabled: overlay.viewed !== null && !overlay.busy; onClicked: overlay.startRename() } + Action { visible: !overlay.editing && !overlay.renaming && !overlay.contentMode && !overlay.confirmingDelete; text: "Delete"; accent: Color.urgent; tooltipText: overlay.viewedIsDefault ? "The default layout cannot be deleted; make another the default first" : "Delete this layout's file (d)"; enabled: overlay.viewed !== null && !overlay.viewedIsDefault && !overlay.busy; onClicked: { overlay.choosingNew = false; overlay.confirmingDelete = true } } + // the Scenes tab + Action { visible: rail.scenesTab && !overlay.namingScene && rail.sceneNamed; text: "Save"; tooltipText: rail.sceneModified ? "Save the changes to " + rail.scene.document.name : "Saved"; enabled: rail.sceneModified && !overlay.busy; onClicked: overlay.saveScene(rail.scene.document.name) } + Action { visible: rail.scenesTab && !overlay.namingScene && rail.contentReady; text: rail.sceneNamed ? "Save as…" : "Save as scene…"; tooltipText: "Save this workspace's layout and content under a name"; enabled: !overlay.busy; onClicked: overlay.startSceneSave() } + Action { visible: rail.scenesTab && !overlay.namingScene && rail.scene !== null && rail.scene.can_restore === true && ["restored", "none"].indexOf(rail.scene.phase) === -1; text: "Restore previous"; tooltipText: "Put back the layout and content the workspace had before the scene"; enabled: !overlay.busy; onClicked: overlay.sceneAction("restore") } + Action { visible: rail.scenesTab && !overlay.namingScene && rail.scene !== null && (rail.scene.phase === "partial" || rail.scene.phase === "needs-attention"); text: "Retry"; tooltipText: "Check the pending content again"; enabled: !overlay.busy; onClicked: overlay.sceneAction("retry") } + // naming a scene + Action { visible: overlay.namingScene; text: "Save"; onClicked: overlay.confirmSceneName() } + Action { visible: overlay.namingScene; text: "Cancel"; onClicked: { overlay.namingScene = false; overlay.errorText = ""; overlay.focusKeys() } } // edit mode Action { visible: overlay.editing && !overlay.naming; text: overlay.numbering ? "Done numbering" : "Renumber"; selected: overlay.numbering; tooltipText: "Click zones in fill order (f)"; onClicked: overlay.numbering ? overlay.finishNumbering() : overlay.startNumbering() } Action { visible: overlay.editing && !overlay.naming; text: "Undo"; tooltipText: "Undo (u)"; enabled: overlay.undoStack.length > 0; onClicked: overlay.undo() } @@ -507,7 +562,7 @@ Card { // ---- View mode: new layout, blank or a copy. Prompt { - visible: !overlay.editing && overlay.choosingNew + visible: !overlay.editing && !overlay.contentMode && overlay.choosingNew PromptTitle { text: "Start a new layout from" } Flow { width: parent.width @@ -520,7 +575,7 @@ Card { // ---- View mode: confirm a delete. Prompt { - visible: !overlay.editing && overlay.confirmingDelete && overlay.viewed !== null + visible: !overlay.editing && !overlay.contentMode && overlay.confirmingDelete && overlay.viewed !== null warning: true PromptTitle { text: "Delete " + (overlay.viewed ? overlay.viewed.name : "") + "?" } Muted { @@ -543,6 +598,23 @@ Card { } } + // ---- View mode: a layout switch over assigned content replaces it. + Prompt { + visible: !overlay.editing && overlay.pendingSwitch !== null && overlay.viewed !== null + warning: true + PromptTitle { text: "Use " + (overlay.viewed ? overlay.viewed.name : "") + " anyway?" } + Muted { width: parent.width; text: overlay.switchSummary() } + Flow { + width: parent.width + spacing: Style.spacing.sm + Action { text: "Use " + (overlay.viewed ? overlay.viewed.name : ""); accent: Color.urgent; selected: true; tooltipText: "Enter"; enabled: !overlay.busy; onClicked: overlay.confirmSwitch() } + Action { text: "Cancel"; tooltipText: "Esc"; onClicked: overlay.pendingSwitch = null } + } + } + + // ---- The Scenes tab: saved scenes, what each zone holds, the selected zone. + ContentPane { id: contentPane; visible: rail.scenesTab; width: column.width; overlay: rail.overlay } + // ---- Edit mode: unsaved changes. Prompt { visible: overlay.editing && overlay.confirmingDiscard @@ -560,7 +632,7 @@ Card { // ---- View mode: every layout on disk, with a picture of each. Section { - visible: !overlay.editing && overlay.layouts.length > 1 + visible: !overlay.editing && !overlay.contentMode && overlay.layouts.length > 1 title: "LAYOUTS" Column { @@ -651,7 +723,7 @@ Card { // ---- View mode: fill order. Section { - visible: !overlay.editing && overlay.viewed !== null && overlay.viewed.spec !== undefined + visible: !overlay.editing && !overlay.contentMode && overlay.viewed !== null && overlay.viewed.spec !== undefined title: "FILL ORDER" Body { text: { @@ -668,7 +740,7 @@ Card { // ---- View mode: workspaces. Section { - visible: !overlay.editing && overlay.viewed !== null && overlay.workspaces.length > 0 + visible: !overlay.editing && !overlay.contentMode && overlay.viewed !== null && overlay.workspaces.length > 0 title: "WORKSPACES" Column { @@ -755,7 +827,7 @@ Card { // ---- View mode: the SUPER+L cycle. Section { - visible: !overlay.editing && overlay.viewed !== null + visible: !overlay.editing && !overlay.contentMode && overlay.viewed !== null title: "CYCLE" Switch { label: "In the SUPER+L cycle" @@ -1100,7 +1172,7 @@ Card { } Muted { - visible: !overlay.showKeys && !overlay.naming && !overlay.renaming + visible: !overlay.showKeys && !overlay.naming && !overlay.renaming && !overlay.namingScene text: overlay.editing ? "Hold Space to peek at the windows · ? for the keys" : "Hold Space to peek · ? for the keys" } } diff --git a/plugin/Thumb.qml b/plugin/Thumb.qml index 598c881..c6736a8 100644 --- a/plugin/Thumb.qml +++ b/plugin/Thumb.qml @@ -4,11 +4,14 @@ import "Geometry.js" as Geometry // A small picture of a layout: its zones in the monitor's proportions, the // first fill number in each zone that has room for it. Used by the rail's -// layout list so a shape can be recognised before it is browsed to. +// layout list so a shape can be recognised before it is browsed to. Given +// a scene's sources, the zones that hold an app show its icon instead, and +// the ones kept empty are drawn like spacers. Item { id: thumb required property var overlay property var spec: null + property var sources: null property bool current: false property color foreground: overlay.foreground property color accent: overlay.accent @@ -21,6 +24,12 @@ Item { height: Math.round(width * ratio) + function sourceFor(name) { + if (!sources) return null + for (var i = 0; i < sources.length; i++) if (sources[i].zone === name) return sources[i] + return null + } + Rectangle { anchors.fill: parent radius: Style.space(3) @@ -34,9 +43,13 @@ Item { Repeater { model: thumb.boxes Rectangle { + id: cell required property var modelData - readonly property bool spacer: modelData.spacer === true + readonly property var src: thumb.sourceFor(modelData.name) + readonly property bool spacer: modelData.spacer === true || (src !== null && src.type === "empty") + readonly property string icon: src !== null ? thumb.overlay.iconFor(src) : "" readonly property var box: modelData.fitted ? modelData.fit : modelData + readonly property int iconSize: Math.round(Math.min(width, height) * 0.62) x: box.x + thumb.gap y: box.y + thumb.gap width: Math.max(1, box.w - thumb.gap * 2) @@ -45,9 +58,21 @@ Item { color: spacer ? "transparent" : Util.alpha(thumb.accent, thumb.current ? 0.35 : 0.22) border.width: 1 border.color: Util.alpha(spacer ? thumb.foreground : thumb.accent, spacer ? 0.25 : 0.7) + Image { + visible: cell.icon !== "" && cell.iconSize >= 6 + anchors.centerIn: parent + width: cell.iconSize + height: cell.iconSize + sourceSize.width: cell.iconSize + sourceSize.height: cell.iconSize + source: cell.icon + smooth: true + mipmap: true + asynchronous: true + } Text { anchors.centerIn: parent - visible: !parent.spacer && parent.width > implicitWidth + 4 && parent.height > implicitHeight + 2 && modelData.numbers.length > 0 + visible: !cell.spacer && cell.icon === "" && parent.width > implicitWidth + 4 && parent.height > implicitHeight + 2 && modelData.numbers.length > 0 textFormat: Text.PlainText text: modelData.numbers.length > 0 ? String(modelData.numbers[0]) : "" color: Util.alpha(thumb.foreground, 0.85) diff --git a/plugin/ZoneItem.qml b/plugin/ZoneItem.qml index b3b3b0b..238da2a 100644 --- a/plugin/ZoneItem.qml +++ b/plugin/ZoneItem.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Effects import qs.Commons import qs.Ui +import "Content.js" as Content // One zone of the viewed or edited layout, drawn at true scale over the // windows it will hold. A badge in the corner carries the fill-order @@ -21,9 +22,18 @@ Item { readonly property color fg: overlay.foreground readonly property color accent: overlay.accent readonly property bool editing: overlay.editing - readonly property bool isSelected: editing && overlay.selected === modelData.name - readonly property bool isHovered: editing && !overlay.numbering && !overlay.dragDivider && !overlay.hoverDivider && overlay.hoverZone === modelData.name - readonly property bool isSpacer: modelData.spacer === true + readonly property bool isSelected: (editing || overlay.contentMode) && overlay.selected === modelData.name + readonly property bool isHovered: (editing || overlay.contentMode) && !overlay.numbering && !overlay.dragDivider && !overlay.hoverDivider && overlay.hoverZone === modelData.name + readonly property var source: overlay.contentFor(modelData.name) + // The Scenes tab: the card names what the zone holds, with its icon. + readonly property bool content: overlay.contentMode + readonly property string icon: (content && source !== null) ? overlay.iconFor(source) : "" + readonly property var contentState: content ? Content.state(source) : ({ text: "", urgent: false }) + // A picker row under the pointer previews itself in the selected card. + readonly property var ghost: (content && isSelected) ? overlay.hoverMatch : null + readonly property bool urgent: content && contentState.urgent + readonly property bool showCentre: content && roomy && (ghost !== null || (source !== null && source.type !== "empty")) + readonly property bool isSpacer: modelData.spacer === true || (source !== null && source.type === "empty") readonly property bool fitted: modelData.fitted === true && !isSpacer readonly property int inset: Style.space(6) readonly property int stackCount: modelData.neverSplit ? 1 : Math.max(1, modelData.numbers.length) @@ -69,6 +79,7 @@ Item { : Util.alpha(zone.accent, zone.fitted ? 0.04 : (zone.isSelected ? 0.16 : (zone.isHovered ? 0.12 : (zone.editing ? 0.08 : 0.03))))) border.width: zone.isSelected && !zone.peek ? Math.max(2, Style.space(2)) : 1 border.color: zone.peek ? Util.alpha(zone.isSelected ? zone.accent : zone.fg, zone.isSelected ? 0.7 : 0.25) + : zone.urgent ? Util.alpha(Color.urgent, zone.isSelected ? 1 : 0.8) : zone.isSelected ? zone.accent : (zone.isHovered ? Util.alpha(zone.accent, 0.8) : Util.alpha(zone.isSpacer ? zone.fg : zone.accent, zone.isSpacer ? 0.35 : 0.55)) @@ -85,11 +96,11 @@ Item { radius: zone.overlay.effectiveRounding color: "transparent" border.width: Math.max(2, Style.space(2)) - border.color: zone.accent + border.color: zone.urgent ? Color.urgent : zone.accent layer.enabled: visible layer.effect: MultiEffect { shadowEnabled: true - shadowColor: zone.accent + shadowColor: zone.urgent ? Color.urgent : zone.accent shadowOpacity: 0.85 shadowBlur: 1.0 blurMax: 32 @@ -130,7 +141,8 @@ Item { // Faint numeral in the middle. Text { - visible: !zone.peek + id: numeral + visible: !zone.peek && !zone.showCentre anchors.centerIn: parent width: parent.width - zone.pad * 2 textFormat: Text.PlainText @@ -143,6 +155,70 @@ Item { elide: Text.ElideRight } + // Under the numeral while scenes are edited: what an unassigned zone does. + Text { + visible: zone.content && zone.roomy && !zone.peek && !zone.showCentre + anchors.top: numeral.bottom + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - zone.pad * 2 + textFormat: Text.PlainText + text: zone.modelData.spacer === true ? "Spacer · never holds windows" + : zone.isSpacer ? "Empty · nothing opens here" + : "Local windows · fill order" + color: Util.alpha(zone.fg, zone.isSelected ? 0.7 : 0.45) + font.family: zone.overlay.fontFamily + font.pixelSize: zone.overlay.uiFontSmall + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + + // What the zone holds: the app's icon, its name, and its state. + Column { + id: centre + visible: zone.showCentre && !zone.peek + anchors.centerIn: parent + width: parent.width - zone.pad * 2 + spacing: Style.spacing.md + opacity: zone.ghost !== null ? 0.55 : 1 + Behavior on opacity { NumberAnimation { duration: zone.overlay.motionFast } } + readonly property int iconSize: Math.round(Math.min(zone.width * 0.2, zone.height * 0.26, zone.overlay.uiFont * 4.5)) + readonly property string shownIcon: zone.ghost !== null ? zone.ghost.icon : zone.icon + Image { + visible: centre.shownIcon !== "" + anchors.horizontalCenter: parent.horizontalCenter + width: centre.iconSize + height: centre.iconSize + sourceSize.width: centre.iconSize + sourceSize.height: centre.iconSize + source: centre.shownIcon + smooth: true + mipmap: true + opacity: zone.isSelected ? 1 : 0.9 + } + Text { + width: parent.width + textFormat: Text.PlainText + text: zone.ghost !== null ? zone.ghost.name : zone.overlay.contentName(zone.source) + color: Util.alpha(zone.fg, 0.92) + font.family: zone.overlay.fontFamily + font.pixelSize: Math.round(zone.overlay.uiFont * 1.25) + font.bold: true + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + Text { + visible: text !== "" + width: parent.width + textFormat: Text.PlainText + text: zone.ghost !== null ? "click to put it here" : zone.contentState.text + color: (zone.contentState.urgent && zone.ghost === null) ? Color.urgent : Util.alpha(zone.fg, 0.62) + font.family: zone.overlay.fontFamily + font.pixelSize: zone.overlay.uiFontSmall + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + // Badge row: number, name (edit mode: names only matter when editing // rules or the file), then the constraint chips. The size chip opens the // rail's exact size field. @@ -172,7 +248,7 @@ Item { } } Chip { - visible: zone.editing + visible: zone.editing || zone.overlay.contentMode text: zone.modelData.name bold: true foreground: zone.fg @@ -180,6 +256,17 @@ Item { fontSize: zone.overlay.uiFontSmall anchors.verticalCenter: parent.verticalCenter } + // What the zone holds when it is not simply local windows: a remote + // desktop (accent), an app, or nothing. + Chip { + visible: zone.source !== null && !zone.showCentre + text: zone.overlay.contentChip(zone.modelData.name) + foreground: zone.fg + fontFamily: zone.overlay.fontFamily + fontSize: zone.overlay.uiFontSmall + strong: zone.source !== null && zone.source.type === "app" + anchors.verticalCenter: parent.verticalCenter + } } Flow { @@ -229,9 +316,22 @@ Item { radius: zone.overlay.radiusControl } + // Quick actions on a zone while scenes are edited. + Row { + visible: zone.content && (zone.isSelected || zone.isHovered || zone.urgent) && zone.roomy && !zone.peek && zone.modelData.spacer !== true + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: zone.pad + spacing: Style.spacing.sm + + ZoneButton { visible: zone.urgent; text: "Retry"; accent: Color.urgent; tooltipText: "Check the pending content again"; enabled: !zone.overlay.busy; onClicked: zone.overlay.sceneAction("retry") } + ZoneButton { text: "Change…"; tooltipText: "Choose what opens here"; onClicked: { zone.overlay.selected = zone.modelData.name; zone.overlay.focusSearch() } } + ZoneButton { visible: zone.source !== null; text: "Clear"; tooltipText: "Back to local windows in fill order"; enabled: !zone.overlay.busy; onClicked: { zone.overlay.selected = zone.modelData.name; zone.overlay.assignContent("local") } } + } + // Quick actions on the selected zone. Row { - visible: zone.isSelected && !zone.overlay.numbering && zone.roomy && !zone.peek + visible: zone.isSelected && zone.editing && !zone.overlay.numbering && zone.roomy && !zone.peek anchors.right: parent.right anchors.bottom: parent.bottom anchors.margins: zone.pad diff --git a/scenes/apps.py b/scenes/apps.py new file mode 100644 index 0000000..2efc37c --- /dev/null +++ b/scenes/apps.py @@ -0,0 +1,189 @@ +"""Installed desktop entries and one-shot scene placement. Apps own their lifecycle.""" +import configparser +import os +from pathlib import Path +import subprocess +import time + + +def text(value, label): + if not isinstance(value, str) or not value or len(value) > 250 or any(ord(c) < 32 for c in value): + raise ValueError("invalid " + label) + return value + + +def matches(window, source): + return window.get("class") == source["app_class"] and (not source.get("app_title") or window.get("title") == source["app_title"]) + + +def identity(window): + return {key: window[key] for key in ("address", "stable_id", "pid")} + + +class DesktopApps: + def __init__(self): + self.entries, self.next_scan = {}, 0 + self.children = {} + + def scan(self): + if time.monotonic() < self.next_scan: + return self.entries + directories = [Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share")] + directories += [Path(p) for p in (os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share").split(":") if p] + entries, seen = {}, set() + for directory in directories: + base = directory / "applications" + for path in sorted(base.rglob("*.desktop")): + desktop_id = str(path.relative_to(base)).replace("/", "-") + if desktop_id in seen: + continue + seen.add(desktop_id) # Hidden user entries mask system entries too. + parser = configparser.ConfigParser(interpolation=None, strict=False) + try: + parser.read(path) + app = parser["Desktop Entry"] + if app.get("Type") != "Application" or app.getboolean("Hidden", fallback=False) or not app.get("Exec"): + continue + match = app.get("X-RemoteDesktops-WindowClass") or app.get("StartupWMClass") + title = app.get("X-RemoteDesktops-WindowTitle") + entry = {"desktop_id": desktop_id, "name": app.get("Name", desktop_id), "path": str(path), + "visible": not app.getboolean("NoDisplay", fallback=False), + "icon": (app.get("Icon") or "")[:250]} + if match: + entry["app_class"] = text(match, "app class") + if title: + entry["app_title"] = text(title, "app title") + entries[desktop_id] = entry + except (OSError, UnicodeError, configparser.Error, KeyError, ValueError): + continue + self.entries, self.next_scan = entries, time.monotonic() + 5 + return entries + + def resolve(self, source): + desktop_id = text(source.get("desktop_id"), "desktop ID") + if not desktop_id.endswith(".desktop") or "/" in desktop_id or "\\" in desktop_id: + raise ValueError("Use an installed desktop ID, not a path") + entry = self.scan().get(desktop_id) + if not entry: + raise ValueError("Install the app first: " + desktop_id) + result = {"desktop_id": desktop_id, "app_name": entry["name"], + "app_class": text(source.get("app_class") or entry.get("app_class"), "app class")} + title = source.get("app_title") or entry.get("app_title") + if title: + result["app_title"] = text(title, "app title") + # Per-computer launchers publish exact identity; do not weaken it to + # Moonlight's shared class and accidentally claim another computer. + for key in ("app_class", "app_title"): + if entry.get(key) and result.get(key) != entry[key]: + raise ValueError("Window match disagrees with installed app: " + desktop_id) + return result + + def catalog(self, windows): + out = [] + for entry in self.scan().values(): + if not entry["visible"]: + continue + app = {k: v for k, v in entry.items() if k not in ("path", "visible")} + # Wayland desktop IDs often are the app ID. Only offer this fallback + # when a live window demonstrates it; otherwise the CLI accepts an + # explicit match instead of guessing. + if not app.get("app_class"): + stem = entry["desktop_id"][:-8] + if any(w.get("class") == stem for w in windows): + app["app_class"] = stem + # A packaging placeholder such as "@@startup_wm_class" never + # matches a window; the entry would only clutter the picker. + if app.get("app_class") and "@@" not in app["app_class"]: + out.append(app) + return sorted(out, key=lambda v: (v["name"].casefold(), v["desktop_id"])) + + def launch(self, source): + # Re-resolve immediately before launch. gio implements desktop Exec + # expansion; neither stored commands nor shell interpolation are used. + self.next_scan = 0 + self.resolve(source) + path = self.entries[source["desktop_id"]]["path"] + self.children[source["desktop_id"]] = subprocess.Popen(["gio", "launch", path], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + + def failure(self, desktop_id): + child = self.children.get(desktop_id) + code = child.poll() if child else None + if code is not None: + del self.children[desktop_id] + return "App launcher exited with status " + str(code) if code else None + + +class AppPlacement: + def __init__(self, controller, desktop=None): + self.ctl, self.desktop = controller, desktop or DesktopApps() + self.launches = controller.state.setdefault("app_launches", {}) + + def reap(self): + for desktop_id in list(self.desktop.children): + error = self.desktop.failure(desktop_id) + if error and desktop_id in self.launches: + self.launches[desktop_id]["error"] = error + + def retry(self, record): + for source in record["document"]["sources"].values(): + if source["type"] == "app": + attempt = self.launches.get(source["desktop_id"]) + if attempt and (attempt.get("error") or self.ctl.now() >= attempt["deadline"]): + self.launches.pop(source["desktop_id"], None) + + def observe(self, record, snap): + before = [(k, v["status"]) for k, v in record.get("apps", {}).items()] + for key, state in record.get("apps", {}).items(): + if state["status"] != "ready": + continue + source = record["document"]["sources"][key] + window = next((w for w in snap["windows"] if identity(w) == state["window"]), None) + if not window: + state["status"] = "closed" + elif window["workspace"] != record["workspace"] or window.get("floating") or window.get("pin") != source["zone"]: + state["status"] = "moved" + return before != [(k, v["status"]) for k, v in record.get("apps", {}).items()] + + def step(self, record, snap): + self.observe(record, snap) + states = record.setdefault("apps", {}) + results = [] + for key, source in record["document"]["sources"].items(): + if source["type"] != "app": + continue + state = states.setdefault(key, {"status": "pending"}) + if state["status"] in ("pending", "waiting-window"): + found = [w for w in snap["windows"] if matches(w, source)] + if len(found) > 1: + state.update(status="needs-attention", error="More than one matching window is open; close extras or use an exact title") + elif found: + window = found[0] + # Consume placement before IPC. An uncertain reply must never + # cause a later move to be undone by a retry after restart. + state.update(status="needs-attention", error="Placement was interrupted; apply the scene again", window=identity(window)) + self.ctl.persist() + pin = self.ctl.compositor.call("scene_app_place", {**identity(window), "workspace": record["workspace"], + "layout": "lua:" + record["document"]["layout"], "zone": source["zone"], "zone_id": key, + "operation": record["operation"], "app_class": source["app_class"], "app_title": source.get("app_title")}) + record.setdefault("pins", []).append(pin) + state.update(status="ready", error=None) + self.launches.pop(source["desktop_id"], None) + else: + attempt = self.launches.get(source["desktop_id"]) + if not attempt: + attempt = {"deadline": self.ctl.now() + 45} + self.launches[source["desktop_id"]] = attempt + self.ctl.persist() # A crash here leaves an uncertain launch, never an automatic duplicate. + try: + self.desktop.launch(source) + except (OSError, ValueError) as error: + attempt["error"] = str(error) + failure = self.desktop.failure(source["desktop_id"]) + if failure: + attempt["error"] = failure + if self.ctl.now() >= attempt["deadline"]: + attempt.setdefault("error", "No matching window appeared within 45 seconds; check the app, then Retry") + state.update(status="needs-attention" if attempt.get("error") else "waiting-window", error=attempt.get("error")) + results.append({"zone": source["zone"], "status": state["status"], "error": state.get("error")}) + return results diff --git a/scenes/browse.py b/scenes/browse.py new file mode 100644 index 0000000..b8302db --- /dev/null +++ b/scenes/browse.py @@ -0,0 +1,102 @@ +"""Leased layout previews: source intent stays on the committed layout.""" +import copy +import re +import subprocess +import time +import uuid + + +class Browser: + def __init__(self, controller, clock=time.monotonic): + self.ctl, self.clock = controller, clock + self.epoch = uuid.uuid4().hex + self.state = controller.state.setdefault("browse", {"active": {}, "closed": []}) + self.active = self.state["active"] + + def retire(self, token): + if token not in self.state["closed"]: + self.state["closed"].append(token) + del self.state["closed"][:-64] + + def end(self, workspace, token=None): + # A close may reach the writer before the first debounced preview. + # Retire that owner even when it has not started yet. + if token: + self.retire(token) + record = self.active.get(workspace) + if record and (token is None or record["token"] == token): + self.retire(record["token"]) + record["ending"] = True + self.ctl.persist() + snap = self.ctl.compositor.snapshot() + ws = next((w for w in snap["workspaces"] if w["selector"] == workspace), None) + # A separate workspace change or a new compositor supersedes this + # preview. Never overwrite it with a late overlay close. + if record["instance"] == self.ctl.compositor.instance and ws and ws["layout"] in record["shown"]: + self.ctl.compositor.call("scene_layout", {"workspace": workspace, **record["base"]}) + del self.active[workspace] + self.ctl.persist() + return {"preview": False} + + def command(self, request): + workspace, token = str(request.get("workspace", "")), request.get("browse_token", "") + if not re.fullmatch(r"[1-9][0-9]*", workspace) or not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", token): + raise ValueError("Layout preview requires a workspace and owner token") + if request["action"] == "browse-end": + return self.end(workspace, token) + if token in self.state["closed"]: + return {"preview": False} + layout = request["name"].removeprefix("lua:") + entry = self.ctl.scenes.layouts.get(layout) + record = self.active.get(workspace) + if record and record["token"] != token: + self.end(workspace) + record = None + if not record: + snap = self.ctl.compositor.snapshot() + ws = next((w for w in snap["workspaces"] if w["selector"] == workspace), None) + if not ws: + raise ValueError("Preview workspace no longer exists") + scene = self.ctl.scenes.records.get(workspace, {}) + if scene.get("phase", "ready") not in ("ready", "partial", "restored", "needs-attention"): + raise ValueError("Wait for the scene to finish before browsing layouts") + base = {"layout": ws["layout"]} + spec = snap.get("layouts", {}).get(ws["layout"].removeprefix("lua:"), {}).get("spec") + if spec: + base["spec"] = copy.deepcopy(spec) + record = {"token": token, "base": base, "shown": [ws["layout"]], + "instance": self.ctl.compositor.instance, "epoch": self.epoch} + self.active[workspace] = record + record["deadline"] = self.clock() + 10 + target = "lua:" + entry["name"] + if target not in record["shown"]: + record["shown"].append(target) + # Persist the restoration target before changing the compositor. + self.ctl.persist() + try: + self.ctl.compositor.call("scene_layout", {"workspace": workspace, "layout": target, "spec": entry["spec"]}) + except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired): + record["ending"] = True + self.ctl.persist() + raise + return {"preview": True, "layout": target} + + def heartbeat(self, workspace, token): + record = self.active.get(workspace) + if record and record["token"] == token and not record.get("ending") and record["epoch"] == self.epoch: + record["deadline"] = self.clock() + 10 + + def tick(self): + for workspace, record in list(self.active.items()): + ended = record.get("ending") or record["epoch"] != self.epoch or self.clock() >= record["deadline"] + if ended: + self.end(workspace) + + def before_command(self, request): + command = request.get("command") + if command == "scene": + if request.get("action") in ("browse", "browse-end", "catalog", "current", "list", "show", "validate", "remove"): + return + workspace = request.get("workspace") or self.ctl.compositor.snapshot()["workspace"] + if str(workspace) in self.active: + self.end(str(workspace)) diff --git a/scenes/ipc.py b/scenes/ipc.py new file mode 100644 index 0000000..1dca92c --- /dev/null +++ b/scenes/ipc.py @@ -0,0 +1,101 @@ +"""Private single-writer IPC for the scene service.""" +import fcntl +import json +import os +import select +import signal +import socket +import subprocess +import time +from service import Compositor + +def require(value, message): + if not value: + raise ValueError(message) + + +def request(runtime, payload, timeout=55): + with socket.socket(socket.AF_UNIX) as client: + client.settimeout(timeout) + client.connect(str(runtime / "control.sock")) + client.sendall(json.dumps(payload).encode() + b"\n") + data = bytearray() + while not data.endswith(b"\n") and len(data) < 2_000_000: + part = client.recv(65536) + if not part: + break + data.extend(part) + result = json.loads(data) + require(result.get("ok"), result.get("error", "controller request failed")) + return result["result"] + + +def daemon(root, runtime, config, factory): + root.mkdir(mode=0o700, parents=True, exist_ok=True) + runtime.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(root, 0o700) + os.chmod(runtime, 0o700) + with (root / "writer.lock").open("w") as lock: + instance = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE") + require(instance, "start the controller inside the Hyprland session") + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + previous = request(runtime, {"command": "status"}) + if previous.get("instance") == instance: + return + alive = subprocess.run(["hyprctl", "-i", previous["instance"], "version"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5) + require(alive.returncode != 0, "controller belongs to another running compositor") + request(runtime, {"command": "stop"}) + for _ in range(50): + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + time.sleep(.1) + else: + raise ValueError("previous controller has not stopped") + controller = factory(root, config, Compositor(instance, runtime)) + def stop(*_): + controller.running = False + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + path = runtime / "control.sock" + path.unlink(missing_ok=True) + with socket.socket(socket.AF_UNIX) as server: + server.bind(str(path)) + os.chmod(path, 0o600) + server.listen(16) + try: + next_tick = 0 + while controller.running: + if select.select([server], [], [], min(1, max(0, next_tick - time.monotonic())))[0]: + with server.accept()[0] as client: + client.settimeout(2) + try: + data = bytearray() + while not data.endswith(b"\n") and len(data) < 65536: + part = client.recv(8192) + if not part: + break + data.extend(part) + payload = json.loads(data) + result = {"ok": True, "result": controller.command(payload)} + if payload.get("command") not in ("status", "stop"): + next_tick = 0 + except Exception as error: + result = {"ok": False, "error": str(error)} + try: + client.sendall(json.dumps(result).encode() + b"\n") + except OSError: + pass # Intent remains durable if the CLI disconnects. + if time.monotonic() >= next_tick: + try: + controller.tick() + except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired): + # A compositor outage must not erase sources or launch duplicates. + controller.applied.clear() + next_tick = time.monotonic() + controller.tick_interval() + finally: + path.unlink(missing_ok=True) diff --git a/scenes/scene_service.py b/scenes/scene_service.py new file mode 100644 index 0000000..032dfb8 --- /dev/null +++ b/scenes/scene_service.py @@ -0,0 +1,130 @@ +"""Independent scene writer: no host config, stream lock, or connection controller.""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +from service import atomic_json, read_json +from scenes import Manager +from browse import Browser +from ipc import daemon, request + + +class SceneController: + def __init__(self, root, config, compositor, now=time.time): + self.root, self.config, self.compositor, self.now = root, config, compositor, now + self.state = read_json(root / "state.json") if (root / "state.json").exists() else {"version": 1} + if self.state.get("version") != 1: + raise ValueError("unsupported scene state version") + self.running = True + self.scenes = Manager(self) + self.browser = Browser(self) + if self.state.get("instance") != compositor.instance: + self.state["app_launches"].clear() + for record in self.scenes.records.values(): + if record.get("phase") != "restored": + record.update(phase="waiting-session") + record.pop("baseline", None) + self.state["instance"] = compositor.instance + self.persist() + + def persist(self): + atomic_json(self.root / "state.json", self.state) + + def command(self, payload): + command = payload.get("command") + if command == "status": + return {"instance": self.compositor.instance, "scenes": len(self.scenes.records)} + if command == "stop": + for workspace in list(self.browser.active): + self.browser.end(workspace) + self.running = False + return {"stopping": True} + if command == "session-restore": + self.scenes.restore_refs(payload.get("scenes", [])) + return {"accepted": True} + if command != "scene": + raise ValueError("Scenes only manages layouts and app placement") + self.browser.before_command(payload) + return self.scenes.command(payload) + + def tick(self): + before = json.dumps(self.state, sort_keys=True) + self.scenes.apps.reap() + self.browser.tick() + self.scenes.tick() + if before != json.dumps(self.state, sort_keys=True): + self.persist() + + def tick_interval(self): + if any(r["phase"] in ("stopping", "layout", "connecting", "waiting-workspace", "restore-builtin") + or any(a["status"] in ("pending", "waiting-window") for a in r.get("apps", {}).values()) + for r in self.scenes.records.values()): + return .2 + return 1 if self.browser.active else 30 + + +def paths(): + return (Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "hypertile/scenes", + Path(os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}") / "hypertile-scenes", + Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "hypertile/scenes.json") + + +def main(argv=None): + os.umask(0o077) + parser = argparse.ArgumentParser(description="Save and apply layouts with ordinary desktop apps") + commands = parser.add_subparsers(dest="action", required=True) + for action in ("daemon", "status", "stop", "list", "show", "save", "validate", "apply", "current", "restore", "cancel", "retry", "remove", "catalog", "content", "layout", "browse", "browse-end"): + child = commands.add_parser(action) + if action in ("show", "save", "apply", "remove", "layout", "browse"): + child.add_argument("name") + if action in ("browse", "browse-end", "catalog"): + child.add_argument("--browse-token", required=action != "catalog") + if action in ("validate", "save"): + child.add_argument("--file") + if action == "content": + child.add_argument("--zone", required=True) + child.add_argument("--type", choices=("local", "app", "empty"), required=True) + child.add_argument("--desktop-id") + child.add_argument("--app-class") + child.add_argument("--app-title") + child.add_argument("--workspace") + child.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + root, runtime, config = paths() + try: + if args.action == "daemon": + daemon(root, runtime, config, SceneController) + return 0 + payload = {**vars(args), "command": args.action if args.action in ("stop", "status") else "scene"} + if getattr(args, "file", None): + payload["document"] = json.loads(sys.stdin.read() if args.file == "-" else Path(args.file).read_text()) + if args.action == "validate" and not payload.get("document"): + raise ValueError("validate requires --file FILE (or - for stdin)") + if args.action not in ("stop", "status"): + try: + request(runtime, {"command": "status"}, timeout=1) + except (OSError, ValueError): + entry = Path(__file__).resolve().parents[1] / "bin/hypertile-scenes" + if not entry.exists(): + entry = Path.home() / ".local/bin/hypertile-scenes" + subprocess.Popen([sys.executable, str(entry), "daemon"], stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + for _ in range(50): + try: + request(runtime, {"command": "status"}, timeout=.2) + break + except (OSError, ValueError): + time.sleep(.1) + print(json.dumps(request(runtime, payload), indent=2)) + return 0 + except (OSError, ValueError, KeyError, RuntimeError, subprocess.TimeoutExpired) as error: + print("hypertile-scenes: " + str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scenes/scenes.py b/scenes/scenes.py new file mode 100644 index 0000000..9c79146 --- /dev/null +++ b/scenes/scenes.py @@ -0,0 +1,433 @@ +"""Scene definitions and orchestration. App lifecycle belongs to each app.""" +import copy +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import uuid + +from service import atomic_json +from apps import AppPlacement + + +def check(condition, message): + if not condition: + raise ValueError(message) + + +def name(value): + check(isinstance(value, str) and re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}", value), "invalid scene name") + return value + + +def leaves(spec): + children = spec.get("columns", spec.get("rows")) + if children is None: + return [spec] + return [leaf for child in children for leaf in leaves(child)] + + +def identify(spec): + spec = copy.deepcopy(spec) + spec.setdefault("layout_id", str(uuid.uuid4())) + for leaf in leaves(spec): + leaf.setdefault("id", str(uuid.uuid4())) + return spec + + +class Layouts: + def __init__(self): + src = os.environ.get("HYPERTILE_SRC") + self.ctl = str(Path(src) / "bin/hypertile-ctl" if src else Path.home() / ".local/bin/hypertile-ctl") + self.directory = Path(os.environ.get("HYPERTILE_LAYOUTS_DIR") or + Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "hypr/layouts") + self.stamp, self.cache = None, [] + + def run(self, *args, document=None): + result = subprocess.run([self.ctl, *args], input=json.dumps(document) if document is not None else None, + capture_output=True, text=True, timeout=10) + check(result.returncode == 0, result.stderr.strip() or "layout command failed") + return result.stdout + + def all(self): + stamp = [(str(p), p.stat().st_mtime_ns, p.stat().st_size) for p in sorted(self.directory.glob("*.lua"))] + if stamp != self.stamp: + self.cache = [v for v in json.loads(self.run("list", "--json"))["layouts"] if v.get("spec")] + self.stamp = stamp + return copy.deepcopy(self.cache) + + def get(self, hint, identity=None): + entries = self.all() + matches = [v for v in entries if (v["spec"].get("layout_id") == identity if identity else v["name"] == hint)] + check(len(matches) == 1, "Scene layout is missing or its identity is ambiguous; choose a layout again") + return matches[0] + + def ensure(self, hint): + entry = self.get(hint) + spec = identify(entry["spec"]) + if spec != entry["spec"]: + # One-time metadata migration. No geometry, fill or app rules change. + self.run("save", "-", "--no-reload", document={"name": hint, "spec": spec}) + self.stamp = None + return {"name": hint, "spec": spec} + + def persist(self, workspace, rule): + check(re.fullmatch(r"[1-9][0-9]*", workspace), "invalid scene workspace") + state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") + directory = Path(os.environ.get("HYPERTILE_RULES_DIR") or state / "hypertile/workspace-rules") + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + fd, path = tempfile.mkstemp(prefix=".scene-", dir=directory) + try: + with os.fdopen(fd, "w") as stream: + stream.write(rule + "\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(path, directory / (workspace + ".lua")) + (state / "omarchy/workspace-layouts" / (workspace + ".lua")).unlink(missing_ok=True) + finally: + Path(path).unlink(missing_ok=True) + + +class Manager: + def __init__(self, controller, layouts=None, directory=None): + self.ctl = controller + self.apps = AppPlacement(controller) + self.layouts = layouts or Layouts() + self.directory = directory or controller.config.parent / "scenes" + self.records = controller.state.setdefault("scenes", {}) + + def path(self, value): + return self.directory / (name(value) + ".json") + + def load(self, value): + try: + return json.loads(self.path(value).read_text()) + except FileNotFoundError: + raise ValueError("No saved scene named " + value) from None + + def resolve(self, doc, migrate=False): + check(isinstance(doc, dict) and doc.get("version") == 1, "unsupported scene schema") + hint = doc.get("layout", "").removeprefix("lua:") + name(hint) + entry = self.layouts.get(hint, doc.get("layout_id")) + if migrate: + entry = self.layouts.ensure(entry["name"]) + spec = entry["spec"] + nodes = leaves(spec) + ids = [n.get("id") for n in nodes if n.get("id")] + check(len(ids) == len(set(ids)), "duplicate zone identities") + inputs = doc.get("sources", {}) + check(isinstance(inputs, dict), "scene sources must be an object") + output, blocked, apps = {}, set(), set() + for key, source in inputs.items(): + check(isinstance(source, dict), "invalid scene source") + matches = [n for n in nodes if n.get("id") == key] + if not matches and not doc.get("layout_id"): + matches = [n for n in nodes if n["name"] == key] + check(len(matches) == 1, "Scene zone is missing: " + str(source.get("zone", key)) + "; choose its replacement") + leaf = matches[0] + kind = source.get("type") + check(kind != "stream", "Legacy stream source: replace it with an installed app desktop ID") + check(kind in ("local", "empty", "app"), "unsupported scene source type") + check(not leaf.get("spacer") or kind == "empty", "A spacer can only contain Empty") + value = {"type": kind, "zone": leaf["name"]} + if kind == "app": + value.update(self.apps.desktop.resolve(source)) + match = (value["app_class"], value.get("app_title")) + check(not any(c == match[0] and (not t or not match[1] or t == match[1]) for c, t in apps), + "Overlapping app matches cannot occupy separate scene zones") + apps.add(match) + if kind == "local" and source.get("app_class"): + app = source["app_class"] + check(isinstance(app, str) and 0 < len(app) <= 250 and "\n" not in app, "invalid app class") + check(not any(c == app for c, _ in apps), "An app class can occupy only one scene zone") + apps.add((app, None)) + value["app_class"] = app + if kind == "empty": + blocked.add(leaf["name"]) + output[leaf.get("id", leaf["name"])] = value + cycle = spec.get("cycle", spec.get("fill", [n["name"] for n in nodes if not n.get("spacer")])) + check(any(zone not in blocked for zone in cycle), "Leave one fill zone for local windows") + normalized = {"version": 1, "layout": entry["name"], "sources": output} + if spec.get("layout_id"): + normalized["layout_id"] = spec["layout_id"] + if doc.get("name"): + normalized["name"] = name(doc["name"]) + return normalized, spec + + def workspace(self, request, snap): + workspace = str(request.get("workspace") or snap["workspace"]) + check(re.fullmatch(r"[1-9][0-9]*", workspace), "Scenes currently use numbered workspaces") + check(any(w["selector"] == workspace for w in snap["workspaces"]), "Create this workspace before applying a scene") + return workspace + + def capture(self, workspace, snap, migrate=True): + ws = next(w for w in snap["workspaces"] if w["selector"] == workspace) + check(ws["layout"].startswith("lua:"), "Choose a Hypertile layout before saving content") + entry = self.layouts.ensure(ws["layout"][4:]) if migrate else self.layouts.get(ws["layout"][4:]) + spec, bindings = entry["spec"], {} + active = self.records.get(workspace) + if active and active.get("phase") not in ("waiting-workspace", "restored") and active.get("document") and active["document"].get("layout_id") == spec.get("layout_id"): + bindings = copy.deepcopy(active["document"]["sources"]) + bindings = {k: v for k, v in bindings.items() if v["type"] != "stream"} + doc = {"version": 1, "layout": entry["name"], "sources": bindings} + if spec.get("layout_id"): + doc["layout_id"] = spec["layout_id"] + # A change to a named scene keeps the name: the scene is then modified + # (Save writes it back) rather than a new unsaved one. + if active and active.get("phase") not in ("waiting-workspace", "restored") and active.get("document") \ + and active["document"].get("name") and active["document"].get("layout_id") == spec.get("layout_id"): + doc["name"] = active["document"]["name"] + return doc + + def modified(self, document): + """Whether the document differs from its saved definition (unnamed: always).""" + if not document.get("name"): + return True + try: + saved, _ = self.resolve(self.load(document["name"])) + except (ValueError, KeyError, TypeError): + return True + return saved["sources"] != document["sources"] or saved.get("layout_id") != document.get("layout_id") + + def public(self, record): + if not record: + return {"phase": "none", "sources": [], "document": None} + out = {k: copy.deepcopy(record[k]) for k in ("document", "workspace", "generation", "operation", "phase", "error", "modified", "results") if k in record} + out["can_restore"] = bool(record.get("baseline")) + out["sources"] = [] + for key, source in record.get("document", {}).get("sources", {}).items(): + item = {**source, "zone_id": key} + item["status"] = "ready" + for result in record.get("results", []): + if result["zone"] == source["zone"]: + item.update(result) + if source["type"] == "app": + state = record.get("apps", {}).get(key, {}) + item.update(status=state.get("status", "pending"), error=state.get("error")) + out["sources"].append(item) + return out + + def has_apps(self, document): + return any(s["type"] == "app" for s in document["sources"].values()) + + def start(self, doc, workspace, snap, restoring=False, force=False): + document, spec = self.resolve(doc) + check(document.get("layout_id"), "Save the scene first to establish layout and zone identities") + old = self.records.get(workspace) + if old: + self.apps.observe(old, snap) + if not force and not restoring and old and not any(a.get("status") in ("moved", "closed", "needs-attention") for a in old.get("apps", {}).values()) and old.get("document") == document and old["phase"] not in ("needs-attention", "restored", "waiting-workspace", "waiting-session"): + return self.public(old) + if old and old.get("baseline") and old["phase"] != "restored": + baseline = copy.deepcopy(old["baseline"]) + else: + ws = next((w for w in snap["workspaces"] if w["selector"] == workspace), None) + if not ws: + check(self.has_apps(document), "Workspace is unavailable") + ws = {"layout": "dwindle"} + baseline = {"layout": ws["layout"], "document": self.capture(workspace, snap) if ws["layout"].startswith("lua:") else None, + "windows": [{k: w[k] for k in ("address", "stable_id", "pid", "pin", "pin_exclusive") if k in w} + for w in snap["windows"] if w["workspace"] == workspace], "instance": self.ctl.compositor.instance} + record = {"workspace": workspace, "document": document, "spec": spec, "baseline": baseline, + "generation": (old or {}).get("generation", 0) + 1, "operation": uuid.uuid4().hex, + "phase": "stopping", "restoring": restoring, "modified": self.modified(document)} + record["retired_pins"] = copy.deepcopy((old or {}).get("retired_pins", []) + (old or {}).get("pins", [])) + # A new explicit assignment supersedes pending placement elsewhere. + # An older workspace must not claim the app when its late window arrives. + for other_ws, other in self.records.items(): + if other_ws == workspace or other.get("phase") == "restored": + continue + for key, source in other.get("document", {}).get("sources", {}).items(): + if source["type"] == "app" and any(s["type"] == "app" and s["app_class"] == source["app_class"] + and (not s.get("app_title") or not source.get("app_title") or s["app_title"] == source["app_title"]) + for s in document["sources"].values()): + other.setdefault("apps", {})[key] = {"status": "moved"} + self.records[workspace] = record + self.ctl.persist() + return self.public(record) + + def command(self, request): + action = request.get("action", "current") + if action in ("browse", "browse-end"): + return self.ctl.browser.command(request) + if action == "list": + entries = [] + for path in sorted(self.directory.glob("*.json")): + try: + doc, _ = self.resolve(json.loads(path.read_text())) + # What the scene places, for the overlay's scene cards. + sources = [{k: v for k, v in s.items() if k in ("zone", "type", "desktop_id", "app_name", "app_class")} + for s in doc["sources"].values()] + entries.append({"name": path.stem, "layout": doc["layout"], "valid": True, "sources": sources}) + except (ValueError, KeyError, TypeError) as error: + entries.append({"name": path.stem, "valid": False, "error": str(error)}) + return {"version": 1, "scenes": entries} + if action == "show": + return self.load(request["name"]) + if action == "remove": + self.path(request["name"]).unlink() + return {"removed": request["name"]} + if action == "validate": + doc, _ = self.resolve(request["document"]) + return {"valid": True, "document": doc} + snap = self.ctl.compositor.snapshot() + requested_workspace = str(request.get("workspace") or snap["workspace"]) + workspace = requested_workspace if action == "current" and requested_workspace in self.records else self.workspace(request, snap) + changed = [self.apps.observe(r, snap) for r in self.records.values()] + if any(changed): + self.ctl.persist() + if action == "current": + return self.public(self.records.get(workspace)) + if action == "catalog": + self.ctl.browser.heartbeat(workspace, request.get("browse_token")) + return {"version": 1, "current": self.public(self.records.get(workspace)), + "scenes": self.command({"action": "list"})["scenes"], + "active_workspaces": [w for w, r in self.records.items() if r["phase"] != "restored"], + "apps": self.apps.desktop.catalog(snap["windows"]), + "monitor_inputs": [], "workspace": workspace} + if action == "save": + doc = request.get("document") or self.capture(workspace, snap) + doc, _ = self.resolve(doc, migrate=True) + doc["name"] = name(request["name"]) + self.directory.mkdir(mode=0o700, parents=True, exist_ok=True) + atomic_json(self.path(doc["name"]), doc) + active = self.records.get(workspace) + if active and active["document"]["sources"] == doc["sources"] and active["document"]["layout_id"] == doc["layout_id"]: + active["document"] = copy.deepcopy(doc) + active["modified"] = False + if active["phase"] == "restored": + # The restored arrangement is now this named scene, applied. + active.update(phase="ready", restoring=False) + active.pop("error", None) + self.ctl.persist() + return {"saved": doc["name"], "document": doc} + if action == "apply": + return self.start(self.load(request["name"]), workspace, snap) + if action == "layout": + entry = self.layouts.ensure(request["name"]) + return self.start({"version": 1, "layout": entry["name"], "layout_id": entry["spec"]["layout_id"], "sources": {}}, workspace, snap) + if action in ("restore", "cancel"): + active = self.records.get(workspace) + check(active and active.get("baseline"), "No scene changes to restore") + baseline = active["baseline"] + if baseline["document"]: + return self.start(baseline["document"], workspace, snap, restoring=True) + active.update(phase="restore-builtin", restoring=True) + self.ctl.persist() + return self.public(active) + if action == "retry": + active = self.records.get(workspace) + check(active, "No active scene") + self.apps.retry(active) + return self.start(active["document"], workspace, snap, force=True) + if action == "content": + doc = self.capture(workspace, snap) + spec = self.layouts.get(doc["layout"], doc["layout_id"])["spec"] + leaf = next((n for n in leaves(spec) if n["name"] == request.get("zone")), None) + check(leaf, "Select a zone in the current layout") + source = {"type": request["type"], "zone": leaf["name"]} + for k in ("app_class", "app_title", "desktop_id"): + if request.get(k): + source[k] = request[k] + if source["type"] == "app": + doc["sources"] = {k: v for k, v in doc["sources"].items() if v.get("desktop_id") != source.get("desktop_id")} + doc["sources"][leaf["id"]] = source + return self.start(doc, workspace, snap) + raise ValueError("unknown scene command") + + def restore_refs(self, refs): + for ref in refs: + workspace = str(ref.get("workspace", "")) + if (workspace in self.records and self.records[workspace]["phase"] != "waiting-session") or not re.fullmatch(r"[1-9][0-9]*", workspace): + continue + self.records[workspace] = {"workspace": workspace, "document": copy.deepcopy(ref["document"]), + "phase": "waiting-workspace", "generation": 0, "operation": uuid.uuid4().hex, + "deadline": self.ctl.now() + 45} + self.ctl.persist() + + def tick(self): + for record in self.records.values(): + if record["workspace"] in self.ctl.browser.active: + continue + try: + self.step(record) + except (OSError, ValueError, RuntimeError, KeyError, subprocess.TimeoutExpired) as error: + record.update(phase="needs-attention", error=str(error)) + + def step(self, record): + phase, workspace = record["phase"], record["workspace"] + if phase == "waiting-workspace": + snap = self.ctl.compositor.snapshot() + if any(w["selector"] == workspace for w in snap["workspaces"]) or (self.has_apps(record["document"])): + self.start(record["document"], workspace, snap) + elif self.ctl.now() > record["deadline"]: + record.update(phase="needs-attention", error="Workspace did not return during session recovery") + return + if phase in ("restored", "needs-attention", "waiting-session"): + return + if phase in ("stopping", "restore-builtin"): + self.ctl.compositor.call("scene_clear", {"workspace": workspace}) + if phase == "restore-builtin": + rule = self.ctl.compositor.call("scene_layout", {"workspace": workspace, "layout": record["baseline"]["layout"]}) + self.layouts.persist(workspace, rule) + record["phase"] = "restored" + return + # Resolve again before writes: a queued scene cannot use stale IDs. + document, spec = self.resolve(record["document"]) + record.update(document=document, spec=spec, phase="layout") + self.ctl.persist() + if record["phase"] == "layout": + rule = self.ctl.compositor.call("scene_layout", {"workspace": workspace, "layout": "lua:" + record["document"]["layout"], "spec": record["spec"]}) + self.layouts.persist(workspace, rule) + record.update(phase="connecting", content_applied=False) + self.ctl.persist() + snap = self.ctl.compositor.snapshot() + live_ws = next((w for w in snap["workspaces"] if w["selector"] == workspace), None) + if not live_ws: + # An empty workspace can disappear while its app is starting. Its + # committed layout rule and content operation still exist; moving + # the eventual window there recreates it without taking focus. + if not record.get("content_applied") and self.has_apps(record["document"]): + sources = [{**value, "zone_id": key} for key, value in record["document"]["sources"].items()] + content = self.ctl.compositor.call("scene_content_apply", {"workspace": workspace, + "layout": "lua:" + record["document"]["layout"], "sources": sources, + "operation": record["operation"], "allow_missing_workspace": True}) + record.update(content_applied=True, results=content["results"], pins=content["pins"]) + if record.get("content_applied"): + results = self.apps.step(record, snap) + zones = {r["zone"] for r in results} + record["results"] = [r for r in record.get("results", []) if r["zone"] not in zones] + results + if any(r["status"] == "needs-attention" for r in results): + record["phase"] = "partial" + return + live_spec = snap.get("layouts", {}).get(live_ws["layout"].removeprefix("lua:"), {}).get("spec", {}) + if live_spec.get("layout_id") == record["document"].get("layout_id"): + document, spec = self.resolve(record["document"]) + if document != record["document"]: + record.update(document=document, spec=spec, content_applied=False, phase="stopping") + return # Reconcile the full reservation set before either new name is assigned. + # A user selected a different layout directly: don't force this scene back. + if live_ws["layout"] != "lua:" + record["document"]["layout"]: + record.update(phase="needs-attention", error="Workspace layout changed; apply or restore the scene") + return + if not record.get("content_applied") or workspace not in snap.get("scene_content", {}): + sources = [{**value, "zone_id": key} for key, value in record["document"]["sources"].items()] + content = self.ctl.compositor.call("scene_content_apply", {"workspace": workspace, "layout": live_ws["layout"], "sources": sources, "operation": record["operation"]}) + record["results"], record["pins"] = content["results"], content["pins"] + record["content_applied"] = True + app_results = self.apps.step(record, snap) + app_zones = {r["zone"] for r in app_results} + record["results"] = [r for r in record.get("results", []) if r["zone"] not in app_zones] + app_results + problems = any(r.get("status") == "needs-attention" for r in record.get("results", [])) + if not problems and not any(r["status"] == "waiting-window" for r in app_results): + record["phase"] = "ready" + record.pop("error", None) + else: + record["phase"] = "partial" if problems else "connecting" + if record.get("restoring") and record["phase"] == "ready": + if record["baseline"].get("instance") == self.ctl.compositor.instance: + self.ctl.compositor.call("scene_restore_pins", {"workspace": workspace, "windows": record.get("retired_pins", [])}) + record["phase"] = "restored" diff --git a/session/scene_recovery.py b/session/scene_recovery.py new file mode 100644 index 0000000..e2ea99b --- /dev/null +++ b/session/scene_recovery.py @@ -0,0 +1,82 @@ +"""Session capture and recovery for generic app scenes.""" +import copy +import json +import os +from pathlib import Path +import socket + + +def capture(desktop): + root = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "hypertile" + scene_path = root / "scenes/state.json" + scene_state = json.loads(scene_path.read_text()) if scene_path.exists() else {"version": 1} + if scene_state.get("version") != 1: + raise ValueError("unsupported scene state version; session capture paused") + if scene_state.get("browse", {}).get("active"): + raise ValueError("layout preview is active; retaining the last committed session checkpoint") + scene_refs, scene_windows = [], set() + for workspace, record in scene_state.get("scenes", {}).items(): + if not record.get("document") or record.get("phase") in ("restored", "waiting-session"): + continue + doc = copy.deepcopy(record["document"]) + for key, source in list(doc["sources"].items()): + if source["type"] != "app": + continue + app = record.get("apps", {}).get(key, {}) + ref = app.get("window") + window = next((w for w in desktop["windows"] if ref and all(w.get(k) == v for k, v in ref.items())), None) + if app.get("status") in ("moved", "closed") or (ref and (not window or window["workspace"] != workspace or window.get("pin") != source["zone"] or window.get("floating"))): + del doc["sources"][key] # Recovery preserves user departures; saved definitions stay intact. + elif window: + scene_windows.add(window["address"]) + else: + candidates = [w for w in desktop["windows"] if w.get("class") == source["app_class"] + and (not source.get("app_title") or w.get("title") == source["app_title"])] + if len(candidates) > 1: + del doc["sources"][key] # Preserve every unassigned peer through normal app recovery. + elif candidates: + scene_windows.add(candidates[0]["address"]) + scene_refs.append({"workspace": workspace, "document": doc}) + windows = [w for w in desktop["windows"] if w["address"] not in scene_windows] + desktop["windows"] = windows + desktop.pop("streams", None) + desktop.pop("scene_content", None) # Compositor addresses are not scene definitions. + desktop["scenes"] = scene_refs + addresses = {w["address"] for w in windows} + for ws in desktop["workspaces"]: + ws["order"] = [a for a in ws.get("order", []) if a in addresses] + if desktop.get("active") not in addresses: + desktop["active"] = None + return desktop + + +def restore(sources, scenes=()): + warnings = ["Legacy remote assignments were not reopened. Migrate them to installed app sources."] if sources else [] + runtime = Path(os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}") + # Keep source recovery available without coupling generic scenes to it. + legacy = [r for r in scenes if any(s["type"] == "stream" for s in r["document"]["sources"].values())] + if legacy: + warnings.append("Legacy stream scenes need migration to installed app sources; their saved definitions were kept.") + scenes = [r for r in scenes if r not in legacy] + for entry, payload, label in ( + ("hypertile-scenes", {"command": "session-restore", "scenes": scenes}, "Scene service"), + ): + if not payload.get("sources") and not payload.get("scenes"): + continue + try: + with socket.socket(socket.AF_UNIX) as client: + client.settimeout(1) + client.connect(str(runtime / entry / "control.sock")) + client.sendall(json.dumps(payload).encode() + b"\n") + data = bytearray() + while not data.endswith(b"\n") and len(data) < 65536: + part = client.recv(4096) + if not part: + break + data.extend(part) + result = json.loads(data) + if not result.get("ok"): + raise ValueError(result.get("error", "restore was refused")) + except (OSError, ValueError) as error: + warnings.append(label + " unavailable; local recovery continued. Saved assignments were kept. " + str(error)) + return warnings diff --git a/session/service.py b/session/service.py index 973bb14..c45d2da 100644 --- a/session/service.py +++ b/session/service.py @@ -19,6 +19,7 @@ import sys import tempfile import time +import scene_recovery def atomic_json(path, value): @@ -193,6 +194,7 @@ def __init__(self, config, proc=Path("/proc")): raise ValueError("replay must be an array of command names") self.proc = proc self.entries = {} + self.exact_entries = [] directories = [Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share")] directories += [Path(p) for p in (os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share").split(":")] seen = set() @@ -209,6 +211,10 @@ def __init__(self, config, proc=Path("/proc")): app = entry["Desktop Entry"] if app.get("Type") != "Application" or app.getboolean("Hidden", fallback=False): continue + exact_class = app.get("X-RemoteDesktops-WindowClass") + exact_title = app.get("X-RemoteDesktops-WindowTitle") + if exact_class and exact_title: + self.exact_entries.append((exact_class, exact_title, str(path))) keys = [desktop_id[:-len(".desktop")], app.get("StartupWMClass", "")] for key in keys: if key: @@ -217,6 +223,8 @@ def __init__(self, config, proc=Path("/proc")): continue def recipe(self, window): + if window.get("stream"): + return None cls = window["initial_class"] or window["class"] explicit = self.apps.get(cls, self.apps.get(window["class"])) if explicit is False: @@ -226,6 +234,11 @@ def recipe(self, window): if not isinstance(argv, list) or not argv or not all(isinstance(a, str) for a in argv): raise ValueError(f"apps.{cls}.argv must be a nonempty array of strings") return {"argv": argv, "per_window": explicit.get("per_window", False)} + exact = [path for app_class, title, path in self.exact_entries + if window.get("class") == app_class and window.get("title") == title] + if exact: + return {"argv": ["gio", "launch", exact[0]], "per_window": False, + "match": {"class": window["class"], "title": window["title"]}} if len(exact) == 1 else None # Browser owns the tabs and profiles. Only carry profile selectors; # startup URLs, remote-debugging flags and arbitrary process args are # deliberately not replayed. @@ -342,9 +355,13 @@ def capture(self, desktop): return desktop +def recipe_matches(saved, candidate): + return all(candidate.get(k) == v for k, v in (saved.get("launch") or {}).get("match", {}).items()) + + def match_windows(saved, current, matches, same_instance=False): """Match uniquely, allowing titles to settle. Never guess between peers.""" - available = {w["address"]: w for w in current if w["address"] not in matches.values()} + available = {w["address"]: w for w in current if not w.get("scene_app") and w["address"] not in matches.values()} pending = [w for w in saved if w["address"] not in matches] if same_instance: for old in pending[:]: @@ -360,7 +377,8 @@ def identity(w): if field and not old.get(field): continue peers = [w for w in pending if identity(w) == identity(old) and (not field or w.get(field) == old.get(field))] - candidates = [w for w in available.values() if identity(w) == identity(old) and (not field or w.get(field) == old.get(field))] + candidates = [w for w in available.values() if identity(w) == identity(old) and recipe_matches(old, w) + and (not field or w.get(field) == old.get(field))] if len(peers) == len(candidates) == 1: live = candidates[0] matches[old["address"]] = live["address"] @@ -389,6 +407,7 @@ def __init__(self, record, compositor, launchers, now, persist, progress=None): self.deadline = now + max(30, len(self.desktop["windows"]) * 3 + 10) self.settled = None compositor.call("prepare", self.desktop) + self.warnings.extend(scene_recovery.restore(self.desktop.get("streams", []), self.desktop.get("scenes", []))) def progress(self): return {"matches": self.matches, "launched": sorted(self.launched)} @@ -398,6 +417,7 @@ def reap(self): def tick(self, now): current = self.compositor.snapshot() + current["windows"] = [w for w in current["windows"] if not w.get("scene_app")] alive = {w["address"] for w in current["windows"]} for old, new in list(self.matches.items()): if new not in alive: @@ -405,8 +425,9 @@ def tick(self, now): self.placed.discard(old) if self.outstanding: old, cls, before, until = self.outstanding + saved = next(w for w in self.desktop["windows"] if w["address"] == old) candidates = [w for w in current["windows"] if w["address"] not in before - and (w["initial_class"] or w["class"]) == cls] + and (w["initial_class"] or w["class"]) == cls and recipe_matches(saved, w)] if len(candidates) == 1 and candidates[0]["address"] not in self.matches.values(): self.matches[old] = candidates[0]["address"] self.outstanding = None @@ -450,8 +471,9 @@ def tick(self, now): if not recipe: self.hopeless[saved["address"]] = "no launch recipe" continue - peers = [w for w in current["windows"] if (w["initial_class"] or w["class"]) == cls] - expected = sum((w["initial_class"] or w["class"]) == cls for w in self.desktop["windows"]) + matcher = {"launch": recipe} + peers = [w for w in current["windows"] if (w["initial_class"] or w["class"]) == cls and recipe_matches(matcher, w)] + expected = sum((w["initial_class"] or w["class"]) == cls and recipe_matches(matcher, w) for w in self.desktop["windows"]) if len(peers) >= expected or (peers and not recipe.get("per_window")): continue key = saved["address"] if recipe.get("per_window") else str(saved["pid"]) + json.dumps(recipe["argv"]) @@ -481,7 +503,7 @@ def tick(self, now): def finish(self): result = self.compositor.call("finish", {"snapshot": self.desktop, "matches": self.matches}) if isinstance(result, dict): - self.warnings = [str(w) for w in result.get("warnings", [])] + self.warnings.extend(str(w) for w in result.get("warnings", [])) def report(self): limitations = list(self.warnings) @@ -510,7 +532,7 @@ def __init__(self, store, compositor, launchers): def record(self): return {"version": 1, "instance": self.compositor.instance, "saved_at": time.time(), - "desktop": self.launchers.capture(self.compositor.snapshot())} + "desktop": self.launchers.capture(scene_recovery.capture(self.compositor.snapshot()))} def status(self): value = {"instance": self.compositor.instance, "mode": self.mode, "error": self.error} diff --git a/session/upgrade.py b/session/upgrade.py new file mode 100644 index 0000000..c110914 --- /dev/null +++ b/session/upgrade.py @@ -0,0 +1,31 @@ +"""Retire known legacy runtime files; never remove configuration or journals. + +Callers must hold the shared legacy writer lock before checking or deleting. +Remote Desktops holds the same shared lock; the old controller requires EX. +""" +import json +from pathlib import Path + +def check_legacy(state): + path = state / "streams/state.json" + if not path.exists(): + return + value = json.loads(path.read_text()) + if value.get("version") != 1 or not isinstance(value.get("computers"), dict): + raise ValueError("Unrecognized legacy state; preserve its recovery tools before upgrading") + pending = [name for name, record in value["computers"].items() if record.get("desired") or record.get("journal")] + if pending: + raise ValueError("Disconnect/restore legacy Hypertile connections before upgrading: " + ", ".join(pending)) + +def obsolete(bin_dir, data): + root = data / "hypertile" + paths = [bin_dir / "hypertile-stream", root / "session/streams.py"] + paths += [root / "stream" / (name + ".py") for name in + ("controller", "mac_display", "windows_display", "audio", "quality", "scenes", "scene_service", "apps", "browse", "ipc")] + paths += [root / "stream/windows" / name for name in + ("Guard.ps1", "Policy.ps1", "Display.cs", "Test.ps1", "Install.ps1")] + return paths + +def cleanup(bin_dir, data): + for path in obsolete(bin_dir, data): + path.unlink(missing_ok=True) diff --git a/test/apps.py b/test/apps.py new file mode 100644 index 0000000..09a6d0a --- /dev/null +++ b/test/apps.py @@ -0,0 +1,369 @@ +"""Generic app scenes: launch ownership, exact identity, recovery and user moves.""" +import copy +import fcntl +import importlib.util +import json +import multiprocessing +import time +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("scene_fixtures", Path(__file__).with_name("scenes.py")) +f = importlib.util.module_from_spec(spec) +spec.loader.exec_module(f) +from apps import DesktopApps +from scene_service import SceneController +from service import Launchers, match_windows +from ipc import daemon, request +import scene_recovery + + +class Desktop(DesktopApps): + def __init__(self, ctl): + super().__init__() + self.ctl, self.launched, self.error = ctl, [], None + + def launch(self, source): + state = json.loads((self.ctl.root / "state.json").read_text()) + assert source["desktop_id"] in state["app_launches"], "launch intent must precede process creation" + self.launched.append(source["desktop_id"]) + + def failure(self, desktop_id): + return self.error + + +class Compositor(f.Compositor): + def call(self, method, args): + if method == "scene_layout": + for ws in self.desktop["workspaces"]: + if ws["selector"] == args["workspace"]: + ws["layout"] = args["layout"] + self.desktop["layouts"][args["layout"].removeprefix("lua:")] = {"spec": args.get("spec", {})} + self.calls.append((method, copy.deepcopy(args))) + return "scene rule" + if method == "scene_app_place": + self.calls.append((method, copy.deepcopy(args))) + window = next(w for w in self.desktop["windows"] if w["address"] == args["address"]) + pin = {k: window[k] for k in ("address", "stable_id", "pid")} + pin.update(zone=args["zone"], before=window.get("pin")) + window.update(workspace=args["workspace"], pin=args["zone"], pin_exclusive=True, floating=False) + return pin + return super().call(method, args) + + +class AppTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + self.data = self.root / "data" + self.apps_dir = self.data / "applications" + self.apps_dir.mkdir(parents=True) + env = patch.dict(os.environ, XDG_DATA_HOME=str(self.data), XDG_DATA_DIRS=str(self.root / "system"), + XDG_STATE_HOME=str(self.root), XDG_CONFIG_HOME=str(self.root / "config")) + env.start() + self.addCleanup(env.stop) + self.desktop_file = self.apps_dir / "remote-desktops-macbook.desktop" + self.desktop_file.write_text('[Desktop Entry]\nType=Application\nName=MacBook\nExec=remote-desktops open macbook\n' + 'X-RemoteDesktops-WindowClass=com.moonlight_stream.Moonlight\nX-RemoteDesktops-WindowTitle=MacBook - Moonlight\n') + self.layouts = f.Layouts() + self.comp = Compositor(self.layouts) + self.now = 100 + self.ctl = self.controller() + self.desktop = Desktop(self.ctl) + self.ctl.scenes.apps.desktop = self.desktop + + def controller(self): + ctl = SceneController(self.root / "hypertile/scenes", self.root / "config/hypertile/scenes.json", self.comp, now=lambda: self.now) + ctl.scenes.layouts = self.layouts + return ctl + + def command(self, action, **kw): + return self.ctl.command({"command": "scene", "action": action, "workspace": "1", **kw}) + + def save(self): + return self.command("save", name="work", document={"version": 1, "layout": "quad", "sources": { + "right": {"type": "app", "desktop_id": self.desktop_file.name}}}) + + def start(self): + self.save() + self.command("apply", name="work") + self.tick() + + def tick(self, n=1): + for _ in range(n): + self.ctl.tick() + self.now += 1 + + def window(self, title="MacBook - Moonlight", address="macbook", workspace="2"): + window = {"address": address, "pid": 7, "stable_id": 22, "class": "com.moonlight_stream.Moonlight", + "initial_class": "com.moonlight_stream.Moonlight", "title": title, "workspace": workspace, + "floating": False} + self.comp.desktop["windows"].append(window) + return window + + def placements(self): + return [v for k, v in self.comp.calls if k == "scene_app_place"] + + def test_installed_entry_supplies_exact_identity(self): + source = self.save()["document"]["sources"]["z-right"] + self.assertEqual(source["app_title"], "MacBook - Moonlight") + self.assertEqual(source["app_class"], "com.moonlight_stream.Moonlight") + self.assertEqual(self.command("catalog")["apps"][0]["name"], "MacBook") + self.assertNotIn("computers", self.command("catalog")) + (self.apps_dir / "placeholder.desktop").write_text('[Desktop Entry]\nType=Application\nName=Placeholder\nExec=placeholder\nStartupWMClass=@@startup_wm_class\n') + self.ctl.scenes.apps.desktop.next_scan = 0 + self.assertNotIn("Placeholder", [a["name"] for a in self.command("catalog")["apps"]]) + self.assertFalse(self.desktop.launched) + + def test_standalone_service_does_not_take_stream_lock_or_read_computers(self): + legacy = self.root / "hypertile/streams" + legacy.mkdir() + (legacy / "state.json").write_text("not a scene journal") + with (legacy / "writer.lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + ctl = self.controller() + self.assertFalse(ctl.scenes.records) + ctl.tick() + self.assertEqual((legacy / "state.json").read_text(), "not a scene journal") + + def test_launch_once_wait_for_final_title_and_reuse_exact_window(self): + other = self.window("Work Laptop - Moonlight", "work") + startup = self.window("Moonlight", "startup") + self.start() + self.tick(5) + self.assertEqual(self.desktop.launched, [self.desktop_file.name]) + self.assertFalse(self.placements()) + startup["title"] = "MacBook - Moonlight" + self.tick(3) + self.assertEqual(len(self.placements()), 1) + self.assertEqual(other["workspace"], "2") + self.assertEqual(startup["workspace"], "1") + self.assertEqual(self.command("current")["phase"], "ready") + self.command("apply", name="work") + self.tick() + self.assertEqual(len(self.placements()), 1) + + def test_existing_window_reused_and_manual_move_survives_restart(self): + window = self.window() + self.start() + self.assertFalse(self.desktop.launched) + window.update(workspace="2", pin=None, floating=True) + self.tick(3) + self.ctl = self.controller() + self.ctl.scenes.apps.desktop = self.desktop + self.tick(3) + self.assertEqual(len(self.placements()), 1) + self.assertEqual(self.command("current")["sources"][0]["status"], "moved") + self.command("apply", name="work") + self.tick() + self.assertEqual(len(self.placements()), 2) + self.assertEqual(window["workspace"], "1") + + def test_close_does_not_relaunch_until_explicit_apply(self): + self.window() + self.start() + self.comp.desktop["windows"].clear() + self.tick(4) + self.assertFalse(self.desktop.launched) + self.assertEqual(self.command("current")["sources"][0]["status"], "closed") + self.command("apply", name="work") + self.tick() + self.assertEqual(len(self.desktop.launched), 1) + + def test_ambiguous_match_neither_launches_nor_places(self): + self.window() + self.window(address="duplicate") + self.start() + self.assertFalse(self.desktop.launched) + self.assertFalse(self.placements()) + self.assertEqual(self.command("current")["phase"], "partial") + + def test_uncertain_launch_survives_restart_timeout_and_explicit_retry(self): + self.start() + self.ctl = self.controller() + self.ctl.scenes.apps.desktop = self.desktop + self.tick(3) + self.assertEqual(len(self.desktop.launched), 1) + self.now += 46 + self.tick() + self.assertEqual(self.command("current")["phase"], "partial") + self.command("retry") + self.tick() + self.assertEqual(len(self.desktop.launched), 2) + + def test_new_scene_does_not_duplicate_pending_launch_or_place_late_app_after_cancel(self): + self.start() + self.command("content", type="app", desktop_id=self.desktop_file.name, zone="left") + self.tick() + self.assertEqual(len(self.desktop.launched), 1) + self.command("cancel") + self.tick() + window = self.window() + self.tick(5) + self.assertFalse(self.placements()) + self.assertEqual(window["workspace"], "2") + + def test_lost_placement_reply_does_not_retry_after_manual_move(self): + window = self.window() + original = self.comp.call + def fail(method, args): + value = original(method, args) + if method == "scene_app_place": + raise RuntimeError("reply lost") + return value + with patch.object(self.comp, "call", side_effect=fail): + self.start() + window["workspace"] = "2" + self.ctl = self.controller() + self.tick(4) + self.assertEqual(len(self.placements()), 1) + self.assertEqual(window["workspace"], "2") + + def test_session_checkpoint_uses_one_launcher_and_preserves_manual_departure(self): + window = self.window() + self.start() + captured = scene_recovery.capture(self.comp.snapshot()) + self.assertNotIn("macbook", [w["address"] for w in captured["windows"]]) + self.assertEqual(captured["scenes"][0]["document"]["sources"]["z-right"]["desktop_id"], self.desktop_file.name) + window.update(workspace="2", pin=None) + captured = scene_recovery.capture(self.comp.snapshot()) + self.assertIn("macbook", [w["address"] for w in captured["windows"]]) + self.assertEqual(captured["scenes"][0]["document"]["sources"], {}) + recipe = Launchers({}).recipe(window) + self.assertEqual(recipe["argv"], ["gio", "launch", str(self.desktop_file)]) + self.comp.instance = "next-compositor" + self.ctl = self.controller() + self.tick() + self.assertEqual(self.command("current")["phase"], "waiting-session") + self.ctl.command({"command": "session-restore", "scenes": captured["scenes"]}) + self.tick(3) + self.assertEqual(self.command("current")["document"]["sources"], {}) + + def test_scene_preview_pauses_capture_without_any_stream_journal(self): + self.start() + self.ctl.state["browse"]["active"]["1"] = {"token": "preview"} + self.ctl.persist() + with self.assertRaisesRegex(ValueError, "layout preview"): + scene_recovery.capture(self.comp.snapshot()) + + def test_missing_empty_workspace_can_return_with_the_app(self): + self.start() + self.comp.desktop["workspaces"].clear() + window = self.window() + self.tick() + self.assertEqual(len(self.placements()), 1) + self.assertEqual(window["workspace"], "1") + + def test_new_assignment_supersedes_pending_placement_on_another_workspace(self): + self.start() + self.comp.desktop["workspaces"].append({"selector": "2", "layout": "dwindle"}) + self.ctl.command({"command": "scene", "action": "apply", "name": "work", "workspace": "2"}) + self.assertEqual(self.ctl.scenes.records["1"]["apps"]["z-right"]["status"], "moved") + self.window() + # The first workspace may observe this late window, but has relinquished placement. + self.ctl.scenes.apps.step(self.ctl.scenes.records["1"], self.comp.snapshot()) + self.assertFalse(self.placements()) + + def test_current_observes_manual_move_before_idle_tick(self): + window = self.window() + self.start() + self.assertEqual(self.ctl.tick_interval(), 30) + window["workspace"] = "2" + self.assertEqual(self.command("current")["sources"][0]["status"], "moved") + self.assertEqual(len(self.placements()), 1) + + def test_recovery_never_matches_other_computer_by_shared_class(self): + saved = self.window() + saved["launch"] = Launchers({}).recipe(saved) + other = {**saved, "address": "other", "title": "Work Laptop - Moonlight"} + matched = {} + match_windows([saved], [other], matched) + self.assertEqual(matched, {}) + final = {**saved, "address": "new"} + match_windows([saved], [other, final], matched) + self.assertEqual(matched, {saved["address"]: "new"}) + + def test_extra_matching_windows_remain_in_normal_checkpoint(self): + self.window() + self.start() + self.window(address="extra") + captured = scene_recovery.capture(self.comp.snapshot()) + self.assertEqual([w["address"] for w in captured["windows"]], ["extra"]) + self.assertIn("z-right", captured["scenes"][0]["document"]["sources"]) + + def test_normal_recovery_does_not_claim_the_window_already_placed_by_scenes(self): + saved = self.window() + current = {**saved, "address": "scene-owned", "scene_app": True} + matches = {} + match_windows([saved], [current], matches) + self.assertEqual(matches, {}) + + def test_app_only_scene_recovery_does_not_wait_for_another_app_to_create_workspace(self): + doc = self.save()["document"] + self.comp.desktop["workspaces"].clear() + self.ctl.command({"command": "session-restore", "scenes": [{"workspace": "1", "document": doc}]}) + self.tick(3) + self.assertEqual(self.desktop.launched, [self.desktop_file.name]) + self.window() + self.tick() + self.assertEqual(len(self.placements()), 1) + + def test_daemon_has_independent_socket_and_writer_lock(self): + root, runtime = self.root / "ipc/scenes", self.root / "ipc/runtime" + legacy = self.root / "ipc/streams" + legacy.mkdir(parents=True) + with (legacy / "writer.lock").open("a") as lock, patch.dict(os.environ, HYPRLAND_INSTANCE_SIGNATURE="fake-scenes"): + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + proc = multiprocessing.get_context("fork").Process(target=daemon, args=(root, runtime, self.ctl.config, SceneController)) + proc.start() + try: + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + try: + status = request(runtime, {"command": "status"}, timeout=.2) + break + except (OSError, ValueError): + time.sleep(.02) + else: + self.fail("scene daemon did not start") + self.assertEqual(status["instance"], "fake-scenes") + self.assertEqual((runtime / "control.sock").stat().st_mode & 0o777, 0o600) + with (root / "writer.lock").open("a") as competing: + with self.assertRaises(BlockingIOError): + fcntl.flock(competing, fcntl.LOCK_EX | fcntl.LOCK_NB) + request(runtime, {"command": "stop"}, timeout=1) + proc.join(3) + self.assertEqual(proc.exitcode, 0) + finally: + if proc.is_alive(): + proc.terminate() + proc.join(3) + + def test_launcher_validation_and_xdg_precedence(self): + with self.assertRaisesRegex(ValueError, "not a path"): + self.desktop.resolve({"desktop_id": "../evil.desktop"}) + with self.assertRaisesRegex(ValueError, "disagrees"): + self.desktop.resolve({"desktop_id": self.desktop_file.name, "app_title": "Work Laptop - Moonlight"}) + system = self.root / "system/applications" + system.mkdir(parents=True) + (system / self.desktop_file.name).write_text(self.desktop_file.read_text()) + self.desktop_file.write_text("[Desktop Entry]\nType=Application\nHidden=true\n") + self.desktop.next_scan = 0 + with self.assertRaisesRegex(ValueError, "Install the app"): + self.desktop.resolve({"desktop_id": self.desktop_file.name}) + + def test_launch_uses_desktop_file_without_shell(self): + source = self.save()["document"]["sources"]["z-right"] + with patch("apps.subprocess.Popen") as launch: + DesktopApps().launch(source) + self.assertEqual(launch.call_args.args[0], ["gio", "launch", str(self.desktop_file)]) + self.assertNotIn("shell", launch.call_args.kwargs) + + + +if __name__ == "__main__": + unittest.main() diff --git a/test/browse.js b/test/browse.js new file mode 100644 index 0000000..1abbbc8 --- /dev/null +++ b/test/browse.js @@ -0,0 +1,38 @@ +// Exercise the actual overlay routing, including the managed-content regression. +const fs = require("fs"), vm = require("vm"), assert = require("assert") +const qml = fs.readFileSync("plugin/Overlay.qml", "utf8") +const functions = ["browseTo", "browseArgs", "runBrowse", "revertBrowse"].map(name => { + const start = qml.indexOf(" function " + name + "(") + assert(start >= 0, name) + const end = qml.indexOf("\n }", start) + 4 + return qml.slice(start, end) +}).join("\n") +const detached = [] +const context = { + root: {ctl: "ctl", workspaceId: "1", contentMode: false, managedContent: true, + contentCatalog: {}, catalogFailed: false, browseToken: "", committedLayout: "lua:quad", + browseTarget: "lua:quad", browseLaunched: "lua:quad", liveLayout: "lua:quad"}, + browseProc: {running: false}, browseTimer: {stop() {}}, Editor: {newId: () => "owner"}, + Quickshell: {execDetached: args => detached.push(args)} +} +vm.createContext(context) +vm.runInContext(functions, context) +context.browseTo("lua:wide") +assert(context.browseProc.running, "managed content must still move the underlying windows") +assert.deepStrictEqual(Array.from(context.browseProc.command), ["ctl", "scene", "browse", "lua:wide", "--workspace", "1", "--browse-token", "owner", "--json"]) +context.browseTo("lua:tall") +assert.strictEqual(context.browseProc.command[3], "lua:wide", "inflight switch stays serialized") +context.browseProc.running = false +context.runBrowse() +assert.strictEqual(context.browseProc.command[3], "lua:tall", "newest layout wins") +context.revertBrowse() +assert.deepStrictEqual(Array.from(detached[0]), ["ctl", "scene", "browse-end", "--workspace", "1", "--browse-token", "owner", "--json"]) +assert.strictEqual(context.root.browseToken, "") +assert.strictEqual(context.root.liveLayout, "lua:quad") +context.browseProc.running = false +context.runBrowse() +assert.strictEqual(context.browseProc.running, false, "late completion cannot reopen a closed preview") +context.root.managedContent = false +context.browseTo("lua:wide") +assert.deepStrictEqual(Array.from(context.browseProc.command), ["ctl", "apply", "lua:wide", "--workspace", "1", "--no-persist", "--quiet"]) +console.log("layout browse routing: all checks passed") diff --git a/test/browse.py b/test/browse.py new file mode 100644 index 0000000..b1d5edd --- /dev/null +++ b/test/browse.py @@ -0,0 +1,115 @@ +"""Managed layout previews exercise real scene state transitions.""" +import copy +import importlib.util +import json +import os +from pathlib import Path +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("scene_fixtures", Path(__file__).with_name("scenes.py")) +fixtures = importlib.util.module_from_spec(spec) +spec.loader.exec_module(fixtures) + + +class BrowseTests(unittest.TestCase): + setUp = fixtures.SceneTests.setUp + controller = fixtures.SceneTests.controller + tick = fixtures.SceneTests.tick + command = fixtures.SceneTests.command + save = fixtures.SceneTests.save + apply = fixtures.SceneTests.apply + ready = fixtures.SceneTests.ready + + def start(self, token="overlay", name="other"): + if len(self.layouts.entries) == 1: + self.layouts.entries.append({"name": "other", "spec": {"columns": [{"name": "one"}, {"name": "two"}]}}) + return self.command("browse", name=name, browse_token=token) + + def test_preview_and_cancel_keep_scene_and_saved_files(self): + self.ready() + scene = self.command("current") + saved = (self.root / "scenes/work.json").read_bytes() + self.start() + self.assertEqual(self.comp.desktop["workspaces"][0]["layout"], "lua:other") + self.tick(4) + self.assertEqual(self.command("current"), scene) + self.command("browse-end", browse_token="overlay") + self.tick() + self.assertEqual(self.comp.desktop["workspaces"][0]["layout"], "lua:quad") + self.assertEqual(self.command("current"), scene) + self.assertEqual((self.root / "scenes/work.json").read_bytes(), saved) + + def test_close_before_start_and_late_old_owner_cannot_move_windows(self): + self.ready() + self.command("browse-end", browse_token="old") + self.assertFalse(self.start("old")["preview"]) + self.start("new") + self.command("browse-end", browse_token="old") + self.assertEqual(self.comp.desktop["workspaces"][0]["layout"], "lua:other") + self.assertFalse(self.start("old")["preview"]) + self.assertEqual(self.ctl.browser.active["1"]["token"], "new") + + def test_lease_heartbeat_expiry_and_controller_restart_restore(self): + self.ready() + self.ctl.browser.clock = lambda: self.now + self.start() + self.now += 8 + self.command("catalog", browse_token="overlay") + self.now += 8 + self.tick() + self.assertIn("1", self.ctl.browser.active) + self.now += 11 + self.tick() + self.assertFalse(self.ctl.browser.active) + self.start("restart") + self.ctl = self.controller() + self.tick() + self.assertFalse(self.ctl.browser.active) + self.assertEqual(self.comp.desktop["workspaces"][0]["layout"], "lua:quad") + + def test_new_scene_restores_base_before_capturing_and_retains_client(self): + self.ready() + self.save("move", zone="left") + self.start() + self.apply("move") + self.tick(4) + self.assertFalse(self.ctl.browser.active) + self.assertEqual(self.command("current")["phase"], "ready") + self.command("browse-end", browse_token="overlay") + + def test_session_capture_keeps_last_checkpoint_during_preview(self): + self.ready() + self.start() + directory = self.root / "hypertile/scenes" + directory.mkdir(parents=True, exist_ok=True) + (directory / "state.json").write_text(json.dumps(self.ctl.state)) + with patch.dict(os.environ, {"XDG_STATE_HOME": str(self.root)}): + with self.assertRaisesRegex(ValueError, "layout preview is active"): + fixtures.scene_recovery.capture(self.comp.snapshot()) + + def test_preview_rejects_inflight_scene_and_failed_restore_stays_blocked(self): + self.save() + self.apply() + with self.assertRaisesRegex(ValueError, "Wait for the scene"): + self.start() + self.tick(6) + self.start() + self.comp.fail_layout = True + with self.assertRaises(RuntimeError): + self.command("browse-end", browse_token="overlay") + self.assertIn("1", self.ctl.browser.active) + self.comp.fail_layout = False + self.tick() + self.assertFalse(self.ctl.browser.active) + + def test_late_close_does_not_override_unrelated_external_layout(self): + self.ready() + self.start() + self.comp.desktop["workspaces"][0]["layout"] = "dwindle" + self.command("browse-end", browse_token="overlay") + self.assertEqual(self.comp.desktop["workspaces"][0]["layout"], "dwindle") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/content.js b/test/content.js new file mode 100644 index 0000000..24deebe --- /dev/null +++ b/test/content.js @@ -0,0 +1,88 @@ +const fs = require("fs"), vm = require("vm"), assert = require("assert") +const C = {} +vm.runInNewContext(fs.readFileSync("plugin/Content.js", "utf8"), C) +const catalog = {current: {phase: "ready", sources: [{type: "empty", zone: "left"}]}} +assert.strictEqual(C.source(catalog, "1", "left", false), null) +assert.strictEqual(C.label(C.source(catalog, "1", "left", true)), "Empty") +assert.strictEqual(C.label(null), "Local windows") +assert.strictEqual(C.chip(null), "") +assert.strictEqual(C.state(null).text, "") +assert(C.detail(null).includes("fill order")) +assert.strictEqual(C.chip({ type: "empty", zone: "left" }), "Empty") +const app = { type: "local", zone: "left", app_class: "org.example.Editor", status: "needs-attention", error: "Open this app on the workspace" } +assert.strictEqual(C.label(app), "org.example.Editor") +assert.strictEqual(C.state(app).text, "Pending") +assert.strictEqual(C.detail(app), "Open this app on the workspace") +assert.strictEqual(C.detail({ type: "local", zone: "left", app_class: "x", status: "ready" }), "One matching window is pinned here") +// The scene header. +assert.strictEqual(C.sceneTitle(null), "No scene") +assert.strictEqual(C.sceneTitle({ phase: "none", document: null }), "No scene") +assert.strictEqual(C.sceneTitle({ phase: "restored", document: { name: "work" } }), "No scene") +assert.strictEqual(C.sceneTitle({ phase: "ready", document: {} }), "Unsaved scene") +assert.strictEqual(C.sceneTitle({ phase: "ready", document: { name: "work" } }), "work") +assert.strictEqual(C.sceneModified({ phase: "ready", modified: true, document: { name: "work" } }), true) +assert.strictEqual(C.sceneModified({ phase: "ready", modified: true, document: {} }), false) +assert.strictEqual(C.sceneModified({ phase: "restored", modified: true, document: { name: "work" } }), false) +assert.strictEqual(C.sceneTitle({ phase: "none", document: null }, "1"), "Workspace 1") +assert.strictEqual(C.sceneTitle({ phase: "restored", document: { name: "work" } }, "2"), "Workspace 2") +assert.strictEqual(C.sceneMeta({ phase: "ready" }, "quad", "1"), "quad · workspace 1 · Ready") +assert.strictEqual(C.sceneMeta({ phase: "none" }, "quad", "1"), "quad · local windows in every zone") +assert.strictEqual(C.sceneMeta({ phase: "restored" }, "quad", "1"), "quad · Previous arrangement restored") +assert.strictEqual(C.sceneMeta(null, "", ""), "") +const placing = { phase: "connecting", sources: [ + { type: "app", zone: "a", status: "ready" }, { type: "app", zone: "b", status: "waiting-window" }, + { type: "local", zone: "c", app_class: "x", status: "needs-attention" }, { type: "local", zone: "d" }, { type: "empty", zone: "e" }] } +assert.strictEqual(C.sceneProgress(placing), "1 of 3 placed · 1 needs attention") +assert.strictEqual(C.sceneMeta(placing, "quad", "1"), "quad · workspace 1 · 1 of 3 placed · 1 needs attention") +assert.strictEqual(C.sceneProgress({ phase: "layout", sources: placing.sources }), "Applying layout…") +assert.strictEqual(C.sceneProgress({ phase: "ready", sources: [] }), "Ready") +// Scene cards and the picker. +assert.strictEqual(JSON.stringify(C.appNames([{ type: "app", app_name: "MacBook (Remote Desktop)" }, { type: "local", app_class: "foot" }, { type: "empty" }, { type: "local" }])), '["MacBook","foot"]') +assert.strictEqual(C.summary(["A", "B", "C", "D"]), "A, B +2") +assert.strictEqual(C.summary(["A", "B"]), "A, B") +assert.strictEqual(C.summary([]), "") +assert.strictEqual(C.isRemoteDesktop({ desktop_id: "remote-desktops-macbook.desktop" }), true) +assert.strictEqual(C.isRemoteDesktop({ desktop_id: "foot.desktop", app_title: "x - Moonlight" }), true) +assert.strictEqual(C.isRemoteDesktop({ desktop_id: "foot.desktop" }), false) +assert.strictEqual(C.displayName("Work laptop (Remote Desktop)"), "Work laptop") +assert.strictEqual(C.label({ type: "app", desktop_id: "remote-desktops-macbook.desktop", app_name: "MacBook (Remote Desktop)" }), "MacBook") +assert.strictEqual(C.matches("", ["anything"]), true) +assert.strictEqual(C.matches(" CHR ", ["Google Chrome", "google-chrome"]), true) +assert.strictEqual(C.matches("zzz", ["Google Chrome", null]), false) +const windows = [{ class: "foot", title: "~", workspace: 1 }, { class: "foot", title: "vim", workspace: "1" }, { class: "cursor", title: "a.py - Cursor", workspace: 1 }, { class: "x", title: "", workspace: 2 }, { title: "no class", workspace: 1 }] +assert.strictEqual(JSON.stringify(C.openApps(windows, "1")), JSON.stringify([{ app_class: "cursor", count: 1, title: "a.py - Cursor" }, { app_class: "foot", count: 2, title: "vim" }])) +assert.strictEqual(JSON.stringify(C.openApps(windows, "3")), "[]") +catalog.current.phase = "restored" +assert.strictEqual(C.source(catalog, "1", "left", true), null) +const E = {} +vm.runInNewContext(fs.readFileSync("plugin/Editor.js", "utf8"), E) +const original = E.identify({ columns: [{ name: "a" }, { name: "b" }], fill: ["a", "b"] }) +const a = E.findLeaf(original, "a").node.id +const renamed = E.renameZone(original, "a", "renamed") +assert.strictEqual(E.findLeaf(renamed, "renamed").node.id, a) +assert.strictEqual(E.findLeaf(E.setFill(original, ["b", "a"]), "a").node.id, a) +const split = E.splitZone(original, "a", "rows") +assert.strictEqual(E.findLeaf(split, "a").node.id, a) +assert.notStrictEqual(E.findLeaf(split, "a-2").node.id, a) +assert(!E.findLeaf(E.deleteZone(original, "a"), "a")) +const fresh = E.identify(original, true) +assert.notStrictEqual(fresh.layout_id, original.layout_id) +assert.notStrictEqual(E.findLeaf(fresh, "a").node.id, a) +console.log("content and scene identities: all checks passed") +assert.equal(C.label({ type: "app", desktop_id: "remote-desktops-macbook.desktop", app_name: "MacBook" }), "MacBook") +assert.equal(C.state({ type: "app", status: "moved" }).text, "Moved") +assert.match(C.detail({ type: "app", status: "moved" }), /Moved by you/) +assert.match(C.detail({ type: "app", status: "closed" }), /Closed by you/) +// Zone positions in words. +const area = { x: 10, y: 40, w: 6124, h: 2510 } +const quad = [{ name: "main", x: 10, y: 40, w: 3062, h: 1255 }, { name: "main-2", x: 3072, y: 40, w: 3062, h: 1255 }, + { name: "main-3", x: 10, y: 1295, w: 3062, h: 1255 }, { name: "main-4", x: 3072, y: 1295, w: 3062, h: 1255 }] +assert.strictEqual(JSON.stringify(C.positionLabels(quad, area)), JSON.stringify({ main: "Top left", "main-2": "Top right", "main-3": "Bottom left", "main-4": "Bottom right" })) +const cols = [{ name: "a", x: 10, y: 40, w: 2041, h: 2510 }, { name: "b", x: 2051, y: 40, w: 2041, h: 2510 }, { name: "c", x: 4092, y: 40, w: 2042, h: 2510 }] +assert.strictEqual(JSON.stringify(C.positionLabels(cols, area)), JSON.stringify({ a: "Left", b: "Center", c: "Right" })) +assert.strictEqual(C.positionLabel({ x: 10, y: 40, w: 6124, h: 2510 }, area), "Full screen") +assert.strictEqual(C.positionLabel({ x: 2000, y: 900, w: 2000, h: 800 }, area), "Middle center") +// Four columns: the two inner ones would both be "Center", so they fall back to their names. +const four = [{ name: "a", x: 10, y: 40, w: 1531, h: 2510 }, { name: "b", x: 1541, y: 40, w: 1531, h: 2510 }, { name: "c", x: 3072, y: 40, w: 1531, h: 2510 }, { name: "d", x: 4603, y: 40, w: 1531, h: 2510 }] +assert.strictEqual(JSON.stringify(C.positionLabels(four, area)), JSON.stringify({ a: "Left", b: "", c: "", d: "Right" })) +assert.strictEqual(JSON.stringify(C.positionLabels(quad, null)), "{}") diff --git a/test/dev.py b/test/dev.py index eef6f34..ecf1c57 100644 --- a/test/dev.py +++ b/test/dev.py @@ -81,6 +81,23 @@ def run(*argv, **kwargs): runner.start() self.addCleanup(runner.stop) + def test_apply_guard_can_coexist_with_remote_desktops_shared_lock(self): + dev.BIN.mkdir(parents=True) + for name in ("hypertile-stream", "hypertile-scenes"): + (dev.BIN / name).write_text("placeholder") + path = self.state / "streams/writer.lock" + path.parent.mkdir(parents=True) + with path.open("a") as remote: + fcntl.flock(remote, fcntl.LOCK_SH | fcntl.LOCK_NB) + with dev.stopped_session(): + with path.open("a") as legacy: + with self.assertRaises(BlockingIOError): + fcntl.flock(legacy, fcntl.LOCK_EX | fcntl.LOCK_NB) + self.assertFalse(any(c[-2:] == ["hypertile-stream", "stop"] or + (c[0].endswith("hypertile-stream") and c[-1] == "stop") for c in self.commands)) + with (self.state / "scenes/writer.lock").open("a") as scene: + fcntl.flock(scene, fcntl.LOCK_EX | fcntl.LOCK_NB) + def test_link_preserves_dirty_checkout_and_is_idempotent(self): (self.plugin / ".git").mkdir(parents=True) (self.plugin / ".git/HEAD").write_text("original HEAD") diff --git a/test/navigation.lua b/test/navigation.lua index f101426..ad06a39 100644 --- a/test/navigation.lua +++ b/test/navigation.lua @@ -26,9 +26,18 @@ hl = { dispatch = function(args) dispatched = args end, } package.loaded["hypr.hypertile"] = { live = { test = {} } } +local routed +package.loaded["hypr.hypertile-session"] = { navigation_slots = function() end, swap = function(first, second) + if routed then routed.first, routed.second = first, second; return true end + return false +end } a.workspace.tiled_layout = "lua:test" nav.swap("l") assert(dispatched.target == "address:left", "swap by address") +dispatched, routed = nil, {} +nav.swap("l") +assert(routed.first == a and routed.second == left and not dispatched, "managed swap bypasses native reorder") +routed = nil dispatched = nil nav.swap("u") assert(dispatched == nil, "no neighbor does nothing") @@ -43,3 +52,112 @@ assert(dispatched.direction == "l", "standard layouts use stock swap") nav.focus("r") assert(dispatched.focus and dispatched.direction == "r", "standard layouts use stock focus") print("navigation: all checks passed") + +-- Exercise the real provider/session path, including recalculation after keys. +local engine = require("hypertile") +local session = require("hypertile-session") +package.loaded["hypr.hypertile"] = engine +package.loaded["hypr.hypertile-session"] = session +local provider +hl.layout = { register = function(name, value) provider = value end } +dofile("layouts/quad.lua") +local ws = {id=7, name="7", tiled_layout="lua:quad"} +local live = engine.live.quad +local app = window("app", 0, 0, 1, 1, {workspace=ws, mapped=true, stable_id=1, pid=11}) +local apps = {app} +local function recalculate() + local ctx = {area={x=10, y=30, w=1000, h=600}, targets={}} + for _, w in ipairs(apps) do + ctx.targets[#ctx.targets+1] = {window=w, place=function(_, box) + w.at = {x=box.x+2000, y=box.y-800} -- Offset monitor, not the origin. + w.size = {x=box.w, y=box.h} + end} + end + provider.recalculate(ctx) +end +hl.get_active_window = function() return app end +hl.get_windows = function() return apps end +hl.get_workspaces = function() return {ws} end +hl.dsp.window.resize = function(args) args.resize=true; return args end +hl.dispatch = function(args) + dispatched = args + if args.resize then recalculate() end +end +recalculate() +local function zone(w) + return session.navigation_slots(w).zone +end +assert(zone(app) == "tl") +for _, step in ipairs({{"r","tr"}, {"d","br"}, {"l","bl"}, {"u","tl"}, {"l","left"}, + {"r","tl"}, {"r","tr"}, {"r","right"}}) do + nav.swap(step[1]) + assert(zone(app) == step[2], "single app reaches " .. step[2]) +end +dispatched = nil +nav.swap("r") +assert(zone(app) == "right" and not dispatched, "edge does not wrap") +nav.focus("l") +assert(not dispatched, "focus never moves into empty slots") + +-- Moving the first fill window must not pull the second into its old slot. +live.state.pins, live.state.exclusive_pins = {}, {} +local other = window("other", 0, 0, 1, 1, {workspace=ws, mapped=true, stable_id=2, pid=22}) +apps = {app, other} +recalculate() +assert(zone(app) == "tl" and zone(other) == "tr") +nav.swap("d") +assert(zone(app) == "bl" and zone(other) == "tr", "other app stays put") +nav.swap("r") +nav.swap("u") +assert(zone(app) == "tr" and zone(other) == "br", "occupied destination swaps pinned apps") +nav.swap("d") +assert(zone(app) == "br" and zone(other) == "tr", "swap back") + +live.state.scene_empty = {["7"]={bl=true}} +recalculate() +local _, slots = session.navigation_slots(app) +for _, slot in ipairs(slots) do assert(slot.zone ~= "bl", "scene Empty excluded") end +assert(not pcall(session.move_to_empty, app, "bl"), "reservation revalidated") +assert(not pcall(session.move_to_empty, app, "tr"), "occupied slot rejected") +local before = live.state.pins.app +local stale = window("app", 0, 0, 1, 1, {workspace=ws, stable_id=99, pid=11}) +assert(not pcall(session.move_to_empty, stale, "left")) +assert(live.state.pins.app == before, "stale identity leaves pins unchanged") + +provider = engine.provider("custom", {columns={{name="a"}, {name="gap", spacer=true}, + {name="b"}, {name="c"}}, empty="keep", single="slot"}) +ws.tiled_layout = "lua:custom" +apps = {app} +recalculate() +nav.swap("r") +assert(zone(app) == "b", "custom layout skips spacer") +nav.swap("r") +assert(zone(app) == "c", "custom layout empty slot reached") +print("empty-slot navigation: all checks passed") + +provider = engine.provider("collapsed", {columns={{name="a"}, {rows={{name="b"}, {name="c"}}}}}) +ws.tiled_layout = "lua:collapsed" +recalculate() +assert(app.size.x == 1000, "single window initially collapses") +nav.swap("r") +assert(zone(app) == "b" and app.size.x == 500 and app.size.y == 300, + "moving into collapsed slot reveals configured geometry") +nav.swap("d") +assert(zone(app) == "c", "nested collapsed slots remain reachable") +local collapsed = engine.live.collapsed +engine.handle_msg(collapsed.compiled, collapsed.state, "reset", app) +recalculate() +assert(app.size.x == 1000, "reset restores collapse policy") +print("collapsed-slot navigation: all checks passed") + +provider = engine.provider("stacked", {columns={{name="a"}, {name="b"}}, + fill={"a", "a", "b"}, empty="keep", single="slot"}) +ws.tiled_layout = "lua:stacked" +apps = {app, other} +recalculate() +dispatched = nil +nav.swap("d") +assert(dispatched and dispatched.target == "address:other", "retain swaps within a stack") +nav.swap("r") +assert(zone(app) == "b" and zone(other) == "a", "move out of a stack into an empty slot") +print("stack navigation: all checks passed") diff --git a/test/scenes.lua b/test/scenes.lua new file mode 100644 index 0000000..813aeb0 --- /dev/null +++ b/test/scenes.lua @@ -0,0 +1,88 @@ +package.path = "./?.lua;" .. package.path +local engine = require("hypertile") +local spec = { layout_id = "layout", columns = { { name = "left", id = "a" }, { name = "middle", id = "b" }, { name = "right", id = "c" } }, + fill = { "left", "middle", "right" }, empty = "collapse", single = "collapse" } +engine.provider("test", spec) +local ws = { id = 1, name = "1", tiled_layout = "lua:test" } +local windows = { + { address = "a", stable_id = 1, pid = 11, class = "editor", workspace = ws, mapped = true, fullscreen = 0 }, + { address = "b", stable_id = 2, pid = 22, class = "terminal", workspace = ws, mapped = true, fullscreen = 0 }, +} +local calls, timers = {}, {} +local function tag(kind) return function(args) args.kind = kind; return args end end +hl = { get_windows = function() return windows end, get_workspaces = function() return { ws } end, + window_rule = function() end, timer = function(callback) timers[#timers + 1] = callback end, + dsp = { window = { resize = tag("resize"), move = tag("move"), float = tag("float"), fullscreen_state = tag("fullscreen") }, focus = tag("focus"), send_key_state = tag("key") }, + dispatch = function(args) calls[#calls + 1] = args end } +local session = require("hypertile-session") +package.loaded["hypertile-bridge"] = { + rule_source = function() return "hl.scene_test_applied = true" end, + apply = function() error("must not call hyprctl from the compositor thread") end, +} +assert(session.scene_layout({ workspace = "1", layout = "dwindle" }) == "hl.scene_test_applied = true") +assert(hl.scene_test_applied, "scene layout applies directly without recursive IPC") +local request = { workspace = "1", layout = "lua:test", sources = { + { type = "empty", zone = "right", zone_id = "c" }, { type = "local", zone = "left", zone_id = "a", app_class = "editor" } } } +local result = session.scene_content_apply(request) +assert(result.results[1].status == "ready" and result.pins[1].stable_id == 1) +assert(engine.state.test.scene_empty["1"].right and not engine.state.test.scene_empty["2"]) +local ctx = { area = { x = 0, y = 0, w = 900, h = 500 }, targets = {} } +for _, w in ipairs(windows) do + local target = { window = w, place = function(self, box) self.box = box end } + ctx.targets[#ctx.targets + 1] = target +end +engine.recalculate(engine.live.test.compiled, ctx, engine.state.test) +assert(ctx.targets[1].box.x == 0 and ctx.targets[2].box.x == 300, "local app pin and empty reservation preserve fill") +engine.state.test.pins.a = "middle" +engine.provider("other", spec) +engine.state.other.pins.a = "left" +session.scene_clear({ workspace = "1" }) +assert(engine.state.test.pins.a == "middle", "scene removal preserves a manually changed app pin") +assert(engine.state.other.pins.a == "left", "scene removal preserves the window's pin in other layouts") +assert(not engine.state.test.scene_empty["1"], "scene removal clears Empty") +windows[3] = { address = "c", stable_id = 3, pid = 33, class = "editor", workspace = ws, mapped = true } +result = session.scene_content_apply(request) +assert(result.results[1].status == "needs-attention" and #result.pins == 0, "ambiguous app never picks an arbitrary window") +windows[3] = nil +local ok = pcall(engine.compile, { columns = { { name = "a", id = "same" }, { name = "b", id = "same" } } }) +assert(not ok, "duplicate zone identities are refused") +-- Ordinary apps share no stream reservation or launch rule. Match and move +-- one final window, rejecting a second copy and recycled compositor identities. +windows[2] = nil +windows[1].title = "Document" +request.operation = "app-operation" +session.scene_content_apply(request) +local place = { workspace = "1", layout = "lua:test", operation = "app-operation", zone_id = "b", zone = "middle", + address = "a", stable_id = 1, pid = 11, app_class = "editor", app_title = "Document" } +local count = #calls +place.stable_id = 999 +assert(not pcall(session.scene_app_place, place) and #calls == count, "reused address cannot receive app placement") +place.stable_id = 1 +windows[2] = { address = "extra", stable_id = 44, pid = 77, class = "editor", title = "Document", workspace = ws, mapped = true } +assert(not pcall(session.scene_app_place, place) and #calls == count, "atomic placement rejects a late duplicate") +windows[2] = nil +local pin = session.scene_app_place(place) +assert(pin.zone == "middle" and engine.state.test.pins.a == "middle") +assert(engine.state.test.exclusive_pins.a, "generic app occupies the requested zone") +local ws2 = { id = 2, name = "2", tiled_layout = "lua:test" } +windows[1].workspace = ws2 +count = #calls +session.scene_app_place(place) +assert(#calls == count, "a repeated operation never moves a departed app back") +session.scene_content_apply(request) +assert(#calls == count, "repeated content apply preserves the original operation") +session.scene_clear({ workspace = "1" }) +assert(engine.state.test.pins.a == "middle", "clearing a scene preserves a window moved to another workspace") +assert(not pcall(session.scene_app_place, place) and #calls == count, "superseded operation cannot place a late window") +windows[1].workspace = ws +request.operation = "new-app-operation" +session.scene_content_apply(request) +place.operation = request.operation +hl.get_workspaces = function() return {} end +local before = #calls +session.scene_app_place(place) +assert(#calls > before, "a pending app can recreate its vanished empty workspace") +local move +for i = before + 1, #calls do if calls[i].kind == "move" then move = calls[i] end end +assert(move and move.workspace == "1" and move.follow == false, "app placement does not take focus") +print("scene adapter: all checks passed") diff --git a/test/scenes.py b/test/scenes.py new file mode 100644 index 0000000..793d401 --- /dev/null +++ b/test/scenes.py @@ -0,0 +1,131 @@ +"""Generic scene fixtures and definition checks, with no host or client process.""" +import copy +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch +sys.path[:0] = [str(Path(__file__).resolve().parents[1] / p) for p in ("scenes", "session")] +from scene_service import SceneController +from scenes import identify +import scene_recovery + +class Layouts: + def __init__(self): + self.entries = [{"name": "quad", "spec": {"layout_id": "layout-one", "columns": [ + {"name": "left", "id": "z-left"}, {"name": "right", "id": "z-right"}, + {"name": "extra", "id": "z-extra"}], "fill": ["left", "right", "extra"]}}] + + def get(self, hint, identity=None): + found = [e for e in self.entries if (e["spec"].get("layout_id") == identity if identity else e["name"] == hint)] + if len(found) != 1: + raise ValueError("layout identity is missing or ambiguous") + return copy.deepcopy(found[0]) + + def ensure(self, hint): + return self.get(hint) + + def persist(self, workspace, rule): + pass + + + +class Compositor: + instance = "one" + def __init__(self, layouts): + self.desktop = {"windows": [], "workspace": "1", "monitors": [], "scene_content": {}, + "layouts": {"quad": {"spec": layouts.entries[0]["spec"]}}, + "workspaces": [{"selector": "1", "layout": "lua:quad", "visible": False}]} + self.calls, self.fail_layout = [], False + def snapshot(self): + return copy.deepcopy(self.desktop) + def call(self, method, args): + self.calls.append((method, copy.deepcopy(args))) + if method == "scene_layout": + if self.fail_layout: + raise RuntimeError("injected scene layout failure") + self.desktop["workspaces"][0]["layout"] = args["layout"] + self.desktop["layouts"][args["layout"][4:]] = {"spec": args.get("spec", {})} + return "scene rule" + if method == "scene_content_apply": + self.desktop["scene_content"][args["workspace"]] = copy.deepcopy(args) + return {"results": [], "pins": []} + if method == "scene_clear": + self.desktop["scene_content"].pop(args["workspace"], None) + return True + +class SceneTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + env = patch.dict(os.environ, XDG_STATE_HOME=str(self.root)) + env.start() + self.addCleanup(env.stop) + self.layouts = Layouts() + self.comp = Compositor(self.layouts) + self.now = 100 + self.ctl = self.controller() + def controller(self): + ctl = SceneController(self.root / "hypertile/scenes", self.root / "scenes.json", self.comp, now=lambda: self.now) + ctl.scenes.layouts = self.layouts + return ctl + def tick(self, count=1): + for _ in range(count): self.ctl.tick() + def command(self, action, **kw): + return self.ctl.command({"command": "scene", "action": action, "workspace": "1", **kw}) + def save(self, name="work", zone="right"): + return self.command("save", name=name, document={"version": 1, "layout": "quad", "sources": {zone: {"type": "empty"}}}) + def apply(self, name="work"): + return self.command("apply", name=name) + def ready(self): + self.save() + self.apply() + self.tick(3) + self.assertEqual(self.command("current")["phase"], "ready") + def test_private_save_and_idempotent_apply(self): + self.ready() + self.assertEqual((self.root / "scenes/work.json").stat().st_mode & 0o777, 0o600) + self.assertEqual(self.command("current")["document"]["layout_id"], "layout-one") + count = len(self.comp.calls) + self.apply() + self.tick() + self.assertEqual(len(self.comp.calls), count) + def test_named_edit_and_restore(self): + self.ready() + self.command("content", zone="left", type="local") + self.tick() + self.assertEqual(self.command("current")["document"]["name"], "work") + self.assertTrue(self.command("current")["modified"]) + self.command("restore") + self.tick() + self.assertEqual(self.command("current")["phase"], "restored") + def test_invalid_empty_and_legacy_sources_do_not_write(self): + for sources, message in [({n: {"type": "empty"} for n in ("left", "right", "extra")}, "Leave one"), + ({"right": {"type": "stream", "computer": "laptop"}}, "Legacy stream")]: + with self.assertRaisesRegex(ValueError, message): + self.command("save", name="bad", document={"version": 1, "layout": "quad", "sources": sources}) + self.assertFalse((self.root / "scenes/bad.json").exists()) + def test_failure_retry_and_stable_zone_rename(self): + self.save() + self.apply() + self.comp.fail_layout = True + self.tick() + self.assertEqual(self.command("current")["phase"], "needs-attention") + self.comp.fail_layout = False + self.command("retry") + self.tick() + self.assertEqual(self.command("current")["phase"], "ready") + self.layouts.entries[0]["spec"]["columns"][1]["name"] = "renamed" + self.layouts.entries[0]["spec"]["fill"][1] = "renamed" + self.tick(3) + self.assertEqual(self.command("current")["document"]["sources"]["z-right"]["zone"], "renamed") + def test_identity_generation_is_pure(self): + source = {"columns": [{"name": "left"}, {"name": "right"}]} + result = identify(source) + self.assertNotIn("layout_id", source) + self.assertNotEqual(result["columns"][0]["id"], result["columns"][1]["id"]) + +if __name__ == "__main__": unittest.main() diff --git a/test/session.lua b/test/session.lua index dffeeab..e8943bf 100644 --- a/test/session.lua +++ b/test/session.lua @@ -22,7 +22,7 @@ end local rules = {} package.loaded["hypertile"] = engine package.loaded["hypertile-bridge"] = { - rule_source = function(layout, workspace, spec) + rule_source = function(workspace, layout, spec) rules[#rules + 1] = { layout = layout, workspace = workspace, spec = spec } return "return true" end, @@ -61,12 +61,13 @@ local session = require("hypertile-session") -- Order restoration: saved order o1..o4 maps to live c, a, d, b. local snapshot = { - workspaces = { { selector = "1", layout = "lua:test", order = { "o1", "o2", "o3", "o4" }, monitor = "DP-1", visible = true } }, + workspaces = { { selector = "1", layout = "lua:test", order = { "o1", "o2", "o3", "o4" }, monitor = "DP-1", visible = true, navigation_keep = true } }, windows = {}, layouts = {}, active = nil, workspace = "1", } local result = session.finish({ snapshot = snapshot, matches = { o1 = "c", o2 = "a", o3 = "d", o4 = "b" } }) assert(table.concat(order, ",") == "c,a,d,b", "order restored without relying on recalculation: " .. table.concat(order, ",")) assert(#result.warnings == 0, "no warnings") +assert(engine.live.test.state.navigation_keep["1"], "explicit slot geometry restored") local swaps = 0 for _, args in ipairs(dispatched) do if args.kind == "swap" then swaps = swaps + 1 end end assert(swaps <= 3, "at most n-1 swaps") @@ -87,11 +88,16 @@ assert(#engine.registered == 1 and engine.registered[1] == "old", "only the miss assert(engine.live.test.spec.name == "live", "existing layout keeps its current definition") assert(engine.state.test.sizes.s == 1, "saved sizes restored") assert(rules[1].spec.name == "live" and rules[2].spec.name == "old" and rules[3].spec == nil, "rules use the live spec when there is one") +assert(rules[1].workspace == "1" and rules[1].layout == "lua:test" and rules[3].workspace == "3" and rules[3].layout == "dwindle", "restore passes workspace and layout in the bridge API order") -- snapshot prunes cached orders for workspaces that no longer exist. engine.live.test.orders["1"] = { "a", "b", "c", "d" } +engine.live.test.boxes = {["7"] = {}} +engine.live.test.state.navigation_keep["7"] = true local snap = session.snapshot() assert(engine.live.test.orders["7"] == nil and engine.live.test.orders["1"], "stale workspace order pruned") assert(#snap.workspaces == 1 and table.concat(snap.workspaces[1].order, ",") == "a,b,c,d", "snapshot order follows the engine cache") +assert(snap.workspaces[1].navigation_keep, "snapshot preserves explicit slot geometry") +assert(not engine.live.test.boxes["7"] and not engine.live.test.state.navigation_keep["7"], "stale navigation state pruned") print("session adapter: all checks passed") diff --git a/test/swap.lua b/test/swap.lua new file mode 100644 index 0000000..fa3ce8e --- /dev/null +++ b/test/swap.lua @@ -0,0 +1,31 @@ +-- Generic pinned swaps preserve identities and validate both sides atomically. +package.path = './?.lua;' .. package.path +local engine = require('hypertile') +engine.provider('test', {columns={{name='left', id='a'}, {name='right', id='b'}}}) +local ws = {id=1, name='1', tiled_layout='lua:test'} +local windows = { + {address='a', stable_id=1, pid=11, mapped=true, workspace=ws, class='editor'}, + {address='b', stable_id=2, pid=22, mapped=true, workspace=ws, class='com.moonlight_stream.Moonlight'} +} +local calls = {} +hl = {get_windows=function() return windows end, get_workspaces=function() return {ws} end, + dsp={window={resize=function(a) return a end}}, dispatch=function(a) calls[#calls+1]=a end} +local session = require('hypertile-session') +local live = engine.live.test +live.orders['1'] = {'a','b'} +live.state.pins.a = 'left' +live.state.exclusive_pins = {a=true} +local plan = session.swap_plan({windows=windows}) +windows[2].pid = 99 +assert(not pcall(session.swap_apply, plan)) +assert(live.state.pins.a == 'left' and not live.state.pins.b, 'stale identity cannot partially swap') +windows[2].pid = 22 +session.swap_apply(plan) +assert(live.state.pins.a == 'right' and live.state.pins.b == 'left') +session.swap_apply(plan) +assert(live.state.pins.a == 'right' and live.state.pins.b == 'left', 'replay is absolute') +assert(live.state.exclusive_pins.a and live.state.exclusive_pins.b) +live.state.scene_empty = {['1']={right=true}} +assert(not pcall(session.swap_apply, plan), 'cannot occupy an Empty zone') +assert(not session.stream_assign and not session.stream_launch, 'compositor owns no remote lifecycle') +print('generic pinned swaps: all checks passed') diff --git a/test/upgrade.py b/test/upgrade.py new file mode 100644 index 0000000..42b159c --- /dev/null +++ b/test/upgrade.py @@ -0,0 +1,42 @@ +"""Upgrade preserves unresolved host recovery and removes only owned runtime files.""" +import json +from pathlib import Path +import sys +import tempfile +import unittest +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'session')) +from upgrade import check_legacy, cleanup, obsolete + +class UpgradeTests(unittest.TestCase): + def test_pending_or_corrupt_state_refuses_retirement(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = root / 'streams/state.json' + path.parent.mkdir() + for value in ({'version': 1, 'computers': {'mac': {'desired': True}}}, + {'version': 1, 'computers': {'mac': {'desired': False, 'journal': {'output': {}}}}}, + {'version': 99, 'computers': {}}): + path.write_text(json.dumps(value)) + with self.assertRaises(ValueError): check_legacy(root) + path.write_text('not JSON') + with self.assertRaises(ValueError): check_legacy(root) + path.write_text(json.dumps({'version': 1, 'computers': {'mac': {'desired': False, 'journal': {}}}})) + check_legacy(root) + def test_cleanup_preserves_user_files_and_new_scene_modules(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + binary, data = root / 'bin', root / 'data' + for path in obsolete(binary, data): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('legacy') + preserved = [data / 'hypertile/stream/custom.py', data / 'hypertile/scenes/scenes.py', + data / 'hypertile/streams/state.json', binary / 'remote-desktops'] + for path in preserved: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('keep') + cleanup(binary, data) + cleanup(binary, data) + self.assertTrue(all(not p.exists() for p in obsolete(binary, data))) + self.assertTrue(all(p.read_text() == 'keep' for p in preserved)) + +if __name__ == '__main__': unittest.main() diff --git a/uninstall.sh b/uninstall.sh index 8e42be2..d06fec0 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -40,11 +40,36 @@ state="${XDG_STATE_HOME:-$HOME/.local/state}/hypertile" plugin_id="jmartin.hypertile" plugin_dst="$config/omarchy/plugins/$plugin_id" +PYTHONPATH="$src/session" python3 - "$state" <<'PY_PREFLIGHT' +from pathlib import Path +from upgrade import check_legacy +import sys +check_legacy(Path(sys.argv[1])) +PY_PREFLIGHT + # Stop the writer before removing its code; retain recovery snapshots unless # --purge was requested. A missing/stopped service is harmless. if [[ -x "$bin/hypertile-session" ]]; then "$bin/hypertile-session" stop >/dev/null 2>&1 || true fi +if [[ -x "$bin/hypertile-stream" ]]; then + "$bin/hypertile-stream" stop >/dev/null 2>&1 || true +fi + +if [[ -x "$bin/hypertile-scenes" ]]; then + "$bin/hypertile-scenes" stop >/dev/null 2>&1 || true +fi + +# Keep pending host recovery tools intact and prevent legacy writer restarts. +mkdir -p "$state/streams" +exec 9>"$state/streams/writer.lock" +flock -sn 9 || { echo "uninstall.sh: legacy controller is still running" >&2; exit 1; } +PYTHONPATH="$src/session" python3 - "$state" <<'PY_CHECK' +from pathlib import Path +from upgrade import check_legacy +import sys +check_legacy(Path(sys.argv[1])) +PY_CHECK # One backup per edited config file, overwritten on each edit. backup() { @@ -121,7 +146,19 @@ for f in hypertile.lua hypertile-json.lua hypertile-bridge.lua hypertile-layouts rm -f "$hypr/$f" done rm -f "$bin/hypertile-ctl" -rm -f "$bin/hypertile-session" "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/session/service.py" +PYTHONPATH="$src/session" python3 - "$bin" "${XDG_DATA_HOME:-$HOME/.local/share}" <<'PY_CLEANUP' +from pathlib import Path +from upgrade import cleanup +import sys +cleanup(Path(sys.argv[1]), Path(sys.argv[2])) +PY_CLEANUP +rm -f "$bin/hypertile-session" "$bin/hypertile-scenes" +for module in service scene_recovery upgrade; do + rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/session/$module.py" +done +for module in scene_service apps ipc scenes browse; do + rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/hypertile/scenes/$module.py" +done echo "removed the engine files and hypertile-ctl" if (( purge )); then