feat: Color Picker plugin (#12)
* feat: add color picker panel (WIP - render bug) * docs: update README and add thumbnail * fix: rgb and hex options label key * fix: current swatch not loading on open * Modify plugin.toml metadata for color picker Updated plugin metadata fields including tags. --------- Co-authored-by: Lemmy <studio@quadbyte.net>
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
# Color Picker
|
||||||
|
|
||||||
|
A screen color picker for Noctalia v5, built on top of [hyprpicker](https://github.com/hyprwm/hyprpicker).
|
||||||
|
|
||||||
|
## Plugin
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| ------- | ----- |
|
||||||
|
| ID | `oldirtty/color_picker` |
|
||||||
|
| Entries | Service: `service`; bar widget: `widget`; panel: `panel` |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Install `hyprpicker`
|
||||||
|
|
||||||
|
- [hyprpicker](https://github.com/hyprwm/hyprpicker)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
The headless `service` triggers `hyprpicker` with arguments set in the plugin's settings.
|
||||||
|
|
||||||
|
Widget controls:
|
||||||
|
|
||||||
|
| Action | Behavior |
|
||||||
|
| ----------- | -------- |
|
||||||
|
| Left click | Open plugin's panel. |
|
||||||
|
| Right click | Sample a color directly, without opening the panel. |
|
||||||
|
|
||||||
|
Panel controls:
|
||||||
|
|
||||||
|
| Action | Behavior |
|
||||||
|
| ----------- | -------- |
|
||||||
|
| Click bigger swatch | Open color picker dialog. |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
- **Left-click** the bar widget to open the panel.
|
||||||
|
- **Right-click** the bar widget to sample a color directly, without opening the panel.
|
||||||
|
- Inside the panel, click any recent color swatch to select it, or edit the HEX/RGB/HSL fields directly or click the bigger swatch to open color picker dialog.
|
||||||
|
- Colors picked during a panel session are added to history when the panel closes.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
| setting | type | default | description |
|
||||||
|
| ---------------------------- | -------- | ----------- | ----------- |
|
||||||
|
| `hyprpicker-format` | `select` | `"hex"` | Default color format to copy to clipboard (`hex` or `rgb`). |
|
||||||
|
| `hyprpicker-lowercase` | `bool` | `false` | Outputs the hexcode in lowercase. |
|
||||||
|
| `swatch-radius` | `int` | `8` | Corner radius of the history swatches and current-color preview. |
|
||||||
|
| `hyprpicker-no-zoom` | `bool` | `false` | Turns off the magnifying zoom lens while picking. |
|
||||||
|
| `hyprpicker-scale` | `int` | `10` | Zoom lens magnification, from `1` to `10`. |
|
||||||
|
| `hyprpicker-radius` | `int` | `100` | Zoom lens circle radius in pixels, from `1` to `1000`. |
|
||||||
|
| `hyprpicker-disable-preview` | `bool` | `false` | Turns off the live color preview while picking. |
|
||||||
|
| `hyprpicker-cursor` | `bool` | `false` | Includes the cursor in the frozen screen preview. |
|
||||||
|
| `glyph` | `glyph` | `"palette"` | Glyph |
|
||||||
|
|
||||||
|
## IPC
|
||||||
|
|
||||||
|
Runs `hyprpicker` with the arguments given in plugin's settings.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
noctalia msg panel-toggle oldirtty/color_picker:panel
|
||||||
|
|
||||||
|
# Sample a color without opening the panel
|
||||||
|
noctalia msg plugin oldirtty/color_picker:service all pick
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- There is a known behavior where invoking `hyprpicker` through the plugin's background service imposes a timeout. The picker will close prematurely if a color is not selected within a certain timeframe, rather than staying open indefinitely waiting for a click.
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
|
||||||
|
-- [[panel]] entry:
|
||||||
|
-- shows the last/current picked color with a button to
|
||||||
|
-- pick a new one, a history of the last colors, and
|
||||||
|
-- color fields with real-time editing.
|
||||||
|
--
|
||||||
|
-- No hyprpicker, no disk, no history mutation here -- that all lives in
|
||||||
|
-- service.luau. This script only reads noctalia.state and dispatches IPC
|
||||||
|
-- events to the service for anything that touches picking or persistence.
|
||||||
|
--!nocheck
|
||||||
|
--!nolint UnknownGlobal
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- CONSTANTS
|
||||||
|
--==============================================
|
||||||
|
local SERVICE = "oldirtty/color_picker:service"
|
||||||
|
local swatch_radius = noctalia.getConfig("swatch-radius")
|
||||||
|
local tr_color_picker = noctalia.tr("color_picker")
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- TRANSLATION HELPER
|
||||||
|
--==============================================
|
||||||
|
local function tr(key: string, subst: table?): string
|
||||||
|
if subst then
|
||||||
|
return noctalia.tr(key, subst)
|
||||||
|
end
|
||||||
|
return noctalia.tr(key)
|
||||||
|
end
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- COLOR CONVERSION
|
||||||
|
--==============================================
|
||||||
|
|
||||||
|
local function hexToRgb(hex: string)
|
||||||
|
local r = tonumber(hex:sub(2, 3), 16)
|
||||||
|
local g = tonumber(hex:sub(4, 5), 16)
|
||||||
|
local b = tonumber(hex:sub(6, 7), 16)
|
||||||
|
return r, g, b
|
||||||
|
end
|
||||||
|
|
||||||
|
local function rgbToHsl(r: number, g: number, b: number)
|
||||||
|
r, g, b = r / 255, g / 255, b / 255
|
||||||
|
local max, min = math.max(r, g, b), math.min(r, g, b)
|
||||||
|
local h, s, l = 0, 0, (max + min) / 2
|
||||||
|
|
||||||
|
if max ~= min then
|
||||||
|
local d = max - min
|
||||||
|
s = if l > 0.5 then d / (2 - max - min) else d / (max + min)
|
||||||
|
if max == r then
|
||||||
|
h = (g - b) / d + (if g < b then 6 else 0)
|
||||||
|
elseif max == g then
|
||||||
|
h = (b - r) / d + 2
|
||||||
|
else
|
||||||
|
h = (r - g) / d + 4
|
||||||
|
end
|
||||||
|
h = h / 6
|
||||||
|
end
|
||||||
|
|
||||||
|
return math.floor(h * 360 + 0.5), math.floor(s * 100 + 0.5), math.floor(l * 100 + 0.5)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function formatHex(hex: string, alpha: number): string
|
||||||
|
if alpha < 1 then
|
||||||
|
return hex .. string.format("%02x", math.floor(alpha * 255 + 0.5))
|
||||||
|
end
|
||||||
|
return hex
|
||||||
|
end
|
||||||
|
|
||||||
|
local function formatRgb(hex: string, alpha: number): string
|
||||||
|
local r, g, b = hexToRgb(hex)
|
||||||
|
if alpha < 1 then
|
||||||
|
return string.format("rgba(%d, %d, %d, %.2f)", r, g, b, alpha)
|
||||||
|
end
|
||||||
|
return string.format("rgb(%d, %d, %d)", r, g, b)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function formatHsl(hex: string, alpha: number): string
|
||||||
|
local r, g, b = hexToRgb(hex)
|
||||||
|
local h, s, l = rgbToHsl(r, g, b)
|
||||||
|
if alpha < 1 then
|
||||||
|
return string.format("hsla(%d, %d%%, %d%%, %.2f)", h, s, l, alpha)
|
||||||
|
end
|
||||||
|
return string.format("hsl(%d, %d%%, %d%%)", h, s, l)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function hexWithAlpha(hex: string, alpha: number): string
|
||||||
|
if alpha >= 1 then
|
||||||
|
return hex
|
||||||
|
end
|
||||||
|
return hex .. string.format("%02x", math.floor(alpha * 255 + 0.5))
|
||||||
|
end
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- COLOR TEXT PARSING (field input -> hex)
|
||||||
|
--==============================================
|
||||||
|
|
||||||
|
local function parseRgbText(text: string): string?
|
||||||
|
local r, g, b = text:match("^%s*(%d+)%s*,%s*(%d+)%s*,%s*(%d+)")
|
||||||
|
if r == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
r, g, b = tonumber(r), tonumber(g), tonumber(b)
|
||||||
|
if r > 255 or g > 255 or b > 255 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return string.format("#%02x%02x%02x", r, g, b)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseHslText(text: string): string?
|
||||||
|
local h, s, l = text:match("^%s*(%d+)%s*,%s*(%d+)%%?%s*,%s*(%d+)%%?")
|
||||||
|
if h == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
h, s, l = tonumber(h), tonumber(s) / 100, tonumber(l) / 100
|
||||||
|
|
||||||
|
local function hueToRgb(p: number, q: number, t: number): number
|
||||||
|
if t < 0 then t = t + 1 end
|
||||||
|
if t > 1 then t = t - 1 end
|
||||||
|
if t < 1 / 6 then return p + (q - p) * 6 * t end
|
||||||
|
if t < 1 / 2 then return q end
|
||||||
|
if t < 2 / 3 then return p + (q - p) * (2 / 3 - t) * 6 end
|
||||||
|
return p
|
||||||
|
end
|
||||||
|
|
||||||
|
local r, g, b
|
||||||
|
if s == 0 then
|
||||||
|
r, g, b = l, l, l
|
||||||
|
else
|
||||||
|
local q = if l < 0.5 then l * (1 + s) else l + s - l * s
|
||||||
|
local p = 2 * l - q
|
||||||
|
local hn = h / 360
|
||||||
|
r = hueToRgb(p, q, hn + 1 / 3)
|
||||||
|
g = hueToRgb(p, q, hn)
|
||||||
|
b = hueToRgb(p, q, hn - 1 / 3)
|
||||||
|
end
|
||||||
|
|
||||||
|
return string.format(
|
||||||
|
"#%02x%02x%02x",
|
||||||
|
math.floor(r * 255 + 0.5),
|
||||||
|
math.floor(g * 255 + 0.5),
|
||||||
|
math.floor(b * 255 + 0.5)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseHexText(text: string): string?
|
||||||
|
local clean = text:match("^#?(%x%x%x%x%x%x)$")
|
||||||
|
if clean == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return "#" .. clean
|
||||||
|
end
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- STATE (read-only view + pure-UI selection)
|
||||||
|
--
|
||||||
|
-- selectedColor / selectedOpacity / colorHistory all live in noctalia.state,
|
||||||
|
-- owned and mutated by service.luau. This panel only reads them for
|
||||||
|
-- rendering, except for pure-UI interactions (history swatch click, field
|
||||||
|
-- edit, opacity drag) that change the *selection* without picking or
|
||||||
|
-- persisting -- those are cheap enough to set directly here.
|
||||||
|
--==============================================
|
||||||
|
|
||||||
|
local function getSelectedColor(): string?
|
||||||
|
return noctalia.state.get("selectedColor")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getSelectedOpacity(): number
|
||||||
|
return tonumber(noctalia.state.get("selectedOpacity")) or 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setSelected(hex: string, opacity: number)
|
||||||
|
noctalia.state.set("selectedColor", hex)
|
||||||
|
noctalia.state.set("selectedOpacity", opacity)
|
||||||
|
end
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- SERVICE DISPATCH
|
||||||
|
--==============================================
|
||||||
|
|
||||||
|
local function sendToService(event: string, payload: string?)
|
||||||
|
local cmd = "noctalia msg plugin " .. SERVICE .. " all " .. event
|
||||||
|
if payload ~= nil then
|
||||||
|
cmd = cmd .. " " .. payload
|
||||||
|
end
|
||||||
|
noctalia.runAsync(cmd, function() end)
|
||||||
|
end
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- RENDERING
|
||||||
|
--==============================================
|
||||||
|
|
||||||
|
local function renderRecentItem(color: string, index: number)
|
||||||
|
return ui.box({
|
||||||
|
width = 32,
|
||||||
|
height = 32,
|
||||||
|
fill = color,
|
||||||
|
radius = swatch_radius / 2.5,
|
||||||
|
border = "outline",
|
||||||
|
onClick = "onHistoryColorClicked" .. index,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Recent-colors row: history positions 2..N. Position 1 is the selected
|
||||||
|
-- color, already shown by its own preview swatch.
|
||||||
|
local function renderRecent()
|
||||||
|
local recent = noctalia.state.get("colorHistory") or {}
|
||||||
|
|
||||||
|
if #recent <= 1 then
|
||||||
|
return ui.label({ text = tr("no_history"), fontSize = 12, color = "muted" })
|
||||||
|
end
|
||||||
|
|
||||||
|
local items = {}
|
||||||
|
for i = #recent, 2, -1 do
|
||||||
|
table.insert(items, renderRecentItem(recent[i].hex, i))
|
||||||
|
end
|
||||||
|
|
||||||
|
return ui.row({ gap = 8, align = "center" }, items)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function renderTitlebar()
|
||||||
|
return ui.row({ align = "center", justify = "space_between", gap = 8 }, {
|
||||||
|
ui.label({ text = tr("color_picker"), fontSize = 18, fontWeight = "bold", color = "primary", flexGrow = 1 }),
|
||||||
|
ui.button({ glyph = "color-picker", onClick = "onPickClickedFromPanel" }),
|
||||||
|
ui.button({ glyph = "close", onClick = "onCloseClicked" }),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function renderColorspaceColumn(name: string, value: string, onSubmit: string, onCopy: string, keySuffix: string)
|
||||||
|
local text_align = "left"
|
||||||
|
if name ~= "HEX" then
|
||||||
|
value = value:match("%((.-)%)")
|
||||||
|
text_align = "center"
|
||||||
|
end
|
||||||
|
|
||||||
|
return ui.column({ gap = 4, flexGrow = 1 }, {
|
||||||
|
ui.label({ text = name, fontSize = 14, color = "on_surface_variant" }),
|
||||||
|
ui.row({ gap = 8, align = "center" }, {
|
||||||
|
ui.input({
|
||||||
|
key = name .. "-input-" .. keySuffix,
|
||||||
|
value = value,
|
||||||
|
textAlign = text_align,
|
||||||
|
flexGrow = 1,
|
||||||
|
onSubmit = onSubmit,
|
||||||
|
}),
|
||||||
|
ui.button({ glyph = "copy", variant = "ghost", onClick = onCopy }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function render()
|
||||||
|
local current = getSelectedColor()
|
||||||
|
local opacity = getSelectedOpacity()
|
||||||
|
|
||||||
|
if current == nil then
|
||||||
|
panel.render(ui.column({ gap = 12, padding = 16 }, {
|
||||||
|
renderTitlebar(),
|
||||||
|
ui.label({ text = tr("no_color_picked"), padding = { top = 8 } }),
|
||||||
|
renderRecent(),
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
panel.render(ui.column({ gap = 12, padding = 16 }, {
|
||||||
|
renderTitlebar(),
|
||||||
|
|
||||||
|
ui.row({ gap = 12, align = "stretch" }, {
|
||||||
|
ui.column({ gap = 8, flexGrow = 2 }, {
|
||||||
|
ui.label({ text = tr("recent_colors"), fontSize = 14, color = "on_surface_variant" }),
|
||||||
|
renderRecent(),
|
||||||
|
}),
|
||||||
|
ui.column({ gap = 8, flexGrow = 2 }, {
|
||||||
|
ui.label({ text = tr("opacity"), fontSize = 14, color = "on_surface_variant" }),
|
||||||
|
ui.slider({ min = 0, max = 1, step = 0.01, value = opacity, onChange = "onOpacityChange", onDragEnd = "onOpacityChange" }),
|
||||||
|
}),
|
||||||
|
-- Render current swatch
|
||||||
|
ui.box({
|
||||||
|
width = 128,
|
||||||
|
height = 64,
|
||||||
|
fill = hexWithAlpha(current, opacity),
|
||||||
|
radius = swatch_radius,
|
||||||
|
border = "outline",
|
||||||
|
borderWidth = 2,
|
||||||
|
flexGrow = 1,
|
||||||
|
onClick = "onNativePickerClicked"
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
ui.row({ gap = 12 }, {
|
||||||
|
renderColorspaceColumn("HEX", formatHex(current, opacity), "onSubmitHex", "onCopyHex", current .. "-" .. opacity),
|
||||||
|
renderColorspaceColumn("RGB", formatRgb(current, opacity), "onSubmitRgb", "onCopyRgb", current .. "-" .. opacity),
|
||||||
|
renderColorspaceColumn("HSL", formatHsl(current, opacity), "onSubmitHsl", "onCopyHsl", current .. "-" .. opacity),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
end
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- CALLBACKS: history interaction (pure UI, no service call)
|
||||||
|
--==============================================
|
||||||
|
|
||||||
|
function handleHistoryClick(index: number)
|
||||||
|
local history = noctalia.state.get("colorHistory") or {}
|
||||||
|
local entry = history[index]
|
||||||
|
if entry then
|
||||||
|
setSelected(entry.hex, entry.opacity or 1)
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function onHistoryColorClicked2() handleHistoryClick(2) end
|
||||||
|
function onHistoryColorClicked3() handleHistoryClick(3) end
|
||||||
|
function onHistoryColorClicked4() handleHistoryClick(4) end
|
||||||
|
function onHistoryColorClicked5() handleHistoryClick(5) end
|
||||||
|
function onHistoryColorClicked6() handleHistoryClick(6) end
|
||||||
|
function onHistoryColorClicked7() handleHistoryClick(7) end
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- CALLBACKS: colorspace fields (pure UI, no service call)
|
||||||
|
--==============================================
|
||||||
|
|
||||||
|
local function processSubmit(parseText: (string) -> string?, text: string)
|
||||||
|
local color_value = parseText(text)
|
||||||
|
if color_value == nil then return end
|
||||||
|
|
||||||
|
setSelected(color_value, getSelectedOpacity())
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
|
||||||
|
function onSubmitHex(text: string) processSubmit(parseHexText, text) end
|
||||||
|
function onSubmitRgb(text: string) processSubmit(parseRgbText, text) end
|
||||||
|
function onSubmitHsl(text: string) processSubmit(parseHslText, text) end
|
||||||
|
|
||||||
|
local function processCopy(format: (string, number) -> string)
|
||||||
|
local current = getSelectedColor()
|
||||||
|
if current == nil then return end
|
||||||
|
|
||||||
|
local text = format(current, getSelectedOpacity())
|
||||||
|
noctalia.copyToClipboard(text, "text/plain")
|
||||||
|
noctalia.notify(tr_color_picker, tr("color_copied", { color = text }))
|
||||||
|
end
|
||||||
|
|
||||||
|
function onCopyHex() processCopy(formatHex) end
|
||||||
|
function onCopyRgb() processCopy(formatRgb) end
|
||||||
|
function onCopyHsl() processCopy(formatHsl) end
|
||||||
|
|
||||||
|
function onOpacityChange(value)
|
||||||
|
local parsed = tonumber(value)
|
||||||
|
if parsed == nil then return end
|
||||||
|
|
||||||
|
noctalia.state.set("selectedOpacity", parsed)
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- CALLBACKS: picking (delegates to the service)
|
||||||
|
--==============================================
|
||||||
|
|
||||||
|
-- Panel's own pick button: asks the service to pick and update the
|
||||||
|
-- selection only. No history commit until the panel closes.
|
||||||
|
function onPickClickedFromPanel()
|
||||||
|
sendToService("pick-panel")
|
||||||
|
end
|
||||||
|
|
||||||
|
function onNativePickerClicked()
|
||||||
|
sendToService("picker-dialog")
|
||||||
|
end
|
||||||
|
|
||||||
|
function onCloseClicked()
|
||||||
|
panel.close()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Panel closed (any path: close button, Esc, click outside). Commits
|
||||||
|
-- whatever is currently selected to the service's history.
|
||||||
|
function onClose()
|
||||||
|
local hex = getSelectedColor()
|
||||||
|
if hex == nil then return end
|
||||||
|
|
||||||
|
local payload = hex .. ":" .. tostring(getSelectedOpacity())
|
||||||
|
sendToService("commit", payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
function onOpen(context)
|
||||||
|
noctalia.state.watch("selectedColor", function() render() end)
|
||||||
|
noctalia.state.watch("colorHistory", function() render() end)
|
||||||
|
render()
|
||||||
|
end
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
author = "oldirtty"
|
||||||
|
id = "oldirtty/color_picker"
|
||||||
|
name = "Color Picker"
|
||||||
|
description = "Pick a color from your screen with hyprpicker."
|
||||||
|
license = "MIT"
|
||||||
|
version = "1.0.0"
|
||||||
|
min_noctalia = "5.0.0"
|
||||||
|
icon = "palette"
|
||||||
|
dependencies = ["hyprpicker"]
|
||||||
|
tags = ["theming", "utility", "bar", "panel"]
|
||||||
|
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "hyprpicker-format"
|
||||||
|
type = "select"
|
||||||
|
default = "hex"
|
||||||
|
label_key = "settings.hyprpicker_format.label"
|
||||||
|
description_key = "settings.hyprpicker_format.description"
|
||||||
|
options = [
|
||||||
|
{ value = "hex", label_key = "hex" },
|
||||||
|
{ value = "rgb", label_key = "rgb" }
|
||||||
|
]
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "hyprpicker-lowercase"
|
||||||
|
type = "bool"
|
||||||
|
label_key = "settings.hyprpicker_lowercase.label"
|
||||||
|
description_key = "settings.hyprpicker_lowercase.description"
|
||||||
|
visible_when = { key = "hyprpicker-format", values = ["hex"]}
|
||||||
|
default = false
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "swatch-radius"
|
||||||
|
type = "int"
|
||||||
|
label_key = "settings.swatch_radius.label"
|
||||||
|
description_key = "settings.swatch_radius.description"
|
||||||
|
default = 8
|
||||||
|
min = 0
|
||||||
|
max = 64
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "hyprpicker-no-zoom"
|
||||||
|
type = "bool"
|
||||||
|
label_key = "settings.hyprpicker_no_zoom.label"
|
||||||
|
description_key = "settings.hyprpicker_no_zoom.description"
|
||||||
|
default = false
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "hyprpicker-scale"
|
||||||
|
type = "int"
|
||||||
|
label_key = "settings.hyprpicker_scale.label"
|
||||||
|
description_key = "settings.hyprpicker_scale.description"
|
||||||
|
visible_when = { key = "hyprpicker-no-zoom", values = ["false"]}
|
||||||
|
default = 10
|
||||||
|
min = 1
|
||||||
|
max = 10
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "hyprpicker-radius"
|
||||||
|
type = "int"
|
||||||
|
label_key = "settings.hyprpicker_radius.label"
|
||||||
|
description_key = "settings.hyprpicker_radius.description"
|
||||||
|
visible_when = { key = "hyprpicker-no-zoom", values = ["false"]}
|
||||||
|
default = 100
|
||||||
|
min = 1
|
||||||
|
max = 1000
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "hyprpicker-disable-preview"
|
||||||
|
type = "bool"
|
||||||
|
label_key = "settings.hyprpicker_disable_preview.label"
|
||||||
|
description_key = "settings.hyprpicker_disable_preview.description"
|
||||||
|
visible_when = { key = "hyprpicker-no-zoom", values = ["false"]}
|
||||||
|
default = false
|
||||||
|
|
||||||
|
[[setting]]
|
||||||
|
key = "hyprpicker-cursor"
|
||||||
|
type = "bool"
|
||||||
|
label_key = "settings.hyprpicker_cursor.label"
|
||||||
|
description_key = "settings.hyprpicker_cursor.description"
|
||||||
|
default = false
|
||||||
|
|
||||||
|
[[widget]]
|
||||||
|
id = "widget"
|
||||||
|
entry = "widget.luau"
|
||||||
|
|
||||||
|
[[widget.setting]]
|
||||||
|
key = "glyph"
|
||||||
|
type = "glyph"
|
||||||
|
label_key = "glyph"
|
||||||
|
default = "palette"
|
||||||
|
|
||||||
|
[[panel]]
|
||||||
|
id = "panel"
|
||||||
|
entry = "panel.luau"
|
||||||
|
width = 720
|
||||||
|
height = 240
|
||||||
|
placement = "floating"
|
||||||
|
position = "center"
|
||||||
|
|
||||||
|
# Headless background service: owns the hyprpicker backend
|
||||||
|
[[service]]
|
||||||
|
id = "service"
|
||||||
|
entry = "service.luau"
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
-- [[service]] entry:
|
||||||
|
-- owns all hyprpicker interaction, color history, and disk persistence.
|
||||||
|
-- Headless -- no UI. Widget and panel talk to it via onIpc, never touch
|
||||||
|
-- hyprpicker or the history file directly.
|
||||||
|
--!nocheck
|
||||||
|
--!nolint UnknownGlobal
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- CONSTANTS
|
||||||
|
--==============================================
|
||||||
|
local MAX_HISTORY = 7 -- selected color (index 1) + 6 recent
|
||||||
|
local STATE_DIR = "~/.local/state/noctalia/plugin-cache/color_picker"
|
||||||
|
local HISTORY_FILE = STATE_DIR .. "/history.json"
|
||||||
|
|
||||||
|
local tr_color_picker = noctalia.tr("color_picker")
|
||||||
|
|
||||||
|
local function tr(key: string, subst: table?): string
|
||||||
|
if subst then
|
||||||
|
return noctalia.tr(key, subst)
|
||||||
|
end
|
||||||
|
return noctalia.tr(key)
|
||||||
|
end
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- HYPRPICKER COMMAND BUILDER
|
||||||
|
--==============================================
|
||||||
|
|
||||||
|
-- Builds the hyprpicker argv from plugin settings. Returns an array of
|
||||||
|
-- shell-safe tokens; join with spaces before passing to noctalia.runAsync,
|
||||||
|
-- which only accepts a single command string.
|
||||||
|
local function buildHyprpickerArgs(): { string }
|
||||||
|
local args = { "hyprpicker", "-a" }
|
||||||
|
|
||||||
|
if noctalia.getConfig("hyprpicker-format") == "rgb" then
|
||||||
|
table.insert(args, "-f")
|
||||||
|
table.insert(args, "rgb")
|
||||||
|
end
|
||||||
|
|
||||||
|
if noctalia.getConfig("hyprpicker-cursor") then
|
||||||
|
table.insert(args, "--cursor")
|
||||||
|
end
|
||||||
|
|
||||||
|
if noctalia.getConfig("hyprpicker-disable-preview") then
|
||||||
|
table.insert(args, "--disable-preview")
|
||||||
|
end
|
||||||
|
|
||||||
|
if noctalia.getConfig("hyprpicker-lowercase") then
|
||||||
|
table.insert(args, "--lowercase-hex")
|
||||||
|
end
|
||||||
|
|
||||||
|
if noctalia.getConfig("hyprpicker-no-zoom") then
|
||||||
|
table.insert(args, "--no-zoom")
|
||||||
|
return args -- returns earlier since next args apply only when zoom is enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
local scale = noctalia.getConfig("hyprpicker-scale")
|
||||||
|
if scale ~= nil then
|
||||||
|
table.insert(args, "--scale=" .. tostring(scale))
|
||||||
|
end
|
||||||
|
|
||||||
|
local radius = noctalia.getConfig("hyprpicker-radius")
|
||||||
|
if radius ~= nil then
|
||||||
|
table.insert(args, "--radius=" .. tostring(radius))
|
||||||
|
end
|
||||||
|
|
||||||
|
return args
|
||||||
|
end
|
||||||
|
|
||||||
|
local function hyprpickerCommand(): string
|
||||||
|
return table.concat(buildHyprpickerArgs(), " ")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parsePickerOutput(output: string): string?
|
||||||
|
-- Tries Hex first
|
||||||
|
local hex = output:match("#%x%x%x%x%x%x")
|
||||||
|
if hex then
|
||||||
|
-- Garante que o retorno é maiúsculo
|
||||||
|
return hex:upper()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Then tries RGB
|
||||||
|
local r, g, b = output:match("(%d+)%s+(%d+)%s+(%d+)")
|
||||||
|
if r and g and b then
|
||||||
|
-- Usa %02X (X MAIÚSCULO) para retornar Hex maiúsculo
|
||||||
|
return string.format("#%02X%02X%02X", tonumber(r), tonumber(g), tonumber(b))
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function formatRgb(hex: string): string
|
||||||
|
local r = tonumber(hex:sub(2, 3), 16)
|
||||||
|
local g = tonumber(hex:sub(4, 5), 16)
|
||||||
|
local b = tonumber(hex:sub(6, 7), 16)
|
||||||
|
return string.format("rgb(%d, %d, %d)", r, g, b)
|
||||||
|
end
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- STATE MODEL
|
||||||
|
--
|
||||||
|
-- selectedColor / selectedOpacity: ephemeral, what the panel is currently
|
||||||
|
-- showing. Set here on every successful pick; also updated by the panel
|
||||||
|
-- itself for pure-UI interactions (history swatch click, field edit,
|
||||||
|
-- opacity slider) that don't touch hyprpicker or disk.
|
||||||
|
--
|
||||||
|
-- colorHistory: persisted list (index 1 = most recently committed color).
|
||||||
|
-- Only mutated by pushCurrentColor(), called on immediate-commit picks
|
||||||
|
-- (widget right-click) and on "commit" (panel close).
|
||||||
|
--==============================================
|
||||||
|
|
||||||
|
local function setSelected(hex: string, opacity: number)
|
||||||
|
noctalia.state.set("selectedColor", hex)
|
||||||
|
noctalia.state.set("selectedOpacity", opacity)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Moves (or inserts) a color to the front of colorHistory, capped at
|
||||||
|
-- MAX_HISTORY entries. Memory only -- callers decide when to persist.
|
||||||
|
local function pushCurrentColor(hex: string, opacity: number)
|
||||||
|
hex = hex:upper() -- Garante o padrão maiúsculo
|
||||||
|
local history = noctalia.state.get("colorHistory") or {}
|
||||||
|
|
||||||
|
for i, entry in ipairs(history) do
|
||||||
|
if entry.hex and entry.hex:upper() == hex then
|
||||||
|
table.remove(history, i)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(history, 1, { hex = hex, opacity = opacity })
|
||||||
|
|
||||||
|
while #history > MAX_HISTORY do
|
||||||
|
table.remove(history)
|
||||||
|
end
|
||||||
|
|
||||||
|
noctalia.state.set("colorHistory", history)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function loadHistoryFromDisk()
|
||||||
|
local content = noctalia.readFile(HISTORY_FILE)
|
||||||
|
if content == nil or content == "" then return end
|
||||||
|
|
||||||
|
local decoded = noctalia.json.decode(content)
|
||||||
|
if type(decoded) ~= "table" then return end
|
||||||
|
|
||||||
|
noctalia.state.set("colorHistory", decoded)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function saveHistoryToDisk()
|
||||||
|
local history = noctalia.state.get("colorHistory") or {}
|
||||||
|
local encoded = noctalia.json.encode(history)
|
||||||
|
if encoded ~= nil then
|
||||||
|
noctalia.writeFile(HISTORY_FILE, encoded)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- PICKING
|
||||||
|
--==============================================
|
||||||
|
|
||||||
|
-- Runs hyprpicker and invokes onResult(hex) on success. onResult is nil-safe
|
||||||
|
-- to call with nil (caller decides what "no result" means for that flow).
|
||||||
|
local function runPicker(onResult: (string?, string?) -> ())
|
||||||
|
if not noctalia.commandExists("hyprpicker") then
|
||||||
|
noctalia.notifyError(tr_color_picker, tr("hyprpicker_not_installed"))
|
||||||
|
onResult(nil, nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
noctalia.runAsync(hyprpickerCommand(), function(result)
|
||||||
|
if result.exitCode ~= 0 then
|
||||||
|
onResult(nil, nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local hex = parsePickerOutput(result.stdout or "")
|
||||||
|
if hex == nil then
|
||||||
|
noctalia.notifyError(tr_color_picker, tr("could_not_parse"))
|
||||||
|
onResult(nil, nil)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local copyText = hex
|
||||||
|
if noctalia.getConfig("hyprpicker-format") == "rgb" then
|
||||||
|
copyText = formatRgb(hex)
|
||||||
|
elseif noctalia.getConfig("hyprpicker-lowercase") then
|
||||||
|
copyText = hex:lower()
|
||||||
|
end
|
||||||
|
|
||||||
|
noctalia.copyToClipboard(copyText, "text/plain")
|
||||||
|
onResult(hex, copyText)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- IPC
|
||||||
|
--
|
||||||
|
-- "pick" - immediate commit (widget right-click). Picks, pushes to
|
||||||
|
-- history, saves to disk right away.
|
||||||
|
-- "pick-panel" - panel's own pick button. Picks and updates the selection
|
||||||
|
-- only; history/disk are untouched until "commit".
|
||||||
|
-- "commit" - panel is closing. payload = "<hex>:<opacity>", the
|
||||||
|
-- selection at close time. Pushes it to history and saves.
|
||||||
|
--==============================================
|
||||||
|
|
||||||
|
local function handlePick()
|
||||||
|
runPicker(function(hex, copyText)
|
||||||
|
if hex == nil then return end
|
||||||
|
|
||||||
|
setSelected(hex, 1)
|
||||||
|
pushCurrentColor(hex, 1)
|
||||||
|
saveHistoryToDisk()
|
||||||
|
noctalia.notify(tr_color_picker, tr("color_picked", { color = copyText }))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handlePickFromPanel()
|
||||||
|
runPicker(function(hex, copyText)
|
||||||
|
if hex == nil then return end
|
||||||
|
|
||||||
|
setSelected(hex, 1)
|
||||||
|
noctalia.notify(tr_color_picker, tr("color_picked", { color = copyText }))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handleCommit(payload: string?)
|
||||||
|
if payload == nil then return end
|
||||||
|
|
||||||
|
local hex, opacityStr = payload:match("^(#%x%x%x%x%x%x):([%d%.]+)$")
|
||||||
|
if hex == nil then return end
|
||||||
|
|
||||||
|
pushCurrentColor(hex, tonumber(opacityStr) or 1)
|
||||||
|
saveHistoryToDisk()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Opens Noctalia's native color picker dialog (canvas + hue slider).
|
||||||
|
-- Only updates the selection, same as the hyprpicker panel button --
|
||||||
|
-- history commit still happens on panel close, not here. The panel
|
||||||
|
-- re-renders on its own via noctalia.state.watch("selectedColor", ...).
|
||||||
|
local function handleNativePicker()
|
||||||
|
local current = noctalia.state.get("selectedColor") or "#FFFFFF"
|
||||||
|
local accepted = noctalia.openColorPicker(current, function(color)
|
||||||
|
if color == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local copyText = color
|
||||||
|
if noctalia.getConfig("hyprpicker-format") == "rgb" then
|
||||||
|
copyText = formatRgb(color)
|
||||||
|
elseif noctalia.getConfig("hyprpicker-lowercase") then
|
||||||
|
copyText = color:lower()
|
||||||
|
end
|
||||||
|
noctalia.notify(tr_color_picker, tr("color_picked", { color = copyText }))
|
||||||
|
setSelected(color, noctalia.state.get("selectedOpacity") or 1)
|
||||||
|
pushCurrentColor(color, 1)
|
||||||
|
saveHistoryToDisk()
|
||||||
|
end)
|
||||||
|
|
||||||
|
if not accepted then
|
||||||
|
noctalia.notify(tr_color_picker, tr("picker_already_open"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function onIpc(event: string, payload: string?)
|
||||||
|
if event == "pick" then
|
||||||
|
handlePick()
|
||||||
|
elseif event == "picker-dialog" then
|
||||||
|
handleNativePicker()
|
||||||
|
elseif event == "pick-panel" then
|
||||||
|
handlePickFromPanel()
|
||||||
|
elseif event == "commit" then
|
||||||
|
handleCommit(payload)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--==============================================
|
||||||
|
-- BOOT (runs once at load)
|
||||||
|
--==============================================
|
||||||
|
|
||||||
|
noctalia.mkdirAll(STATE_DIR)
|
||||||
|
loadHistoryFromDisk()
|
||||||
|
|
||||||
|
local bootHistory = noctalia.state.get("colorHistory") or {}
|
||||||
|
local bootTop = bootHistory[1]
|
||||||
|
if bootTop ~= nil then
|
||||||
|
setSelected(bootTop.hex, bootTop.opacity or 1)
|
||||||
|
end
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"color_picker": "Color Picker",
|
||||||
|
"no_color_picked": "No color picked yet.",
|
||||||
|
"recent_colors": "Recent Colors",
|
||||||
|
"opacity": "Opacity",
|
||||||
|
"no_history": "No colors in history yet.",
|
||||||
|
"color_copied": "Color {color} copied.",
|
||||||
|
"color_picked": "Color {color} picked.",
|
||||||
|
"hyprpicker_not_installed": "hyprpicker is not installed.",
|
||||||
|
"could_not_parse": "could not parse hyprpicker output.",
|
||||||
|
"glyph": "Glyph",
|
||||||
|
"hex": "HEX",
|
||||||
|
"rgb": "RGB",
|
||||||
|
|
||||||
|
"settings.hyprpicker_format.label": "Default Format",
|
||||||
|
"settings.hyprpicker_format.description": "Default color format to copy to clipboard (Hex or RGB).",
|
||||||
|
"settings.swatch_radius.label": "Swatches Corner Roundness",
|
||||||
|
"settings.swatch_radius.description": "Corner radius of the history swatches and current-color preview.",
|
||||||
|
"settings.hyprpicker_lowercase.label": "Lowercase Hex",
|
||||||
|
"settings.hyprpicker_lowercase.description": "Outputs the hexcode in lowercase.",
|
||||||
|
"settings.hyprpicker_scale.label": "Zoom Scale",
|
||||||
|
"settings.hyprpicker_scale.description": "Zoom lens magnification, from 1 to 10.",
|
||||||
|
"settings.hyprpicker_radius.label": "Zoom Radius",
|
||||||
|
"settings.hyprpicker_radius.description": "Zoom lens circle radius in pixels, from 1 to 1000.",
|
||||||
|
"settings.hyprpicker_no_zoom.label": "Disable Zoom Lens",
|
||||||
|
"settings.hyprpicker_no_zoom.description": "Turns off the magnifying zoom lens while picking.",
|
||||||
|
"settings.hyprpicker_disable_preview.label": "Disable Live Preview",
|
||||||
|
"settings.hyprpicker_disable_preview.description": "Turns off the live color preview while picking.",
|
||||||
|
"settings.hyprpicker_cursor.label": "Show cursor",
|
||||||
|
"settings.hyprpicker_cursor.description": "Includes the cursor in the frozen screen preview."
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"color_picker": "Seletor de Cores",
|
||||||
|
"no_color_picked": "Nenhuma cor escolhida ainda.",
|
||||||
|
"recent_colors": "Cores Recentes",
|
||||||
|
"opacity": "Opacidade",
|
||||||
|
"no_history": "Ainda não há cores no histórico.",
|
||||||
|
"color_copied": "Cor {color} copiada.",
|
||||||
|
"color_picked": "Cor {color} selecionada.",
|
||||||
|
"hyprpicker_not_installed": "hyprpicker não está instalado.",
|
||||||
|
"could_not_parse": "não foi possível extrair a saída do hyprpicker.",
|
||||||
|
"glyph": "Símbolo",
|
||||||
|
"hex": "HEX",
|
||||||
|
"rgb": "RGB",
|
||||||
|
|
||||||
|
"settings.hyprpicker_format.label": "Formato Padrão",
|
||||||
|
"settings.hyprpicker_format.description": "Formato de cor padrão para copiar para a área de transferência (Hex ou RGB).",
|
||||||
|
"settings.swatch_radius.label": "Arredondamento de Cantos das Amostras",
|
||||||
|
"settings.swatch_radius.description": "Raio dos cantos das amostras do histórico e da prévia da cor atual.",
|
||||||
|
"settings.hyprpicker_lowercase.label": "Hexadecimal em Minúsculas",
|
||||||
|
"settings.hyprpicker_lowercase.description": "Exibe o código hexadecimal em letras minúsculas.",
|
||||||
|
"settings.hyprpicker_scale.label": "Escala do Zoom",
|
||||||
|
"settings.hyprpicker_scale.description": "Ampliação da lupa, de 1 a 10.",
|
||||||
|
"settings.hyprpicker_radius.label": "Raio do Zoom",
|
||||||
|
"settings.hyprpicker_radius.description": "Raio do círculo da lupa em pixels, de 1 a 1000.",
|
||||||
|
"settings.hyprpicker_no_zoom.label": "Desabilitar Lupa",
|
||||||
|
"settings.hyprpicker_no_zoom.description": "Dpesativa a lupa ao selecionar a cor.",
|
||||||
|
"settings.hyprpicker_disable_preview.label": "Desabilitar Prévia em Tempo Real",
|
||||||
|
"settings.hyprpicker_disable_preview.description": "Desativa a prévia da cor em tempo real ao selecionar.",
|
||||||
|
"settings.hyprpicker_cursor.label": "Mostrar cursor",
|
||||||
|
"settings.hyprpicker_cursor.description": "Inclui o cursor na prévia congelada da tela."
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Bar [[widget]] entry:
|
||||||
|
-- Left Click: opens the panel,
|
||||||
|
-- Right Click: picks a color directly
|
||||||
|
-- and copies the hex to clipboard (no panel).
|
||||||
|
|
||||||
|
local glyph = barWidget.getConfig("glyph")
|
||||||
|
local tr_color_picker = noctalia.tr("color_picker");
|
||||||
|
|
||||||
|
local function render()
|
||||||
|
barWidget.setGlyph(glyph)
|
||||||
|
barWidget.setTooltip(tr_color_picker)
|
||||||
|
end
|
||||||
|
|
||||||
|
function onClick()
|
||||||
|
noctalia.togglePanel("oldirtty/color_picker:panel")
|
||||||
|
end
|
||||||
|
|
||||||
|
function onRightClick()
|
||||||
|
noctalia.runAsync("noctalia msg plugin oldirtty/color_picker:service all pick", function(result)
|
||||||
|
if result.exitCode ~= 0 then
|
||||||
|
noctalia.notifyError(tr_color_picker, noctalia.tr("could_not_parse"))
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
render()
|
||||||
Reference in New Issue
Block a user