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