From d27034eb319c20fed1c6c53691792032f0ba6309 Mon Sep 17 00:00:00 2001 From: Marcelo Alcantara Date: Mon, 28 Sep 2026 03:53:17 +1000 Subject: [PATCH] Move the Mac's desktop defaults into omarchy-mac's platform hooks Omarchy's Hyprland config, keybindings menu and bar now read a platform package's defaults from the packaged tree, as upstream's step 6 does: Hyprland defaults before Omarchy's and after the user's files, key names, and the panels' camera cutouts. omarchy-mac fills them with the Mac's lid switch, capture chords, keyboard backlight chords, trackpad default, menus on the built-in screen, F1/F2 names and notch sizes, which leave the shared files. The workspace swipe no longer depends on a helper upstream lacks. --- bin/omarchy-brightness-shift | 26 -- bin/omarchy-menu-keybindings | 25 +- config/hypr/input.lua | 5 - default/hypr/bindings/media.lua | 5 +- default/hypr/bindings/utilities.lua | 18 -- default/hypr/helpers.lua | 96 +++--- default/hypr/input.lua | 4 - default/hypr/omarchy.lua | 13 + default/hypr/toggles.lua | 7 +- docs/lifecycle-dispatch.md | 9 + docs/omarchy-shell.md | 8 + packages/omarchy-mac/ORIGINS.md | 1 + packages/omarchy-mac/README.md | 7 +- .../default/hypr/platform/apple-gestures.lua | 2 +- .../default/hypr/platform/defaults/apple.lua | 77 +++++ .../default/omarchy/platform/key-names | 2 + .../shell/platform/display-cutouts.json | 8 + .../omarchy-mac/test/desktop-setup-test.sh | 7 +- shell/plugins/bar/Bar.qml | 53 ++-- shell/plugins/bar/BarModel.js | 115 ++++---- test/shell.d/apple-capture-chords-test.sh | 77 ----- test/shell.d/apple-fnmode-test.sh | 28 -- test/shell.d/apple-platform-hooks-test.sh | 198 +++++++++++++ test/shell.d/bar-notch-test.sh | 127 ++++---- test/shell.d/bar-test.sh | 4 +- test/shell.d/hw-laptop-test.sh | 6 +- .../hyprland-apple-workspace-gesture-test.sh | 30 +- .../hyprland-binding-conflicts-test.sh | 7 +- .../hyprland-builtin-keyboard-screen-test.sh | 157 ---------- test/shell.d/hyprland-input-default-test.sh | 18 +- .../hyprland-platform-defaults-test.sh | 278 ++++++++++++++++++ test/shell.d/menu-keybindings-fkeys-test.sh | 4 +- .../menu-keybindings-key-names-test.sh | 141 +++++++++ 33 files changed, 996 insertions(+), 567 deletions(-) delete mode 100755 bin/omarchy-brightness-shift create mode 100644 packages/omarchy-mac/share/omarchy/default/hypr/platform/defaults/apple.lua create mode 100644 packages/omarchy-mac/share/omarchy/default/omarchy/platform/key-names create mode 100644 packages/omarchy-mac/share/omarchy/default/shell/platform/display-cutouts.json delete mode 100755 test/shell.d/apple-capture-chords-test.sh create mode 100755 test/shell.d/apple-platform-hooks-test.sh mode change 100755 => 100644 test/shell.d/bar-notch-test.sh delete mode 100755 test/shell.d/hyprland-builtin-keyboard-screen-test.sh create mode 100755 test/shell.d/hyprland-platform-defaults-test.sh create mode 100644 test/shell.d/menu-keybindings-key-names-test.sh diff --git a/bin/omarchy-brightness-shift b/bin/omarchy-brightness-shift deleted file mode 100755 index e7d56e39bc0..00000000000 --- a/bin/omarchy-brightness-shift +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Shift+brightness: keyboard backlight on Apple Silicon, display max/min otherwise -# omarchy:args= -# omarchy:hidden=true - -case ${1:-} in - up) - if omarchy-hw-apple-silicon; then - omarchy-brightness-keyboard up - else - omarchy-brightness-display 100% - fi - ;; - down) - if omarchy-hw-apple-silicon; then - omarchy-brightness-keyboard down - else - omarchy-brightness-display 1% - fi - ;; - *) - echo "Usage: omarchy-brightness-shift " >&2 - exit 2 - ;; -esac diff --git a/bin/omarchy-menu-keybindings b/bin/omarchy-menu-keybindings index b2d2e01782c..2ec4cd75679 100755 --- a/bin/omarchy-menu-keybindings +++ b/bin/omarchy-menu-keybindings @@ -9,14 +9,21 @@ declare -A LUA_BIND_KEY_MAP declare -A LUA_BIND_DISPATCHER_MAP declare -A LUA_BIND_ARG_MAP +# A platform package can name keys the way its keyboards print them: one +# " " per line (on a MacBook, XF86MonBrightnessUp is F2). +key_names_file="${OMARCHY_PACKAGED_PATH:-/usr/share/omarchy}/default/omarchy/platform/key-names" + # Hyprland reports XKB keycodes for code: bindings. Resolve them to symbols # via the compiled keymap, with a small fallback for common keys so the menu # remains readable if xkbcli cannot resolve a symbol. parse_keycodes() { - local apple=0 - omarchy-hw-apple-silicon && apple=1 - awk -v apple="$apple" ' + awk -v key_names="$key_names_file" ' BEGIN { + while ((getline line < key_names) > 0) { + if (split(line, name_parts, /[ \t]+/) == 2 && name_parts[1] ~ /^[A-Za-z0-9_]+$/ && name_parts[2] ~ /^[A-Za-z0-9_]+$/) key_name[name_parts[1]] = name_parts[2] + } + close(key_names) + split("10=1 11=2 12=3 13=4 14=5 15=6 16=7 17=8 18=9 19=0 20=MINUS 21=EQUAL 59=COMMA 60=PERIOD 61=SLASH", fallbacks, " ") for (i in fallbacks) { separator = index(fallbacks[i], "=") @@ -61,10 +68,13 @@ parse_keycodes() { symbol = mouse_symbol[code] if (symbol == "") symbol = "mouse:" code sub("mouse:" code, symbol) - } else if (apple) { - # On Apple Silicon MacBooks XF86MonBrightnessUp/Down ARE F2/F1. - gsub(/XF86MonBrightnessUp/, "F2") - gsub(/XF86MonBrightnessDown/, "F1") + } else if ((first = index($0, ",")) > 0) { + # Only the key (the second field) is renamed: the description, the + # dispatcher and its argument are left as they are. + rest = substr($0, first + 1) + second = index(rest, ",") + key = second > 0 ? substr(rest, 1, second - 1) : rest + if (key in key_name) $0 = substr($0, 1, first) key_name[key] (second > 0 ? substr(rest, second) : "") } print @@ -538,6 +548,7 @@ keybindings_cache_key() { { printf 'v14\n' hyprctl devices 2>/dev/null | grep -F 'active keymap:' + cat "$key_names_file" 2>/dev/null hyprctl binds 2>/dev/null } | sha256sum | awk '{ print $1 }' } diff --git a/config/hypr/input.lua b/config/hypr/input.lua index 3e0be878b27..d06e05c74d1 100644 --- a/config/hypr/input.lua +++ b/config/hypr/input.lua @@ -44,11 +44,6 @@ -- }, -- }) --- Re-enable tap-to-click on the built-in Apple touchpad. --- Find its name with `hyprctl devices` and uncomment the matching line. --- hl.device({ name = "apple-mtp-multi-touch", tap_to_click = true }) --- hl.device({ name = "apple-spi-trackpad", tap_to_click = true }) - -- App-specific touchpad scroll speeds. -- o.window("(Alacritty|kitty)", { scroll_touchpad = 1.5 }) -- o.window("foot", { scroll_touchpad = 2.0 }) diff --git a/default/hypr/bindings/media.lua b/default/hypr/bindings/media.lua index 401864aee5a..52779baa8a4 100644 --- a/default/hypr/bindings/media.lua +++ b/default/hypr/bindings/media.lua @@ -5,9 +5,8 @@ o.bind("XF86AudioMute", "Mute", "omarchy-audio-output-volume mute-toggle", { loc o.bind("XF86AudioMicMute", "Mute microphone", "omarchy-audio-input-mute", { locked = true }) o.bind("XF86MonBrightnessUp", "Brightness up", "omarchy-brightness-display +5%", { locked = true, repeating = true }) o.bind("XF86MonBrightnessDown", "Brightness down", "omarchy-brightness-display 5%-", { locked = true, repeating = true }) -o.bind("SHIFT + XF86MonBrightnessUp", "Brightness maximum", "omarchy-brightness-shift up", { locked = true, repeating = true }) -o.bind("SHIFT + XF86MonBrightnessDown", "Brightness minimum", "omarchy-brightness-shift down", { locked = true, repeating = true }) - +o.bind("SHIFT + XF86MonBrightnessUp", "Brightness maximum", "omarchy-brightness-display 100%", { locked = true, repeating = true }) +o.bind("SHIFT + XF86MonBrightnessDown", "Brightness minimum", "omarchy-brightness-display 1%", { locked = true, repeating = true }) o.bind("XF86KbdBrightnessUp", "Keyboard brightness up", "omarchy-brightness-keyboard up", { locked = true, repeating = true }) o.bind("XF86KbdBrightnessDown", "Keyboard brightness down", "omarchy-brightness-keyboard down", { locked = true, repeating = true }) o.bind("XF86KbdLightOnOff", "Keyboard backlight cycle", "omarchy-brightness-keyboard cycle", { locked = true }) diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 0894607ee5f..9c03d273edd 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -34,27 +34,9 @@ o.bind("SUPER + CTRL + Delete", "Toggle laptop display", "omarchy-hyprland-monit o.bind("SUPER + CTRL + ALT + Delete", "Toggle laptop display mirroring", "omarchy-hyprland-monitor-internal-mirror toggle") o.bind("switch:on:Lid Switch", nil, "omarchy-system-lid-close", { locked = true }) o.bind("switch:off:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell", { locked = true }) --- Apple Silicon names the lid switch "Apple SMC power/lid events", so the --- generic "Lid Switch" binds above never fire on that hardware. -o.bind("switch:on:Apple SMC power/lid events", nil, "omarchy-system-lid-close", { locked = true }) -o.bind("switch:off:Apple SMC power/lid events", nil, "omarchy-hyprland-monitor-clamshell", { locked = true }) - o.bind("PRINT", "Screenshot", "omarchy-capture-screenshot") o.bind("ALT + PRINT", "Screenrecording", "omarchy-capture-screenrecording --stop-recording || omarchy-menu toggle trigger.capture.screenrecord") --- Apple keyboards have no PRINT key. SUPER+F10–F12 and the same chords on the --- top-row media keysyms cover capture without holding Fn in media-first mode. Only --- on Apple Silicon: everywhere else SUPER+F10–F12 stay unbound, as shipped, so --- these must not claim them. -if o.shell_succeeds("omarchy-hw-apple-silicon") then - o.bind("SUPER + F12", "Screenshot Display", "omarchy-capture-screenshot fullscreen") - o.bind("SUPER + F11", "Screenshot Region", "omarchy-capture-screenshot region") - o.bind("SUPER + F10", "Screenshot Window", "omarchy-capture-screenshot windows") - o.bind("SUPER + XF86AudioMute", "Screenshot Window (Apple top row)", "omarchy-capture-screenshot windows") - o.bind("SUPER + XF86AudioLowerVolume", "Screenshot Region (Apple top row)", "omarchy-capture-screenshot region") - o.bind("SUPER + XF86AudioRaiseVolume", "Screenshot Display (Apple top row)", "omarchy-capture-screenshot fullscreen") -end - o.bind("SUPER + ALT + code:34", "Make webcam overlay smaller", "omarchy-capture-webcam-resize smaller") o.bind("SUPER + ALT + code:35", "Make webcam overlay larger", "omarchy-capture-webcam-resize larger") o.bind("SUPER + PRINT", "Color picker", "pkill hyprpicker || hyprpicker -a") diff --git a/default/hypr/helpers.lua b/default/hypr/helpers.lua index b5c46cb6cbd..60c02a2ae97 100644 --- a/default/hypr/helpers.lua +++ b/default/hypr/helpers.lua @@ -89,62 +89,35 @@ function o.preinstalled_bindings_enabled() return not file_exists((os.getenv("HOME") or "") .. "/.local/state/omarchy/preinstalls-removed") end --- The MacBook's own keyboard as Hyprland names it: SPI on M1, MTP on M2 and later. -local builtin_keyboards = { "apple-spi-keyboard", "apple-mtp-keyboard" } -local overlay_prefixes = { - "omarchy-menu", - "omarchy-shell shell toggle ", - "omarchy-shell -q shell togglePanelAt ", -} --- Pickers that paste into the focused window stay with that window's screen. -local pastes_into_focused_window = { - ["omarchy-shell shell toggle omarchy.emojis"] = true, - ["omarchy-shell shell toggle omarchy.clipboard"] = true, -} -local apple_silicon - -local function opens_overlay(command) - if type(command) ~= "string" or pastes_into_focused_window[command] then - return false - end - - for _, prefix in ipairs(overlay_prefixes) do - if command:sub(1, #prefix) == prefix then - return true +-- A platform package's defaults load before Omarchy's (see omarchy.lua). A chord +-- they bind replaces Omarchy's own default for it, and a decorator they add runs +-- for every later bind, before it is made, so it can bind something that must +-- run first (Hyprland runs the binds of a key press in the order they were +-- added). The user's files, loaded after both, can still unbind or rebind any +-- chord. Both start empty on every load. +o.platform_chords = {} +o.bind_decorators = {} +o.decorating = false + +-- Modifier order, case and aliases don't change the chord Hyprland binds. +local modifier_aliases = { CONTROL = "CTRL", WIN = "SUPER", LOGO = "SUPER", MOD4 = "SUPER", META = "SUPER", MOD1 = "ALT" } + +local function chord(keys) + local parts = {} + for raw in (tostring(keys) .. "+"):gmatch("([^+]*)%+") do + local part = raw:match("^%s*(.-)%s*$"):upper() + if part ~= "" then + table.insert(parts, part) end end - - return false -end - -function o.apple_silicon() - if apple_silicon == nil then - apple_silicon = o.shell_succeeds("omarchy-hw-apple-silicon") - end - - return apple_silicon -end - -function o.focus_builtin_screen() - for _, monitor in ipairs(hl.get_monitors()) do - if monitor.name:match("^eDP%-") then - if not monitor.focused then - hl.dispatch(hl.dsp.focus({ monitor = monitor.name })) - end - return - end + for index = 1, #parts - 1 do + parts[index] = modifier_aliases[parts[index]] or parts[index] end -end --- A menu or panel pressed on the MacBook's own keyboard opens on the MacBook's --- own screen; apps keep opening on the focused screen. Hyprland runs every bind --- matching a key press in the order they were added, so this bind, scoped to the --- built-in keyboard, moves focus before the menu bind runs. Other keyboards only --- match the menu bind. -local function bind_builtin_screen_focus(keys) - if o.apple_silicon() then - hl.bind(keys, o.focus_builtin_screen, { device = { inclusive = true, list = builtin_keyboards } }) - end + local key = table.remove(parts) or "" + table.sort(parts) + table.insert(parts, key) + return table.concat(parts, "+") end function o.bind(keys, description, dispatcher, options) @@ -156,8 +129,23 @@ function o.bind(keys, description, dispatcher, options) dispatcher = command_from(dispatcher, description) - if opens_overlay(dispatcher) and not opts.locked then - bind_builtin_screen_focus(keys) + if o.binding_phase == "defaults" and o.platform_chords[chord(keys)] then + return + elseif o.binding_phase == "platform" then + o.platform_chords[chord(keys)] = true + end + + -- A bind a decorator makes through o.bind is not decorated again. + if not o.decorating then + o.decorating = true + for _, decorate in ipairs(o.bind_decorators) do + local ok, err = pcall(decorate, keys, dispatcher, opts) + if not ok then + o.decorating = false + error(err, 0) + end + end + o.decorating = false end if type(dispatcher) == "string" then diff --git a/default/hypr/input.lua b/default/hypr/input.lua index 7d6ecb0b9c4..a87ad994548 100644 --- a/default/hypr/input.lua +++ b/default/hypr/input.lua @@ -74,10 +74,6 @@ hl.config({ }, }) --- Avoid stray taps while typing on the built-in Apple touchpad. -hl.device({ name = "apple-mtp-multi-touch", tap_to_click = false }) -hl.device({ name = "apple-spi-trackpad", tap_to_click = false }) - -- Scroll nicely in the terminal. o.window("(Alacritty|kitty)", { scroll_touchpad = 1.5 }) -- foot only applies its scrollback multiplier to wheel clicks, not precise touchpad scrolling. diff --git a/default/hypr/omarchy.lua b/default/hypr/omarchy.lua index 6586024d8a6..f0ce96b2729 100644 --- a/default/hypr/omarchy.lua +++ b/default/hypr/omarchy.lua @@ -1,8 +1,20 @@ -- Omarchy Hyprland setup: helpers, defaults, and current theme overrides. require("default.hypr.helpers") +local paths = require("default.hypr.paths") +local require_all = require("default.hypr.require_all") local require_optional = require("default.hypr.require_optional") +-- A platform package's own defaults (default/hypr/platform/defaults/*.lua in +-- the packaged tree), loaded before Omarchy's so that a chord they bind replaces +-- Omarchy's default for it and the user's files can override both. See +-- o.platform_chords in helpers.lua. +local platform_defaults = paths.packaged_path .. "/default/hypr/platform/defaults" +package.path = platform_defaults .. "/?.lua;" .. package.path +o.binding_phase = "platform" +require_all.files(platform_defaults, nil, { reload = true }) +o.binding_phase = "defaults" + -- Use Omarchy defaults, but don't edit these directly. require("default.hypr.autostart") if _G.omarchy_default_bindings ~= false then @@ -13,6 +25,7 @@ if _G.omarchy_default_bindings ~= false then require("default.hypr.bindings.voxtype") require_optional.module("default.hypr.bindings.applications") end +o.binding_phase = nil require("default.hypr.envs") require("default.hypr.looknfeel") require("default.hypr.qconsole") diff --git a/default/hypr/toggles.lua b/default/hypr/toggles.lua index a0a68778039..2ff6bcb06c6 100644 --- a/default/hypr/toggles.lua +++ b/default/hypr/toggles.lua @@ -22,9 +22,10 @@ disabled_input_device("touchscreen") require("default.hypr.workspace-layouts") --- A platform package's own defaults, loaded after the user's files so that the --- user's settings can keep them out. They are read from the packaged tree, so a --- development checkout keeps them. +-- A platform package's own late defaults (default/hypr/platform/*.lua), loaded +-- after the user's files so that the user's settings can keep them out (a +-- default gesture stepping aside for the user's own, say). They are read from +-- the packaged tree, so a development checkout in OMARCHY_PATH keeps them. local platform_dir = paths.packaged_path .. "/default/hypr/platform" package.path = platform_dir .. "/?.lua;" .. package.path require_all.files(platform_dir, nil, { reload = true }) diff --git a/docs/lifecycle-dispatch.md b/docs/lifecycle-dispatch.md index 5a2fbe450e3..456346372dd 100644 --- a/docs/lifecycle-dispatch.md +++ b/docs/lifecycle-dispatch.md @@ -152,6 +152,15 @@ The recovery passphrase is core code; whether setup creates one is the platform Snapshot restore is not a dispatch operation. A Limine Mac restores through `limine-snapper-restore`, as x86 does: limine-snapper-sync swaps the root and puts the snapshot's saved UKI and menu entry back itself, so nothing is rebuilt afterwards. `omarchy-mac-boot` checks the restore through limine-snapper-sync's own hook interface, the one its Limine activation gate already uses. Its pre hook (`/etc/boot/hooks/pre.d/04-omarchy-mac-snapshot-check`) runs the boot check `update-verify` runs (`--boot-chain`) on the booted snapshot, with no reboot pending, and stops a restore the boot files do not match. Its post hook (`post.d/99-omarchy-mac-snapshot-check`) checks the root the restore put back, which limine-snapper-restore's own list can make a different snapshot, and keeps the reboot from being offered when it does not match. `omarchy-snapshot restore` sends a Mac that still boots GRUB to `omarchy-system-snapshot-restore`, which refuses a Limine Mac and asks `omarchy-mac-snapshot-check` about the chosen snapshot on a GRUB one. +## Platform desktop defaults + +A platform's runtime package can also ship Hyprland defaults, without an operation and without a branch in Omarchy's config. Both directories are read from the packaged tree (`/usr/share/omarchy`, which a development checkout in `OMARCHY_PATH` does not replace; `OMARCHY_PACKAGED_PATH` moves it for tests), and Omarchy ships nothing in either. + +- **Early: `default/hypr/platform/defaults/*.lua`**, loaded by `default/hypr/omarchy.lua` right after the helpers, before Omarchy's own defaults and the user's files. A chord bound there with `o.bind` replaces Omarchy's default for the same chord (`o.platform_chords`), and a function added to `o.bind_decorators` runs for every later `o.bind`, Omarchy's and the user's, before the bind is made, so it can bind something that has to run first (Hyprland runs a key press's binds in the order they were added). The user's files load after and can unbind or rebind any of it. A file that binds keys honors `omarchy_default_bindings = false`. Settings Omarchy's defaults also set belong in the late directory. +- **Late: `default/hypr/platform/*.lua`**, loaded by `default/hypr/toggles.lua` after the user's files, for defaults that must see the user's: `o.registered_gestures` lists the gestures registered before it, so a default gesture can step aside for the user's own. +- **Key names: `default/omarchy/platform/key-names`**, one ` ` per line, which the keybindings menu shows in place of the keysym (a MacBook's `XF86MonBrightnessUp` is its F2 key). +- **Display cutouts: `default/shell/platform/display-cutouts.json`**, the camera cutouts the bar keeps a top bar out of (see `docs/omarchy-shell.md`). + ## Qualcomm Snapdragon laptops boot Limine with unified kernel images, like x86, and `qualcomm` is unregistered. Every operation is a no-op there, and provisioning uses the Limine UKI callbacks, so Dragon behaves exactly as before. To plug in a Qualcomm implementation: diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index d62ff7436df..12aa480e8ee 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -368,6 +368,14 @@ size-vertical = 28 # left/right bar width at base-size 12 Set `scale-with-font = false` to keep those bar sizes as fixed pixels. +A panel with a camera cutout (a notch) at its top keeps a top bar out of it: the bar is never shorter than the cutout, and its center section sits beside the right one. Omarchy knows no panel's cutout itself; the platform's own package describes them in `default/shell/platform/display-cutouts.json` under the packaged tree (`/usr/share/omarchy`): + +```json +{ "panels": [ { "connector": "eDP", "width": 3024, "height": 1964, "top": 64 } ] } +``` + +A panel matches a screen whose connector name starts with `connector` and whose mode is `width` x `height` physical pixels; `top` is the physical rows the cutout covers. On such a panel, `[bar] notch-height` (logical pixels, not scaled with the font) sets the floor by hand. The bar reads the file when the shell starts and whenever it changes; a file installed where its directory didn't exist yet is read at the next shell start. + ## Custom bar modules If a full plugin is overkill, declare a one-off module inline in diff --git a/packages/omarchy-mac/ORIGINS.md b/packages/omarchy-mac/ORIGINS.md index cf14c3524b2..e8ab931ee5a 100644 --- a/packages/omarchy-mac/ORIGINS.md +++ b/packages/omarchy-mac/ORIGINS.md @@ -21,3 +21,4 @@ The network backend default follows Marcelo Alcantara's Apple Silicon integratio - `omarchy-hw-apple` alias: Scott Jones, `bea1a1ba5` (Preserve optional Apple audio and hardware detection interfaces), moved from the runtime's `bin/`; root no longer falls back to anything but the predicate in `/usr/bin`. - Three-finger workspace swipe: Marcelo Alcantara, omarchy-mac#629 (`25454328c`, `4ba3490a8`), moved from the runtime's `default/hypr/apple-gestures.lua` into the runtime's platform directory; the gesture registry it reads stays in the runtime's helpers, now on every platform. - Steam FEX launcher setup: Scott Jones, from the runtime's Steam installer branch and migration `1789522888` (`b5463029e`); the launcher itself is omarchy-pkgs' `omarchy-steam-fex`. +- Mac desktop defaults moved into the platform hooks: Scott Jones's SMC lid binds (`30e876969`), capture chords (`b86e2aff9`), Shift+brightness on the keyboard backlight (`0022c4374`) and F1/F2 key names (`c1673ae67`); Marcelo Alcantara's menus on the built-in screen (`cf575a9d4`, `3598e5493`); the trackpad's tap-to-click default and the notch sizes (from the runtime's `BarModel.js`), moved out of the runtime's shared files unchanged in behaviour. diff --git a/packages/omarchy-mac/README.md b/packages/omarchy-mac/README.md index 7d5c13d5fb0..42527789010 100644 --- a/packages/omarchy-mac/README.md +++ b/packages/omarchy-mac/README.md @@ -20,7 +20,12 @@ A runtime whose `omarchy-lifecycle-dispatch` has the `setup-system` and `setup-u System setup also wraps Chromium, 1Password and Cursor where they are installed: without the GPU driver an Apple Silicon Mac has no DRM render node and their GPU process fails, so the runtime's `omarchy-cmd-electron-gl-wrap` puts a wrapper in `/usr/local/bin` that passes software GL flags only while no render node exists; a launcher an administrator owns is kept. User setup points the user's own desktop entries for them at a ready wrapper (`omarchy-cmd-desktop-exec-repair`), appends natural scrolling and physical clicks to `~/.config/hypr/input.lua` once where the file sets neither (Asahi's disable-while-typing does not stop stray taps), and turns `AcceleratedVideoDecoder` off in each Chromium-family `*-flags.conf`, joining an existing `--disable-features` list: Brave and other VA-API builds render green frames through the Apple Video Decoder, while mpv, ffmpeg and GStreamer keep it. A browser install runs user setup again after it writes a fresh flags file. Where Steam is installed, system setup also installs `omarchy-steam-fex` (Steam runs x86 under FEX in a muvm VM) from the configured repositories, and user setup runs its `omarchy-launch-steam --prepare` for a user who has Steam; the runtime's Steam installer reaches both through the dispatcher. These and the Electron steps need the live machine, so a staging root skips them. -Hyprland swipes between workspaces with three fingers on a Mac, as macOS does: `/usr/share/omarchy/default/hypr/platform/apple-gestures.lua`, which Omarchy loads after the user's files, adds the gesture unless the user set a three-finger sideways gesture of their own or `omarchy_workspace_gesture = false` in `~/.config/hypr/input.lua`, so Hyprland never sees a duplicate. +The Mac's desktop defaults fill the platform hooks Omarchy reads from its packaged tree (`/usr/share/omarchy`), so Omarchy's own config carries no Apple branch. The two Lua files re-check the platform; the other two are data. + +- `default/hypr/platform/defaults/apple.lua`, loaded before Omarchy's defaults: tap-to-click off on the built-in trackpad (`hl.device` for `apple-mtp-multi-touch` and `apple-spi-trackpad`; the same line with `tap_to_click = true` in `~/.config/hypr/input.lua` turns it back on); with Omarchy's default bindings, the SMC lid switch ("Apple SMC power/lid events", which Omarchy's "Lid Switch" binds never match), capture on SUPER+F10-F12 and the same chords on the top row's media keys (no Print Screen key), and Shift+brightness on the keyboard backlight in place of Omarchy's display maximum and minimum; and a bind decorator that makes a menu or panel typed on the MacBook's own keyboard open on the built-in screen, apps and the pasting pickers staying on the focused one. +- `default/hypr/platform/apple-gestures.lua`, loaded after the user's files: the three-finger workspace swipe, unless the user set a three-finger sideways gesture of their own or `omarchy_workspace_gesture = false` in `~/.config/hypr/input.lua`, so Hyprland never sees a duplicate. +- `default/omarchy/platform/key-names`: the keybindings menu shows `XF86MonBrightnessUp` and `Down` as the F2 and F1 they are. +- `default/shell/platform/display-cutouts.json`: the notch of each MacBook panel (the 14" Pro measured at 64 physical rows, the 16" Pro inferred at 64 and the Airs at 56), which the bar keeps clear of. A notched panel not listed gets no floor until the file lists it; `[bar] notch-height` in `shell.toml` only adjusts the floor of a listed panel. `omarchy-hw-apple` is the pre-Quattro name of the runtime's `omarchy-hw-apple-silicon`, kept for user services an older Mac still has; it runs the predicate installed beside it. diff --git a/packages/omarchy-mac/share/omarchy/default/hypr/platform/apple-gestures.lua b/packages/omarchy-mac/share/omarchy/default/hypr/platform/apple-gestures.lua index 0196dab4de2..51ce74b77d0 100644 --- a/packages/omarchy-mac/share/omarchy/default/hypr/platform/apple-gestures.lua +++ b/packages/omarchy-mac/share/omarchy/default/hypr/platform/apple-gestures.lua @@ -5,7 +5,7 @@ -- rejecting it as a duplicate. Lines added to hyprland.lua after the toggles -- come too late for that, which is why the manual points at input.lua. -if _G.omarchy_workspace_gesture == false or not (o and o.apple_silicon and o.apple_silicon()) then +if _G.omarchy_workspace_gesture == false or not (o and o.shell_succeeds and o.shell_succeeds("omarchy-hw-apple-silicon")) then return end diff --git a/packages/omarchy-mac/share/omarchy/default/hypr/platform/defaults/apple.lua b/packages/omarchy-mac/share/omarchy/default/hypr/platform/defaults/apple.lua new file mode 100644 index 00000000000..e80f15908af --- /dev/null +++ b/packages/omarchy-mac/share/omarchy/default/hypr/platform/defaults/apple.lua @@ -0,0 +1,77 @@ +-- omarchy-mac: Apple Silicon Hyprland defaults. Omarchy loads this directory +-- (default/hypr/platform/defaults in the packaged tree) before its own +-- defaults, so a chord bound here replaces Omarchy's default for it, and the +-- user's files, loaded after both, can still unbind or rebind any of it. + +if not (o and o.shell_succeeds and o.shell_succeeds("omarchy-hw-apple-silicon")) then + return +end + +-- The built-in trackpad clicks physically; Asahi's disable-while-typing does +-- not stop stray taps, so tap-to-click stays off. The user's input.lua can turn +-- it back on with the same line and tap_to_click = true. +hl.device({ name = "apple-mtp-multi-touch", tap_to_click = false }) +hl.device({ name = "apple-spi-trackpad", tap_to_click = false }) + +if _G.omarchy_default_bindings ~= false then + -- The lid switch is "Apple SMC power/lid events" here, so Omarchy's "Lid + -- Switch" binds never fire. + o.bind("switch:on:Apple SMC power/lid events", nil, "omarchy-system-lid-close", { locked = true }) + o.bind("switch:off:Apple SMC power/lid events", nil, "omarchy-hyprland-monitor-clamshell", { locked = true }) + + -- No Print Screen key: SUPER+F10-F12 and the same chords on the top row's + -- media keysyms capture without holding Fn in media-first mode. + o.bind("SUPER + F12", "Screenshot Display", "omarchy-capture-screenshot fullscreen") + o.bind("SUPER + F11", "Screenshot Region", "omarchy-capture-screenshot region") + o.bind("SUPER + F10", "Screenshot Window", "omarchy-capture-screenshot windows") + o.bind("SUPER + XF86AudioMute", "Screenshot Window (Apple top row)", "omarchy-capture-screenshot windows") + o.bind("SUPER + XF86AudioLowerVolume", "Screenshot Region (Apple top row)", "omarchy-capture-screenshot region") + o.bind("SUPER + XF86AudioRaiseVolume", "Screenshot Display (Apple top row)", "omarchy-capture-screenshot fullscreen") + + -- The keyboard has no backlight keys: Shift+brightness drives it, in place of + -- Omarchy's display maximum and minimum. + o.bind("SHIFT + XF86MonBrightnessUp", "Keyboard brightness up", "omarchy-brightness-keyboard up", { locked = true, repeating = true }) + o.bind("SHIFT + XF86MonBrightnessDown", "Keyboard brightness down", "omarchy-brightness-keyboard down", { locked = true, repeating = true }) +end + +-- A menu or panel pressed on the MacBook's own keyboard opens on the MacBook's +-- own screen; apps keep opening on the focused screen. Hyprland runs every bind +-- matching a key press in the order they were added, so a bind scoped to the +-- built-in keyboard (SPI on M1, MTP on M2 and later), made just before the menu +-- bind, moves focus first. Other keyboards only match the menu bind. Pickers +-- that paste into the focused window stay with that window's screen. +local builtin_keyboards = { "apple-spi-keyboard", "apple-mtp-keyboard" } +local overlay_prefixes = { "omarchy-menu", "omarchy-shell shell toggle ", "omarchy-shell -q shell togglePanelAt " } +local pastes_into_focused_window = { + ["omarchy-shell shell toggle omarchy.emojis"] = true, + ["omarchy-shell shell toggle omarchy.clipboard"] = true, +} + +local function opens_overlay(command) + if type(command) ~= "string" or pastes_into_focused_window[command] then + return false + end + for _, prefix in ipairs(overlay_prefixes) do + if command:sub(1, #prefix) == prefix then + return true + end + end + return false +end + +local function focus_builtin_screen() + for _, monitor in ipairs(hl.get_monitors()) do + if monitor.name:match("^eDP%-") then + if not monitor.focused then + hl.dispatch(hl.dsp.focus({ monitor = monitor.name })) + end + return + end + end +end + +table.insert(o.bind_decorators, function(keys, dispatcher, opts) + if opens_overlay(dispatcher) and not (opts and opts.locked) then + hl.bind(keys, focus_builtin_screen, { device = { inclusive = true, list = builtin_keyboards } }) + end +end) diff --git a/packages/omarchy-mac/share/omarchy/default/omarchy/platform/key-names b/packages/omarchy-mac/share/omarchy/default/omarchy/platform/key-names new file mode 100644 index 00000000000..58264ee9f66 --- /dev/null +++ b/packages/omarchy-mac/share/omarchy/default/omarchy/platform/key-names @@ -0,0 +1,2 @@ +XF86MonBrightnessUp F2 +XF86MonBrightnessDown F1 diff --git a/packages/omarchy-mac/share/omarchy/default/shell/platform/display-cutouts.json b/packages/omarchy-mac/share/omarchy/default/shell/platform/display-cutouts.json new file mode 100644 index 00000000000..1345e671dd2 --- /dev/null +++ b/packages/omarchy-mac/share/omarchy/default/shell/platform/display-cutouts.json @@ -0,0 +1,8 @@ +{ + "panels": [ + { "connector": "eDP", "width": 3024, "height": 1964, "top": 64 }, + { "connector": "eDP", "width": 3456, "height": 2234, "top": 64 }, + { "connector": "eDP", "width": 2560, "height": 1664, "top": 56 }, + { "connector": "eDP", "width": 2880, "height": 1864, "top": 56 } + ] +} diff --git a/packages/omarchy-mac/test/desktop-setup-test.sh b/packages/omarchy-mac/test/desktop-setup-test.sh index f0aaa6e040c..e3c3fa5a6c1 100644 --- a/packages/omarchy-mac/test/desktop-setup-test.sh +++ b/packages/omarchy-mac/test/desktop-setup-test.sh @@ -19,8 +19,11 @@ for channel in stable rc edge; do done [[ -x $stage/usr/bin/omarchy-hw-apple && $(readlink "$stage/usr/share/omarchy/bin/omarchy-hw-apple") == /usr/bin/omarchy-hw-apple ]] || fail 'the legacy alias is staged in /usr/bin and linked from the runtime tree' -[[ -f $stage/usr/share/omarchy/default/hypr/platform/apple-gestures.lua ]] || - fail "the three-finger workspace swipe is staged in the runtime's platform directory" +for file in hypr/platform/apple-gestures.lua hypr/platform/defaults/apple.lua omarchy/platform/key-names shell/platform/display-cutouts.json; do + [[ -f $stage/usr/share/omarchy/default/$file ]] || fail "$file is staged in the runtime's platform hooks" +done +python3 -c 'import json, sys; json.load(open(sys.argv[1]))' "$stage/usr/share/omarchy/default/shell/platform/display-cutouts.json" || + fail 'the cutout description is valid JSON' for helper in electron-launchers electron-desktop-entries; do [[ -x $stage/usr/lib/omarchy-mac/$helper ]] || fail "$helper is staged" done diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 7eb2bba5c93..00a14464d1e 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -555,9 +555,10 @@ Item { readonly property bool vertical: position === "left" || position === "right" readonly property int barSize: vertical ? Style.bar.sizeVertical : Style.bar.sizeHorizontal - // Whether this machine is an Apple Silicon laptop, whose built-in panel may - // have a camera notch. Invoke the detector by OMARCHY_PATH so a session - // whose PATH does not include bin/ still identifies the hardware. + // Whether this machine is an Apple Silicon Mac, for the microphone widget, + // whose Asahi mapping is a virtual source. Invoke the detector by + // OMARCHY_PATH so a session whose PATH does not include bin/ still + // identifies the hardware. property bool appleSiliconHost: false Process { running: root.omarchyPath !== "" @@ -565,6 +566,20 @@ Item { onExited: function(exitCode) { root.appleSiliconHost = exitCode === 0 } } + // Display cutouts (a camera notch) the platform's own package describes in + // the packaged tree, which a development checkout in OMARCHY_PATH does not + // replace. Most machines have none. See BarModel.parseCutouts. + readonly property string packagedPath: Quickshell.env("OMARCHY_PACKAGED_PATH") || "/usr/share/omarchy" + property var displayCutouts: [] + FileView { + path: root.packagedPath + "/default/shell/platform/display-cutouts.json" + watchChanges: true + printErrors: false + onLoaded: root.displayCutouts = BarModel.parseCutouts(text()) + onLoadFailed: root.displayCutouts = [] + onFileChanged: reload() + } + function normalizePosition(value) { return BarModel.normalizePosition(value) } @@ -1257,11 +1272,13 @@ Item { window: barWindow } + // Parked by its full thickness, which a notch floor may make more than + // barSize, so no strip of it stays on screen. margins { - top: root.barHidden && root.position === "top" ? -root.barSize : 0 - bottom: root.barHidden && root.position === "bottom" ? -root.barSize : 0 - left: root.barHidden && root.position === "left" ? -root.barSize : 0 - right: root.barHidden && root.position === "right" ? -root.barSize : 0 + top: root.barHidden && root.position === "top" ? -barWindow.thickness : 0 + bottom: root.barHidden && root.position === "bottom" ? -barWindow.thickness : 0 + left: root.barHidden && root.position === "left" ? -barWindow.thickness : 0 + right: root.barHidden && root.position === "right" ? -barWindow.thickness : 0 } anchors { @@ -1271,21 +1288,17 @@ Item { right: root.position === "right" || !root.vertical } - // A top bar shorter than the camera cutout of an Apple notched panel - // leaves a sliver of every window peeking out beside the camera, so the - // cutout height is this panel's minimum sensible top-bar height. An - // intentionally taller bar still wins, and a calibrated [bar] - // notch-height in shell.toml overrides the derived value. - readonly property int notchFloor: root.appleSiliconHost && root.position === "top" - ? (Style.bar.notchHeight > 0 - ? Style.bar.notchHeight - : BarModel.notchHeight(screen.name, screen.width, screen.height, screen.devicePixelRatio)) - : 0 + // A top bar shorter than a panel's camera cutout leaves a sliver of every + // window peeking out beside the camera, so the cutout is this panel's + // minimum sensible top-bar height. An intentionally taller bar still wins. + readonly property int notchFloor: BarModel.notchFloor(root.displayCutouts, root.position, screen.name, screen.width, screen.height, screen.devicePixelRatio, Style.bar.notchHeight) + + readonly property bool centerBesideRight: BarModel.centerBesideRight(root.displayCutouts, root.position, screen.name, screen.width, screen.height, screen.devicePixelRatio) - readonly property bool centerBesideRight: BarModel.centerBesideRight(root.appleSiliconHost, root.position, screen.name, screen.width, screen.height, screen.devicePixelRatio) + readonly property int thickness: root.vertical ? root.barSize : Math.max(root.barSize, notchFloor) - implicitWidth: root.vertical ? root.barSize : 0 - implicitHeight: root.vertical ? 0 : Math.max(root.barSize, notchFloor) + implicitWidth: root.vertical ? thickness : 0 + implicitHeight: root.vertical ? 0 : thickness color: root.transparent ? "transparent" : root.background surfaceFormat.opaque: false WlrLayershell.namespace: "omarchy-bar" diff --git a/shell/plugins/bar/BarModel.js b/shell/plugins/bar/BarModel.js index 9b2876beb16..cb63dc62a0d 100644 --- a/shell/plugins/bar/BarModel.js +++ b/shell/plugins/bar/BarModel.js @@ -208,74 +208,79 @@ function nearestDropTarget(candidates, point, vertical) { return best } -// Camera-cutout depths measured on real hardware, keyed by physical panel -// size. Apple's notched panels expose more rows above the 16:10 area than -// the cutout actually covers (Apple's own menu bar extends below the notch -// too), so a measured panel gets its exact cutout and an unmeasured panel -// keeps the full strip — which errs taller, never shorter. -var measuredNotchPanels = [ - // MacBook Pro 14" (3024x1964): 64 of the 74 rows above the 16:10 area. - { width: 3024, height: 1964, cutoutRows: 64 }, - // MacBook Pro 16" (3456x2234): inferred from the 14" — same 254ppi panel - // family and camera module, so the cutout spans the same 64 rows. - { width: 3456, height: 2234, cutoutRows: 64 }, - // MacBook Air 13.6" and 15" (224ppi): the cutout is physically the same - // size as the Pros', so it spans fewer rows there (64 x 224/254 ~= 56). - { width: 2560, height: 1664, cutoutRows: 56 }, - { width: 2880, height: 1864, cutoutRows: 56 } -] - -function measuredCutoutRows(physicalWidth, physicalHeight) { - for (var i = 0; i < measuredNotchPanels.length; i++) { - var panel = measuredNotchPanels[i] - // Logical sizes are rounded, so the reconstructed physical size can be - // a couple of pixels off at fractional scales. - if (Math.abs(physicalWidth - panel.width) <= 4 && Math.abs(physicalHeight - panel.height) <= 4) - return panel.cutoutRows +// Display cutouts (a camera notch at the top of a laptop panel) are described +// by the platform's own package, in default/shell/platform/display-cutouts.json +// under the packaged tree: +// +// { "panels": [ { "connector": "eDP", "width": 3024, "height": 1964, "top": 64 } ] } +// +// A panel matches a screen whose connector name starts with `connector` and +// whose mode is width x height physical pixels; `top` is how many physical rows +// at its top the cutout covers. Anything malformed is dropped. +function parseCutouts(text) { + var parsed + try { + parsed = JSON.parse(String(text || "")) + } catch (error) { + return [] } - return 0 + var panels = parsed && Array.isArray(parsed.panels) ? parsed.panels : [] + var cutouts = [] + for (var i = 0; i < panels.length; i++) { + var panel = panels[i] || {} + var width = Number(panel.width) + var height = Number(panel.height) + var top = Number(panel.top) + if (typeof panel.connector !== "string" || panel.connector === "") continue + if (!(width > 0) || !(height > 0) || !(top > 0) || top >= height) continue + cutouts.push({ connector: panel.connector, width: width, height: height, top: top }) + } + return cutouts } -// Apple's notched laptop panels are a 16:10 display plus a camera strip above -// it, so whatever extends beyond the 16:10 area is the notch strip. Hyprland -// applies the display scale to both axes, so the scale cancels out and the -// strip height falls straight out of the logical screen size. -// -// Callers gate on the machine being Apple Silicon; this only guards against -// panels the formula does not describe: external monitors (not eDP), 16:10 -// or wider panels (no leftover), and taller aspect ratios (rotated or 3:2 -// panels), where the leftover is far more than a camera strip. Every notched -// Apple panel's strip is ~3.8% of its height, so 5% is a comfortable bound. -function notchHeight(screenName, logicalWidth, logicalHeight, devicePixelRatio) { - if (String(screenName || "").indexOf("eDP") !== 0) return 0 - - var width = Number(logicalWidth) - var height = Number(logicalHeight) +// The cutout at the top of this screen, in logical pixels, or 0. Logical sizes +// are rounded, so the reconstructed mode can be a couple of pixels off at +// fractional scales. +function cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio) { + var name = String(screenName || "") + var scale = Number(devicePixelRatio) > 0 ? Number(devicePixelRatio) : 1 + var width = Math.round(Number(logicalWidth) * scale) + var height = Math.round(Number(logicalHeight) * scale) if (!(width > 0) || !(height > 0)) return 0 - - var strip = height - (width * 10) / 16 - if (strip <= 0 || strip > height / 20) return 0 - - var scale = Number(devicePixelRatio) - if (scale > 0) { - var cutout = measuredCutoutRows(Math.round(width * scale), Math.round(height * scale)) - if (cutout > 0) return Math.ceil(cutout / scale) + var list = Array.isArray(cutouts) ? cutouts : [] + for (var i = 0; i < list.length; i++) { + var panel = list[i] + if (name.indexOf(panel.connector) !== 0) continue + if (Math.abs(width - panel.width) <= 4 && Math.abs(height - panel.height) <= 4) + return Math.ceil(panel.top / scale) } - return Math.ceil(strip) + return 0 +} + +// The height a top bar on this screen must reach to cover its cutout, or a +// calibrated [bar] notch-height in its place. A screen without a cutout, or a +// bar on another edge, has no floor, so a calibration never reaches an +// external monitor. +function notchFloor(cutouts, position, screenName, logicalWidth, logicalHeight, devicePixelRatio, calibrated) { + if (position !== "top") return 0 + var top = cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio) + if (!(top > 0)) return 0 + return Number(calibrated) > 0 ? Math.round(Number(calibrated)) : top } -// The camera cutout covers the middle of a top bar on an Apple notched panel, -// so that bar draws its center section beside the right one, as macOS does. -function centerBesideRight(appleSiliconHost, position, screenName, logicalWidth, logicalHeight, devicePixelRatio) { - return appleSiliconHost === true && position === "top" && - notchHeight(screenName, logicalWidth, logicalHeight, devicePixelRatio) > 0 +// A cutout covers the middle of a top bar, so that bar draws its center +// section beside the right one. +function centerBesideRight(cutouts, position, screenName, logicalWidth, logicalHeight, devicePixelRatio) { + return position === "top" && cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio) > 0 } if (typeof module !== "undefined") { module.exports = { centerBesideRight: centerBesideRight, + cutoutTop: cutoutTop, isDrawnSlot: isDrawnSlot, - notchHeight: notchHeight, + notchFloor: notchFloor, + parseCutouts: parseCutouts, pickDrawnSlot: pickDrawnSlot, pickPanelSlot: pickPanelSlot, nearestDropTarget: nearestDropTarget, diff --git a/test/shell.d/apple-capture-chords-test.sh b/test/shell.d/apple-capture-chords-test.sh deleted file mode 100755 index 82830428ee2..00000000000 --- a/test/shell.d/apple-capture-chords-test.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/bash - -# Apple keyboards have no Print Screen, so the utilities bindings add -# SUPER+F10-F12 and the top-row media chords for capture. Those keys are -# unbound on every other machine and must stay that way: the binds exist only -# when the Apple Silicon detector says so. - -source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" - -require_command lua - -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT -stub_bin="$test_tmp/bin" -mkdir -p "$stub_bin" -cat >"$stub_bin/omarchy-hw-apple-silicon" <<'SH' -#!/bin/bash -[[ ${APPLE_SILICON:-0} == "1" ]] -SH -chmod +x "$stub_bin"/* - -# Load only the helpers and the utilities bindings, printing every key that -# reaches hl.bind. The "-" makes lua report a chunk error in its exit status. -list_utility_keys() { - APPLE_SILICON="$1" PATH="$stub_bin:$PATH" OMARCHY_PATH="$ROOT" lua - <<'LUA' -package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path - -local function proxy() - return setmetatable({}, { - __index = function(self, key) - local value = proxy() - rawset(self, key, value) - return value - end, - __call = function() - return {} - end, - }) -end - -hl = setmetatable({ - dsp = proxy(), - bind = function(keys) - print(keys) - end, - on = function() end, - config = function() end, - get_config = function() return nil end, -}, { - __index = function() - return function() - return {} - end - end, -}) - -require("default.hypr.helpers") -require("default.hypr.bindings.utilities") -LUA -} - -apple_chords=("SUPER + F12" "SUPER + F11" "SUPER + F10" "SUPER + XF86AudioMute" "SUPER + XF86AudioLowerVolume" "SUPER + XF86AudioRaiseVolume") - -keys=$(list_utility_keys 0) || fail "utilities bindings load off Apple Silicon" "$keys" -grep -Fxq 'PRINT' <<<"$keys" || fail "the shared Print Screen bind is present" "$keys" -for chord in "${apple_chords[@]}"; do - ! grep -Fxq "$chord" <<<"$keys" || fail "$chord is bound off Apple Silicon" "$keys" -done -! grep -Fq 'SUPER + ALT + F12' <<<"$keys" || fail "Super+Alt+F12 stays unbound" -pass "capture chords leave SUPER+F10-F12 and the media chords unbound off Apple Silicon" - -keys=$(list_utility_keys 1) || fail "utilities bindings load on Apple Silicon" "$keys" -for chord in "${apple_chords[@]}"; do - grep -Fxq "$chord" <<<"$keys" || fail "$chord is bound on Apple Silicon" "$keys" -done -grep -Fxq 'PRINT' <<<"$keys" || fail "the shared Print Screen bind is still present on Apple Silicon" -pass "capture chords are bound on Apple Silicon" diff --git a/test/shell.d/apple-fnmode-test.sh b/test/shell.d/apple-fnmode-test.sh index 3eaadf3de16..ac4312d268d 100755 --- a/test/shell.d/apple-fnmode-test.sh +++ b/test/shell.d/apple-fnmode-test.sh @@ -6,8 +6,6 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" leaf="$ROOT/install/hardware/fix-fkeys.sh" migration="$ROOT/migrations/1790327324.sh" -media="$ROOT/default/hypr/bindings/media.lua" -utilities="$ROOT/default/hypr/bindings/utilities.lua" test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT @@ -102,29 +100,3 @@ matches=$(grep -l 'fnmode' "$ROOT"/migrations/*.sh) [[ $matches == "$migration" ]] || fail "one migration changes the keyboard mode, so an update rebuilds the boot image once" "$matches" pass "one migration changes the keyboard mode, so an update rebuilds the boot image once" - -grep -F 'omarchy-brightness-shift up' "$media" >/dev/null || - fail "SHIFT+brightness up goes through the shift wrapper" -grep -F 'omarchy-brightness-shift down' "$media" >/dev/null || - fail "SHIFT+brightness down goes through the shift wrapper" -! grep -F 'omarchy-brightness-display 100%' "$media" >/dev/null || - fail "SHIFT+brightness no longer calls display max directly" -pass "SHIFT+brightness uses the hardware-gated wrapper" - -PATH="$stub_bin:$PATH" APPLE_SILICON=0 "$ROOT/bin/omarchy-brightness-shift" up | - grep -qx 'display 100%' || fail "SHIFT+brightness up is display max on x86" -PATH="$stub_bin:$PATH" APPLE_SILICON=0 "$ROOT/bin/omarchy-brightness-shift" down | - grep -qx 'display 1%' || fail "SHIFT+brightness down is display min on x86" -pass "SHIFT+brightness is display max/min off Apple Silicon" - -PATH="$stub_bin:$PATH" APPLE_SILICON=1 "$ROOT/bin/omarchy-brightness-shift" up | - grep -qx 'keyboard up' || fail "SHIFT+brightness up is keyboard backlight on Apple Silicon" -PATH="$stub_bin:$PATH" APPLE_SILICON=1 "$ROOT/bin/omarchy-brightness-shift" down | - grep -qx 'keyboard down' || fail "SHIFT+brightness down is keyboard backlight on Apple Silicon" -pass "SHIFT+brightness is keyboard backlight on Apple Silicon" - -grep -F 'SUPER + F12' "$utilities" >/dev/null || fail "SUPER+F12 captures without PRINT" -grep -F 'SUPER + XF86AudioRaiseVolume' "$utilities" >/dev/null || - fail "SUPER+volume-up captures on the Apple media row" -grep -F 'PRINT' "$utilities" >/dev/null || fail "PRINT capture binds remain" -pass "capture binds cover PRINT, F-keys, and the Apple media row" diff --git a/test/shell.d/apple-platform-hooks-test.sh b/test/shell.d/apple-platform-hooks-test.sh new file mode 100755 index 00000000000..01ed608b649 --- /dev/null +++ b/test/shell.d/apple-platform-hooks-test.sh @@ -0,0 +1,198 @@ +#!/bin/bash +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command lua +require_command node + +# omarchy-mac fills Omarchy's platform hooks with the Mac's defaults: early +# Hyprland defaults (trackpad, lid switch, capture chords, Shift+brightness on +# the keyboard backlight, menus on the built-in screen), the keybindings menu's +# key names and the notch the bar keeps clear of. The runtime carries none of +# them. OMARCHY_TEST_RUNTIME points the test at another runtime layout (an +# upstream checkout with the same hooks) to show the package behaves the same. + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT +runtime=${OMARCHY_TEST_RUNTIME:-$ROOT} +"$ROOT/packages/omarchy-mac/install" "$tmpdir/pkg" >/dev/null +packaged=$tmpdir/pkg/usr/share/omarchy + +mkdir -p "$tmpdir/apple-bin" "$tmpdir/other-bin" +printf '#!/bin/sh\nexit 0\n' >"$tmpdir/apple-bin/omarchy-hw-apple-silicon" +printf '#!/bin/sh\nexit 1\n' >"$tmpdir/other-bin/omarchy-hw-apple-silicon" +chmod +x "$tmpdir"/*-bin/omarchy-hw-apple-silicon + +# Loads the runtime's hyprland.lua against a user's ~/.config/hypr and prints +# every bind ("bindkeyscommand", or "focus " for a bind +# scoped to keyboards) and device setting ("devicenametap_to_click"). +load_config() { + local platform=$1 packaged_path=${2:-$packaged} edit=${3:-} home + home=$(mktemp -d "$tmpdir/home.XXXXXX") + mkdir -p "$home/.config" + cp -R "$runtime/config/hypr" "$home/.config/hypr" + [[ -z $edit ]] || printf '%s\n' "$edit" >>"$home/.config/hypr/input.lua" + [[ -z ${NO_DEFAULT_BINDINGS:-} ]] || sed -i 's/^-- omarchy_default_bindings = false$/omarchy_default_bindings = false/' "$home/.config/hypr/hyprland.lua" + HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_STATE_HOME="$home/.local/state" OMARCHY_PATH="$runtime" \ + OMARCHY_PACKAGED_PATH="$packaged_path" PATH="$tmpdir/$platform-bin:$PATH" lua <<'LUA' +local function proxy() + return setmetatable({}, { + __index = function(self, key) + local value = proxy() + rawset(self, key, value) + return value + end, + __call = function() + return {} + end, + }) +end + +local dsp = proxy() +rawset(dsp, "exec_cmd", function(cmd) return { cmd = cmd } end) + +hl = setmetatable({ + dsp = dsp, + bind = function(keys, dispatcher, opts) + if opts and opts.device then + print("bind\t" .. keys .. "\tfocus " .. table.concat(opts.device.list, ",")) + elseif type(dispatcher) == "table" and dispatcher.cmd then + print("bind\t" .. keys .. "\t" .. dispatcher.cmd) + else + print("bind\t" .. keys .. "\tother") + end + end, + unbind = function(keys) print("unbind\t" .. keys) end, + device = function(device) print("device\t" .. device.name .. "\t" .. tostring(device.tap_to_click)) end, + get_config = function() return nil end, + get_active_window = function() return nil end, + get_monitors = function() return {} end, +}, { + __index = function() + return function() + return {} + end + end, +}) + +dofile(os.getenv("HOME") .. "/.config/hypr/hyprland.lua") +LUA +} + +apple=$(load_config apple) || fail "the config loads on a Mac with omarchy-mac" "$apple" +other=$(load_config other) || fail "the config loads elsewhere with omarchy-mac on disk" "$other" +bare=$(load_config apple "$tmpdir/none") || fail "the config loads on a Mac without omarchy-mac" "$bare" + +bound() { grep -Fxq "bind"$'\t'"$2"$'\t'"$3" <<<"$1"; } + +lid_on=("switch:on:Apple SMC power/lid events" "omarchy-system-lid-close") +lid_off=("switch:off:Apple SMC power/lid events" "omarchy-hyprland-monitor-clamshell") +captures=("SUPER + F12|omarchy-capture-screenshot fullscreen" "SUPER + F11|omarchy-capture-screenshot region" + "SUPER + F10|omarchy-capture-screenshot windows" "SUPER + XF86AudioMute|omarchy-capture-screenshot windows" + "SUPER + XF86AudioLowerVolume|omarchy-capture-screenshot region" "SUPER + XF86AudioRaiseVolume|omarchy-capture-screenshot fullscreen") + +bound "$apple" "${lid_on[@]}" && bound "$apple" "${lid_off[@]}" || fail "a Mac binds its SMC lid switch" "$apple" +for capture in "${captures[@]}"; do + bound "$apple" "${capture%%|*}" "${capture#*|}" || fail "a Mac binds ${capture%%|*} to capture" "$apple" +done +! grep -q $'^bind\tSUPER + ALT + F12\t' <<<"$apple" || fail "Super+Alt+F12 stays unbound" "$apple" +bound "$apple" "SHIFT + XF86MonBrightnessUp" "omarchy-brightness-keyboard up" && + bound "$apple" "SHIFT + XF86MonBrightnessDown" "omarchy-brightness-keyboard down" || + fail "Shift+brightness drives a Mac's keyboard backlight" "$apple" +(( $(grep -c $'^bind\tSHIFT + XF86MonBrightnessUp\t' <<<"$apple") == 1 )) || + fail "the Mac's Shift+brightness replaces Omarchy's display maximum instead of joining it" "$apple" +grep -Fxq $'device\tapple-mtp-multi-touch\tfalse' <<<"$apple" && grep -Fxq $'device\tapple-spi-trackpad\tfalse' <<<"$apple" || + fail "a Mac's built-in trackpad does not tap to click" "$apple" +pass "a Mac gets its lid switch, capture chords, keyboard backlight chords and trackpad from omarchy-mac" + +for output in "$other" "$bare"; do + ! grep -q 'Apple SMC\|omarchy-capture-screenshot \(fullscreen\|region\|windows\)$\|SHIFT + XF86MonBrightness.*brightness-keyboard\|^device\|focus apple' <<<"$output" || + fail "no Mac default without a Mac or without omarchy-mac" "$output" + bound "$output" "SHIFT + XF86MonBrightnessUp" "omarchy-brightness-display 100%" || fail "Omarchy's Shift+brightness stays elsewhere" "$output" + bound "$output" "PRINT" "omarchy-capture-screenshot" || fail "Omarchy's own capture bind stays" "$output" +done +pass "off a Mac, or on a Mac without omarchy-mac, the runtime alone adds nothing of the Mac's" + +keyboards="apple-spi-keyboard,apple-mtp-keyboard" +focus_then() { + [[ $'\n'"$apple"$'\n' == *$'\nbind\t'"$1"$'\tfocus '"$keyboards"$'\nbind\t'"$1"$'\t'"$2"$'\n'* ]] || + fail "$1 focuses the built-in screen first when typed on the MacBook keyboard" "$apple" +} +focus_then "SUPER + SPACE" "omarchy-menu toggle" +focus_then "SUPER + ESCAPE" "omarchy-menu toggle system" +focus_then "SUPER + K" "omarchy-menu-keybindings" +focus_then "SUPER + CTRL + A" "omarchy-shell shell toggle omarchy.audio" +focus_then "SUPER + CTRL + code:10" "omarchy-shell -q shell togglePanelAt right 1" +for keys in "SUPER + RETURN" "SUPER + CTRL + E" "SUPER + CTRL + V" "PRINT" "SUPER + F12" "SUPER + 1"; do + ! grep -qxF "bind"$'\t'"$keys"$'\tfocus '"$keyboards" <<<"$apple" || fail "$keys keeps today's focus" "$apple" +done +pass "menus and panels typed on the MacBook keyboard focus its screen first; apps, pasting pickers and captures don't" + +# A user's rebind goes through the same decoration: a menu keeps the focus +# bind, an app gets none. +rebinds=$(load_config apple "$packaged" $'o.rebind("SUPER + ESCAPE", "System menu", "omarchy-menu toggle system")\no.rebind("SUPER + SHIFT + F", "File manager", "uwsm-app -- flea")') || + fail "the config loads with the user's rebinds" "$rebinds" +[[ $rebinds == *$'unbind\tSUPER + ESCAPE\nbind\tSUPER + ESCAPE\tfocus '"$keyboards"$'\nbind\tSUPER + ESCAPE\tomarchy-menu toggle system'* ]] || + fail "rebinding a menu keeps the built-in screen focus" "$rebinds" +[[ $rebinds == *$'unbind\tSUPER + SHIFT + F\nbind\tSUPER + SHIFT + F\tuwsm-app -- flea'* ]] || + fail "rebinding an app opens it on the focused screen" "$rebinds" +pass "the user's rebinds keep the menu and app split" + +grep -qx -- '-- omarchy_default_bindings = false' "$runtime/config/hypr/hyprland.lua" || fail "hyprland.lua documents the default bindings switch" +nodefaults=$(NO_DEFAULT_BINDINGS=1 load_config apple) || fail "the config loads without default bindings" "$nodefaults" +! grep -q 'Apple SMC\|SUPER + F12' <<<"$nodefaults" || fail "omarchy_default_bindings = false drops the Mac's binds too" "$nodefaults" +pass "omarchy_default_bindings = false turns the Mac's binds off with Omarchy's" + +# The keybindings menu shows the brightness keys as the F1 and F2 they are. +[[ $(<"$packaged/default/omarchy/platform/key-names") == $'XF86MonBrightnessUp F2\nXF86MonBrightnessDown F1' ]] || + fail "omarchy-mac names the brightness keys F2 and F1" +grep -Fq 'default/omarchy/platform/key-names' "$runtime/bin/omarchy-menu-keybindings" || fail "the keybindings menu reads the platform's key names" +pass "the keybindings menu names the Mac's brightness keys as its F-keys" + +# The bar keeps clear of each MacBook panel's notch. +PACKAGED="$packaged" RUNTIME="$runtime" node <<'JS' +const fs = require('fs') +const model = require(process.env.RUNTIME + '/shell/plugins/bar/BarModel.js') +const cutouts = model.parseCutouts(fs.readFileSync(process.env.PACKAGED + '/default/shell/platform/display-cutouts.json', 'utf8')) +const expect = (ok, what) => { if (!ok) { console.error('not ok - ' + what); process.exit(1) } } +expect(cutouts.length === 4, 'four MacBook panels') +expect(model.notchFloor(cutouts, 'top', 'eDP-1', 1728, 1117, 2, 0) === 32, '16" MacBook Pro at scale 2: 32 px') +expect(model.notchFloor(cutouts, 'top', 'eDP-1', 1512, 982, 2, 0) === 32, '14" MacBook Pro at scale 2: 32 px') +expect(model.notchFloor(cutouts, 'top', 'eDP-1', 1280, 832, 2, 0) === 28, 'MacBook Air 13.6" at scale 2: 28 px') +expect(model.notchFloor(cutouts, 'top', 'DP-1', 1728, 1117, 2, 0) === 0, 'external monitors keep no floor') +expect(model.notchFloor(cutouts, 'bottom', 'eDP-1', 1728, 1117, 2, 0) === 0, 'a bottom bar keeps no floor') +expect(model.centerBesideRight(cutouts, 'top', 'eDP-1', 1728, 1117, 2), 'the center section moves beside the right') +JS +pass "the bar keeps clear of the notch on every MacBook panel omarchy-mac describes" + +# The focus bind moves to the built-in screen only when another screen has it. +focus_with() { + PATH="$tmpdir/apple-bin:$PATH" lua - "$packaged/default/hypr/platform/defaults/apple.lua" "$1" <<'LUA' +local file, layout = arg[1], arg[2] +local focus +hl = { + dsp = { focus = function(args) return args end, exec_cmd = function(cmd) return cmd end }, + dispatch = function(dispatcher) print("focus " .. dispatcher.monitor) end, + device = function() end, + bind = function(_, dispatcher, opts) if opts and opts.device then focus = dispatcher end end, + get_monitors = function() + local monitors = {} + for name, focused in layout:gmatch("([%w%-]+)(%*?)") do + monitors[#monitors + 1] = { name = name, focused = focused == "*" } + end + return monitors + end, +} +o = { bind_decorators = {}, bind = function() end, shell_succeeds = function() return true end } +_G.omarchy_default_bindings = false +dofile(file) +o.bind_decorators[1]("SUPER + SPACE", "omarchy-menu toggle", {}) +focus() +print("done") +LUA +} +[[ $(focus_with "DP-1* eDP-1") == $'focus eDP-1\ndone' ]] || fail "focus moves to the built-in screen from an external one" +[[ $(focus_with "DP-1 eDP-1*") == "done" ]] || fail "focus stays when the built-in screen already has it" +[[ $(focus_with "DP-1* HDMI-A-1") == "done" ]] || fail "clamshell: no built-in screen, focus stays" +[[ $(focus_with "eDP-1*") == "done" ]] || fail "built-in screen alone: nothing to do" +pass "the built-in screen focus handles external, built-in only and clamshell layouts" diff --git a/test/shell.d/bar-notch-test.sh b/test/shell.d/bar-notch-test.sh old mode 100755 new mode 100644 index a5f0d26b3e7..c5a96711e51 --- a/test/shell.d/bar-notch-test.sh +++ b/test/shell.d/bar-notch-test.sh @@ -11,91 +11,90 @@ const barSource = fs.readFileSync(root + '/shell/plugins/bar/Bar.qml', 'utf8') const styleSource = fs.readFileSync(root + '/shell/Commons/Style.qml', 'utf8') const shellJson = fs.readFileSync(root + '/config/omarchy/shell.json', 'utf8') -// Panels measured on real hardware use their camera-cutout depth, which is -// shallower than the strip the panel exposes above its 16:10 area. -assertEqual(bar.notchHeight('eDP-1', 1512, 982, 2), 32, 'MacBook Pro 14" at scale 2 uses its measured 32px cutout') -assertEqual(bar.notchHeight('eDP-1', 1890, 1227, 1.6), 40, 'MacBook Pro 14" at scale 1.6 uses its measured cutout') -assertEqual(bar.notchHeight('eDP-1', 1728, 1117, 2), 32, 'MacBook Pro 16" at scale 2 uses the cutout inferred from the 14"') -assertEqual(bar.notchHeight('eDP-1', 1280, 832, 2), 28, 'MacBook Air 13.6" at scale 2 uses its density-scaled cutout') -assertEqual(bar.notchHeight('eDP-1', 1600, 1040, 1.6), 35, 'MacBook Air 13.6" at scale 1.6 uses its density-scaled cutout') -assertEqual(bar.notchHeight('eDP-1', 1440, 932, 2), 28, 'MacBook Air 15" at scale 2 uses its density-scaled cutout') +// A platform package's cutout description, as it would ship it. +const cutouts = bar.parseCutouts(JSON.stringify({ panels: [ + { connector: 'eDP', width: 3024, height: 1964, top: 64 }, + { connector: 'eDP', width: 2560, height: 1664, top: 56 } +] })) +assertEqual(cutouts.length, 2, 'a well-formed description yields its panels') -// Unmeasured panels fall back to the full strip above the 16:10 area, which -// errs taller than the cutout, never shorter. The display scale applies to -// both axes, so the same panel yields its strip at any scale. -assertEqual(bar.notchHeight('eDP-1', 1536, 990, 2), 30, 'an unmeasured notched panel falls back to its strip') +// Malformed descriptions and entries are dropped, never guessed at. +for (const text of ['', 'not json', '[]', '{}', '{"panels": {}}']) + assertDeepEqual(bar.parseCutouts(text), [], `an unusable description (${JSON.stringify(text)}) yields no cutouts`) +assertDeepEqual(bar.parseCutouts(JSON.stringify({ panels: [ + { width: 3024, height: 1964, top: 64 }, + { connector: '', width: 3024, height: 1964, top: 64 }, + { connector: 'eDP', width: 0, height: 1964, top: 64 }, + { connector: 'eDP', width: 3024, height: 1964, top: 0 }, + { connector: 'eDP', width: 3024, height: 1964, top: 1964 }, + null +] })), [], 'entries without a connector, a size or a sensible cutout are dropped') -assertEqual(bar.notchHeight('eDP-1', 1280, 800, 2), 0, 'an exactly 16:10 panel (M1 Air) has no notch') -assertEqual(bar.notchHeight('DP-1', 1512, 982, 2), 0, 'external monitors never report a notch') -assertEqual(bar.notchHeight('eDP-1', 982, 1512, 2), 0, 'a rotated panel is not mistaken for a notch') -assertEqual(bar.notchHeight('eDP-1', 1128, 752, 2), 0, 'a 3:2 panel is not mistaken for a notch') -assertEqual(bar.notchHeight('eDP-1', 0, 0, 2), 0, 'degenerate screen sizes report no notch') -assertEqual(bar.notchHeight('', 1512, 982, 2), 0, 'a missing screen name reports no notch') -assertEqual(bar.notchHeight('eDP-1', 1512, 982, 0), 37, 'a missing scale still yields the strip fallback') +// A cutout is described in physical rows; the bar works in logical pixels at +// any scale, and rounding at fractional scales still matches. +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1512, 982, 2), 32, 'a 64-row cutout is 32 px at scale 2') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1890, 1227, 1.6), 40, 'a fractional scale still matches its panel') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 3024, 1964, 1), 64, 'scale 1 uses the rows as they are') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 3024, 1964, 0), 64, 'a missing scale reads as 1') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1280, 832, 2), 28, 'each described panel gets its own cutout') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1512, 945, 2), 0, 'the same panel with its cutout rows hidden has none') +assertEqual(bar.cutoutTop(cutouts, 'DP-1', 1512, 982, 2), 0, 'another connector with the same mode has none') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1728, 1117, 2), 0, 'a panel nobody described has none') +assertEqual(bar.cutoutTop([], 'eDP-1', 1512, 982, 2), 0, 'with no description no panel has a cutout') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 0, 0, 2), 0, 'degenerate screen sizes have none') -// The bar must gate the floor on Apple Silicon and only floor top bars — -// the strip formula alone would also match some non-Apple panels, and a -// bar on any other edge does not cover the notch. -assert( - /notchFloor: root\.appleSiliconHost && root\.position === "top"/.test(barSource), - 'bar floors only top bars on Apple Silicon machines' -) +// Only a top bar on a described panel has a floor; a calibrated [bar] +// notch-height replaces the described value there and nowhere else. +assertEqual(bar.notchFloor(cutouts, 'top', 'eDP-1', 1512, 982, 2, 0), 32, 'a top bar floors at the cutout') +for (const position of ['bottom', 'left', 'right']) + assertEqual(bar.notchFloor(cutouts, position, 'eDP-1', 1512, 982, 2, 0), 0, `a ${position} bar has no floor`) +assertEqual(bar.notchFloor(cutouts, 'top', 'eDP-1', 1512, 982, 2, 36), 36, 'a calibrated notch-height overrides the described floor') +assertEqual(bar.notchFloor(cutouts, 'top', 'DP-1', 2560, 1440, 1, 36), 0, 'a calibrated notch-height never floors an external monitor') +assertEqual(bar.notchFloor([], 'top', 'eDP-1', 1512, 982, 2, 36), 0, 'a calibrated notch-height never floors a panel without a cutout') +assertEqual(bar.notchFloor(cutouts, 'top', 'eDP-1', 1512, 982, 2, 'x'), 32, 'an unreadable calibration falls back to the described floor') + +// The bar reads the description from the packaged tree, not from a detector. assert( - /omarchyPath \+ "\/bin\/omarchy-hw-apple-silicon"/.test(barSource), - 'bar probes Apple Silicon through OMARCHY_PATH, not PATH' + /path: root\.packagedPath \+ "\/default\/shell\/platform\/display-cutouts\.json"/.test(barSource) && + /root\.displayCutouts = BarModel\.parseCutouts\(text\(\)\)/.test(barSource), + 'bar reads the platform package\'s cutout description' ) +// The notch needs no detector; on quattro-upstream the microphone widget still +// asks one (appleSiliconHost), which the notch never reads. +assert(!/notchFloor[^\n]*appleSiliconHost|centerBesideRight[^\n]*appleSiliconHost/.test(barSource), 'the notch asks no hardware detector') assert( - /BarModel\.notchHeight\(screen\.name, screen\.width, screen\.height, screen\.devicePixelRatio\)/.test(barSource), - 'bar derives the floor from its own screen geometry' + /BarModel\.notchFloor\(root\.displayCutouts, root\.position, screen\.name, screen\.width, screen\.height, screen\.devicePixelRatio, Style\.bar\.notchHeight\)/.test(barSource), + 'bar derives the floor from its own screen geometry and the calibration' ) assert( - /implicitHeight: root\.vertical \? 0 : Math\.max\(root\.barSize, notchFloor\)/.test(barSource), - 'bar height is floored at the notch, never shrunk to it' + /readonly property int thickness: root\.vertical \? root\.barSize : Math\.max\(root\.barSize, notchFloor\)/.test(barSource) && + /implicitHeight: root\.vertical \? 0 : thickness/.test(barSource), + 'bar height is floored at the cutout, never shrunk to it' ) +// A hidden bar parks by that same thickness (bar-test.sh), so a floored top +// bar leaves no strip of itself on screen. -// A calibrated [bar] notch-height wins over the derived value, and must not -// scale with the font — it describes physical pixels beside the camera. -assert( - /Style\.bar\.notchHeight > 0[\s\S]{0,80}\? Style\.bar\.notchHeight/.test(barSource), - 'a calibrated notch-height overrides the derived floor' -) +// The calibration must not scale with the font: it describes physical pixels +// beside the camera. assert( /notchHeight:[\s\S]{0,240}barOverrides\["notch-height"\]/.test(styleSource) && !/barToken\("notch-height"/.test(styleSource), 'notch-height is read raw, not through the font-scaled bar tokens' ) -// Every notched panel moves the center section beside the right one, at any scale. -const notchedPanels = [ - ['MacBook Pro 14"', 3024, 1964], - ['MacBook Pro 16"', 3456, 2234], - ['MacBook Air 13.6"', 2560, 1664], - ['MacBook Air 15"', 2880, 1864] -] -for (const [name, width, height] of notchedPanels) { - for (const scale of [1, 2]) { - assertEqual( - bar.centerBesideRight(true, 'top', 'eDP-1', width / scale, height / scale, scale), - true, - `${name} at scale ${scale} draws the center section beside the right one` - ) - } -} -assertEqual(bar.centerBesideRight(true, 'top', 'eDP-1', 1890, 1228, 1.6), true, 'a fractional scale still moves the center section') - -// Everything else keeps the configured layout. -assertEqual(bar.centerBesideRight(true, 'top', 'USB-2', 3440, 1440, 1), false, 'an external monitor on a notched Mac keeps the center section') -assertEqual(bar.centerBesideRight(true, 'top', 'DP-1', 1728, 1117, 2), false, 'an external monitor shaped like a notched panel keeps the center section') -assertEqual(bar.centerBesideRight(false, 'top', 'eDP-1', 1728, 1117, 2), false, 'a machine that is not Apple Silicon keeps the center section') +// A top bar on a described panel draws its center section beside the right +// one; everything else keeps the configured layout. +for (const scale of [1, 2]) + assertEqual(bar.centerBesideRight(cutouts, 'top', 'eDP-1', 3024 / scale, 1964 / scale, scale), true, `a described panel at scale ${scale} moves the center section`) +assertEqual(bar.centerBesideRight(cutouts, 'top', 'USB-2', 3440, 1440, 1), false, 'an external monitor keeps the center section') +assertEqual(bar.centerBesideRight([], 'top', 'eDP-1', 1512, 982, 2), false, 'a machine without a description keeps the center section') for (const position of ['bottom', 'left', 'right']) - assertEqual(bar.centerBesideRight(true, position, 'eDP-1', 1728, 1117, 2), false, `a ${position} bar keeps the center section`) -assertEqual(bar.centerBesideRight(true, 'top', 'eDP-1', 1728, 1080, 2), false, 'a notched panel with its strip hidden keeps the center section') -assertEqual(bar.centerBesideRight(true, 'top', 'eDP-1', 1280, 800, 2), false, 'a panel without a notch keeps the center section') + assertEqual(bar.centerBesideRight(cutouts, position, 'eDP-1', 1512, 982, 2), false, `a ${position} bar keeps the center section`) // The move is per screen and draw-time only: the user's layout is read as // configured, and the center entries keep their order and their own region. assert( - /centerBesideRight: BarModel\.centerBesideRight\(root\.appleSiliconHost, root\.position, screen\.name, screen\.width, screen\.height, screen\.devicePixelRatio\)/.test(barSource), + /centerBesideRight: BarModel\.centerBesideRight\(root\.displayCutouts, root\.position, screen\.name, screen\.width, screen\.height, screen\.devicePixelRatio\)/.test(barSource), 'each bar surface decides the move from its own screen' ) assert( diff --git a/test/shell.d/bar-test.sh b/test/shell.d/bar-test.sh index 0b06258a4ca..e5f5b10971c 100644 --- a/test/shell.d/bar-test.sh +++ b/test/shell.d/bar-test.sh @@ -58,8 +58,8 @@ assert( ) for (const edge of ['top', 'bottom', 'left', 'right']) { assert( - new RegExp(`${edge}: root\\.barHidden && root\\.position === "${edge}" \\? -root\\.barSize : 0`).test(barSource), - `a hidden bar parks past the ${edge} edge` + new RegExp(`${edge}: root\\.barHidden && root\\.position === "${edge}" \\? -barWindow\\.thickness : 0`).test(barSource), + `a hidden bar parks past the ${edge} edge by its whole thickness` ) } diff --git a/test/shell.d/hw-laptop-test.sh b/test/shell.d/hw-laptop-test.sh index 7924dd22299..bc6679dc296 100755 --- a/test/shell.d/hw-laptop-test.sh +++ b/test/shell.d/hw-laptop-test.sh @@ -99,8 +99,4 @@ grep -F 'switch:on:Lid Switch' "$bindings" >/dev/null || fail "lid-close still binds the generic lid switch" grep -F 'switch:off:Lid Switch' "$bindings" >/dev/null || fail "clamshell still binds the generic lid switch" -grep -F 'switch:on:Apple SMC power/lid events' "$bindings" >/dev/null || - fail "lid-close also binds the Apple SMC lid switch" -grep -F 'switch:off:Apple SMC power/lid events' "$bindings" >/dev/null || - fail "clamshell also binds the Apple SMC lid switch" -pass "Hyprland binds both the generic and Apple SMC lid switches" +pass "Hyprland binds the generic lid switch (omarchy-mac binds the Apple SMC one; see apple-platform-hooks-test.sh)" diff --git a/test/shell.d/hyprland-apple-workspace-gesture-test.sh b/test/shell.d/hyprland-apple-workspace-gesture-test.sh index e07c06de6a3..7c943b1a7d7 100755 --- a/test/shell.d/hyprland-apple-workspace-gesture-test.sh +++ b/test/shell.d/hyprland-apple-workspace-gesture-test.sh @@ -7,16 +7,12 @@ require_command lua tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT -# The runtime as installed beside omarchy-mac, which puts its gesture in the -# runtime's platform directory. -omarchy="$tmpdir/omarchy" -mkdir -p "$omarchy" -for entry in "$ROOT"/*; do - [[ ${entry##*/} == "default" ]] || ln -s "$entry" "$omarchy/${entry##*/}" -done -cp -R "$ROOT/default" "$omarchy/default" -mkdir -p "$omarchy/default/hypr/platform" -cp "$ROOT/packages/omarchy-mac/share/omarchy/default/hypr/platform/apple-gestures.lua" "$omarchy/default/hypr/platform/" +# The runtime under test (this tree, or another layout's through +# OMARCHY_TEST_RUNTIME) with omarchy-mac staged, whose gesture Omarchy loads +# from the packaged tree. +runtime=${OMARCHY_TEST_RUNTIME:-$ROOT} +"$ROOT/packages/omarchy-mac/install" "$tmpdir/pkg" >/dev/null +omarchy=$tmpdir/pkg/usr/share/omarchy mkdir -p "$tmpdir/apple-bin" "$tmpdir/other-bin" printf '#!/bin/sh\nexit 0\n' >"$tmpdir/apple-bin/omarchy-hw-apple-silicon" @@ -29,12 +25,12 @@ chmod +x "$tmpdir"/*-bin/omarchy-hw-apple-silicon # gesture on the same fingers and mods is refused once an earlier one covers # its direction or axis, and the refusal is a config error. load_config() { - local platform="$1" edit="${2:-}" omarchy_path="${OMARCHY_UNDER_TEST:-$omarchy}" packaged_path="${PACKAGED_UNDER_TEST:-$omarchy}" + local platform="$1" edit="${2:-}" omarchy_path="${OMARCHY_UNDER_TEST:-$runtime}" packaged_path="${PACKAGED_UNDER_TEST:-$omarchy}" local home home=$(mktemp -d "$tmpdir/home.XXXXXX") mkdir -p "$home/.config" - cp -R "$ROOT/config/hypr" "$home/.config/hypr" + cp -R "$runtime/config/hypr" "$home/.config/hypr" [[ -z $edit ]] || printf '%s\n' "$edit" >>"$home/.config/hypr/input.lua" HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_STATE_HOME="$home/.local/state" OMARCHY_PATH="$omarchy_path" OMARCHY_PACKAGED_PATH="$packaged_path" \ @@ -125,21 +121,21 @@ swipe="3 horizontal workspace" [[ $(load_config apple) == "$swipe" ]] || fail "a Mac swipes between workspaces with three fingers" "$(load_config apple)" [[ -z $(load_config other) ]] || fail "x86 and Snapdragon keep no default gesture" "$(load_config other)" -[[ -z $(OMARCHY_UNDER_TEST=$ROOT PACKAGED_UNDER_TEST=$ROOT load_config apple) ]] || - fail "the runtime alone carries no Mac gesture" "$(OMARCHY_UNDER_TEST=$ROOT PACKAGED_UNDER_TEST=$ROOT load_config apple)" +[[ -z $(PACKAGED_UNDER_TEST=$tmpdir/none load_config apple) ]] || + fail "the runtime alone carries no Mac gesture" "$(PACKAGED_UNDER_TEST=$tmpdir/none load_config apple)" [[ $(OMARCHY_UNDER_TEST=$ROOT load_config apple) == "$swipe" ]] || fail "a development checkout in OMARCHY_PATH keeps the packaged gesture" "$(OMARCHY_UNDER_TEST=$ROOT load_config apple)" -[[ ! -e $ROOT/default/hypr/apple-gestures.lua && -z $(find "$ROOT/default/hypr" -path '*platform*') ]] || +[[ ! -e $ROOT/default/hypr/apple-gestures.lua && ! -e $ROOT/default/hypr/platform ]] || fail "the Mac gesture is omarchy-mac's, not the runtime's" pass "three-finger workspace swipe is on by default on Macs only, from omarchy-mac" -shipped_line=$(sed -nE 's/^-- (hl\.gesture\(\{ fingers = 3, direction = "horizontal".*)$/\1/p' "$ROOT/config/hypr/input.lua") +shipped_line=$(sed -nE 's/^-- (hl\.gesture\(\{ fingers = 3, direction = "horizontal".*)$/\1/p' "$runtime/config/hypr/input.lua") [[ -n $shipped_line ]] || fail "input.lua still ships the workspace gesture example" output=$(load_config apple "$shipped_line") [[ $output == "$swipe" ]] || fail "uncommenting the shipped example keeps one gesture and no config error" "$output" pass "uncommenting the shipped example does not duplicate the gesture" -focus_lines=$(sed -nE 's/^-- (hl\.gesture\(\{ fingers = 3, direction = "(left|right)".*)$/\1/p' "$ROOT/config/hypr/input.lua") +focus_lines=$(sed -nE 's/^-- (hl\.gesture\(\{ fingers = 3, direction = "(left|right)".*)$/\1/p' "$runtime/config/hypr/input.lua") (( $(wc -l <<<"$focus_lines") == 2 )) || fail "input.lua still ships the focus gesture examples" "$focus_lines" output=$(load_config apple "$focus_lines") [[ $output == $'3 left function\n3 right function' ]] || fail "three-finger focus gestures replace the workspace swipe" "$output" diff --git a/test/shell.d/hyprland-binding-conflicts-test.sh b/test/shell.d/hyprland-binding-conflicts-test.sh index c3afa7191f9..a7136e9573c 100755 --- a/test/shell.d/hyprland-binding-conflicts-test.sh +++ b/test/shell.d/hyprland-binding-conflicts-test.sh @@ -183,13 +183,14 @@ pass "allowed duplicate chords are still stacked on purpose" fail "release bindings keep their own signature" pass "press and release bindings on one key do not read as a conflict" -# On Apple Silicon a menu also gets a bind scoped to the built-in keyboard that -# runs ahead of it. It adds to the menu rather than competing with it. +# On Apple Silicon omarchy-mac gives a menu a bind scoped to the built-in +# keyboard that runs ahead of it. It adds to the menu rather than competing with it. apple_bin="$tmpdir/apple-bin" mkdir -p "$apple_bin" printf '#!/bin/bash\nexit 0\n' >"$apple_bin/omarchy-hw-apple-silicon" chmod +x "$apple_bin/omarchy-hw-apple-silicon" -apple_bindings=$(PATH="$apple_bin:$stub_bin:$PATH" list_bindings "$home") +"$ROOT/packages/omarchy-mac/install" "$tmpdir/mac" >/dev/null +apple_bindings=$(OMARCHY_PACKAGED_PATH="$tmpdir/mac/usr/share/omarchy" PATH="$apple_bin:$stub_bin:$PATH" list_bindings "$home") grep -q $'^SUPER+SPACE (apple-spi-keyboard,apple-mtp-keyboard)\t' <<<"$apple_bindings" || fail "Apple Silicon menus carry a built-in keyboard bind" "$apple_bindings" grep -q $'^SUPER+RETURN (apple-spi-keyboard,apple-mtp-keyboard)\t' <<<"$apple_bindings" && diff --git a/test/shell.d/hyprland-builtin-keyboard-screen-test.sh b/test/shell.d/hyprland-builtin-keyboard-screen-test.sh deleted file mode 100755 index e1d7c5dac6f..00000000000 --- a/test/shell.d/hyprland-builtin-keyboard-screen-test.sh +++ /dev/null @@ -1,157 +0,0 @@ -#!/bin/bash -set -euo pipefail -source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" - -require_command lua - -# Prints one line per hl.bind: keys, then "focus " for a bind scoped to -# keyboards, or the exec command for a launcher. -load_bindings() { - local apple="$1" - - lua - "$ROOT" "$apple" <<'LUA' -local root, apple = arg[1], arg[2] == "1" -local function proxy() - return setmetatable({}, { - __index = function(self, key) - local value = proxy() - rawset(self, key, value) - return value - end, - __call = function() - return {} - end, - }) -end -hl = { - dsp = proxy(), - on = function() end, - unbind = function(keys) - print("unbind\t" .. keys) - end, - bind = function(keys, dispatcher, opts) - if opts and opts.device then - assert(opts.device.inclusive == true, "focus bind only matches the listed keyboards") - assert(dispatcher == o.focus_builtin_screen, "focus bind runs the built-in screen focus") - print(keys .. "\tfocus " .. table.concat(opts.device.list, ",")) - elseif type(dispatcher) == "table" and dispatcher.cmd then - print(keys .. "\t" .. dispatcher.cmd) - else - print(keys .. "\tother") - end - end, -} -hl.dsp.exec_cmd = function(cmd) - return { cmd = cmd } -end -dofile(root .. "/default/hypr/helpers.lua") -local probes = 0 -o.shell_succeeds = function(command) - if command == "omarchy-hw-apple-silicon" then - probes = probes + 1 - end - return apple -end -o.preinstalled_bindings_enabled = function() - return true -end -dofile(root .. "/default/hypr/bindings/applications.lua") -dofile(root .. "/default/hypr/bindings/utilities.lua") -dofile(root .. "/default/hypr/bindings/clipboard.lua") -dofile(root .. "/default/hypr/bindings/tiling.lua") -o.bind("SUPER + F9", "Power menu (locked)", "omarchy-menu toggle system", { locked = true }) -o.rebind("SUPER + SHIFT + F", "File manager", { launch = "flea" }) -o.rebind("SUPER + ESCAPE", "System menu", "omarchy-menu toggle system") -assert(probes <= 2, "hardware probe runs once per config load, not per bind (" .. probes .. ")") -LUA -} - -apple=$(load_bindings 1) || fail "bindings load on Apple Silicon" "$apple" -other=$(load_bindings 0) || fail "bindings load elsewhere" "$other" - -keyboards="apple-spi-keyboard,apple-mtp-keyboard" - -expect_focus_then() { - local keys="$1" command="$2" - local want=$'\n'"$keys"$'\tfocus '"$keyboards"$'\n'"$keys"$'\t'"$command"$'\n' - - [[ $'\n'"$apple"$'\n' == *"$want"* ]] || fail "$keys focuses the built-in screen first when typed on the MacBook keyboard" "$apple" -} - -expect_focus_then "SUPER + SPACE" "omarchy-menu toggle" -expect_focus_then "SUPER + ALT + SPACE" "omarchy-menu toggle apps" -expect_focus_then "SUPER + ESCAPE" "omarchy-menu toggle system" -expect_focus_then "SUPER + K" "omarchy-menu-keybindings" -expect_focus_then "SUPER + CTRL + A" "omarchy-shell shell toggle omarchy.audio" -expect_focus_then "SUPER + CTRL + W" "omarchy-shell shell toggle omarchy.network" -expect_focus_then "SUPER + CTRL + code:10" "omarchy-shell -q shell togglePanelAt right 1" -pass "menus and panels typed on the MacBook keyboard focus the built-in screen first" - -expect_no_focus() { - local keys="$1" command="$2" - - grep -qxF "$keys"$'\t'"$command" <<<"$apple" || fail "$keys still binds $command" "$apple" - grep -qxF "$keys"$'\tfocus '"$keyboards" <<<"$apple" && fail "$keys opens on the focused screen" "$apple" - return 0 -} - -expect_no_focus "SUPER + RETURN" "omarchy-launch-terminal" -expect_no_focus "SUPER + SHIFT + RETURN" "omarchy-launch-browser" -expect_no_focus "SUPER + SHIFT + A" "omarchy-launch-webapp 'https://chatgpt.com'" -expect_no_focus "SUPER + SHIFT + ALT + M" "omarchy-launch-or-focus-tui 'cliamp'" -expect_no_focus "SUPER + SHIFT + W" "uwsm-app -- omawrite" -expect_no_focus "SUPER + CTRL + T" "omarchy-launch-tui 'btop'" -expect_no_focus "SUPER + CTRL + Q" "omacalc" -expect_no_focus "SUPER + SHIFT + CTRL + A" "omarchy-agent --pick" -pass "apps open on the focused screen from any keyboard" - -expect_no_focus "SUPER + CTRL + E" "omarchy-shell shell toggle omarchy.emojis" -expect_no_focus "SUPER + CTRL + V" "omarchy-shell shell toggle omarchy.clipboard" -pass "pickers that paste into the focused window stay on its screen" - -[[ $apple == *$'unbind\tSUPER + ESCAPE\nSUPER + ESCAPE\tfocus '"$keyboards"$'\nSUPER + ESCAPE\tomarchy-menu toggle system'* ]] || - fail "rebinding a menu keeps the built-in screen focus" "$apple" -[[ $apple == *$'unbind\tSUPER + SHIFT + F\nSUPER + SHIFT + F\tuwsm-app -- flea'* ]] || - fail "rebinding an app opens it on the focused screen" "$apple" -pass "rebinding keeps the menu and app split" - -for keys in "SUPER + F9" "SUPER + BACKSPACE" "SUPER + CTRL + N" "PRINT" "ALT + PRINT" "SUPER + F12" "SUPER + LEFT" "SUPER + 1" "SUPER + SHIFT + ALT + comma"; do - grep -qxF "$keys"$'\tfocus '"$keyboards" <<<"$apple" && fail "$keys is not a menu and keeps today's focus" "$apple" -done -pass "toggles, captures, notifications, locked and tiling binds keep today's focus" - -grep -q $'\tfocus ' <<<"$other" && fail "no keyboard-scoped binds off Apple Silicon" "$other" -grep -qxF $'SUPER + SPACE\tomarchy-menu toggle' <<<"$other" || fail "off Apple Silicon the launchers still bind" "$other" -pass "no keyboard-scoped binds off Apple Silicon" - -focus_with() { - lua - "$ROOT" "$1" <<'LUA' -local root, layout = arg[1], arg[2] -hl = { - dsp = { - focus = function(args) - return args - end, - }, - dispatch = function(dispatcher) - print("focus " .. dispatcher.monitor) - end, - get_monitors = function() - local monitors = {} - for name, focused in layout:gmatch("([%w%-]+)(%*?)") do - monitors[#monitors + 1] = { name = name, focused = focused == "*" } - end - return monitors - end, -} -dofile(root .. "/default/hypr/helpers.lua") -o.focus_builtin_screen() -print("done") -LUA -} - -[[ $(focus_with "DP-1* eDP-1") == $'focus eDP-1\ndone' ]] || fail "focus moves to the built-in screen from an external one" -[[ $(focus_with "DP-1 eDP-1*") == "done" ]] || fail "focus stays when the built-in screen already has it" -[[ $(focus_with "DP-1* HDMI-A-1") == "done" ]] || fail "clamshell: no built-in screen, focus stays" -[[ $(focus_with "eDP-1*") == "done" ]] || fail "built-in screen alone: nothing to do" -pass "built-in screen focus handles external, built-in only and clamshell layouts" diff --git a/test/shell.d/hyprland-input-default-test.sh b/test/shell.d/hyprland-input-default-test.sh index 2706e966f3f..80b6c1bb825 100755 --- a/test/shell.d/hyprland-input-default-test.sh +++ b/test/shell.d/hyprland-input-default-test.sh @@ -4,29 +4,19 @@ source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" require_command lua lua - "$ROOT" <<'LUA' local root = arg[1] -local devices = {} +local devices = 0 local config hl = { config = function(value) config = value end, - device = function(value) devices[value.name] = value end, + device = function() devices = devices + 1 end, } o = { window = function() end } dofile(root .. "/default/hypr/input.lua") assert(config.input.touchpad.tap_to_click == nil, "no global tap override") assert(config.input.touchpad.natural_scroll == false, "traditional scrolling") -assert(devices["apple-mtp-multi-touch"].tap_to_click == false, "Apple tap disabled") -local count = 0 -for _ in pairs(devices) do count = count + 1 end -assert(count == 2, "only Apple devices are overridden") -assert(devices["apple-spi-trackpad"].tap_to_click == false, "Apple SPI tap disabled") -local user = assert(io.open(root .. "/config/hypr/input.lua")):read("*a") -for override in user:gmatch("%-%- (hl%.device%([^\n]+)") do - assert(load(override))() -end -assert(devices["apple-mtp-multi-touch"].tap_to_click == true, "MTP user override wins") -assert(devices["apple-spi-trackpad"].tap_to_click == true, "SPI user override wins") +assert(devices == 0, "the runtime overrides no device; a platform package's defaults may") LUA -pass "Apple-only touchpad default and documented user override" +pass "the shipped touchpad default overrides no device" default_input="$ROOT/default/hypr/input.lua" user_input="$ROOT/config/hypr/input.lua" diff --git a/test/shell.d/hyprland-platform-defaults-test.sh b/test/shell.d/hyprland-platform-defaults-test.sh new file mode 100755 index 00000000000..31f93fc61df --- /dev/null +++ b/test/shell.d/hyprland-platform-defaults-test.sh @@ -0,0 +1,278 @@ +#!/bin/bash +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command lua + +# Hyprland loads a platform package's own defaults from default/hypr/platform in +# the packaged tree, after the user's files, so the user's settings can keep them +# out. The fixture below is such a default: a three-finger sideways workspace +# swipe that steps aside for a gesture the user set. + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +packaged="$tmpdir/packaged" +mkdir -p "$packaged/default/hypr/platform" +cat >"$packaged/default/hypr/platform/fixture-gesture.lua" <<'LUA' +if _G.fixture_gesture == false then + return +end +for _, gesture in ipairs(o.registered_gestures or {}) do + if gesture.fingers == 3 and not gesture.modified and (gesture.direction == "horizontal" or gesture.direction == "left" or gesture.direction == "right") then + return + end +end +hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" }) +LUA + +# Loads the shipped hyprland.lua against a user's ~/.config/hypr and prints one +# line per gesture Hyprland accepted, then "error" for each one it rejected. +# The stub applies Hyprland 0.56's rule (CTrackpadGestures::addGesture): a +# gesture on the same fingers and mods is refused once an earlier one covers +# its direction or axis, and the refusal is a config error. +load_config() { + local edit="${1:-}" omarchy_path="${OMARCHY_UNDER_TEST:-$ROOT}" packaged_path="${PACKAGED_UNDER_TEST:-$packaged}" + local home + home=$(mktemp -d "$tmpdir/home.XXXXXX") + + mkdir -p "$home/.config" + cp -R "$ROOT/config/hypr" "$home/.config/hypr" + [[ -z $edit ]] || printf '%s\n' "$edit" >>"$home/.config/hypr/input.lua" + + HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_STATE_HOME="$home/.local/state" OMARCHY_PATH="$omarchy_path" OMARCHY_PACKAGED_PATH="$packaged_path" \ + lua <<'LUA' +local function proxy() + return setmetatable({}, { + __index = function(self, key) + local value = proxy() + rawset(self, key, value) + return value + end, + __call = function() + return {} + end, + }) +end + +local aliases = { l = "left", r = "right", u = "up", d = "down", horiz = "horizontal", vert = "vertical" } +local axes = { + left = "horizontal", right = "horizontal", horizontal = "horizontal", + up = "vertical", down = "vertical", vertical = "vertical", + swipe = "swipe", +} +local accepted = {} + +-- KeybindManager::stringToModMask: any string naming no modifier is mask zero. +local function mod_mask(mods) + local mask = {} + mods = (mods or ""):upper() + for name, bit in pairs({ SHIFT = "S", CAPS = "C", CTRL = "T", CONTROL = "T", ALT = "A", SUPER = "M", WIN = "M", META = "M" }) do + if mods:find(name, 1, true) then + mask[bit] = true + end + end + local bits = {} + for bit in pairs(mask) do + table.insert(bits, bit) + end + table.sort(bits) + return table.concat(bits) +end + +hl = setmetatable({ + dsp = proxy(), + gesture = function(gesture) + local direction = gesture.direction:lower() + direction = aliases[direction] or direction + local axis = axes[direction] + local mods = mod_mask(gesture.mods) + + for _, g in ipairs(accepted) do + if g.fingers == gesture.fingers and g.mods == mods and + (g.direction == axis or g.direction == direction or + ((axis == "horizontal" or axis == "vertical") and g.direction == "swipe")) then + print("error") + return + end + end + + table.insert(accepted, { fingers = gesture.fingers, direction = direction, mods = mods }) + local action = type(gesture.action) == "string" and gesture.action or "function" + print(gesture.fingers .. " " .. direction .. " " .. (mods ~= "" and "+" .. mods .. " " or "") .. action) + end, + get_config = function() return nil end, + get_active_window = function() return nil end, + get_monitors = function() return {} end, +}, { + __index = function() + return function() + return {} + end + end, +}) + +dofile(os.getenv("HOME") .. "/.config/hypr/hyprland.lua") +LUA +} + +swipe="3 horizontal workspace" + +[[ ! -e $ROOT/default/hypr/platform ]] || fail "Omarchy ships no platform defaults of its own" +[[ -z $(PACKAGED_UNDER_TEST=$ROOT load_config) ]] || fail "without a platform package there is no default gesture" "$(PACKAGED_UNDER_TEST=$ROOT load_config)" +pass "without a platform package, Omarchy adds no gesture" + +# OMARCHY_PATH stays this checkout, as in a development setup, while the +# platform package's file sits only in the packaged tree. +[[ $(load_config) == "$swipe" ]] || fail "a platform package's default loads from the packaged tree" "$(load_config)" +pass "a platform package's defaults load from the packaged tree, whatever OMARCHY_PATH points at" + +output=$(load_config 'hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })') +[[ $output == "$swipe" ]] || fail "the user's own gesture keeps the platform's out" "$output" +output=$(load_config 'hl.gesture({ fingers = 3, direction = "left", action = function() end })') +[[ $output == "3 left function" ]] || fail "a user gesture on the same axis keeps the platform's out" "$output" +output=$(load_config 'hl.gesture({ fingers = 4, direction = "horizontal", action = "workspace" })') +[[ $output == $'4 horizontal workspace +'"$swipe" ]] || fail "a gesture on other fingers keeps the platform's" "$output" +output=$(load_config 'hl.gesture({ fingers = 3, direction = "horizontal", mods = "SUPER", action = "move" })') +[[ $output == $'3 horizontal +M move +'"$swipe" ]] || fail "a gesture held with a modifier keeps the platform's" "$output" +for mods in '"NONE"' '""'; do + output=$(load_config 'hl.gesture({ fingers = 3, direction = "horizontal", mods = '"$mods"', action = "workspace" })') + [[ $output == "$swipe" ]] || fail "mods = $mods counts as no modifier" "$output" +done +pass "platform defaults load after the user's files and see the user's gestures" + +output=$(load_config 'fixture_gesture = false') +[[ -z $output ]] || fail "a setting in the user's files reaches the platform defaults" "$output" +pass "a setting in the user's files reaches the platform defaults" + +# ── early defaults: binds ──────────────────────────────────────────────────── + +# A platform package's early defaults (default/hypr/platform/defaults) load +# before Omarchy's. This fixture binds a chord Omarchy leaves free, takes over +# one Omarchy binds, and decorates menu binds with a bind that must run first. +early="$tmpdir/early" +mkdir -p "$early/default/hypr/platform/defaults" +cat >"$early/default/hypr/platform/defaults/fixture-binds.lua" <<'LUA' +if _G.omarchy_default_bindings ~= false then + o.bind("SUPER + F12", "Platform screenshot", "platform-screenshot") + o.bind("shift + XF86MonBrightnessUp", "Platform brightness", "platform-brightness") +end +table.insert(o.bind_decorators, function(keys, dispatcher) + if type(dispatcher) == "string" and dispatcher:find("^omarchy%-menu") then + -- A decorator that binds through o.bind is not decorated again. + o.bind("TWIN + " .. keys, "Platform menu twin", dispatcher) + hl.bind(keys, "platform-focus", {}) + end +end) +LUA + +# Prints one line per bind Hyprland ends up with, in the order they run: +# keys, then the command (or "platform-focus"). +load_binds() { + local edit="${1:-}" packaged_path="${PACKAGED_UNDER_TEST:-$early}" top="${2:-}" + local home + home=$(mktemp -d "$tmpdir/home.XXXXXX") + + mkdir -p "$home/.config" + cp -R "$ROOT/config/hypr" "$home/.config/hypr" + [[ -z $edit ]] || printf '%s\n' "$edit" >>"$home/.config/hypr/bindings.lua" + [[ -z $top ]] || printf '%s\n%s\n' "$top" "$(cat "$home/.config/hypr/hyprland.lua")" >"$home/.config/hypr/hyprland.lua" + + HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_STATE_HOME="$home/.local/state" OMARCHY_PATH="$ROOT" OMARCHY_PACKAGED_PATH="$packaged_path" \ + lua <<'LUA' +local function proxy() + return setmetatable({}, { + __index = function(self, key) + local value = proxy() + rawset(self, key, value) + return value + end, + __call = function() + return {} + end, + }) +end + +local binds = {} +hl = setmetatable({ + dsp = setmetatable({ exec_cmd = function(cmd) return { cmd = cmd } end }, { __index = function() return proxy() end }), + bind = function(keys, dispatcher, opts) + -- Only commands and decorator markers are compared; other dispatchers + -- print by kind, not by their (per-run) address. + local command = type(dispatcher) == "table" and dispatcher.cmd or type(dispatcher) == "string" and dispatcher or type(dispatcher) + table.insert(binds, { keys = keys, command = command }) + end, + -- Hyprland parses a chord into a modifier mask and a key, so spelling and + -- modifier order don't matter to unbind. + unbind = function(keys) + local function parsed(value) + local parts = {} + for raw in (value .. "+"):gmatch("([^+]*)%+") do + local part = raw:match("^%s*(.-)%s*$"):upper() + if part ~= "" then table.insert(parts, part) end + end + local key = table.remove(parts) or "" + table.sort(parts) + return table.concat(parts, "+") .. "+" .. key + end + for index = #binds, 1, -1 do + if parsed(binds[index].keys) == parsed(keys) then + table.remove(binds, index) + end + end + end, + get_config = function() return nil end, + get_active_window = function() return nil end, + get_monitors = function() return {} end, +}, { + __index = function() + return function() + return {} + end + end, +}) + +dofile(os.getenv("HOME") .. "/.config/hypr/hyprland.lua") +for _, bind in ipairs(binds) do + print(bind.keys .. "\t" .. tostring(bind.command)) +end +LUA +} + +plain=$(PACKAGED_UNDER_TEST=$ROOT load_binds) || fail "bindings load without a platform package" "$plain" +grep -qxF $'SHIFT + XF86MonBrightnessUp\tomarchy-brightness-display 100%' <<<"$plain" || + fail "without a platform package Omarchy's default binds as before" "$plain" +! grep -q 'platform-' <<<"$plain" || fail "without a platform package nothing platform binds" "$plain" +pass "without a platform package the default bindings are as before" + +binds=$(load_binds) || fail "bindings load with platform defaults" "$binds" +grep -qxF $'SUPER + F12\tplatform-screenshot' <<<"$binds" || fail "a platform default binds a free chord" "$binds" +[[ $(grep -ci '^SHIFT + XF86MonBrightnessUp' <<<"$binds") == 1 ]] && grep -qxF $'shift + XF86MonBrightnessUp\tplatform-brightness' <<<"$binds" || + fail "a platform default replaces Omarchy's default for the same chord, however it is spelled" "$binds" +diff <(grep -v -e 'platform-' -e '^TWIN + ' <<<"$binds") <(grep -v '^SHIFT + XF86MonBrightnessUp' <<<"$plain") >"$tmpdir/bind-diff" || + fail "every other default bind is unchanged, in the same order" "$(cat "$tmpdir/bind-diff")" +pass "a platform's early defaults bind free chords and replace Omarchy's default for theirs" + +[[ $(grep -A1 -xF $'SUPER + SPACE\tplatform-focus' <<<"$binds" | tail -n 1) == $'SUPER + SPACE\tomarchy-menu toggle' ]] || + fail "a decorator's bind runs right before the bind it decorates" "$binds" +! grep -qxF $'SUPER + RETURN\tplatform-focus' <<<"$binds" || fail "a decorator leaves other binds alone" +grep -qxF $'TWIN + SUPER + SPACE\tomarchy-menu toggle' <<<"$binds" && ! grep -qF 'TWIN + TWIN' <<<"$binds" || + fail "a bind a decorator makes through o.bind is not decorated again" "$binds" +pass "a platform decorator binds what must run first, right before the binds it picks" + +user=$(load_binds 'o.rebind("SHIFT + XF86MonBrightnessUp", "Mine", "my-brightness") +hl.unbind("SUPER + F12") +o.bind("SUPER + ALT + M", "My menu", "omarchy-menu toggle")') || fail "user bindings load" "$user" +grep -qxF $'SHIFT + XF86MonBrightnessUp\tmy-brightness' <<<"$user" && ! grep -q 'platform-brightness' <<<"$user" || + fail "the user's rebind replaces the platform's" "$user" +! grep -q '^SUPER + F12' <<<"$user" || fail "the user can unbind a platform chord" "$user" +[[ $(grep -A1 -xF $'SUPER + ALT + M\tplatform-focus' <<<"$user" | tail -n 1) == $'SUPER + ALT + M\tomarchy-menu toggle' ]] || + fail "the user's own menu binds are decorated too" "$user" +pass "the user's files override platform defaults, and their binds are decorated too" + +off=$(load_binds '' 'omarchy_default_bindings = false') || fail "bindings load with defaults off" "$off" +! grep -qE 'platform-(screenshot|brightness)|omarchy-brightness-display 100%' <<<"$off" || + fail "with default bindings off neither Omarchy's nor the platform's binds are made" "$off" +pass "with default bindings off, a platform file that honors it binds nothing" diff --git a/test/shell.d/menu-keybindings-fkeys-test.sh b/test/shell.d/menu-keybindings-fkeys-test.sh index ec69b7bdf27..8bc25c9f98e 100755 --- a/test/shell.d/menu-keybindings-fkeys-test.sh +++ b/test/shell.d/menu-keybindings-fkeys-test.sh @@ -102,7 +102,9 @@ ALT + XF86MonBrightnessUp → Brightness up precise exec omarchy-brightness-disp SUPER + Q → Close window killactive EOF -output=$(PATH="$mock_bin:$PATH" XDG_CACHE_HOME="$cache_dir" "$ROOT/bin/omarchy-menu-keybindings" --print) +# omarchy-mac names the keys (default/omarchy/platform/key-names). +"$ROOT/packages/omarchy-mac/install" "$cache_dir/pkg" >/dev/null +output=$(PATH="$mock_bin:$PATH" XDG_CACHE_HOME="$cache_dir" OMARCHY_PACKAGED_PATH="$cache_dir/pkg/usr/share/omarchy" "$ROOT/bin/omarchy-menu-keybindings" --print) tr -s ' ' <<<"$output" | grep -qF 'SHIFT + F2 → Keyboard brightness up' || \ fail "SHIFT + XF86MonBrightnessUp renders as SHIFT + F2 with its description" "$output" diff --git a/test/shell.d/menu-keybindings-key-names-test.sh b/test/shell.d/menu-keybindings-key-names-test.sh new file mode 100644 index 00000000000..2359b2132b1 --- /dev/null +++ b/test/shell.d/menu-keybindings-key-names-test.sh @@ -0,0 +1,141 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# A platform package names keys the way its keyboards print them. On a MacBook +# XF86MonBrightnessUp/Down are the F2/F1 keys, so without the names the menu +# shows rows like "SHIFT + XF86MonBrightnessUp". A cache written before the +# names were installed must not win over them. + +mock_bin=$(mktemp -d) +cache_dir=$(mktemp -d) +packaged=$(mktemp -d) +cleanup() { + rm -rf "$mock_bin" "$cache_dir" "$packaged" +} +trap cleanup EXIT + +cat >"$mock_bin/hyprctl" <<'SH' +#!/bin/bash +if [[ $1 == "devices" ]]; then + echo "Keyboards:" + echo " active keymap: Apple Keyboard (apple_vndr)" + exit 0 +fi +cat <<'BINDS' +bindl + modmask: 1 + key: XF86MonBrightnessUp + keycode: 0 + description: Keyboard brightness up + dispatcher: exec + arg: omarchy-brightness-keyboard up + +bindl + modmask: 1 + key: XF86MonBrightnessDown + keycode: 0 + description: Keyboard brightness down + dispatcher: exec + arg: omarchy-brightness-keyboard down + +bindi + modmask: 8 + key: XF86MonBrightnessUp + keycode: 0 + description: Brightness up precise + dispatcher: exec + arg: omarchy-brightness-display +1% + +bind + modmask: 64 + key: F5 + keycode: 0 + description: Press brightness up + dispatcher: exec + arg: wtype -k XF86MonBrightnessUp + +bindm + modmask: 64 + key: Q + keycode: 0 + description: Close window + dispatcher: killactive + arg: +BINDS +SH +chmod +x "$mock_bin/hyprctl" + +# Minimal valid keymap so keycode resolution has something to chew on. +cat >"$mock_bin/xkbcli" <<'SH' +#!/bin/bash +cat <<'KEYMAP' +xkb_keycodes { + = 24; +}; +xkb_symbols { + key { [ q ] }; +}; +KEYMAP +SH +chmod +x "$mock_bin/xkbcli" + +cat >"$mock_bin/omarchy-cmd-present" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$mock_bin/omarchy-cmd-present" + +mkdir -p "$packaged/default/omarchy/platform" +printf '%s\n' 'XF86MonBrightnessUp F2' 'XF86MonBrightnessDown F1' 'not a line' 'Bad-Sym F3' >"$packaged/default/omarchy/platform/key-names" + +# Pre-seed the exact cache file that v13, which knew no key names, would read +# for these mocked inputs. Cached records use the rendered row as field 1, followed by +# the dispatcher and argument fields. +mkdir -p "$cache_dir/omarchy" +v13_key=$({ + printf 'v13\n' + "$mock_bin/hyprctl" devices 2>/dev/null | grep -F 'active keymap:' + "$mock_bin/hyprctl" binds 2>/dev/null +} | sha256sum | awk '{ print $1 }') +cat >"$cache_dir/omarchy/keybindings-$v13_key.records" <<'EOF' +SHIFT + XF86MonBrightnessUp → Keyboard brightness up exec omarchy-brightness-keyboard up +SHIFT + XF86MonBrightnessDown → Keyboard brightness down exec omarchy-brightness-keyboard down +ALT + XF86MonBrightnessUp → Brightness up precise exec omarchy-brightness-display +1% +SUPER + Q → Close window killactive +EOF + +output=$(OMARCHY_PACKAGED_PATH="$packaged" PATH="$mock_bin:$PATH" XDG_CACHE_HOME="$cache_dir" "$ROOT/bin/omarchy-menu-keybindings" --print) + +tr -s ' ' <<<"$output" | grep -qF 'SHIFT + F2 → Keyboard brightness up' || \ + fail "SHIFT + XF86MonBrightnessUp renders as SHIFT + F2 with its description" "$output" + +tr -s ' ' <<<"$output" | grep -qF 'SHIFT + F1 → Keyboard brightness down' || \ + fail "SHIFT + XF86MonBrightnessDown renders as SHIFT + F1 with its description" "$output" + +tr -s ' ' <<<"$output" | grep -qF 'ALT + F2 → Brightness up precise' || \ + fail "modifier combos keep the F-key name (ALT + F2)" "$output" + +grep -qF 'XF86MonBrightness' <<<"$output" && \ + fail "raw XF86MonBrightness symbols no longer leak into the menu" "$output" + +tr -s ' ' <<<"$output" | grep -qF 'SUPER + Q → Close window' || \ + fail "unrelated bindings render unchanged (SUPER + Q)" "$output" + +# The menu runs a chosen row from its cached record, which carries what the +# binding runs. +records=$(cat "$cache_dir"/omarchy/keybindings-*.records) +grep -qF 'wtype -k XF86MonBrightnessUp' <<<"$records" && ! grep -qF 'wtype -k F2' <<<"$records" || + fail "a key name never changes what a binding runs" "$records" + +pass "keybindings menu shows a platform's key names (F1/F2), stale caches ignored" + +# Without a platform package the keys keep their own names, and a cache made +# with the names is not reused. +output=$(PATH="$mock_bin:$PATH" XDG_CACHE_HOME="$cache_dir" "$ROOT/bin/omarchy-menu-keybindings" --print) +tr -s ' ' <<<"$output" | grep -qF 'SHIFT + XF86MonBrightnessUp → Keyboard brightness up' || + fail "without key names the brightness key keeps its own name" "$output" +grep -qF 'SHIFT + F2' <<<"$output" && fail "without key names nothing is renamed F2" "$output" +pass "keybindings menu keeps keysym names without a platform package"