diff --git a/README.md b/README.md index d379e6d0..d4b1dd5e 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,17 @@ My personal nix flake. ### NixOS (Linux) -I have a configuration for my gaming desktop: +My desktop has two configurations sharing the same hardware: -```bash +- `desktop` — my main config +- `quote` — an alternate `desktop` running an XP-themed awesome WM with an + embedded Minecraft game as the wallpaper +```bash sudo nixos-rebuild switch --flake .#desktop + +# or, the Minecraft desktop +sudo nixos-rebuild switch --flake .#quote ``` e-ink desktop using [Dasung Paperlike](https://shop.dasung.com/) @@ -33,25 +39,6 @@ My personal MacBook uses [nix-darwin](https://github.com/nix-darwin/nix-darwin): darwin-rebuild switch --flake .#macbook ``` -### Android - -My -[Pixel 9 Pro Fold](https://store.google.com/us/product/pixel_9_pro_fold?hl=en-US) -uses [nixos-avf](https://github.com/nix-community/nixos-avf) on Android's Native -Linux terminal: - -```bash -sudo nixos-rebuild switch --flake .#android -``` - -### Standalone - -Standalone home-manager configuration: - -```bash -home-manager switch --flake .#standalone -``` - ## Manual Setup ### Tresorit diff --git a/flake.lock b/flake.lock index 1ff67379..63801581 100644 --- a/flake.lock +++ b/flake.lock @@ -361,11 +361,11 @@ ] }, "locked": { - "lastModified": 1778606796, - "narHash": "sha256-P2krpSkFVYJ89bgsnAZ9RtQiGwiTW77sfSJp9SEDscM=", + "lastModified": 1778905220, + "narHash": "sha256-ox/5IHc8uwy6UTw6N7Shp6uCHIgu/S2PsWeuXsOHSo8=", "owner": "nix-community", "repo": "home-manager", - "rev": "e1fd7350f4410972bcb8c42a697d8c924ffe642a", + "rev": "d1686dc7d36cbd1234cb226ad6ef97e882716acb", "type": "github" }, "original": { @@ -410,11 +410,11 @@ "xwayland-satellite-unstable": "xwayland-satellite-unstable" }, "locked": { - "lastModified": 1778862364, - "narHash": "sha256-O0qC3IOHRscJcGPuDlIS4cLboKJZq358KH3oVzBeQjo=", + "lastModified": 1778942403, + "narHash": "sha256-SPCWvqeVySTNUgX/shARpRl5fi/NnkObUgDGR/Aco4c=", "owner": "sodiboo", "repo": "niri-flake", - "rev": "9ab3f8b17e22ead80525c4572b74156acf870526", + "rev": "daefca3370581223fedc24d0101c4915a3689f9e", "type": "github" }, "original": { @@ -515,11 +515,11 @@ }, "nixos-hardware": { "locked": { - "lastModified": 1778593042, - "narHash": "sha256-xYGrSg6354UK2K4WSQd4+TfyvfqmvFbSY+ZtGQUXK0c=", + "lastModified": 1779058144, + "narHash": "sha256-OFaHDx6EMVUfufD2cxkLHXT3AZdhLnjS+RChKPLIyAI=", "owner": "NixOS", "repo": "nixos-hardware", - "rev": "9bd7c80d43e258aaa607d83b43661df11444d808", + "rev": "8ea1d787af06d65d83885264cb5572d966bfa289", "type": "github" }, "original": { @@ -578,11 +578,11 @@ }, "nixpkgs-unstable": { "locked": { - "lastModified": 1778794387, - "narHash": "sha256-BL04pOS9453Awkeb9f90XBJXBSkWxN+vB7HIgnL0iMM=", + "lastModified": 1778869304, + "narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", "owner": "nixos", "repo": "nixpkgs", - "rev": "8a1b0127302ea51e05bf4ea5a291743fac442406", + "rev": "d233902339c02a9c334e7e593de68855ad26c4cb", "type": "github" }, "original": { @@ -642,11 +642,11 @@ }, "nixpkgs_5": { "locked": { - "lastModified": 1778443072, - "narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=", + "lastModified": 1778869304, + "narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32", + "rev": "d233902339c02a9c334e7e593de68855ad26c4cb", "type": "github" }, "original": { @@ -804,11 +804,11 @@ "systems": "systems_4" }, "locked": { - "lastModified": 1778793632, - "narHash": "sha256-HYHD6J64bAWB2iT00lyyTn0wWcb0POtV+nPshYvq6Uc=", + "lastModified": 1779000518, + "narHash": "sha256-wdtytSnzMe85J/qeXJALMzSLRFTZ1gBHwn81l1PtT8k=", "owner": "Gerg-L", "repo": "spicetify-nix", - "rev": "e175a8b634e06a1b0635ec3d4db2c72cdc41fd15", + "rev": "5dde76b38418892ccb3d99e99bed7f8a43ac294c", "type": "github" }, "original": { diff --git a/home/common/claude-settings.json b/home/common/claude-settings.json index 496fb14e..aac072be 100644 --- a/home/common/claude-settings.json +++ b/home/common/claude-settings.json @@ -6,6 +6,9 @@ "allow": ["mcp__svelte__*"] }, "model": "opus", + "attribution": { + "commit": "" + }, "enabledPlugins": { "elements-of-style@superpowers-marketplace": true, "superpowers-lab@superpowers-marketplace": true, diff --git a/home/common/ghostty.nix b/home/common/ghostty.nix index abdc8842..11cbd79e 100644 --- a/home/common/ghostty.nix +++ b/home/common/ghostty.nix @@ -7,14 +7,22 @@ fi ''; + xdg.configFile = lib.mkIf pkgs.stdenv.isLinux { + "ghostty/no-rounded.css".text = '' + window, .background, .titlebar, .top-bar { + border-radius: 0; + } + ''; + }; + programs.ghostty = { enable = true; package = if pkgs.stdenv.isLinux then pkgs.ghostty else null; settings = { - command = lib.mkIf pkgs.stdenv.isLinux "zellij"; + command = lib.mkIf pkgs.stdenv.isLinux "${nixpkgs-unstable.zellij}/bin/zellij"; desktop-notifications = false; - font-family = lib.mkForce "MonoLisa Variable"; + font-family = "MonoLisa Variable"; font-family-bold = "MonoLisa Variable Regular Bold"; font-family-italic = "MonoLisa Variable Italic Italic"; font-family-bold-italic = "MonoLisa Variable Italic Bold Italic"; @@ -30,7 +38,12 @@ background-opacity = 1.0; - gtk-titlebar = true; + gtk-titlebar = lib.mkIf pkgs.stdenv.isLinux false; + gtk-custom-css = lib.mkIf pkgs.stdenv.isLinux "no-rounded.css"; + + # The default "single-instance" scope strips PATH down to systemd + + # ghostty; "never" keeps spawned commands as direct ghostty children. + linux-cgroup = lib.mkIf pkgs.stdenv.isLinux "never"; macos-titlebar-style = "native"; diff --git a/home/common/neovim/rocks.toml b/home/common/neovim/rocks.toml index 17450b2c..f9bb8728 100644 --- a/home/common/neovim/rocks.toml +++ b/home/common/neovim/rocks.toml @@ -117,9 +117,9 @@ rev = "2337f109f51a09297596dd6b538b70ccba92b4e4" git = "A7Lavinraj/fyler.nvim" rev = "v2.0.0" -[plugins."nvim-java"] -git = "nvim-java/nvim-java" -rev = "v4.1.0" +# [plugins."nvim-java"] +# git = "nvim-java/nvim-java" +# rev = "v4.1.0" [bundles.neorg] items = [ diff --git a/home/common/zellij.nix b/home/common/zellij.nix index a6d3312b..8183eba2 100644 --- a/home/common/zellij.nix +++ b/home/common/zellij.nix @@ -1,7 +1,20 @@ { pkgs, lib, nixpkgs-unstable, ... }: +let + # zellij splits copy_command via shell-words, so route through a wrapper + # script that picks wl-copy or xclip based on whether a Wayland socket + # is present at runtime (awesome/quote is X11, sway/niri is Wayland). + linuxCopy = pkgs.writeShellScript "zellij-copy" '' + if [ -n "$WAYLAND_DISPLAY" ]; then + exec ${pkgs.wl-clipboard}/bin/wl-copy + else + exec ${pkgs.xclip}/bin/xclip -selection clipboard -in + fi + ''; +in { home.packages = lib.optionals pkgs.stdenv.isLinux (with pkgs; [ wl-clipboard + xclip ]); programs.zellij = { @@ -10,12 +23,12 @@ settings = { theme = "default"; - default_shell = "zsh"; + default_shell = if pkgs.stdenv.isLinux then "${pkgs.zsh}/bin/zsh" else "zsh"; mouse_mode = true; - rounded_corners = true; + rounded_corners = false; show_startup_tips = false; show_release_notes = false; - copy_command = if pkgs.stdenv.isLinux then "wl-copy" else "pbcopy"; + copy_command = if pkgs.stdenv.isLinux then "${linuxCopy}" else "pbcopy"; copy_clipboard = "system"; keybinds = { "locked" = { diff --git a/home/linux/default.nix b/home/linux/default.nix index b7fed5c6..93f61771 100644 --- a/home/linux/default.nix +++ b/home/linux/default.nix @@ -1,17 +1,21 @@ { features, hostName, lib, ... }: +let + de = features.desktopEnvironment; + selfContained = { + plasma = ./plasma; + sway = ./sway; + quote = ./quote; + }; + perHost = lib.optional + (de != null && !(selfContained ? ${de})) + ./${de}/host/${hostName}.nix; +in { imports = [ ./theme.nix - ] ++ lib.optionals features.gui [ - ./desktop.nix - ./mpv - ] ++ lib.optionals features.gaming [ - ./games - ] ++ lib.optionals (features.desktopEnvironment == "plasma") [ - ./plasma - ] ++ lib.optionals (features.desktopEnvironment == "sway") [ - ./sway - ] ++ lib.optionals (features.desktopEnvironment != null && features.desktopEnvironment != "sway") [ - ./${features.desktopEnvironment}/host/${hostName}.nix - ]; + ] + ++ lib.optionals features.gui [ ./desktop.nix ./mpv ] + ++ lib.optionals features.gaming [ ./games ] + ++ lib.optional (de != null && selfContained ? ${de}) selfContained.${de} + ++ perHost; } diff --git a/home/linux/games/minecraft.nix b/home/linux/games/minecraft.nix index c86daceb..9f1fc146 100644 --- a/home/linux/games/minecraft.nix +++ b/home/linux/games/minecraft.nix @@ -6,12 +6,10 @@ }) ]; + # JAVA_HOME pins temurin for general use; prismlauncher carries its own jdks. + # Not using `programs.java` because it adds temurin to home.packages, which + # collides with the openjdk propagated by clojure/leiningen/babashka. home.sessionVariables = { JAVA_HOME = "${pkgs.temurin-bin-21}"; }; - - programs.java = { - enable = true; - package = pkgs.temurin-bin-21; - }; } diff --git a/home/linux/plasma/default.nix b/home/linux/plasma/default.nix index f779a97e..82509317 100644 --- a/home/linux/plasma/default.nix +++ b/home/linux/plasma/default.nix @@ -1,9 +1,12 @@ -{ pkgs, inputs, theme, ... }: +{ pkgs, inputs, theme, hostName, ... }: let isLight = theme.appearance == "light"; in { - imports = [ inputs.plasma-manager.homeModules.plasma-manager ]; + imports = [ + inputs.plasma-manager.homeModules.plasma-manager + ./host/${hostName}.nix + ]; home.packages = with pkgs; [ exfatprogs hfsprogs diff --git a/home/linux/quote/_prism-files.nix b/home/linux/quote/_prism-files.nix new file mode 100644 index 00000000..c17b170c --- /dev/null +++ b/home/linux/quote/_prism-files.nix @@ -0,0 +1,54 @@ +{ pkgs, appearance ? "dark" }: +let + java = "${pkgs.temurin-bin-17}/bin/java"; + + # Prism's registered application theme IDs are "system", "dark", "bright" + # (no "light" — that was triggering ThemeWizardPage every launch because + # isValidApplicationTheme rejected it, regenerating the welcome wizard). + applicationTheme = if appearance == "light" then "bright" else "dark"; + iconTheme = if appearance == "light" then "pe_light" else "pe_dark"; + + # Merges our keys into prismlauncher.cfg without clobbering Prism-owned + # fields (accounts, last-used instance, window state, etc.). Idempotent. + prism-apply-launcher-config = pkgs.writeShellApplication { + name = "prism-apply-launcher-config"; + runtimeInputs = [ pkgs.crudini ]; + text = '' + file="''${1:-}" + [ -n "$file" ] || { echo "prism-apply-launcher-config: file path required" >&2; exit 1; } + [ -f "$file" ] || printf '[General]\n' > "$file" + set_kv() { crudini --ini-options=nospace --set "$file" General "$1" "$2"; } + set_kv ApplicationTheme '${applicationTheme}' + set_kv IconTheme '${iconTheme}' + set_kv BackgroundCat kitteh + set_kv JavaPath '${java}' + set_kv AutomaticJavaSwitch false + set_kv AutomaticJavaDownload false + ''; + }; + + # Merges our keys into an existing Prism instance.cfg without clobbering + # Prism-owned fields (Name, InstanceType, iconKey, etc.). Idempotent. + prism-apply-instance-config = pkgs.writeShellApplication { + name = "prism-apply-instance-config"; + runtimeInputs = [ pkgs.crudini ]; + text = '' + file="''${1:-}" + [ -f "$file" ] || { echo "prism-apply-instance-config: file not found: $file" >&2; exit 1; } + # Prism (QSettings) emits `key=value` with no spaces; nospace keeps us off + # crudini's default `key = value` so the file stays byte-identical when no + # value actually changes. + set_kv() { crudini --ini-options=nospace --set "$file" General "$1" "$2"; } + set_kv OverrideJavaLocation true + set_kv JavaPath '${java}' + set_kv OverrideMemory true + set_kv MinMemAlloc 512 + set_kv MaxMemAlloc 4096 + set_kv OverrideJavaArgs true + set_kv JvmArgs '-Ddirector.autoScreensaver=true' + ''; + }; +in +{ + inherit prism-apply-launcher-config prism-apply-instance-config; +} diff --git a/home/linux/quote/awesome.nix b/home/linux/quote/awesome.nix new file mode 100644 index 00000000..d05a7ec8 --- /dev/null +++ b/home/linux/quote/awesome.nix @@ -0,0 +1,127 @@ +{ config, lib, pkgs, inputs, ... }: +let + inherit (config.lib.stylix) colors; + wintc = inputs.self.packages.${pkgs.system}.xfce-winxp-tc; + + # Procedural Bliss-style wallpaper. The real "Bliss" jpeg (Charles O'Rear, + # 2001) is copyrighted by Microsoft, so we approximate: sky vertical gradient + # + a green hill drawn as a quadratic-bezier band, softened with a small + # blur. Sized to the monitor's native res so wallpaper.maximized doesn't + # rescale. + blissPng = pkgs.runCommand "bliss.png" + { + nativeBuildInputs = [ pkgs.imagemagick ]; + } '' + convert -size 2560x1440 gradient:'#3a78c8'-'#a8d4ee' \ + -fill '#5a8d2c' \ + -draw "path 'M -10,960 Q 640,790 1280,910 Q 1920,1030 2570,840 L 2570,1450 L -10,1450 Z'" \ + -fill '#79b03f' \ + -draw "path 'M -10,960 Q 640,790 1280,910 Q 1920,1030 2570,840 L 2570,852 Q 1920,1042 1280,922 Q 640,802 -10,972 Z'" \ + -blur 0x1.6 \ + "$out" + ''; + + # wintc ships the original Luna titlebar button PNGs (21x23, four states each) + # under its xfwm4 theme. Reuse them instead of redrawing — guarantees pixel + # parity with the bar/start menu. + xpButtonDir = ''${wintc}/share/themes/Windows XP style (Blue)/xfwm4''; + + # rc.fnl uses @placeholder@ markers for stylix-driven colors; replaceVars fills them in. + rcFnl = pkgs.replaceVars ./rc.fnl { + bgNormal = "#${colors.base00}"; + bgFocus = "#${colors.base0B}"; + bgUrgent = "#${colors.base0C}"; + fgNormal = "#${colors.base05}"; + fgFocus = "#${colors.base00}"; + borderNormal = "#${colors.base02}"; + borderFocus = "#${colors.base0B}"; + blissPath = "${blissPng}"; + inherit xpButtonDir; + }; + + # whitelist awesome's bare globals so fennel still catches typos + rcLua = pkgs.runCommand "awesome-rc.lua" { } '' + ${pkgs.luaPackages.fennel}/bin/fennel \ + --globals "client,screen,root,awesome,tag,mouse" \ + --compile ${rcFnl} > $out + ''; + + # OBS icon override; ship 48/128/256 so exact-size lookups don't fall back to upstream + obsRecordIcons = pkgs.runCommand "obs-record-icons" + { + nativeBuildInputs = [ pkgs.imagemagick ]; + } '' + mkdir -p $out + convert -size 256x256 xc:transparent \ + -fill '#dcdcdc' -draw 'circle 128,128 128,11' \ + -fill '#1e1e1e' -draw 'circle 128,128 128,33' \ + -fill '#b40f0f' -draw 'circle 128,128 128,58' \ + -fill 'rgba(255,255,255,0.55)' -draw 'ellipse 128,86 48,22 0,360' \ + "$out/256.png" + for size in 48 128; do + convert "$out/256.png" -resize "''${size}x''${size}" "$out/$size.png" + done + ''; +in +{ + xsession = { + enable = true; + windowManager.awesome = { + enable = true; + package = pkgs.awesome; + }; + # Pin the monitor mode before awesome starts so screen.geometry and the + # MC "wallpaper" placement see the native resolution. Hardware-specific + # to the quote host (single DP-1 panel at 2560x1440 @ 170Hz native). + initExtra = '' + ${pkgs.xorg.xrandr}/bin/xrandr --output DP-1 --mode 2560x1440 --rate 170.07 --primary || true + ''; + }; + + # XP cursors are not freely redistributable, so nixpkgs has nothing labelled + # "Windows XP". Vanilla-DMZ is the closest free family — it's the DMZ theme + # the Windows-style cursor packs derive from. + stylix.cursor = { + package = pkgs.vanilla-dmz; + name = "Vanilla-DMZ"; + size = 24; + }; + + xdg = { + configFile = { + "awesome/rc.lua".source = rcLua; + }; + dataFile = { + "icons/hicolor/48x48/apps/com.obsproject.Studio.png".source = + "${obsRecordIcons}/48.png"; + "icons/hicolor/128x128/apps/com.obsproject.Studio.png".source = + "${obsRecordIcons}/128.png"; + "icons/hicolor/256x256/apps/com.obsproject.Studio.png".source = + "${obsRecordIcons}/256.png"; + }; + }; + + home.packages = with pkgs; [ + rofi + xdotool + wmctrl + xorg.xprop + xorg.xwininfo + xorg.xset + xorg.xrandr + xcape # tap-Super alone → emits Super+Escape, our start-menu toggle + networkmanagerapplet # nm-applet for the systray + pasystray # XP-style volume/output icon in the tray, talks to pipewire-pulse + # corefonts ships Tahoma (and Verdana) under the MS EULA - the actual XP + # face the taskband and titlebars target. + corefonts + wintc + ]; + + # Reload the running awesome in-place after each switch; no-op without DISPLAY. + home.activation.reloadAwesome = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + if [ -n "''${DISPLAY:-}" ]; then + run ${pkgs.awesome}/bin/awesome-client 'awesome.restart()' >/dev/null 2>&1 || true + fi + ''; +} diff --git a/home/linux/quote/default.nix b/home/linux/quote/default.nix new file mode 100644 index 00000000..ac698bef --- /dev/null +++ b/home/linux/quote/default.nix @@ -0,0 +1,15 @@ +{ lib, ... }: +{ + imports = [ + ./awesome.nix + ./picom.nix + ./launcher.nix + ./desktop.nix + ./notifications.nix + ./sandbox.nix + ./prism.nix + ]; + + # atuin panics on first launch in a fresh home (settings.rs:617) + programs.atuin.enable = lib.mkForce false; +} diff --git a/home/linux/quote/desktop.nix b/home/linux/quote/desktop.nix new file mode 100644 index 00000000..5b34a953 --- /dev/null +++ b/home/linux/quote/desktop.nix @@ -0,0 +1,14 @@ +_: +{ + xdg.desktopEntries.quote-mc = { + name = "Quote-MC"; + genericName = "Minecraft Beta 1.7.3 (Quote-MC pack)"; + comment = "Launch the Quote-MC modpack (Babric + StationAPI QoL + local quote-mc build)."; + exec = "quote-mc-launch"; + icon = "org.prismlauncher.PrismLauncher"; + terminal = false; + type = "Application"; + categories = [ "Game" ]; + startupNotify = false; + }; +} diff --git a/home/linux/quote/launcher.nix b/home/linux/quote/launcher.nix new file mode 100644 index 00000000..feaeedf6 --- /dev/null +++ b/home/linux/quote/launcher.nix @@ -0,0 +1,200 @@ +{ config, pkgs, theme, ... }: +let + prismDir = "${config.home.homeDirectory}/QuoteMC/prism"; + defaultPackRoot = "${config.home.homeDirectory}/workspace/3rd-brain"; + instanceId = "Quote-MC"; + prismFiles = import ./_prism-files.nix { inherit pkgs; inherit (theme) appearance; }; + + # avoids buildEnv collision with the same name auto-built by home/common/scripts.nix + inherit ((import ../../../scripts { inherit pkgs; })) quote-pack; + + # packwiz-installer materializes the local pack into a Prism instance's + # .minecraft/, replacing the hand-rolled .pw.toml regex parser + downloader + # that used to live in 3rd-brain/scripts/sync-mods.bb. We pin the bootstrap + # AND the main installer jar so launches stay offline-deterministic — the + # bootstrap's normal job is to self-update the installer from GitHub at run + # time, which `--bootstrap-no-update --bootstrap-main-jar` short-circuits. + packwizInstallerBootstrap = pkgs.fetchurl { + url = "https://github.com/packwiz/packwiz-installer-bootstrap/releases/download/v0.0.3/packwiz-installer-bootstrap.jar"; + hash = "sha256-qPuyTcYEJ46X9GiOgtPZGjGLmO/AjV2/y8vKtkQ9EWw="; + }; + packwizInstaller = pkgs.fetchurl { + url = "https://github.com/packwiz/packwiz-installer/releases/download/v0.5.14/packwiz-installer.jar"; + hash = "sha256-yfZGkI00DYR3OUipp9mLwdriUNNeEBbcbiuEWXYLVZg="; + }; + + quote-mc-launch = pkgs.writeShellApplication { + name = "quote-mc-launch"; + runtimeInputs = with pkgs; [ + prismlauncher + temurin-bin-21 + git + coreutils + libnotify + babashka + packwiz + quote-pack + prismFiles.prism-apply-instance-config + # MirrorCapture shells out to xdotool to resolve a window title to its + # screen geometry; without it on PATH the lookup fails silently and + # capture falls back to (0,0), grabbing whatever's at the screen origin. + xdotool + # Demo stage: Xephyr hosts a nested :2 X server whose framebuffer + # MirrorCapture grabs into MC monitor blocks; firefox + ghostty are + # the captured windows. MC stays on :0 fullscreen; :2 lives in a + # background window so occlusion can't break the capture. + xorg.xorgserver + firefox + ghostty + # While attached, Xephyr's grab masks awesome's :0 Alt+F. xbindkeys runs + # inside :2 and binds Alt+F to touch the detach-request flag rc.fnl polls. + xbindkeys + ]; + text = '' + set -euo pipefail + + pack_root="''${QUOTE_PACK_ROOT:-${defaultPackRoot}}" + export QUOTE_PACK_ROOT="$pack_root" + # Consumed by 3rd-brain/scripts/sync-mods.bb (`bb sync` task). + export PACKWIZ_INSTALLER_BOOTSTRAP_JAR='${packwizInstallerBootstrap}' + export PACKWIZ_INSTALLER_JAR='${packwizInstaller}' + instance_dir="${prismDir}/instances/${instanceId}" + instance_cfg="$instance_dir/instance.cfg" + instance_mods="$instance_dir/.minecraft/mods" + + if [ ! -d "$pack_root/modpack" ]; then + echo "quote-mc-launch: $pack_root/modpack not found." >&2 + notify-send -u critical "quote-mc-launch" "pack missing at $pack_root" || true + exit 1 + fi + + # first run: no .pw.toml means QoL mods aren't pinned yet + if [ -z "$(find "$pack_root/modpack/mods" -maxdepth 1 -name '*.pw.toml' -print -quit 2>/dev/null || true)" ]; then + quote-pack install-mods + fi + + quote-pack pack + + if [ ! -d "$instance_dir" ]; then + mrpack="$(find "$pack_root/modpack" -maxdepth 1 -name 'Quote-MC-*.mrpack' -print -quit || true)" + if [ -z "$mrpack" ]; then + echo "quote-mc-launch: no .mrpack found under $pack_root/modpack" >&2 + exit 1 + fi + notify-send "Quote-MC" "First-time import: click OK in the dialog, then close Prism. Quote-MC will launch automatically." || true + # No exec: we block on Prism, then continue once the user closes it. + prismlauncher --dir "${prismDir}" --import "$mrpack" || true + if [ ! -d "$instance_dir" ]; then + notify-send -u critical "Quote-MC" "Import wasn't completed. Re-run quote-mc-launch when ready." || true + exit 1 + fi + fi + + # Idempotent: stamps our Java/memory/JvmArgs over Prism's import defaults. + prism-apply-instance-config "$instance_cfg" + + quote-pack sync "$instance_mods" + + # Demo stage: ensure the nested :2 server and the captured windows are + # up before MC launches. Each block is a no-op if the process already + # exists, so re-running the launcher doesn't multiply. + # + # Capture geometry: the 3x2-block monitor wall captures 768x512 px + # (DEFAULT_MIRROR_PIXELS_PER_BLOCK = 256). The :2 server is exactly that + # size and firefox + ghostty are *stacked* — both sized to fill it, both + # at (0,0). The capture rect is the whole screen; the lever just raises + # whichever window it wants on top (MirrorCapture's resolveOffset raises + # the title it is handed). + cap_w=768 + cap_h=512 + + # The demo stack (Xephyr :2 + its windows + xbindkeys) is reused across + # launcher runs via the pgrep/search guards below. But a stack left over + # from before a launcher change silently masks the new config — stale + # Xephyr flags, stale window titles — because the guards see "already + # running" and skip recreation. Stamp the stack; if a running one carries + # a different stamp (or none), tear the whole thing down so it + # regenerates clean. Bump demo_stamp whenever the demo spec below changes. + director_dir="''${XDG_RUNTIME_DIR:-/tmp}/director" + mkdir -p "$director_dir" + demo_stamp_file="$director_dir/demo-stack.stamp" + demo_stamp='v3-stacked' + if pgrep -f 'Xephyr.*:2' >/dev/null \ + && [ "$(cat "$demo_stamp_file" 2>/dev/null || true)" != "$demo_stamp" ]; then + pkill -f 'Xephyr.*:2' || true + pkill -f 'xbindkeys.*xbindkeys-detach' || true + rm -f "$demo_stamp_file" + # firefox/ghostty on :2 exit on their own once the server is gone. + sleep 1 + fi + + # -sw-cursor: render the pointer into Xephyr's framebuffer. The X cursor + # is normally a separate sprite the server strips from XGetImage/x11grab + # results, so MirrorCapture would mirror everything *except* the cursor. + if ! pgrep -f 'Xephyr.*:2' >/dev/null; then + Xephyr :2 -screen "$cap_w"x"$cap_h" -ac -sw-cursor >/dev/null 2>&1 & + sleep 1 + fi + if ! DISPLAY=:2 xdotool search --name firefox >/dev/null 2>&1; then + # --no-remote + a dedicated profile: a plain `firefox` would hand the + # URL to the existing :0 instance instead of opening a window on :2. + # firefox errors ("profile cannot be loaded") if --profile points at a + # path that doesn't exist — it won't create it, so mkdir first. + mkdir -p "$HOME/.cache/quote-demo-ff" + # Firefox seeds the new-tab top-sites grid with a sponsored tile + # ("pinned from history" UI). A user.js in the profile disables it + # before the window paints — rewritten each run since the prefs are + # cheap and the profile may have been wiped. + printf '%s\n' \ + 'user_pref("browser.newtabpage.activity-stream.showSponsoredTopSites", false);' \ + 'user_pref("browser.newtabpage.activity-stream.showSponsored", false);' \ + > "$HOME/.cache/quote-demo-ff/user.js" + DISPLAY=:2 firefox --no-remote --profile "$HOME/.cache/quote-demo-ff" \ + 'https://www.youtube.com/watch?v=QmCfdA4Nz0w' >/dev/null 2>&1 & + fi + if ! DISPLAY=:2 xdotool search --name ghostty >/dev/null 2>&1; then + # Bare ghostty (no claude). --title pins the window title so the + # shell inside can't rewrite it out from under MirrorCapture's + # xdotool --name match. + DISPLAY=:2 ghostty --title=ghostty >/dev/null 2>&1 & + fi + + # :2 has no window manager, so windows spawn at their own default + # geometry — size each to fill the :2 screen and stack both at (0,0). + # They fully overlap; MirrorCapture raises whichever the lever selects. + place_demo_window() { + local name="$1" wid="" + for _ in $(seq 1 50); do + # `|| true` inside the subshell: xdotool exits 1 on no match, and + # `set -o pipefail` would otherwise propagate that and trip `set -e`. + wid="$(DISPLAY=:2 xdotool search --name "$name" 2>/dev/null | head -1 || true)" + [ -n "$wid" ] && break + sleep 0.2 + done + if [ -n "$wid" ]; then + DISPLAY=:2 xdotool windowsize "$wid" "$cap_w" "$cap_h" >/dev/null 2>&1 || true + DISPLAY=:2 xdotool windowmove "$wid" 0 0 >/dev/null 2>&1 || true + fi + } + place_demo_window firefox + place_demo_window ghostty + + # xbindkeys on :2 binds Alt+F to `touch detach-request`; rc.fnl polls + # that flag to detach (awesome's :0 Alt+F is masked by Xephyr's grab). + xbindkeys_cfg="$director_dir/xbindkeys-detach.conf" + printf '"touch %s/detach-request"\n Alt + f\n' "$director_dir" > "$xbindkeys_cfg" + if ! pgrep -f 'xbindkeys.*xbindkeys-detach' >/dev/null; then + DISPLAY=:2 xbindkeys -n -f "$xbindkeys_cfg" >/dev/null 2>&1 & + fi + + # Stack is fully up — record the stamp so the next launcher run reuses it + # instead of tearing it down. + printf '%s' "$demo_stamp" > "$demo_stamp_file" + + exec prismlauncher --dir "${prismDir}" --launch "${instanceId}" + ''; + }; +in +{ + home.packages = [ quote-mc-launch ]; +} diff --git a/home/linux/quote/notifications.nix b/home/linux/quote/notifications.nix new file mode 100644 index 00000000..7e95a241 --- /dev/null +++ b/home/linux/quote/notifications.nix @@ -0,0 +1,17 @@ +{ pkgs, ... }: +{ + services.dunst = { + enable = true; + settings.global = { + # font is set by stylix system-wide + frame_width = 1; + corner_radius = 2; + offset = "12x12"; + origin = "top-right"; + notification_limit = 5; + idle_threshold = 120; + }; + }; + + home.packages = [ pkgs.libnotify ]; +} diff --git a/home/linux/quote/picom.nix b/home/linux/quote/picom.nix new file mode 100644 index 00000000..f36ccd2a --- /dev/null +++ b/home/linux/quote/picom.nix @@ -0,0 +1,30 @@ +{ pkgs, ... }: +{ + services.picom = { + enable = true; + package = pkgs.picom; + backend = "xrender"; + vSync = false; + + shadow = true; + fade = true; + fadeDelta = 4; + + settings = { + # shadows on xwinwrap's root drawable smear the wallpaper layer + shadow-exclude = [ + "class_g = 'xwinwrap'" + "name = 'Notification'" + ]; + + detect-rounded-corners = true; + detect-client-opacity = true; + use-damage = true; + + inactive-opacity = 0.95; + active-opacity = 1.0; + frame-opacity = 1.0; + inactive-opacity-override = false; + }; + }; +} diff --git a/home/linux/quote/prism.nix b/home/linux/quote/prism.nix new file mode 100644 index 00000000..934a3138 --- /dev/null +++ b/home/linux/quote/prism.nix @@ -0,0 +1,20 @@ +{ config, lib, pkgs, theme, ... }: +let + prismDir = "${config.home.homeDirectory}/QuoteMC/prism"; + instanceCfg = "${prismDir}/instances/Quote-MC/instance.cfg"; + prismFiles = import ./_prism-files.nix { inherit pkgs; inherit (theme) appearance; }; +in +{ + home.packages = [ + prismFiles.prism-apply-launcher-config + prismFiles.prism-apply-instance-config + ]; + + home.activation.prismConfig = + lib.hm.dag.entryAfter [ "writeBoundary" "quoteSandbox" ] '' + run ${prismFiles.prism-apply-launcher-config}/bin/prism-apply-launcher-config '${prismDir}/prismlauncher.cfg' + if [ -f '${instanceCfg}' ]; then + run ${prismFiles.prism-apply-instance-config}/bin/prism-apply-instance-config '${instanceCfg}' + fi + ''; +} diff --git a/home/linux/quote/rc.fnl b/home/linux/quote/rc.fnl new file mode 100644 index 00000000..84a62739 --- /dev/null +++ b/home/linux/quote/rc.fnl @@ -0,0 +1,742 @@ +;;; quote awesome config (fennel, compiled to rc.lua by home-manager). +;;; minecraft b1.7.3 sits below as a click-through wallpaper. +;;; +;;; Bar + start menu come from xfce-winxp-tc (wintc-taskband), spawned at +;;; startup. We keep awesome responsible for titlebars, tags, demo mode, and +;;; MC framing — the taskband owns the bottom strip. + +(local gears (require :gears)) +(local awful (require :awful)) +(require :awful.autofocus) +(local beautiful (require :beautiful)) +(local naughty (require :naughty)) +(local wibox (require :wibox)) + +;;; surface errors as notifications +(when awesome.startup_errors + (naughty.notify {:preset naughty.config.presets.critical + :title "Awesome startup error" + :text awesome.startup_errors})) + +(var in-error false) +(awesome.connect_signal "debug::error" + (fn [err] + (when (not in-error) + (set in-error true) + (naughty.notify {:preset naughty.config.presets.critical + :title "Awesome runtime error" + :text (tostring err)}) + (set in-error false)))) + +;;; Theme +(beautiful.init (.. (gears.filesystem.get_themes_dir) :default/theme.lua)) +(set beautiful.font "Tahoma 8") +(set beautiful.bg_normal "@bgNormal@") +(set beautiful.bg_focus "@bgFocus@") +(set beautiful.bg_urgent "@bgUrgent@") +(set beautiful.fg_normal "@fgNormal@") +(set beautiful.fg_focus "@fgFocus@") +(set beautiful.border_width 1) +(set beautiful.border_normal "@borderNormal@") +(set beautiful.border_focus "@borderFocus@") +(set beautiful.useless_gap 4) + +(local terminal :ghostty) +(local modkey :Mod4) +(local altkey :Mod1) + +;; floating first; tile variants stay reachable via Mod+e/s/w +(set awful.layout.layouts [awful.layout.suit.floating + awful.layout.suit.tile + awful.layout.suit.tile.bottom + awful.layout.suit.max]) + +(local bliss-path "@blissPath@") + +(fn set-wallpaper [s] + ;; ignore_aspect=false: scale-to-fill with crop, no distortion. + (gears.wallpaper.maximized bliss-path s false)) + +(screen.connect_signal "property::geometry" set-wallpaper) + +;;; --- XP Luna titlebars --- + +(local xp-font-title "Tahoma Bold 9") +(local xp-text-white "#ffffff") + +;; XP Luna palette (titlebar gradients) +(local xp-luna-shine "#5d92e7") +(local xp-luna-top "#3674d6") +(local xp-luna-mid "#245ecb") +(local xp-luna-bot "#1741a3") +(local xp-titlebar-inactive-top "#7c91b3") +(local xp-titlebar-inactive-bot "#475377") + +(fn vgradient-h [stops h] + (gears.color {:type :linear :from [0 0] :to [0 h] : stops})) + +;; wintc ships hide/maximize/close PNGs (21x23, four variants each); the path +;; is filled in by replaceVars. See awesome.nix:xpButtonDir for rationale. +(local xp-button-dir "@xpButtonDir@") + +(fn xp-button-icon [name variant] + (.. xp-button-dir "/" name "-" variant :.png)) + +;; sized so the native 23-tall button PNGs fit with 1px of breathing room +(local xp-titlebar-height 24) +(local xp-titlebar-bg-focus + (vgradient-h [[0 xp-luna-shine] + [0.04 xp-luna-top] + [0.5 xp-luna-mid] + [1 xp-luna-bot]] xp-titlebar-height)) + +(local xp-titlebar-bg-normal + (vgradient-h [[0 xp-titlebar-inactive-top] [1 xp-titlebar-inactive-bot]] + xp-titlebar-height)) + +;; c.name is per-tab/world content; we want the app identity instead. +(fn xp-titlebar-title [c] + (let [w (wibox.widget.textbox) + update (fn [] (w:set_text (or c.class "")))] + (update) + (c:connect_signal "property::class" update) + w)) + +(fn xp-titlebar-button [c {: name : on-click}] + (let [active (xp-button-icon name :active) + inactive (xp-button-icon name :inactive) + prelight (xp-button-icon name :prelight) + pressed (xp-button-icon name :pressed) + idle (fn [] (if (= client.focus c) active inactive)) + img (wibox.widget {:widget wibox.widget.imagebox + :image (idle) + :forced_width 21 + :forced_height 23})] + (c:connect_signal :focus (fn [] (set img.image (idle)))) + (c:connect_signal :unfocus (fn [] (set img.image (idle)))) + (img:connect_signal "mouse::enter" (fn [] (set img.image prelight))) + (img:connect_signal "mouse::leave" (fn [] (set img.image (idle)))) + (img:connect_signal "button::press" (fn [] (set img.image pressed))) + ;; release-without-leave restores prelight (pointer still over button); + ;; the mouse::leave handler will swap to idle if the user moves away. + (img:connect_signal "button::release" (fn [] (set img.image prelight))) + (img:buttons (gears.table.join (awful.button [] 1 on-click))) + img)) + +(client.connect_signal "request::titlebars" + (fn [c] + (let [tb (awful.titlebar c + {:size xp-titlebar-height + :bg_focus xp-titlebar-bg-focus + :bg_normal xp-titlebar-bg-normal + :fg_focus xp-text-white + :fg_normal "#d9dde9" + :font xp-font-title}) + icon-widget (awful.titlebar.widget.iconwidget c) + title-widget (xp-titlebar-title c) + drag-buttons (gears.table.join (awful.button [] + 1 + (fn [] + (c:emit_signal "request::activate" + :titlebar + {:raise true}) + (awful.mouse.client.move c))) + (awful.button [] + 3 + (fn [] + (c:emit_signal "request::activate" + :titlebar + {:raise true}) + (awful.mouse.client.resize c))))] + (set title-widget.align :left) + (set title-widget.font xp-font-title) + (tb:setup {:layout wibox.layout.align.horizontal + 1 {:layout wibox.layout.fixed.horizontal + 1 {:widget wibox.container.margin + :left 4 + :right 6 + :top 3 + :bottom 3 + 1 icon-widget}} + 2 (let [w (wibox.widget {:widget wibox.container.margin + :top 2 + :bottom 2 + 1 title-widget})] + (w:buttons drag-buttons) + w) + 3 {:layout wibox.layout.fixed.horizontal + :spacing 2 + 1 (xp-titlebar-button c + {:name :hide + :on-click (fn [] + (set c.minimized + true))}) + 2 (xp-titlebar-button c + {:name :maximize + :on-click (fn [] + (set c.maximized + (not c.maximized)) + (c:raise))}) + 3 {:widget wibox.container.margin + :right 4 + 1 (xp-titlebar-button c + {:name :close + :on-click (fn [] + (c:kill))})}}})))) + +;;; Per-screen setup: wallpaper + tags. The taskband owns the bottom strip. + +(awful.screen.connect_for_each_screen (fn [s] + (set-wallpaper s) + (awful.tag [:1 :2 :3 :4] s + (. awful.layout.layouts 1)))) + +(local clientkeys + (gears.table.join (awful.key [modkey] :q (fn [c] (c:kill))) + (awful.key [modkey] :f + (fn [c] + (set c.fullscreen (not c.fullscreen)) + (c:raise))) + (awful.key [modkey :Shift] :space + awful.client.floating.toggle))) + +(local clientbuttons + (gears.table.join (awful.button [] 1 + (fn [c] + (when c.focusable + (c:emit_signal "request::activate" + :mouse_click + {:raise true})))) + (awful.button [modkey] 1 + (fn [c] + (c:emit_signal "request::activate" + :mouse_click + {:raise true}) + (awful.mouse.client.move c))) + (awful.button [modkey] 3 + (fn [c] + (c:emit_signal "request::activate" + :mouse_click + {:raise true}) + (awful.mouse.client.resize c))))) + +;;; Alt+m / Alt+Shift+m switch between desk mode and demo mode: +;;; - desk mode (default): other screen-1 windows visible, MC frozen in the +;;; back as a wallpaper-style cinematic (director's autoScreensaver on). +;;; - demo mode: MC focused with the pointer warped into it, MC unfrozen for +;;; live play. Alt+m enters soft demo, leaving overlay windows in place. +;;; Alt+Shift+m enters hard demo, also minimizing other screen-1 windows +;;; out of the way. Either key leaves demo mode. +;;; The director flag is a filesystem touchpoint the mod watches; toggling it +;;; flips the freeze state. We mirror that flip with WM focus + minimize state +;;; so a single keypress drives both layers. +(local director-flag (.. (or (os.getenv :XDG_RUNTIME_DIR) :/tmp) + :/director/toggle-screensaver)) + +;; toggle-screensaver flips the freeze state, but its result depends on which +;; screen is currently open — it can't *guarantee* an outcome. attach/detach +;; need determinism, so they use directional flags: freeze-screensaver always +;; pauses into the cinematic; resume-screensaver always returns to live play +;; (and can never strand the game paused, whatever screen was showing). +(local director-freeze-flag + (.. (or (os.getenv :XDG_RUNTIME_DIR) :/tmp) + :/director/freeze-screensaver)) + +(local director-resume-flag + (.. (or (os.getenv :XDG_RUNTIME_DIR) :/tmp) + :/director/resume-screensaver)) + +(fn touch-director-flag [] + (awful.spawn [:touch director-flag])) + +(fn touch-director-freeze [] + (awful.spawn [:touch director-freeze-flag])) + +(fn touch-director-resume [] + (awful.spawn [:touch director-resume-flag])) + +(fn find-mc-client [] + (var found nil) + (each [_ c (ipairs (client.get)) &until found] + (when (and c.class + (or (= c.class :Minecraft) + (= c.class :org-prismlauncher-EntryPoint))) + (set found c))) + found) + +(var demo-mode? false) +(var hidden-by-demo []) +(var pre-demo-focus nil) + +(fn warp-pointer-to-client [c] + (let [g (c:geometry) + cx (math.floor (+ g.x (/ g.width 2))) + cy (math.floor (+ g.y (/ g.height 2)))] + (mouse.coords {:x cx :y cy}))) + +;; MC (GLFW) only re-grabs the pointer on a real mouse-button event, so after +;; warping into demo mode we synthesize a left click via XTest. Deferred to the +;; next main-loop tick so the focus + warp have been flushed to the X server +;; before the click lands. +(fn synthesize-left-click [] + (gears.timer.delayed_call (fn [] + (root.fake_input :button_press 1) + (root.fake_input :button_release 1)))) + +(fn enter-demo-mode-soft [mc] + (set hidden-by-demo []) + (set pre-demo-focus client.focus) + (set client.focus mc) + (warp-pointer-to-client mc) + (synthesize-left-click) + (set demo-mode? true) + (touch-director-flag)) + +(fn enter-demo-mode [mc] + (set hidden-by-demo []) + (set pre-demo-focus client.focus) + (each [_ c (ipairs (client.get))] + ;; Skip wintc-taskband so the bottom bar stays visible in demo mode. + (when (and (= c.screen mc.screen) (not= c mc) (not c.minimized) + (not= c.class :Wintc-taskband)) + (table.insert hidden-by-demo c) + (set c.minimized true))) + (set client.focus mc) + (warp-pointer-to-client mc) + (synthesize-left-click) + (set demo-mode? true) + (touch-director-flag)) + +(fn leave-demo-mode [] + (each [_ c (ipairs hidden-by-demo)] + (when c.valid (set c.minimized false))) + (set hidden-by-demo []) + (when (and pre-demo-focus pre-demo-focus.valid (not pre-demo-focus.minimized)) + (set client.focus pre-demo-focus)) + (set pre-demo-focus nil) + (set demo-mode? false) + (touch-director-flag)) + +(fn director-toggle-soft [] + (if demo-mode? + (leave-demo-mode) + (let [mc (find-mc-client)] + (if mc + (enter-demo-mode-soft mc) + ;; MC not running yet (early startup or crash) — fall back to + ;; bare flag toggle so the keybind isn't dead. + (touch-director-flag))))) + +(fn director-toggle [] + (if demo-mode? + (leave-demo-mode) + (let [mc (find-mc-client)] + (if mc + (enter-demo-mode mc) + (touch-director-flag))))) + +;;; Alt+F: attach focus to the X11 window mirrored on the monitor block under +;;; the player's crosshair. Two-way IPC with quote-mc: +;;; awesome → quote-mc: touch attach-request +;;; quote-mc → awesome: write window title (or "" for no target) to +;;; attach-response, with a trailing newline so the file +;;; is always non-empty once written +;;; The mirrored windows (firefox, ghostty) run inside a nested Xephyr :2 +;;; server — invisible to awesome's client.get, which only sees :0. And MC +;;; sits fullscreen *above* the (background) Xephyr window, so simply focusing +;;; Xephyr can't route the pointer there. Instead, on attach we: +;;; 1. save focus + pointer, focus the Xephyr window (MC's GLFW releases its +;;; pointer grab on FocusOut), and touch the freeze flag; +;;; 2. DISPLAY=:2 xdotool focuses the specific window within :2 by the title +;;; quote-mc reported; +;;; 3. synthesize Ctrl+Shift to fire Xephyr's own grab, confining real input +;;; into :2 regardless of MC being on top. +;;; Xephyr's grab also holds the keyboard, so awesome's :0 Alt+F can't reach +;;; us while attached. An xbindkeys on :2 (started by the launcher) binds +;;; Alt+F to `touch detach-request`; awesome polls that flag while attached, +;;; and on seeing it drops Xephyr's grab (Ctrl+Shift), restores MC focus + +;;; pointer, unfreezes, and re-grabs MC's cursor — so Alt+F detaches too. +;;; Only fires while in demo mode — outside it, the crosshair has no meaning. +(local attach-request-flag + (.. (or (os.getenv :XDG_RUNTIME_DIR) :/tmp) :/director/attach-request)) + +(local attach-response-flag + (.. (or (os.getenv :XDG_RUNTIME_DIR) :/tmp) :/director/attach-response)) + +;; xbindkeys on :2 touches this when Alt+F is pressed inside Xephyr; rc.fnl +;; polls it to detach (awesome's :0 Alt+F can't reach us under Xephyr's grab). +(local detach-request-flag + (.. (or (os.getenv :XDG_RUNTIME_DIR) :/tmp) :/director/detach-request)) + +(var attached? false) +(var attach-target nil) +(var attach-saved-pointer nil) +(var attach-saved-focus nil) +(var attach-target-was-hidden? false) +(var detach-poll-timer nil) + +(fn trim [s] + (let [s2 (string.gsub s "^%s+" "") + s3 (string.gsub s2 "%s+$" "")] + s3)) + +(fn file-exists? [path] + (let [f (io.open path :r)] + (if f (do + (f:close) true) false))) + +(fn regex-escape [s] + ;; xdotool's `search --name` takes a POSIX extended regex; escape the + ;; metacharacters so a window title matches itself literally. + (pick-values 1 (string.gsub s "([%(%)%.%+%-%*%?%[%]%^%${}|\\])" "\\%1"))) + +(fn shell-quote [s] + (.. "'" (pick-values 1 (string.gsub s "'" "'\\''")) "'")) + +;; Xephyr doesn't reliably set WM_CLASS, so also match on its window name +;; ("Xephyr on :2.0 (ctrl+shift grabs ...)"). +(fn xephyr-client? [c] + (or (= c.class :Xephyr) (and c.name (string.find c.name :Xephyr 1 true) true))) + +(fn find-xephyr-client [] + (var found nil) + (each [_ c (ipairs (client.get)) &until found] + (when (xephyr-client? c) + (set found c))) + found) + +;; After MC loses focus its GLFW pointer grab is released; once that's flushed +;; we synthesize Ctrl+Shift to the (now focused) Xephyr window, firing its +;; built-in grab so real input is confined into the :2 server. The delay lets +;; MC's FocusOut land first — Xephyr's XGrabPointer fails if MC still holds one. +(fn synthesize-xephyr-grab [] + (gears.timer.start_new 0.2 (fn [] + (awful.spawn [:xdotool :key :ctrl+shift]) + false))) + +(fn stop-detach-poll [] + (when detach-poll-timer + (detach-poll-timer:stop) + (set detach-poll-timer nil))) + +(fn detach-from-window [] + (when attached? + (stop-detach-poll) + (os.remove detach-request-flag) + (when (and attach-target attach-target.valid) + (when attach-target-was-hidden? + ;; we un-minimized it on attach; re-hide so demo-mode invariants hold + (table.insert hidden-by-demo attach-target) + (set attach-target.minimized true))) + (when (and attach-saved-focus attach-saved-focus.valid + (not attach-saved-focus.minimized)) + (set client.focus attach-saved-focus)) + (when attach-saved-pointer + (mouse.coords {:x attach-saved-pointer.x :y attach-saved-pointer.y})) + ;; resume deterministically — detach must never leave the game paused + (touch-director-resume) + ;; pointer is back over MC but GLFW only re-grabs on a real button event + (synthesize-left-click) + (set attached? false) + (set attach-target nil) + (set attach-saved-pointer nil) + (set attach-saved-focus nil) + (set attach-target-was-hidden? false))) + +;; Alt+F pressed inside Xephyr: the xbindkeys on :2 has touched detach-request. +;; Xephyr still holds the host grab, so first toggle it off with Ctrl+Shift, +;; then once that release flushes hand off to detach-from-window. +(fn handle-detach-request [] + (stop-detach-poll) + (os.remove detach-request-flag) + (awful.spawn [:xdotool :key :ctrl+shift]) + (gears.timer.start_new 0.25 (fn [] (detach-from-window) false))) + +;; While attached, poll for the detach-request flag. The start_new callback +;; returning false stops the timer; true reschedules it. +(fn start-detach-poll [] + (stop-detach-poll) + (os.remove detach-request-flag) + (set detach-poll-timer + (gears.timer.start_new 0.15 + (fn [] + (if (not attached?) false + (file-exists? detach-request-flag) (do + (handle-detach-request) + false) + true))))) + +(fn complete-attach [title] + (let [xephyr (find-xephyr-client)] + (when xephyr + (let [coords (mouse.coords)] + (set attach-saved-pointer {:x coords.x :y coords.y})) + (set attach-saved-focus client.focus) + (set attach-target-was-hidden? xephyr.minimized) + (when xephyr.minimized + ;; pull it out of hidden-by-demo so leaving demo-mode doesn't try to + ;; restore it a second time + (let [filtered []] + (each [_ c (ipairs hidden-by-demo)] + (when (not= c xephyr) (table.insert filtered c))) + (set hidden-by-demo filtered)) + (set xephyr.minimized false)) + ;; Xephyr must be un-minimized (mapped) to be focusable and to grab, but + ;; MC is only *maximized*, not in the fullscreen layer — so raising Xephyr + ;; would lift it over MC onto the desktop. Lower it instead: focus + the + ;; Ctrl+Shift grab route input regardless of stacking, and it stays + ;; hidden behind MC. + (xephyr:lower) + (set client.focus xephyr) + (let [g (xephyr:geometry) + cx (math.floor (+ g.x (/ g.width 2))) + cy (math.floor (+ g.y (/ g.height 2)))] + (mouse.coords {:x cx :y cy})) + ;; firefox/ghostty are inside the :2 server, so awesome can't focus them + ;; directly. No WM runs on :2, so EWMH windowactivate is a no-op there — + ;; windowraise + windowfocus (XSetInputFocus) pick the target instead. + (awful.spawn.with_shell (.. "DISPLAY=:2 xdotool search --limit 1 --name " + (shell-quote (regex-escape title)) + " windowraise windowfocus")) + ;; freeze deterministically — never rely on toggle state here + (touch-director-freeze) + ;; confine real input into :2 — Ctrl+Shift toggles Xephyr's own grab + (synthesize-xephyr-grab) + (set attach-target xephyr) + (set attached? true) + ;; awesome's :0 Alt+F is now masked by Xephyr's grab — poll for the + ;; detach-request flag the :2 xbindkeys writes instead + (start-detach-poll)))) + +(fn try-attach [] + (when (and demo-mode? (not attached?)) + ;; Truncate the response, signal quote-mc, then poll for ~500ms. Quote-mc + ;; writes either "