diff --git a/default/hypr/apple-gestures.lua b/default/hypr/apple-gestures.lua new file mode 100644 index 00000000000..5e4c51457eb --- /dev/null +++ b/default/hypr/apple-gestures.lua @@ -0,0 +1,27 @@ +-- Macs swipe between workspaces with three fingers, as they do in macOS. +-- Loaded after the user's files, so a three-finger sideways gesture the user +-- set, or omarchy_workspace_gesture = false, keeps this one out instead of +-- Hyprland 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 + return +end + +local sideways = { + horizontal = true, + horiz = true, + left = true, + l = true, + right = true, + r = true, + swipe = true, +} + +for _, gesture in ipairs(o.registered_gestures or {}) do + if gesture.fingers == 3 and not gesture.modified and sideways[gesture.direction] then + return + end +end + +hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" }) diff --git a/default/hypr/helpers.lua b/default/hypr/helpers.lua index cc52c790779..cc986ab8bcb 100644 --- a/default/hypr/helpers.lua +++ b/default/hypr/helpers.lua @@ -117,6 +117,14 @@ local function opens_overlay(command) 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 @@ -134,11 +142,7 @@ end -- 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 apple_silicon == nil then - apple_silicon = o.shell_succeeds("omarchy-hw-apple-silicon") - end - - if apple_silicon then + if o.apple_silicon() then hl.bind(keys, o.focus_builtin_screen, { device = { inclusive = true, list = builtin_keyboards } }) end end @@ -215,3 +219,42 @@ function o.window(match, rules) hl.window_rule(rules) end + +local modifier_names = { "SHIFT", "CAPS", "CTRL", "CONTROL", "ALT", "MOD1", "MOD2", "MOD3", "SUPER", "WIN", "LOGO", "MOD4", "META", "MOD5" } + +-- Hyprland reads a modifier out of any string that contains one's name, so +-- "NONE" or "" is no modifier at all. +local function holds_modifier(mods) + if type(mods) ~= "string" then + return mods ~= nil + end + + mods = mods:upper() + for _, name in ipairs(modifier_names) do + if mods:find(name, 1, true) then + return true + end + end + + return false +end + +-- Hyprland rejects a gesture another one already covers, and gives Lua no way +-- to list what's registered. On a Mac, record each one so the default workspace +-- swipe, added after the user's files, can step aside for the user's own. +if hl and hl.gesture and not o.registered_gestures and o.apple_silicon() then + local register_gesture = hl.gesture + o.registered_gestures = {} + + hl.gesture = function(gesture, ...) + if type(gesture) == "table" then + table.insert(o.registered_gestures, { + fingers = tonumber(gesture.fingers), + direction = type(gesture.direction) == "string" and gesture.direction:lower() or "", + modified = holds_modifier(gesture.mods), + }) + end + + return register_gesture(gesture, ...) + end +end diff --git a/default/hypr/toggles.lua b/default/hypr/toggles.lua index bb9f28d4d71..2a1663aade5 100644 --- a/default/hypr/toggles.lua +++ b/default/hypr/toggles.lua @@ -21,3 +21,4 @@ disabled_input_device("touchpad") disabled_input_device("touchscreen") require("default.hypr.workspace-layouts") +require("default.hypr.apple-gestures") diff --git a/mac-manual/content/06-keyboard.md b/mac-manual/content/06-keyboard.md index 87d12bc9ba9..7e75bd832c1 100644 --- a/mac-manual/content/06-keyboard.md +++ b/mac-manual/content/06-keyboard.md @@ -30,3 +30,9 @@ On Macs with an ambient light sensor, the keyboard backlight follows the room: l ## Trackpad The built-in trackpad scrolls naturally, as in macOS, and tap-to-click is off, because a palm brushing the trackpad while typing would otherwise click. Click by pressing the trackpad. Change either in `~/.config/hypr/input.lua`, as [Keyboard, mouse, trackpad](https://omarchy.org/manual/keyboard-mouse-trackpad/) describes; a value you set yourself is kept. + +Swipe sideways with three fingers to move between workspaces, as in macOS. A three-finger sideways gesture of your own in `~/.config/hypr/input.lua` takes its place. To turn the swipe off, add this line to `~/.config/hypr/input.lua`: + +```lua +omarchy_workspace_gesture = false +``` diff --git a/test/shell.d/hyprland-apple-workspace-gesture-test.sh b/test/shell.d/hyprland-apple-workspace-gesture-test.sh new file mode 100755 index 00000000000..827b8e768af --- /dev/null +++ b/test/shell.d/hyprland-apple-workspace-gesture-test.sh @@ -0,0 +1,145 @@ +#!/bin/bash +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command lua + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +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 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 platform="$1" edit="${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/input.lua" + + HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_STATE_HOME="$home/.local/state" OMARCHY_PATH="$ROOT" \ + 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 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" + +[[ $(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)" +pass "three-finger workspace swipe is on by default on Macs only" + +shipped_line=$(sed -nE 's/^-- (hl\.gesture\(\{ fingers = 3, direction = "horizontal".*)$/\1/p' "$ROOT/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") +(( $(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" +pass "a user's own three-finger sideways gestures replace the default" + +output=$(load_config apple 'hl.gesture({ fingers = 3, direction = "swipe", action = "move" })') +[[ $output == "3 swipe move" ]] || fail "a three-finger swipe gesture replaces the workspace swipe" "$output" +output=$(load_config apple 'hl.gesture({ fingers = 4, direction = "horizontal", action = "workspace" })') +[[ $output == $'4 horizontal workspace\n'"$swipe" ]] || fail "a four-finger gesture keeps the three-finger default" "$output" +output=$(load_config apple 'hl.gesture({ fingers = 3, direction = "up", action = "fullscreen" })') +[[ $output == $'3 up fullscreen\n'"$swipe" ]] || fail "a vertical gesture keeps the sideways default" "$output" +output=$(load_config apple 'hl.gesture({ fingers = 3, direction = "horizontal", mods = "SUPER", action = "move" })') +[[ $output == $'3 horizontal +M move\n'"$swipe" ]] || fail "a gesture held with a modifier keeps the default" "$output" +pass "gestures on other fingers, axes or modifiers keep the default" + +for mods in '"NONE"' '""' '" "'; do + output=$(load_config apple 'hl.gesture({ fingers = 3, direction = "horizontal", mods = '"$mods"', action = "workspace" })') + [[ $output == "$swipe" ]] || fail "mods = $mods counts as no modifier and replaces the default" "$output" +done +pass "a modifier string naming no modifier still replaces the default" + +output=$(load_config apple 'omarchy_workspace_gesture = false') +[[ -z $output ]] || fail "omarchy_workspace_gesture = false turns the default off" "$output" +pass "omarchy_workspace_gesture = false turns the default off" + +grep -Fq 'omarchy_workspace_gesture = false' "$ROOT/mac-manual/content/06-keyboard.md" || + fail "the manual documents how to turn the Mac gesture off" +pass "the manual documents how to turn the Mac gesture off"