diff --git a/extensions/color-picker/.gitignore b/extensions/color-picker/.gitignore new file mode 100644 index 000000000..eb3ae2051 --- /dev/null +++ b/extensions/color-picker/.gitignore @@ -0,0 +1,2 @@ +node_modules +vicinae-env.d.ts diff --git a/extensions/color-picker/README.md b/extensions/color-picker/README.md new file mode 100644 index 000000000..392b2b613 --- /dev/null +++ b/extensions/color-picker/README.md @@ -0,0 +1,57 @@ +# Color Picker for Vicinae + +A powerful color picker, manager, and converter extension for **Vicinae** on **Linux**, ported and adapted from Raycast's Color Picker. + +Supported on **Linux** (GNOME, KDE, Hyprland, Sway, Wayland, and X11). + +--- + +## Commands + +### `pick-color` - Pick Color +Activates a screen loupe/eyedropper across your monitors, allowing you to sample any pixel. +- Copies the sampled color directly to the clipboard in your preferred format. +- Adds the color to your persistent color history. +- Shows a HUD confirmation with the copied color. + +### `organize-colors` - Organize Colors +Manage, search, and export your picked color history. +- View in grid mode (with live SVG color swatches) or list mode. +- Pin your favorite colors to the top. +- Rename colors with custom titles. +- Copy colors in 12 different formats (HEX, RGB, RGBA, HSL, HSV, OKLCH, LCH, Display P3, etc.). +- Export colors as CSS variables, CSS classes, or JSON. +- Trigger color sampling directly with a single click. + +### `favorite-colors` - Favorite Colors +Quick access to all colors pinned as favorites in Organize Colors. + +### `convert-color` - Convert Color +Universal color conversion tool. Pass any color format (or enter one in the search bar) to immediately get conversions in all major formats with one-click copy. + +### `color-names` - Color Names +Search and identify colors by their descriptive names using the `color-namer` palette. + +### `color-wheel` - Color Wheel Reference +Displays an RGB spectrum color wheel reference with quick access to the desktop screen picker. + +--- + +## Screen Picker Architecture + +- **Linux**: Uses D-Bus color picking through `org.freedesktop.portal.Screenshot.PickColor` and `org.gnome.Shell.Screenshot.PickColor`. + +--- + +## Preferences + +- **Color Format**: Default format when copying picked colors (`HEX`, `HEX Lower Case`, `HEX No Prefix`, `RGB`, `RGB %`, `RGBA`, `RGBA %`, `HSLA`, `HSVA`, `OKLCH`, `LCH`, `P3`). +- **Show color name after picking**: Optionally show the matched color name in the notification after sampling. +- **Primary Action**: Configure whether selecting a color copies it to the clipboard or pastes it directly to the frontmost application. + +--- + +## Credits + +- Ported and maintained for Vicinae by [manuelcontrera](https://github.com/manuelcontrera). +- Original Raycast extension created by [thomas](https://github.com/thomas). diff --git a/extensions/color-picker/assets/bin/picker-linux.py b/extensions/color-picker/assets/bin/picker-linux.py new file mode 100755 index 000000000..2308f3d88 --- /dev/null +++ b/extensions/color-picker/assets/bin/picker-linux.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +import sys +import json +import uuid + +def try_gnome(bus): + try: + from gi.repository import Gio + proxy = Gio.DBusProxy.new_sync( + bus, + Gio.DBusProxyFlags.NONE, + None, + 'org.gnome.Shell.Screenshot', + '/org/gnome/Shell/Screenshot', + 'org.gnome.Shell.Screenshot', + None + ) + res = proxy.call_sync('PickColor', None, Gio.DBusCallFlags.NONE, 60000, None) + val = res.unpack()[0] + if 'color' in val: + r, g, b = val['color'] + return { + "red": float(r), + "green": float(g), + "blue": float(b), + "alpha": 1.0, + "colorSpace": "sRGB" + } + return None + except Exception as e: + err_msg = str(e).lower() + if "cancel" in err_msg or "abort" in err_msg or "dismiss" in err_msg: + return "CANCELLED" + return None + +def try_portal(bus): + try: + from gi.repository import Gio, GLib + loop = GLib.MainLoop() + token = "picker_" + uuid.uuid4().hex[:8] + sender = bus.get_unique_name()[1:].replace('.', '_') + handle_path = f"/org/freedesktop/portal/desktop/request/{sender}/{token}" + + result_color = None + + def on_signal(connection, sender_name, object_path, interface_name, signal_name, parameters, user_data): + nonlocal result_color + try: + response_code, results = parameters.unpack() + if response_code == 0 and 'color' in results: + r, g, b = results['color'] + result_color = { + "red": float(r), + "green": float(g), + "blue": float(b), + "alpha": 1.0, + "colorSpace": "sRGB" + } + finally: + loop.quit() + + bus.signal_subscribe( + None, + "org.freedesktop.portal.Request", + "Response", + handle_path, + None, + Gio.DBusSignalFlags.NONE, + on_signal, + None + ) + + params = GLib.Variant.new_tuple( + GLib.Variant.new_string(""), + GLib.Variant('a{sv}', {'handle_token': GLib.Variant('s', token)}) + ) + + bus.call_sync( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.Screenshot", + "PickColor", + params, + None, + Gio.DBusCallFlags.NONE, + -1, + None + ) + loop.run() + return result_color + except Exception: + return None + +def main(): + try: + from gi.repository import Gio + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + except Exception as e: + sys.stderr.write(f"Failed to connect to D-Bus: {e}\n") + sys.exit(2) + + # 1. First try GNOME Shell native loupe (direct, no portal prompt) + color = try_gnome(bus) + if color == "CANCELLED": + sys.exit(1) + if color: + print(json.dumps(color)) + sys.exit(0) + + # 2. Fallback to XDG Desktop Portal (KDE, Wayland compositors) + color = try_portal(bus) + if color: + print(json.dumps(color)) + sys.exit(0) + + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/extensions/color-picker/assets/icon.png b/extensions/color-picker/assets/icon.png new file mode 100644 index 000000000..76fad0915 Binary files /dev/null and b/extensions/color-picker/assets/icon.png differ diff --git a/extensions/color-picker/assets/rgb-color-wheel.png b/extensions/color-picker/assets/rgb-color-wheel.png new file mode 100644 index 000000000..fa9efe547 Binary files /dev/null and b/extensions/color-picker/assets/rgb-color-wheel.png differ diff --git a/extensions/color-picker/package-lock.json b/extensions/color-picker/package-lock.json new file mode 100644 index 000000000..7c38e4b86 --- /dev/null +++ b/extensions/color-picker/package-lock.json @@ -0,0 +1,768 @@ +{ + "name": "color-picker", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "color-picker", + "license": "MIT", + "dependencies": { + "@vicinae/api": "^0.29.0", + "color-namer": "^1.4.0", + "colorjs.io": "^0.5.1", + "lodash": "^4.17.23" + }, + "devDependencies": { + "@types/color-namer": "^1.3.3", + "@types/lodash": "^4.17.23", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "typescript": "^5.8.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/color-namer": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/color-namer/-/color-namer-1.3.3.tgz", + "integrity": "sha512-DJ0MHHqazwb94dUBRhahK49nXhRGk2DIRNrVNVR3U3uQjYWY4uNhEjHXO+TbFQGpZcwli1pCj6xm3S3KsB/+3A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@vicinae/api": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@vicinae/api/-/api-0.29.0.tgz", + "integrity": "sha512-NDXl/oUZkBcO9/YSdR4Q3P9iePmtJDecwYMBiQnL94VsHcOP2+5HayShEjVzcZ3va7+lB5G2bxLeDsMrbwdeiQ==", + "license": "ISC", + "dependencies": { + "@types/react": "19.1.17", + "chokidar": "^4.0.3", + "esbuild": "^0.25.2", + "react": "19.1.1", + "typescript": "5.9.2", + "zod": "^4.0.17" + }, + "bin": { + "vici": "dist/bin.js" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@vicinae/api/node_modules/@types/react": { + "version": "19.1.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", + "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@vicinae/api/node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chroma-js": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-1.4.1.tgz", + "integrity": "sha512-jTwQiT859RTFN/vIf7s+Vl/Z2LcMrvMv3WUFmd/4u76AdlFC0NTNgqEEFPcRiHmAswPsMiQEDZLM8vX8qXpZNQ==" + }, + "node_modules/color-namer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/color-namer/-/color-namer-1.4.0.tgz", + "integrity": "sha512-3mQMY9MJyfdV2uhe+xjQWcKHtYnPtl5svGjt89V2WWT2MlaLAd7C02886Wq7H1MTjjIIEa/NJLYPNF/Lhxhq2A==", + "license": "MIT", + "dependencies": { + "chroma-js": "^1.3.4", + "es6-weak-map": "^2.0.3" + } + }, + "node_modules/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "license": "ISC", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/extensions/color-picker/package.json b/extensions/color-picker/package.json new file mode 100644 index 000000000..74f644f6c --- /dev/null +++ b/extensions/color-picker/package.json @@ -0,0 +1,208 @@ +{ + "$schema": "https://raw.githubusercontent.com/vicinaehq/vicinae/refs/heads/main/extra/schemas/extension.json", + "name": "color-picker", + "title": "Color Picker", + "description": "Pick, organize, and convert colors on Linux", + "icon": "icon.png", + "author": "manuelcontrera", + "pastContributors": [ + "thomas" + ], + "categories": [ + "Design Tools", + "Developer Tools" + ], + "license": "MIT", + "platforms": [ + "Linux" + ], + "commands": [ + { + "name": "pick-color", + "title": "Pick Color", + "description": "Pick a color on your desktop", + "mode": "no-view", + "icon": "icon.png", + "preferences": [ + { + "name": "showColorName", + "title": "Show color name after picking", + "description": "Show the name of the picked color after picking", + "type": "checkbox", + "label": "Show", + "default": false, + "required": false + } + ] + }, + { + "name": "organize-colors", + "title": "Organize Colors", + "description": "Organize and manage your picked colors", + "mode": "view", + "icon": "icon.png", + "preferences": [ + { + "name": "primaryAction", + "title": "Primary Action", + "description": "The primary action to perform on a selected color", + "type": "dropdown", + "default": "copy", + "required": false, + "data": [ + { + "title": "Copy to Clipboard", + "value": "copy" + }, + { + "title": "Paste to Active App", + "value": "paste" + } + ] + } + ] + }, + { + "name": "favorite-colors", + "title": "Favorite Colors", + "description": "View and use your favorite colors", + "mode": "view", + "icon": "icon.png" + }, + { + "name": "convert-color", + "title": "Convert Color", + "description": "Convert any color to a different format", + "mode": "view", + "icon": "icon.png", + "arguments": [ + { + "name": "text", + "type": "text", + "required": false, + "placeholder": "#FF6363" + } + ] + }, + { + "name": "color-names", + "title": "Color Names", + "description": "Search and identify colors by name", + "mode": "view", + "icon": "icon.png", + "preferences": [ + { + "type": "dropdown", + "name": "colorNamesPerGroup", + "required": false, + "title": "Color Names Per Group", + "description": "The number of color names to display per group", + "default": "5", + "data": [ + { + "title": "5", + "value": "5" + }, + { + "title": "10", + "value": "10" + }, + { + "title": "25", + "value": "25" + }, + { + "title": "All", + "value": "Infinity" + } + ] + } + ] + }, + { + "name": "color-wheel", + "title": "Color Wheel Reference", + "description": "Display a color wheel reference and pick colors from screen", + "mode": "view", + "icon": "icon.png" + } + ], + "preferences": [ + { + "name": "colorFormat", + "title": "Color Format", + "description": "The color format to use when copying/pasting colors", + "type": "dropdown", + "default": "hex", + "required": false, + "data": [ + { + "title": "HEX - #FF6363", + "value": "hex" + }, + { + "title": "HEX Lower Case - #ff6363", + "value": "hex-lower-case" + }, + { + "title": "HEX No Prefix - FF6363", + "value": "hex-no-prefix" + }, + { + "title": "RGB - rgb(255 99 99 / 100%)", + "value": "rgb" + }, + { + "title": "RGB % - rgb(100% 38% 38% / 100%)", + "value": "rgb-percentage" + }, + { + "title": "RGBA - rgba(255, 99, 99, 1)", + "value": "rgba" + }, + { + "title": "RGBA % - rgba(100%, 39%, 39%, 1)", + "value": "rgba-percentage" + }, + { + "title": "HSLA - hsla(0, 100%, 69%, 1)", + "value": "hsla" + }, + { + "title": "HSVA - color(hsv 43.082 81.145 100)", + "value": "hsva" + }, + { + "title": "OKLCH - oklch(0.6987 0.1902 23.468)", + "value": "oklch" + }, + { + "title": "LCH - lch(63.127 68.676 28.723)", + "value": "lch" + }, + { + "title": "P3 - color(display-p3 0.9248 0.428 0.4078)", + "value": "p3" + } + ] + } + ], + "scripts": { + "build": "vici build", + "dev": "vici develop", + "lint": "vici lint" + }, + "dependencies": { + "@vicinae/api": "^0.29.0", + "color-namer": "^1.4.0", + "colorjs.io": "^0.5.1", + "lodash": "^4.17.23" + }, + "devDependencies": { + "@types/color-namer": "^1.3.3", + "@types/lodash": "^4.17.23", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "typescript": "^5.8.0" + } +} diff --git a/extensions/color-picker/src/color-names.tsx b/extensions/color-picker/src/color-names.tsx new file mode 100644 index 000000000..f7fe483b2 --- /dev/null +++ b/extensions/color-picker/src/color-names.tsx @@ -0,0 +1,76 @@ +import { getPreferenceValues, List } from "@vicinae/api"; +import colorNamer from "color-namer"; +import { useEffect, useState } from "react"; +import { ColorNameListItem } from "./components/ColorNames"; +import type { ColorNamesPreferences, SortType } from "./lib/types"; +import { getColorByPlatform, getColorByProximity, normalizeColorHex } from "./lib/utils"; + +export default function ColorNames() { + const [isSearching, setIsSearching] = useState(false); + const [searchString, setSearchString] = useState("#FF6363"); + const [colors, setColors] = useState>(); + const [sortBy, setSortBy] = useState("platform"); + const normalizedSearchString = normalizeColorHex(searchString); + + let colorNamesPerGroup = "5"; + try { + const prefs = getPreferenceValues(); + if (prefs?.colorNamesPerGroup) { + colorNamesPerGroup = prefs.colorNamesPerGroup; + } + } catch { + // default + } + + const loadColors = (hexStr: string) => { + setIsSearching(true); + try { + const result = colorNamer(hexStr); + setColors(result); + } catch { + setColors(undefined); + } + setIsSearching(false); + }; + + useEffect(() => { + loadColors(normalizedSearchString); + }, [normalizedSearchString]); + + return ( + { + setSortBy(v as SortType); + }} + > + + + + } + > + {colors ? ( + sortBy === "platform" ? ( + getColorByPlatform(normalizedSearchString, colors).map(([palette, colorList]) => ( + + {colorList.slice(0, Number(colorNamesPerGroup)).map((color, index) => ( + + ))} + + )) + ) : ( + getColorByProximity(colors).map((color, index) => ( + + )) + ) + ) : ( + + )} + + ); +} diff --git a/extensions/color-picker/src/color-wheel.tsx b/extensions/color-picker/src/color-wheel.tsx new file mode 100644 index 000000000..0ad206636 --- /dev/null +++ b/extensions/color-picker/src/color-wheel.tsx @@ -0,0 +1,59 @@ +import { Action, ActionPanel, Clipboard, closeMainWindow, Detail, popToRoot, showHUD } from "@vicinae/api"; +import { useEffect, useRef } from "react"; +import { addToHistory } from "./lib/history"; +import type { Color } from "./lib/types"; +import { getFormattedColor } from "./lib/utils"; +import { pickScreenColor } from "./native/picker"; + +const COLOR_WHEEL_MARKDOWN = ` +# Color Wheel Reference + +A reference RGB color wheel spectrum. Use the desktop screen picker to sample colors directly from the wheel or anywhere on your screen. + +![RGB Color Wheel](rgb-color-wheel.png) +`; + +export default function Command() { + const hasInitialized = useRef(false); + + async function pickAndHandleColor() { + try { + const pickedColor: Color | null = await pickScreenColor(); + if (!pickedColor) { + return; + } + + await addToHistory(pickedColor); + + const formattedColor = getFormattedColor(pickedColor); + if (!formattedColor) { + throw new Error("Failed to format color"); + } + + await Clipboard.copy(formattedColor); + await showHUD(`Copied color ${formattedColor} to clipboard`); + await closeMainWindow(); + await popToRoot(); + } catch (e) { + console.error(e); + await showHUD("❌ Failed picking color"); + } + } + + useEffect(() => { + if (hasInitialized.current) return; + hasInitialized.current = true; + pickAndHandleColor(); + }, []); + + return ( + + + + } + /> + ); +} diff --git a/extensions/color-picker/src/components/ColorConvert.tsx b/extensions/color-picker/src/components/ColorConvert.tsx new file mode 100644 index 000000000..ef9b7b927 --- /dev/null +++ b/extensions/color-picker/src/components/ColorConvert.tsx @@ -0,0 +1,49 @@ +import { Action, ActionPanel, Clipboard, List, LocalStorage, popToRoot, showHUD, showToast, Toast } from "@vicinae/api"; +import type { ColorFormatType } from "../lib/types"; +import { getFormattedColor } from "../lib/utils"; + +type ColorFormatProps = { + text: string; + title: string; + value: string; +}; + +function getConvertedColor(text: string, format: ColorFormatType) { + try { + const convertedColor = getFormattedColor(text, format); + return convertedColor; + } catch { + showToast({ + style: Toast.Style.Failure, + title: "Conversion failed", + message: `"${text}" is not a valid color.`, + }); + } +} + +export const ColorConvertListItem = ({ text, title, value }: ColorFormatProps) => { + const convertedColor = getConvertedColor(text, value as ColorFormatType); + if (!convertedColor) return null; + + return ( + + { + if (convertedColor) { + await Clipboard.copy(convertedColor); + await showHUD("Copied color to clipboard"); + } + await LocalStorage.setItem("lastConvertedColorFormat", value); + popToRoot({ clearSearchBar: true }); + }} + /> + + } + /> + ); +}; diff --git a/extensions/color-picker/src/components/ColorNames.tsx b/extensions/color-picker/src/components/ColorNames.tsx new file mode 100644 index 000000000..635c0b51f --- /dev/null +++ b/extensions/color-picker/src/components/ColorNames.tsx @@ -0,0 +1,35 @@ +import { Action, ActionPanel, Icon, List } from "@vicinae/api"; +import type { Color } from "color-namer"; +import { normalizeColorHex } from "../lib/utils"; + +export const ColorNameListItem = ({ color }: { color: Color }) => { + const hexCode = `#${color.hex.replace(/^#/, "")}`; + + return ( + + + + + } + /> + ); +}; diff --git a/extensions/color-picker/src/components/CopyAsSubmenu.tsx b/extensions/color-picker/src/components/CopyAsSubmenu.tsx new file mode 100644 index 000000000..4a8d22d45 --- /dev/null +++ b/extensions/color-picker/src/components/CopyAsSubmenu.tsx @@ -0,0 +1,43 @@ +import { Action, ActionPanel, Icon, Keyboard } from "@vicinae/api"; +import type { HistoryColor } from "../lib/types"; +import { getFormattedColor } from "../lib/utils"; + +type CopyAsSubmenuProps = { + color: HistoryColor; + onCopy?: () => void; +}; + +export default function CopyAsSubmenu({ color, onCopy }: CopyAsSubmenuProps) { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/extensions/color-picker/src/components/EditTitle.tsx b/extensions/color-picker/src/components/EditTitle.tsx new file mode 100644 index 000000000..9c4a30dcf --- /dev/null +++ b/extensions/color-picker/src/components/EditTitle.tsx @@ -0,0 +1,24 @@ +import { Action, ActionPanel, Form, useNavigation } from "@vicinae/api"; +import type { HistoryItem } from "../lib/types"; + +export function EditTitle(props: { item: HistoryItem; onEdit: (item: HistoryItem) => void }) { + const { item } = props; + const { pop } = useNavigation(); + + return ( +
+ { + props.onEdit({ ...item, title: values.title ? String(values.title) : undefined }); + pop(); + }} + /> + + } + > + + + ); +} diff --git a/extensions/color-picker/src/convert-color.tsx b/extensions/color-picker/src/convert-color.tsx new file mode 100644 index 000000000..4a19c89d8 --- /dev/null +++ b/extensions/color-picker/src/convert-color.tsx @@ -0,0 +1,83 @@ +import { List, LocalStorage, showToast, Toast } from "@vicinae/api"; +import { useEffect, useRef, useState } from "react"; +import { ColorConvertListItem } from "./components/ColorConvert"; + +interface ConvertColorProps { + arguments?: { + text?: string; + }; +} + +export default function ConvertColor(props: ConvertColorProps) { + const [colorText, setColorText] = useState(props.arguments?.text || null); + const [lastConvertedColorFormat, setLastConvertedColorFormat] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + const hasInitialized = useRef(false); + + useEffect(() => { + if (hasInitialized.current) return; + hasInitialized.current = true; + + async function getLastFormatAndText() { + setIsLoading(true); + try { + const lastFormat = await LocalStorage.getItem("lastConvertedColorFormat"); + setLastConvertedColorFormat(lastFormat); + } catch { + // ignore + } + + if (!colorText) { + // Default example color if no text provided + setColorText("#FF6363"); + } + setIsLoading(false); + } + + getLastFormatAndText(); + }, [colorText]); + + const format = [ + { title: "HEX", subtitle: "#FF6363", value: "hex" }, + { title: "HEX Lower Case", subtitle: "#ff6363", value: "hex-lower-case" }, + { title: "HEX No Prefix", subtitle: "FF6363", value: "hex-no-prefix" }, + { title: "RGB", subtitle: "rgb(255 99 99 / 100%)", value: "rgb" }, + { title: "RGB %", subtitle: "rgb(100% 38% 38% / 100%)", value: "rgb-percentage" }, + { title: "RGBA", subtitle: "rgba(255, 99, 99, 1)", value: "rgba" }, + { title: "RGBA %", subtitle: "rgba(100%, 39%, 39%, 1)", value: "rgba-percentage" }, + { title: "HSLA", subtitle: "hsla(0, 100%, 69%, 1)", value: "hsla" }, + { title: "HSVA", subtitle: "color(hsv 43.082 81.145 100)", value: "hsva" }, + { title: "OKLCH", subtitle: "oklch(0.6987 0.1902 23.468)", value: "oklch" }, + { title: "LCH", subtitle: "lch(63.127 68.676 28.723)", value: "lch" }, + { title: "P3", subtitle: "color(display-p3 0.9248 0.428 0.4078)", value: "p3" }, + ]; + + if (lastConvertedColorFormat) { + const index = format.findIndex((item) => item.value === lastConvertedColorFormat); + if (index !== -1) { + const [lastFormat] = format.splice(index, 1); + format.unshift(lastFormat); + } + } + + return ( + { + if (text && text.trim().length > 0) { + setColorText(text.trim()); + } + }} + > + {colorText && + format.map((item) => ( + + ))} + + + ); +} diff --git a/extensions/color-picker/src/favorite-colors.tsx b/extensions/color-picker/src/favorite-colors.tsx new file mode 100644 index 000000000..4bd98fb8a --- /dev/null +++ b/extensions/color-picker/src/favorite-colors.tsx @@ -0,0 +1,65 @@ +import { Action, ActionPanel, Icon, List, showToast } from "@vicinae/api"; +import { useHistory } from "./lib/history"; +import { getAccessories, getFormattedColor, getIcon, getPreviewColor } from "./lib/utils"; +import OrganizeColors from "./organize-colors"; + +function OpenOrganizeColorsAction() { + return ( + } + /> + ); +} + +export default function Command() { + const { history, isLoading, removeFromFavorites } = useHistory(); + const favorites = history?.filter((item) => item.isFavorite) ?? []; + + return ( + + {!isLoading && favorites.length === 0 ? ( + + + + } + /> + ) : ( + favorites.map((item) => { + const formattedColor = getFormattedColor(item.color); + const previewColor = getPreviewColor(item.color); + + return ( + + + { + removeFromFavorites(item.color); + await showToast({ title: "Removed from favorites" }); + }} + /> + + + } + /> + ); + }) + )} + + ); +} diff --git a/extensions/color-picker/src/hooks/useColorsSelection.ts b/extensions/color-picker/src/hooks/useColorsSelection.ts new file mode 100644 index 000000000..80a9ca8d7 --- /dev/null +++ b/extensions/color-picker/src/hooks/useColorsSelection.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from "react"; +import type { HistoryItem, UseColorsSelectionObject } from "../lib/types"; + +type UseSelectionReturn = { + selection: UseColorsSelectionObject; +}; + +export function useColorsSelection( + items: T[], + getKey?: (item: T) => string, +): UseSelectionReturn { + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + + const computeKey = (i: T): string => (getKey ? getKey(i) : (i as unknown as string)); + + useEffect(() => { + setSelectedKeys((prev) => { + if (prev.size === 0) return prev; + const valid = new Set(items.map((i) => (getKey ? getKey(i) : (i as unknown as string)))); + const next = new Set(); + for (const k of prev) if (valid.has(k)) next.add(k); + return next.size === prev.size ? prev : next; + }); + }, [items, getKey]); + + const toggleSelection = (item: T) => { + setSelectedKeys((prev) => { + const next = new Set(prev); + const k = computeKey(item); + if (next.has(k)) next.delete(k); + else next.add(k); + return next; + }); + }; + + const selectAll = () => setSelectedKeys(new Set(items.map(computeKey))); + const clearSelection = () => setSelectedKeys(new Set()); + const getIsItemSelected = (item: T) => selectedKeys.has(computeKey(item)); + + const selectedItems = items.filter((i) => selectedKeys.has(computeKey(i))); + + return { + selection: { + actions: { toggleSelection, selectAll, clearSelection }, + selected: { + selectedItems, + anySelected: selectedItems.length > 0, + allSelected: items.length > 0 && selectedItems.length === items.length, + countSelected: selectedItems.length, + }, + helpers: { getIsItemSelected }, + }, + }; +} diff --git a/extensions/color-picker/src/lib/color-format.ts b/extensions/color-picker/src/lib/color-format.ts new file mode 100644 index 000000000..64d07dce8 --- /dev/null +++ b/extensions/color-picker/src/lib/color-format.ts @@ -0,0 +1,55 @@ +import ColorJS from "colorjs.io"; +import type { ColorFormatType, HistoryColor } from "./types"; + +export function formatColor(_color: HistoryColor, format?: ColorFormatType) { + let color: any; + if (typeof _color === "string") { + color = new ColorJS(_color); + } else if ("colorSpace" in _color) { + color = new ColorJS(_color.colorSpace, [_color.red, _color.green, _color.blue], _color.alpha); + } else { + color = new ColorJS("srgb", [_color.red / 255, _color.green / 255, _color.blue / 255], _color.alpha); + } + + switch (format) { + default: + case "hex": { + return color.to("srgb").toString({ format: "hex" }).toUpperCase(); + } + case "hex-lower-case": { + return color.to("srgb").toString({ format: "hex" }).toLowerCase(); + } + case "hex-no-prefix": { + return color.to("srgb").toString({ format: "hex" }).replace("#", ""); + } + case "rgb": { + return color.to("srgb").toString({ format: "rgb_number" }); + } + case "rgb-percentage": { + return color.to("srgb").toString({ format: "rgb" }); + } + case "rgba": { + return color.to("srgb").toString({ format: "rgba_number" }); + } + case "rgba-percentage": { + return color.to("srgb").toString({ format: "rgba" }); + } + case "hsla": { + return color.to("hsl").toString({ format: "hsla" }); + } + case "hsva": { + return color.to("hsv").toString({ format: "color" }); + } + case "oklch": { + return color.to("oklch").toString(); + } + case "lch": { + const lchColor = color.to("lch"); + const [l, c, h] = lchColor.coords; + return `lch(${l.toFixed(2)}% ${c} ${h})`; + } + case "p3": { + return color.to("p3").toString({ format: "p3" }); + } + } +} diff --git a/extensions/color-picker/src/lib/history.ts b/extensions/color-picker/src/lib/history.ts new file mode 100644 index 000000000..f60ccf13a --- /dev/null +++ b/extensions/color-picker/src/lib/history.ts @@ -0,0 +1,140 @@ +import { LocalStorage } from "@vicinae/api"; +import { useEffect, useState } from "react"; +import type { HistoryColor, HistoryItem } from "./types"; +import { getFormattedColor } from "./utils"; + +const MAX_HISTORY_LENGTH = 200; +const STORAGE_KEY = "history"; + +let inMemoryHistory: HistoryItem[] | null = null; +const listeners = new Set<(items: HistoryItem[]) => void>(); + +export async function getHistory(): Promise { + if (inMemoryHistory !== null) { + return inMemoryHistory; + } + try { + const data = await LocalStorage.getItem(STORAGE_KEY); + if (!data) { + inMemoryHistory = []; + return []; + } + inMemoryHistory = JSON.parse(data) as HistoryItem[]; + return inMemoryHistory; + } catch (error) { + console.error("Failed to read history from LocalStorage:", error); + inMemoryHistory = []; + return []; + } +} + +export async function saveHistory(items: HistoryItem[]): Promise { + inMemoryHistory = items; + try { + await LocalStorage.setItem(STORAGE_KEY, JSON.stringify(items)); + } catch (error) { + console.error("Failed to persist history to LocalStorage:", error); + } + listeners.forEach((listener) => listener(items)); +} + +export async function addToHistory(color: HistoryColor, options?: { isFavorite?: boolean }) { + const previousHistory = await getHistory(); + const colorKey = getFormattedColor(color); + const previousHistoryItem = previousHistory.find((item) => getFormattedColor(item.color) === colorKey); + + const historyItem: HistoryItem = { + date: new Date().toISOString(), + color, + title: previousHistoryItem?.title, + isFavorite: options?.isFavorite ?? previousHistoryItem?.isFavorite, + }; + + const history = previousHistoryItem?.isFavorite + ? previousHistory.map((item) => (getFormattedColor(item.color) === colorKey ? historyItem : item)) + : [historyItem, ...previousHistory.filter((item) => getFormattedColor(item.color) !== colorKey)]; + + const persistentItemsCount = history.filter((item) => item.isFavorite).length; + const maxRegularHistoryLength = Math.max(MAX_HISTORY_LENGTH - persistentItemsCount, 0); + let regularHistoryCount = 0; + const newHistory = history.filter((item) => { + if (item.isFavorite) return true; + regularHistoryCount += 1; + return regularHistoryCount <= maxRegularHistoryLength; + }); + + await saveHistory(newHistory); +} + +export function useHistory() { + const [history, setHistory] = useState(() => inMemoryHistory ?? []); + const [isLoading, setIsLoading] = useState(inMemoryHistory === null); + + useEffect(() => { + let isMounted = true; + + getHistory().then((items) => { + if (isMounted) { + setHistory(items); + setIsLoading(false); + } + }); + + const listener = (newHistory: HistoryItem[]) => { + if (isMounted) { + setHistory(newHistory); + } + }; + listeners.add(listener); + + return () => { + isMounted = false; + listeners.delete(listener); + }; + }, []); + + const update = (color: HistoryColor, updateItem: (item: HistoryItem) => HistoryItem) => { + const next = history.map((item) => + getFormattedColor(item.color) === getFormattedColor(color) ? updateItem(item) : item, + ); + void saveHistory(next); + }; + + return { + history, + isLoading, + remove: (color: HistoryColor) => { + const next = history.filter((item) => getFormattedColor(item.color) !== getFormattedColor(color)); + void saveHistory(next); + }, + edit: (historyItem: HistoryItem) => { + const next = history.map((item) => + getFormattedColor(item.color) === getFormattedColor(historyItem.color) ? historyItem : item, + ); + void saveHistory(next); + }, + addToFavorites: (color: HistoryColor) => update(color, (item) => ({ ...item, isFavorite: true })), + removeFromFavorites: (color: HistoryColor) => update(color, (item) => ({ ...item, isFavorite: false })), + moveFavorite: (color: HistoryColor, direction: "up" | "down") => { + const colorKey = getFormattedColor(color); + const currentIndex = history.findIndex( + (item) => item.isFavorite && getFormattedColor(item.color) === colorKey, + ); + if (currentIndex === -1) return; + + const favoriteIndexes = history.reduce((indexes, item, index) => { + if (item.isFavorite) indexes.push(index); + return indexes; + }, []); + const favoriteIndex = favoriteIndexes.indexOf(currentIndex); + const targetFavoriteIndex = favoriteIndex + (direction === "up" ? -1 : 1); + const targetIndex = favoriteIndexes[targetFavoriteIndex]; + if (targetIndex === undefined) return; + + const nextHistory = [...history]; + [nextHistory[currentIndex], nextHistory[targetIndex]] = [nextHistory[targetIndex], nextHistory[currentIndex]]; + void saveHistory(nextHistory); + }, + clear: () => void saveHistory([]), + }; +} diff --git a/extensions/color-picker/src/lib/types.ts b/extensions/color-picker/src/lib/types.ts new file mode 100644 index 000000000..248dda0bb --- /dev/null +++ b/extensions/color-picker/src/lib/types.ts @@ -0,0 +1,76 @@ +export type Color = { + alpha: number; + red: number; // between 0 and 1 + green: number; // between 0 and 1 + blue: number; // between 0 and 1 + colorSpace: string; +}; + +export type DeprecatedColor = { + alpha: number; + red: number; // between 0 and 255 + green: number; // between 0 and 255 + blue: number; // between 0 and 255 +}; + +export type HistoryColor = Color | DeprecatedColor | string; + +export type HistoryItem = { + date: string; + color: HistoryColor; + title?: string; + isFavorite?: boolean; +}; + +export type SortType = "platform" | "proximity"; + +export type ColorFormatType = + | "hex" + | "hex-lower-case" + | "hex-no-prefix" + | "rgb" + | "rgb-percentage" + | "rgba" + | "rgba-percentage" + | "hsla" + | "hsva" + | "oklch" + | "lch" + | "p3"; + +export type UseColorsSelectionObject = { + actions: { + toggleSelection: (item: T) => void; + selectAll: () => void; + clearSelection: () => void; + }; + selected: { + selectedItems: T[]; + anySelected: boolean; + allSelected: boolean; + countSelected: number; + }; + helpers: { + getIsItemSelected: (item: T) => boolean; + }; +}; + +export type CopyColorsFormat = "json" | "css-classes" | "css-variables"; + +export type SelectMode = "single" | "multi"; + +export interface ExtensionPreferences { + colorFormat?: ColorFormatType; +} + +export interface PickColorPreferences { + showColorName?: boolean; +} + +export interface OrganizeColorsPreferences { + primaryAction?: "copy" | "paste"; +} + +export interface ColorNamesPreferences { + colorNamesPerGroup?: string; +} diff --git a/extensions/color-picker/src/lib/utils.ts b/extensions/color-picker/src/lib/utils.ts new file mode 100644 index 000000000..909b74897 --- /dev/null +++ b/extensions/color-picker/src/lib/utils.ts @@ -0,0 +1,150 @@ +import { getPreferenceValues, Icon, Image, Keyboard, List } from "@vicinae/api"; +import type { Colors, Palette } from "color-namer"; +import uniqBy from "lodash/uniqBy"; +import { formatColor } from "./color-format"; +import type { ColorFormatType, CopyColorsFormat, ExtensionPreferences, HistoryColor, HistoryItem } from "./types"; + +export const isMac = process.platform === "darwin"; +export const isWindows = process.platform === "win32"; + +export function getPreferences(): ExtensionPreferences { + try { + return getPreferenceValues(); + } catch { + return {}; + } +} + +export function getFormattedColor(_color: HistoryColor, format?: ColorFormatType) { + const prefs = getPreferences(); + return formatColor(_color, format || prefs.colorFormat || "hex"); +} + +export function getPreviewColor(color: HistoryColor) { + try { + return getFormattedColor(color, "hex"); + } catch { + return typeof color === "string" ? color : "#000000"; + } +} + +export function getShortcut(index: number) { + const key = index + 1; + let shortcut: Keyboard.Shortcut | undefined; + if (key >= 1 && key <= 9) { + shortcut = { modifiers: ["cmd"], key: String(key) as any }; + } + return shortcut; +} + +export function getIcon(color: HistoryColor): Image.ImageLike | undefined { + let previewColor: string | undefined; + try { + previewColor = getFormattedColor(color, "hex"); + } catch { + previewColor = typeof color === "string" ? color : undefined; + } + if (!previewColor) { + return undefined; + } + + const icon: Image.ImageLike = { + source: Icon.CircleFilled, + tintColor: { light: previewColor, dark: previewColor, adjustContrast: false }, + }; + + return icon; +} + +export function getAccessories(historyItem: HistoryItem) { + const accessories = new Array(); + accessories.push({ text: new Date(historyItem.date).toLocaleDateString(), tooltip: new Date(historyItem.date).toLocaleString() }); + return accessories; +} + +export function normalizeColorHex(colorInput: string) { + let hex = colorInput.replace(/^#/, ""); + const validHexPattern = /^([a-f\d]{3,4}|[a-f\d]{6}|[a-f]\d{8})$/i; + if (validHexPattern.test(hex)) { + switch (hex.length) { + case 3: + case 4: + hex = hex + .slice(0, 3) + .split("") + .map((x) => x.repeat(2)) + .join(""); + break; + case 8: + hex = hex.slice(0, 6); + break; + } + } + return "#" + hex.toUpperCase(); +} + +export function getColorByPlatform(normalizedSearchString: string, colors?: Colors) { + return Object.entries(colors ?? {}).sort(([, a], [, b]) => { + if (normalizeColorHex(a[0].hex) === normalizeColorHex(b[0].hex)) return 0; + if (normalizedSearchString === normalizeColorHex(a[0].hex)) return -1; + return 1; + }); +} + +export function getColorByProximity(colors?: Colors) { + return uniqBy(Object.values(colors ?? {}).flat(), (x) => x.name.toLowerCase()).sort( + (a, b) => a.distance - b.distance, + ); +} + +export const COPY_FORMATS: Array<{ format: CopyColorsFormat; title: string; icon: Icon }> = [ + { format: "json", title: "Copy Colors as JSON", icon: Icon.CodeBlock }, + { format: "css-classes", title: "Copy Colors as CSS Classes", icon: Icon.Brush }, + { format: "css-variables", title: "Copy Colors as CSS Variables", icon: Icon.Cog }, +]; + +export function getColor(item: HistoryItem | string): HistoryColor { + return typeof item === "string" ? item : item.color; +} + +export function parseColorList(colors: string): string[] { + const parsed = colors + .split(";") + .map((color) => color.trim()) + .filter((color) => color.length > 0); + + if (parsed.length === 0) { + throw new Error("Provide at least one CSS color."); + } + + return parsed; +} + +export function copyAsJSON(items: (HistoryItem | string)[]): string { + return JSON.stringify({ colors: items.map((item) => getFormattedColor(getColor(item))) }, null, 2); +} + +export function copyAsCSSClasses(items: (HistoryItem | string)[]): string { + return items + .map((item, index) => { + const color = getFormattedColor(getColor(item)); + return `.color-${index + 1} {\n color: ${color};\n}`; + }) + .join("\n\n"); +} + +export function copyAsCSSVariables(items: (HistoryItem | string)[]): string { + const lines = items.map((item, index) => ` --color-${index + 1}: ${getFormattedColor(getColor(item))};`); + return [":root {", ...lines, "}"].join("\n"); +} + +export function copySelectedColors(items: (HistoryItem | string)[], format: CopyColorsFormat): string { + switch (format) { + case "json": + return copyAsJSON(items); + case "css-classes": + return copyAsCSSClasses(items); + case "css-variables": + return copyAsCSSVariables(items); + } +} diff --git a/extensions/color-picker/src/native/picker.ts b/extensions/color-picker/src/native/picker.ts new file mode 100644 index 000000000..daee9b14a --- /dev/null +++ b/extensions/color-picker/src/native/picker.ts @@ -0,0 +1,61 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; +import { environment } from "@vicinae/api"; +import type { Color } from "../lib/types"; + +const execFileAsync = promisify(execFile); + +export async function pickScreenColor(): Promise { + const platform = process.platform; + + let assetsPath = ""; + try { + assetsPath = environment.assetsPath || ""; + } catch { + // fallback + } + + const candidateDirs = [ + path.join(__dirname, "assets", "bin"), + assetsPath ? path.join(assetsPath, "bin") : "", + path.join(__dirname, "bin"), + ].filter(Boolean); + + if (platform !== "linux") { + throw new Error(`Color picking is currently only supported on Linux (current platform: ${platform})`); + } + + const candidate = candidateDirs + .map((dir) => path.join(dir, "picker-linux.py")) + .find((p) => existsSync(p)); + + if (!candidate) { + throw new Error("Linux picker script picker-linux.py not found in candidate paths: " + candidateDirs.join(", ")); + } + + const binaryPath = "python3"; + const args = [candidate]; + + try { + const { stdout } = await execFileAsync(binaryPath, args, { + timeout: 120000, + }); + + const trimmed = stdout.trim(); + if (!trimmed) { + return null; + } + + const parsed = JSON.parse(trimmed) as Color; + return parsed; + } catch (err: any) { + if (err.code === 1 || err.exitCode === 1) { + // User cancelled + return null; + } + console.error("Error executing color picker:", err); + throw err; + } +} diff --git a/extensions/color-picker/src/organize-colors.tsx b/extensions/color-picker/src/organize-colors.tsx new file mode 100644 index 000000000..a07bea91e --- /dev/null +++ b/extensions/color-picker/src/organize-colors.tsx @@ -0,0 +1,362 @@ +import { + Action, + ActionPanel, + Alert, + Clipboard, + closeMainWindow, + confirmAlert, + getPreferenceValues, + Grid, + Icon, + Keyboard, + List, + showHUD, + showToast, + Toast, +} from "@vicinae/api"; +import { useCallback, useState } from "react"; +import CopyAsSubmenu from "./components/CopyAsSubmenu"; +import { EditTitle } from "./components/EditTitle"; +import { useColorsSelection } from "./hooks/useColorsSelection"; +import { addToHistory, useHistory } from "./lib/history"; +import type { HistoryItem, OrganizeColorsPreferences, SelectMode, UseColorsSelectionObject } from "./lib/types"; +import { COPY_FORMATS, copySelectedColors, getFormattedColor, getIcon, getPreviewColor } from "./lib/utils"; +import { pickScreenColor } from "./native/picker"; + +const EMPTY_VIEW_TITLE = "No colors picked yet ¯\\_(ツ)_/¯"; +const EMPTY_VIEW_DESCRIPTION = "Use the Pick Color command or button below to pick some"; + +function getPreferences(): OrganizeColorsPreferences { + try { + return getPreferenceValues(); + } catch { + return { primaryAction: "copy" }; + } +} + +const PickColorAction = ({ onPicked }: { onPicked?: () => void }) => ( + { + try { + await closeMainWindow(); + const color = await pickScreenColor(); + if (color) { + await addToHistory(color); + const formatted = getFormattedColor(color); + await Clipboard.copy(formatted); + await showHUD(`Copied color ${formatted} to clipboard`); + onPicked?.(); + } + } catch (e: any) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed picking color", + message: e?.message, + }); + } + }} + /> +); + +export default function Command() { + const { history, isLoading } = useHistory(); + const [selectMode, setSelectMode] = useState("single"); + + const getItemKey = useCallback((item: HistoryItem) => `${item.date}-${getFormattedColor(item.color)}`, []); + const { selection } = useColorsSelection(history ?? [], getItemKey); + const favoriteHistory = history?.filter((item) => item.isFavorite) ?? []; + const regularHistory = history?.filter((item) => !item.isFavorite) ?? []; + + if (selectMode === "multi") { + return ( + setSelectMode(v as SelectMode)} + > + + + + } + > + + + + } + /> + + + + ); + } + + return ( + setSelectMode(v as SelectMode)}> + + + + } + > + + + + } + /> + + + + ); +} + +type HistorySectionProps = { + title: string; + history: HistoryItem[]; + selectMode: SelectMode; + selection: UseColorsSelectionObject; +}; + +function GridHistorySection({ title, history, selectMode, selection }: HistorySectionProps) { + if (history.length === 0) { + return null; + } + + return ( + + {history.map((historyItem) => { + const formattedColor = getFormattedColor(historyItem.color); + const previewColor = getPreviewColor(historyItem.color); + const color = { light: previewColor, dark: previewColor, adjustContrast: false }; + + return ( + } + /> + ); + })} + + ); +} + +function ListHistorySection({ title, history, selectMode, selection }: HistorySectionProps) { + if (history.length === 0) { + return null; + } + + return ( + + {history.map((historyItem) => { + const formattedColor = getFormattedColor(historyItem.color); + const previewColor = getPreviewColor(historyItem.color); + const isSelected = selection.helpers.getIsItemSelected(historyItem); + + return ( + } + /> + ); + })} + + ); +} + +type ActionsProps = { + historyItem: HistoryItem; + selectMode: SelectMode; + selection: UseColorsSelectionObject; +}; + +function Actions({ historyItem, selectMode, selection }: ActionsProps) { + const { history, remove, clear, edit, addToFavorites, removeFromFavorites, moveFavorite } = useHistory(); + const preferences = getPreferences(); + + const { toggleSelection, selectAll, clearSelection } = selection.actions; + const { anySelected, allSelected, selectedItems, countSelected } = selection.selected; + const isSelected = selection.helpers.getIsItemSelected(historyItem); + + const color = historyItem.color; + const formattedColor = getFormattedColor(color); + const favoriteHistory = history?.filter((item) => item.isFavorite) ?? []; + const favoriteIndex = favoriteHistory.findIndex((item) => getFormattedColor(item.color) === formattedColor); + const canMoveFavoriteUp = favoriteIndex > 0; + const canMoveFavoriteDown = favoriteIndex !== -1 && favoriteIndex < favoriteHistory.length - 1; + + return ( + + + {preferences.primaryAction === "copy" ? ( + <> + + + + ) : ( + <> + + + + )} + + + + + {selectMode === "multi" ? ( + <> + toggleSelection(historyItem)} + /> + {allSelected ? ( + + ) : ( + + )} + {anySelected && ( + + {COPY_FORMATS.map(({ format, title, icon }) => ( + + ))} + + )} + + ) : ( + + )} + } + /> + {historyItem.isFavorite ? ( + <> + removeFromFavorites(color)} + /> + {canMoveFavoriteUp && ( + moveFavorite(color, "up")} + /> + )} + {canMoveFavoriteDown && ( + moveFavorite(color, "down")} + /> + )} + + ) : ( + addToFavorites(color)} + /> + )} + + + + { + const confirmed = await confirmAlert({ + title: "Delete Color", + message: "Do you want to delete this color from history?", + primaryAction: { + title: "Delete", + style: Alert.ActionStyle.Destructive, + }, + }); + + if (confirmed) { + remove(historyItem.color); + await showToast({ title: "Deleted color" }); + } + }} + /> + { + const confirmed = await confirmAlert({ + title: "Delete All Colors", + message: "Do you want to delete all colors from your history?", + primaryAction: { + title: "Delete All", + style: Alert.ActionStyle.Destructive, + }, + }); + + if (confirmed) { + clear(); + await showToast({ title: "Deleted all colors" }); + } + }} + /> + + + ); +} diff --git a/extensions/color-picker/src/pick-color.ts b/extensions/color-picker/src/pick-color.ts new file mode 100644 index 000000000..bfb81a5e3 --- /dev/null +++ b/extensions/color-picker/src/pick-color.ts @@ -0,0 +1,50 @@ +import { Clipboard, closeMainWindow, getPreferenceValues, showHUD } from "@vicinae/api"; +import colorNamer from "color-namer"; +import { addToHistory } from "./lib/history"; +import type { Color, PickColorPreferences } from "./lib/types"; +import { getColorByProximity, getFormattedColor } from "./lib/utils"; +import { pickScreenColor } from "./native/picker"; + +export default async function Command() { + let showColorName = false; + try { + const prefs = getPreferenceValues(); + showColorName = Boolean(prefs.showColorName); + } catch { + // default false + } + + await closeMainWindow(); + + try { + const pickedColor: Color | null = await pickScreenColor(); + if (!pickedColor) { + return; + } + + await addToHistory(pickedColor); + + const formattedColor = getFormattedColor(pickedColor); + if (!formattedColor) { + throw new Error("Failed to format color"); + } + + await Clipboard.copy(formattedColor); + + if (showColorName) { + try { + const colors = colorNamer(formattedColor); + const colorsByDistance = getColorByProximity(colors); + const firstColorName = colorsByDistance[0]?.name; + await showHUD(`Copied color ${formattedColor} (${firstColorName}) to clipboard`); + } catch { + await showHUD(`Copied color ${formattedColor} to clipboard`); + } + } else { + await showHUD(`Copied color ${formattedColor} to clipboard`); + } + } catch (e: any) { + console.error("Pick color error:", e); + await showHUD("❌ Failed picking color"); + } +} diff --git a/extensions/color-picker/tsconfig.json b/extensions/color-picker/tsconfig.json new file mode 100644 index 000000000..8d9b6bbc1 --- /dev/null +++ b/extensions/color-picker/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Node 16", + "include": ["src/**/*"], + "compilerOptions": { + //"lib": ["es2020"], + "module": "commonjs", + "target": "es2020", + "strict": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "jsx": "react-jsx", + "types": ["node"] + } +}