diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 166ef01..6d64af2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,11 @@ on: jobs: build: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} permissions: id-token: "write" contents: "read" @@ -19,5 +23,5 @@ jobs: - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: hexecute + name: hexecute-${{ matrix.os }} path: ./result/bin/ diff --git a/README.md b/README.md index 18860f7..5cc287e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Hexecute -A gesture-based launcher for Wayland. Launch apps by casting spells! 🪄 +A gesture-based launcher for Wayland and macOS. Launch apps by casting spells! 🪄 ![Demo GIF](.github/assets/demo.gif) @@ -54,14 +54,24 @@ git clone https://github.com/m31-galaxy/Hexecute cd Hexecute ``` -If you have [Nix](https://nixos.org/) installed, simply run `nix build`. +If you have [Nix](https://nixos.org/) installed, simply run `nix build`. This works on both Linux and macOS. -Otherwise, make sure you have Go (and all dependent Wayland (and X11!?) libs) installed, then run: +Otherwise: + +**On Linux**, make sure you have Go (and all dependent Wayland (and X11!?) libs) installed, then run: +```bash +mkdir -p bin +go build -o bin ./... +./bin/hexecute +``` + +**On macOS**, make sure you have Go and the Xcode command line tools (`xcode-select --install`) installed, then run: ```bash mkdir -p bin go build -o bin ./... ./bin/hexecute ``` +The macOS build uses a native Cocoa overlay window and the system OpenGL framework — no Wayland or X11 libraries are required. ## Usage @@ -86,6 +96,12 @@ If you're using Sway, add the following line to your `~/.config/sway/config`: bindsym $mod+space exec hexecute ``` +#### macOS + +Running `hexecute` (with no arguments) **draws a gesture immediately**: the overlay appears, you cast once, and it dismisses. Bind it to a keyboard shortcut with your preferred hotkey tool (e.g. a Shortcuts.app quick action, Raycast, or skhd) to launch it on demand. + +> Note: depending on your macOS version, drawing the overlay over other applications may require granting your terminal (or whichever app launches Hexecute) **Screen Recording** and/or **Accessibility** permission under System Settings → Privacy & Security. + ### Learning a Gesture To configure a gesture to launch an application, run `hexecute --learn [command]` in a terminal. Hexecute should launch - simply draw your chosen gesture **3 times** and it will be mapped to the command. diff --git a/cmd/hexecute/main.go b/cmd/hexecute/main.go index 6c7cec7..74c6052 100644 --- a/cmd/hexecute/main.go +++ b/cmd/hexecute/main.go @@ -15,20 +15,16 @@ import ( gestures "github.com/m31-galaxy/Hexecute/internal/gesture" "github.com/m31-galaxy/Hexecute/internal/models" "github.com/m31-galaxy/Hexecute/internal/opengl" + "github.com/m31-galaxy/Hexecute/internal/platform" "github.com/m31-galaxy/Hexecute/internal/spawn" "github.com/m31-galaxy/Hexecute/internal/stroke" "github.com/m31-galaxy/Hexecute/internal/update" - "github.com/m31-galaxy/Hexecute/pkg/wayland" ) func init() { runtime.LockOSThread() } -type App struct { - *models.App -} - func main() { learnCommand := flag.String("learn", "", "Learn a new gesture for the specified command") listGestures := flag.Bool("list", false, "List all registered gestures") @@ -92,12 +88,6 @@ func main() { return } - window, err := wayland.NewWaylandWindow() - if err != nil { - log.Fatal("Failed to create Wayland window:", err) - } - defer window.Destroy() - settings, err := config.LoadSettings() if err != nil { log.Fatal("Failed to load settings:", err) @@ -112,32 +102,76 @@ func main() { app.LearnMode = true app.LearnCommand = *learnCommand log.Printf("Learn mode: Draw the gesture 3 times for command '%s'", *learnCommand) - } else { - gestures, err := gestures.LoadGestures() - if err != nil { - log.Fatal("Failed to load gestures:", err) - } - app.SavedGestures = gestures - log.Printf("Loaded %d gesture(s)", len(gestures)) + runOnce(app) + return } - opengl := opengl.New(app) - if err := opengl.InitGL(); err != nil { - log.Fatal("Failed to initialize OpenGL:", err) + loaded, err := gestures.LoadGestures() + if err != nil { + log.Fatal("Failed to load gestures:", err) } + app.SavedGestures = loaded + log.Printf("Loaded %d gesture(s)", len(loaded)) - gl.ClearColor(0, 0, 0, 0) + // Launch the overlay and draw a gesture now. + runOnce(app) +} +// initGLAndWarm compiles shaders and pumps a few clear frames so the first real +// frame is ready. +func initGLAndWarm(app *models.App, window platform.Window) error { + o := opengl.New(app) + if err := o.InitGL(); err != nil { + return err + } + + gl.ClearColor(0, 0, 0, 0) for range 5 { window.PollEvents() gl.Clear(gl.COLOR_BUFFER_BIT) window.SwapBuffers() } + return nil +} + +// resetSession clears per-cast state so a reused window (resident mode) starts a +// fresh gesture each time the overlay is shown. +func resetSession(app *models.App, window platform.Window) { + app.StartTime = time.Now() + app.IsExiting = false + app.IsDrawing = false + app.Points = nil + app.Particles = nil + app.CursorVelocity = 0 + app.SmoothVelocity = 0 + app.SmoothRotation = 0 + app.SmoothDrawing = 0 x, y := window.GetCursorPos() app.LastCursorX = float32(x) app.LastCursorY = float32(y) +} + +// runOnce creates a window, runs a single gesture session, and tears it down. +// Used for `--learn` (all platforms) and the per-launch path on non-macOS. +func runOnce(app *models.App) { + window, err := platform.NewWindow() + if err != nil { + log.Fatal("Failed to create window:", err) + } + defer window.Destroy() + + if err := initGLAndWarm(app, window); err != nil { + log.Fatal("Failed to initialize OpenGL:", err) + } + + resetSession(app, window) + runSession(app, window) +} +// runSession runs the gesture-capture loop on an already-shown window, returning +// when the cast ends (gesture executed, Esc, or learn complete). +func runSession(app *models.App, window platform.Window) { lastTime := time.Now() var wasPressed bool diff --git a/flake.lock b/flake.lock index fbc89a9..d4b7931 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1759831965, - "narHash": "sha256-vgPm2xjOmKdZ0xKA6yLXPJpjOtQPHfaZDRtH+47XEBo=", + "lastModified": 1781577229, + "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", "owner": "nixos", "repo": "nixpkgs", - "rev": "c9b6fb798541223bbb396d287d16f43520250518", + "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 4bbedc1..bca69ed 100644 --- a/flake.nix +++ b/flake.nix @@ -8,74 +8,110 @@ outputs = inputs: let - system = "x86_64-linux"; - pkgs = inputs.nixpkgs.legacyPackages.${system}; - hexecute = pkgs.buildGoModule { - pname = "hexecute"; - version = "0.1.0"; + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = + f: + inputs.nixpkgs.lib.genAttrs supportedSystems ( + system: f system inputs.nixpkgs.legacyPackages.${system} + ); - src = ./.; + mkHexecute = + system: pkgs: + let + inherit (pkgs) lib stdenv; + isDarwin = stdenv.isDarwin; + in + pkgs.buildGoModule { + pname = "hexecute"; + version = "0.1.0"; - vendorHash = "sha256-CIlYhcX7F08Xwrr3/0tkgrfuP68UU0CeQ+HV63b6Ddg="; + src = ./.; - nativeBuildInputs = with pkgs; [ - pkg-config - makeWrapper - ]; + vendorHash = "sha256-CIlYhcX7F08Xwrr3/0tkgrfuP68UU0CeQ+HV63b6Ddg="; - buildInputs = with pkgs; [ - wayland - wayland-protocols - libxkbcommon - libGL - libGLU - mesa - xorg.libX11 - ]; + nativeBuildInputs = with pkgs; [ + pkg-config + ] ++ lib.optionals (!isDarwin) [ makeWrapper ]; - postFixup = '' - wrapProgram $out/bin/hexecute \ - --prefix __EGL_VENDOR_LIBRARY_DIRS : "/run/opengl-driver/share/glvnd/egl_vendor.d" \ - --prefix __EGL_VENDOR_LIBRARY_DIRS : "${pkgs.mesa}/share/glvnd/egl_vendor.d" \ - --prefix LIBGL_DRIVERS_PATH : "/run/opengl-driver/lib/dri" \ - --prefix LIBGL_DRIVERS_PATH : "${pkgs.mesa}/lib/dri" - ''; + buildInputs = + if isDarwin then + # On modern nixpkgs the macOS SDK (Cocoa, OpenGL, ...) is provided + # by the stdenv itself, so the cgo `-framework` flags resolve + # without any explicit framework derivations — those legacy + # `darwin.apple_sdk.frameworks.*` stubs have been removed. + [ ] + else + with pkgs; [ + wayland + wayland-protocols + libxkbcommon + libGL + libGLU + mesa + xorg.libX11 + ]; - meta = { - description = "Launch apps by casting spells! 🪄"; - homepage = "https://github.com/m31-galaxy/Hexecute"; - license = pkgs.lib.licenses.gpl3; - platforms = pkgs.lib.platforms.linux; + # The Wayland/EGL driver path wrapping only applies on Linux. + postFixup = lib.optionalString (!isDarwin) '' + wrapProgram $out/bin/hexecute \ + --prefix __EGL_VENDOR_LIBRARY_DIRS : "/run/opengl-driver/share/glvnd/egl_vendor.d" \ + --prefix __EGL_VENDOR_LIBRARY_DIRS : "${pkgs.mesa}/share/glvnd/egl_vendor.d" \ + --prefix LIBGL_DRIVERS_PATH : "/run/opengl-driver/lib/dri" \ + --prefix LIBGL_DRIVERS_PATH : "${pkgs.mesa}/lib/dri" + ''; + + meta = { + description = "Launch apps by casting spells! 🪄"; + homepage = "https://hexecute.app"; + license = lib.licenses.gpl3; + platforms = lib.platforms.linux ++ lib.platforms.darwin; + }; }; - }; in { - packages.${system} = { - inherit hexecute; - default = hexecute; - }; - - devShells.${system}.default = pkgs.mkShell { - name = "hexecute"; - - packages = with pkgs; [ - go - pkg-config + packages = forAllSystems ( + system: pkgs: + let + hexecute = mkHexecute system pkgs; + in + { + inherit hexecute; + default = hexecute; + } + ); - # Wayland libraries - wayland - wayland-protocols - wayland-scanner - libxkbcommon + devShells = forAllSystems ( + system: pkgs: + { + default = pkgs.mkShell { + name = "hexecute"; - # EGL and OpenGL - libGL - libGLU - mesa + packages = + with pkgs; + [ + go + pkg-config + gcc + ] + ++ pkgs.lib.optionals (!pkgs.stdenv.isDarwin) [ + # Wayland libraries + wayland + wayland-protocols + wayland-scanner + libxkbcommon - # Build tools - gcc - ]; - }; + # EGL and OpenGL + libGL + libGLU + mesa + ]; + }; + } + ); }; } diff --git a/internal/draw/draw.go b/internal/draw/draw.go index 3ab6a33..92b8a07 100644 --- a/internal/draw/draw.go +++ b/internal/draw/draw.go @@ -6,7 +6,7 @@ import ( "github.com/go-gl/gl/v4.1-core/gl" "github.com/m31-galaxy/Hexecute/internal/models" - "github.com/m31-galaxy/Hexecute/pkg/wayland" + "github.com/m31-galaxy/Hexecute/internal/platform" ) type App struct { @@ -17,7 +17,7 @@ func New(app *models.App) *App { return &App{app: app} } -func (a *App) Draw(window *wayland.WaylandWindow) { +func (a *App) Draw(window platform.Window) { gl.Clear(gl.COLOR_BUFFER_BIT) currentTime := float32(time.Since(a.app.StartTime).Seconds()) @@ -37,7 +37,7 @@ func (a *App) Draw(window *wayland.WaylandWindow) { } func (a *App) drawLine( - window *wayland.WaylandWindow, + window platform.Window, baseThickness, baseAlpha, currentTime float32, ) { if len(a.app.Points) < 2 { @@ -128,7 +128,7 @@ func (a *App) drawLine( gl.BindVertexArray(0) } -func (a *App) drawParticles(window *wayland.WaylandWindow) { +func (a *App) drawParticles(window platform.Window) { if len(a.app.Particles) == 0 { return } @@ -152,7 +152,7 @@ func (a *App) drawParticles(window *wayland.WaylandWindow) { gl.BindVertexArray(0) } -func (a *App) drawBackground(currentTime float32, window *wayland.WaylandWindow) { +func (a *App) drawBackground(currentTime float32, window platform.Window) { fadeDuration := float32(1.0) targetAlpha := a.app.Settings.OverlayAlpha @@ -200,7 +200,7 @@ func (a *App) drawBackground(currentTime float32, window *wayland.WaylandWindow) gl.BlendFunc(gl.SRC_ALPHA, gl.ONE) } -func (a *App) drawCursorGlow(window *wayland.WaylandWindow, cursorX, cursorY, currentTime float32) { +func (a *App) drawCursorGlow(window platform.Window, cursorX, cursorY, currentTime float32) { width, height := window.GetSize() growDuration := float32(1.2) diff --git a/internal/execute/execute.go b/internal/execute/execute.go index 855a31f..be7521a 100644 --- a/internal/execute/execute.go +++ b/internal/execute/execute.go @@ -7,9 +7,9 @@ import ( "time" "github.com/m31-galaxy/Hexecute/internal/models" + "github.com/m31-galaxy/Hexecute/internal/platform" "github.com/m31-galaxy/Hexecute/internal/spawn" "github.com/m31-galaxy/Hexecute/internal/stroke" - "github.com/m31-galaxy/Hexecute/pkg/wayland" ) type App struct { @@ -36,7 +36,7 @@ func Command(command string) error { return cmd.Start() } -func (a *App) RecognizeAndExecute(window *wayland.WaylandWindow, x, y float32) { +func (a *App) RecognizeAndExecute(window platform.Window, x, y float32) { if len(a.app.Points) < 5 { log.Println("Gesture too short, ignoring") return diff --git a/internal/platform/window.go b/internal/platform/window.go new file mode 100644 index 0000000..816d8d9 --- /dev/null +++ b/internal/platform/window.go @@ -0,0 +1,18 @@ +package platform + +// Window is the platform-agnostic windowing, input, and GL-surface abstraction +// consumed by the rest of Hexecute. Backends (Wayland on Linux, Cocoa on macOS) +// implement this interface, and NewWindow (defined per-OS in a build-tagged +// file) returns the appropriate backend. +type Window interface { + GetSize() (int, int) + ShouldClose() bool + SwapBuffers() + PollEvents() + GetCursorPos() (float64, float64) + GetMouseButton() bool + DisableInput() + GetLastKey() (key uint32, state uint32, ok bool) + ClearLastKey() + Destroy() +} diff --git a/internal/platform/window_darwin.go b/internal/platform/window_darwin.go new file mode 100644 index 0000000..5b16ada --- /dev/null +++ b/internal/platform/window_darwin.go @@ -0,0 +1,13 @@ +//go:build darwin + +package platform + +import "github.com/m31-galaxy/Hexecute/pkg/cocoa" + +// Compile-time check that the Cocoa backend satisfies the Window interface. +var _ Window = (*cocoa.CocoaWindow)(nil) + +// NewWindow constructs the Cocoa-backed window on macOS. +func NewWindow() (Window, error) { + return cocoa.NewCocoaWindow() +} diff --git a/internal/platform/window_linux.go b/internal/platform/window_linux.go new file mode 100644 index 0000000..bd990ef --- /dev/null +++ b/internal/platform/window_linux.go @@ -0,0 +1,13 @@ +//go:build linux + +package platform + +import "github.com/m31-galaxy/Hexecute/pkg/wayland" + +// Compile-time check that the Wayland backend satisfies the Window interface. +var _ Window = (*wayland.WaylandWindow)(nil) + +// NewWindow constructs the Wayland-backed window on Linux. +func NewWindow() (Window, error) { + return wayland.NewWaylandWindow() +} diff --git a/internal/update/update.go b/internal/update/update.go index 9b21135..1114ffa 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -4,7 +4,7 @@ import ( "math" "github.com/m31-galaxy/Hexecute/internal/models" - "github.com/m31-galaxy/Hexecute/pkg/wayland" + "github.com/m31-galaxy/Hexecute/internal/platform" ) type App struct { @@ -31,7 +31,7 @@ func (a *App) UpdateParticles(dt float32) { } } -func (a *App) UpdateCursor(window *wayland.WaylandWindow) { +func (a *App) UpdateCursor(window platform.Window) { x, y := window.GetCursorPos() fx, fy := float32(x), float32(y) diff --git a/macos/Hexecute.icns b/macos/Hexecute.icns new file mode 100644 index 0000000..d7639d4 Binary files /dev/null and b/macos/Hexecute.icns differ diff --git a/macos/Hexecute.icon/Assets/icon.png b/macos/Hexecute.icon/Assets/icon.png new file mode 100644 index 0000000..8faf316 Binary files /dev/null and b/macos/Hexecute.icon/Assets/icon.png differ diff --git a/macos/Hexecute.icon/icon.json b/macos/Hexecute.icon/icon.json new file mode 100644 index 0000000..0cb2799 --- /dev/null +++ b/macos/Hexecute.icon/icon.json @@ -0,0 +1,49 @@ +{ + "fill" : { + "automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "blend-mode-specializations" : [ + { + "appearance" : "dark", + "value" : "normal" + } + ], + "fill-specializations" : [ + { + "appearance" : "dark", + "value" : "automatic" + } + ], + "glass" : false, + "image-name" : "icon.png", + "name" : "icon", + "position" : { + "scale" : 0.85, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/pkg/cocoa/cocoa.go b/pkg/cocoa/cocoa.go new file mode 100644 index 0000000..462b07c --- /dev/null +++ b/pkg/cocoa/cocoa.go @@ -0,0 +1,102 @@ +//go:build darwin + +package cocoa + +/* +#cgo darwin LDFLAGS: -framework Cocoa -framework OpenGL +#cgo darwin CFLAGS: -Wno-deprecated-declarations +#include +#include "cocoa.h" +*/ +import "C" + +import ( + "fmt" +) + +// CocoaError describes a failure setting up the macOS overlay window. +type CocoaError struct { + msg string +} + +func (e *CocoaError) Error() string { + return e.msg +} + +// CocoaWindow is the macOS backend implementing platform.Window. It wraps a +// transparent, borderless, fullscreen NSWindow overlay with an OpenGL 4.1 core +// context, mirroring the behaviour of the Wayland layer-shell overlay. +type CocoaWindow struct { + width, height int32 +} + +// NewCocoaWindow creates the overlay window and a current GL context and shows +// it immediately, satisfying platform.NewWindow. +func NewCocoaWindow() (*CocoaWindow, error) { + if ret := C.cocoa_init(); ret != 0 { + return nil, &CocoaError{fmt.Sprintf("failed to initialise Cocoa window (code %d)", int(ret))} + } + + w := &CocoaWindow{} + var width, height C.int32_t + C.cocoa_get_dimensions(&width, &height) + w.width = int32(width) + w.height = int32(height) + + if w.width == 0 || w.height == 0 { + w.width = 1920 + w.height = 1080 + } + + return w, nil +} + +func (w *CocoaWindow) GetSize() (int, int) { + var width, height C.int32_t + C.cocoa_get_dimensions(&width, &height) + if width > 0 && height > 0 { + w.width = int32(width) + w.height = int32(height) + } + return int(w.width), int(w.height) +} + +func (w *CocoaWindow) ShouldClose() bool { + return false +} + +func (w *CocoaWindow) SwapBuffers() { + C.cocoa_swap_buffers() +} + +func (w *CocoaWindow) PollEvents() { + C.cocoa_poll_events() +} + +func (w *CocoaWindow) GetCursorPos() (float64, float64) { + var x, y C.double + C.cocoa_get_mouse_pos(&x, &y) + return float64(x), float64(y) +} + +func (w *CocoaWindow) GetMouseButton() bool { + return C.cocoa_get_button_state() == 1 +} + +func (w *CocoaWindow) DisableInput() { + C.cocoa_disable_input() +} + +func (w *CocoaWindow) GetLastKey() (uint32, uint32, bool) { + key := uint32(C.cocoa_get_last_key()) + state := uint32(C.cocoa_get_last_key_state()) + return key, state, key != 0 +} + +func (w *CocoaWindow) ClearLastKey() { + C.cocoa_clear_last_key() +} + +func (w *CocoaWindow) Destroy() { + C.cocoa_destroy() +} diff --git a/pkg/cocoa/cocoa.h b/pkg/cocoa/cocoa.h new file mode 100644 index 0000000..987e7a1 --- /dev/null +++ b/pkg/cocoa/cocoa.h @@ -0,0 +1,47 @@ +#ifndef HEXECUTE_COCOA_H +#define HEXECUTE_COCOA_H + +#include + +// cocoa_init creates the shared application, a transparent borderless +// fullscreen overlay window, and a current OpenGL 4.1 core context, then orders +// the overlay front and hides the system cursor. Returns 0 on success, non-zero +// on failure. +int cocoa_init(void); + +// cocoa_get_dimensions reports the overlay size in logical points, which is the +// single coordinate space used throughout (drawable, viewport, gl_FragCoord, +// cursor), matching the Wayland backend. +void cocoa_get_dimensions(int32_t *width, int32_t *height); + +// cocoa_swap_buffers presents the current frame. +void cocoa_swap_buffers(void); + +// cocoa_poll_events drains and dispatches pending Cocoa events, updating the +// cached mouse position, button state, and last key. +void cocoa_poll_events(void); + +// cocoa_get_mouse_pos reports the cursor position in logical points with a +// top-left origin (matching the Wayland backend's convention). +void cocoa_get_mouse_pos(double *x, double *y); + +// cocoa_get_button_state returns 1 while the left mouse button is held. +int cocoa_get_button_state(void); + +// cocoa_disable_input makes the overlay ignore further input (used during the +// exit animation). +void cocoa_disable_input(void); + +// cocoa_get_last_key returns the last key as an XKB keysym (0 if none). +uint32_t cocoa_get_last_key(void); + +// cocoa_get_last_key_state returns 1 for press, 0 for release. +uint32_t cocoa_get_last_key_state(void); + +// cocoa_clear_last_key clears the cached last key. +void cocoa_clear_last_key(void); + +// cocoa_destroy tears down the GL context and window. +void cocoa_destroy(void); + +#endif diff --git a/pkg/cocoa/cocoa.m b/pkg/cocoa/cocoa.m new file mode 100644 index 0000000..20291c7 --- /dev/null +++ b/pkg/cocoa/cocoa.m @@ -0,0 +1,257 @@ +#import +#import +#import +#include "cocoa.h" + +// XKB keysym for Escape. main.go compares the reported key against this value, +// so the Cocoa backend must translate macOS key codes into the same keysyms +// the Wayland backend produces. +#define XKB_KEY_Escape 0xff1b + +static NSWindow *g_window = nil; +static NSOpenGLContext *g_context = nil; +static NSView *g_view = nil; + +static double g_mouse_x = 0.0; +static double g_mouse_y = 0.0; +static int g_button_state = 0; +static uint32_t g_last_key = 0; +static uint32_t g_last_key_state = 0; +static int g_input_disabled = 0; + +// Borderless windows cannot become key/main by default, but the overlay needs +// keyboard (Esc) and mouse-moved events, so override these. +@interface HexWindow : NSWindow +@end + +@implementation HexWindow +- (BOOL)canBecomeKeyWindow { + return YES; +} +- (BOOL)canBecomeMainWindow { + return YES; +} +@end + +// Convert a macOS virtual key code to an XKB keysym. Only the keys the app +// reacts to need mapping; everything else reports 0 (no key). +static uint32_t map_keycode(unsigned short keyCode) { + switch (keyCode) { + case 53: + return XKB_KEY_Escape; + default: + return 0; + } +} + +// Convert an event's window-local location (points, bottom-left origin) into +// logical points with a top-left origin. The app works in logical points (as +// the Wayland backend does), so the cursor must not be scaled by the backing +// factor — only the GL viewport runs at backing resolution. +static void update_mouse_from_event(NSEvent *event) { + if (!g_view) { + return; + } + NSPoint p = [event locationInWindow]; + NSRect bounds = [g_view bounds]; + g_mouse_x = p.x; + g_mouse_y = bounds.size.height - p.y; +} + +// Seed the cursor position from the current global mouse location (logical +// points, top-left origin) so the first frame is correct before any motion +// event arrives. +static void seed_mouse_global(void) { + NSRect screenFrame = [[NSScreen mainScreen] frame]; + NSPoint m = [NSEvent mouseLocation]; + g_mouse_x = m.x - screenFrame.origin.x; + g_mouse_y = screenFrame.size.height - (m.y - screenFrame.origin.y); +} + +// cocoa_init creates the shared application, a transparent borderless fullscreen +// overlay, and a current OpenGL 4.1 core context, then orders the overlay front +// and hides the system cursor. The app pumps its own event loop (no [NSApp run]) +// so cocoa_poll_events can drive the gesture session. +int cocoa_init(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + // Accessory: foreground-capable overlay without a Dock icon. + [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; + [NSApp finishLaunching]; + + NSScreen *screen = [NSScreen mainScreen]; + if (!screen) { + return 1; + } + NSRect frame = [screen frame]; + + g_window = [[HexWindow alloc] initWithContentRect:frame + styleMask:NSWindowStyleMaskBorderless + backing:NSBackingStoreBuffered + defer:NO]; + if (!g_window) { + return 2; + } + + [g_window setOpaque:NO]; + [g_window setBackgroundColor:[NSColor clearColor]]; + [g_window setLevel:NSStatusWindowLevel]; + [g_window setHasShadow:NO]; + [g_window setAcceptsMouseMovedEvents:YES]; + [g_window setCollectionBehavior:NSWindowCollectionBehaviorCanJoinAllSpaces | + NSWindowCollectionBehaviorFullScreenAuxiliary]; + + g_view = [[NSView alloc] initWithFrame:frame]; + // Render in a logical-point coordinate space: keep the drawable sized in + // points rather than backing pixels. The shaders compare gl_FragCoord + // against the points-based resolution and cursor uniforms (e.g. the + // background cursor light), so a backing-pixel drawable offsets anything + // that reads gl_FragCoord by the Retina scale factor. + [g_view setWantsBestResolutionOpenGLSurface:NO]; + [g_window setContentView:g_view]; + + NSOpenGLPixelFormatAttribute attrs[] = { + NSOpenGLPFAOpenGLProfile, NSOpenGLProfileVersion4_1Core, + NSOpenGLPFAColorSize, 24, + NSOpenGLPFAAlphaSize, 8, + NSOpenGLPFADoubleBuffer, + NSOpenGLPFAAccelerated, + 0 + }; + NSOpenGLPixelFormat *pf = [[NSOpenGLPixelFormat alloc] initWithAttributes:attrs]; + if (!pf) { + return 3; + } + + g_context = [[NSOpenGLContext alloc] initWithFormat:pf shareContext:nil]; + if (!g_context) { + return 4; + } + + [g_context setView:g_view]; + + // Make the GL surface itself transparent so the overlay composites over + // the desktop (the analogue of the Wayland alpha overlay). + GLint opacity = 0; + [g_context setValues:&opacity forParameter:NSOpenGLContextParameterSurfaceOpacity]; + + [g_context makeCurrentContext]; + + // Order the overlay front, take focus, and hide the system cursor (the + // Wayland backend hides it by setting a NULL pointer cursor). + [g_window makeKeyAndOrderFront:nil]; + [NSApp activateIgnoringOtherApps:YES]; + [NSCursor hide]; + + [g_context update]; + seed_mouse_global(); + NSRect viewport = [g_view bounds]; + glViewport(0, 0, (GLsizei)viewport.size.width, (GLsizei)viewport.size.height); + + return 0; + } +} + +void cocoa_get_dimensions(int32_t *width, int32_t *height) { + if (!g_view) { + *width = 0; + *height = 0; + return; + } + NSRect bounds = [g_view bounds]; + *width = (int32_t)bounds.size.width; + *height = (int32_t)bounds.size.height; +} + +void cocoa_swap_buffers(void) { + [g_context flushBuffer]; +} + +void cocoa_poll_events(void) { + @autoreleasepool { + NSEvent *event; + while ((event = [NSApp nextEventMatchingMask:NSEventMaskAny + untilDate:[NSDate distantPast] + inMode:NSDefaultRunLoopMode + dequeue:YES])) { + if (!g_input_disabled) { + switch ([event type]) { + case NSEventTypeMouseMoved: + case NSEventTypeLeftMouseDragged: + update_mouse_from_event(event); + break; + case NSEventTypeLeftMouseDown: + g_button_state = 1; + update_mouse_from_event(event); + break; + case NSEventTypeLeftMouseUp: + g_button_state = 0; + update_mouse_from_event(event); + break; + case NSEventTypeKeyDown: { + uint32_t k = map_keycode([event keyCode]); + if (k != 0) { + g_last_key = k; + g_last_key_state = 1; + } + break; + } + case NSEventTypeKeyUp: { + uint32_t k = map_keycode([event keyCode]); + if (k != 0) { + g_last_key = k; + g_last_key_state = 0; + } + break; + } + default: + break; + } + } + [NSApp sendEvent:event]; + } + } +} + +void cocoa_get_mouse_pos(double *x, double *y) { + *x = g_mouse_x; + *y = g_mouse_y; +} + +int cocoa_get_button_state(void) { + return g_button_state; +} + +void cocoa_disable_input(void) { + g_input_disabled = 1; + g_button_state = 0; + if (g_window) { + [g_window setIgnoresMouseEvents:YES]; + } +} + +uint32_t cocoa_get_last_key(void) { + return g_last_key; +} + +uint32_t cocoa_get_last_key_state(void) { + return g_last_key_state; +} + +void cocoa_clear_last_key(void) { + g_last_key = 0; + g_last_key_state = 0; +} + +void cocoa_destroy(void) { + [NSCursor unhide]; + if (g_context) { + [g_context clearDrawable]; + g_context = nil; + } + if (g_window) { + [g_window close]; + g_window = nil; + } + g_view = nil; +} diff --git a/pkg/wayland/wayland.go b/pkg/wayland/wayland.go index d14807f..28661b9 100644 --- a/pkg/wayland/wayland.go +++ b/pkg/wayland/wayland.go @@ -1,3 +1,5 @@ +//go:build linux + package wayland /*