439 lines
13 KiB
Luau
439 lines
13 KiB
Luau
|
|
-- [[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")
|
|
|
|
--==============================================
|
|
-- CONTROL VARIABLES
|
|
--==============================================
|
|
local waitingForPanelPick = false
|
|
local initialSessionColor = nil
|
|
local initialSessionOpacity = 1
|
|
local initialChanged = false
|
|
|
|
--==============================================
|
|
-- 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
|
|
|
|
--==============================================
|
|
-- 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
|
|
|
|
local function commitInitialColorIfFirstChange(newHex: string?)
|
|
if initialChanged then return end
|
|
if initialSessionColor == nil then return end
|
|
if newHex == nil or newHex == initialSessionColor then return end
|
|
|
|
initialChanged = true
|
|
local payload = initialSessionColor .. ":" .. tostring(initialSessionOpacity)
|
|
-- noctalia.notify("DEBUG", "Push initial color = ".. initialSessionColor .. "\nOpacity = " .. initialSessionOpacity)
|
|
sendToService("push-to-history", payload)
|
|
end
|
|
|
|
--==============================================
|
|
-- STATE (read-only view + pure-UI selection)
|
|
-- These states are synced and persisted to disk by the service on close.
|
|
--==============================================
|
|
|
|
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)
|
|
commitInitialColorIfFirstChange(hex)
|
|
noctalia.state.set("selectedColor", hex)
|
|
noctalia.state.set("selectedOpacity", opacity)
|
|
end
|
|
|
|
--==============================================
|
|
-- RENDERING
|
|
--==============================================
|
|
|
|
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 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
|
|
function renderRecent()
|
|
local recent = noctalia.state.get("colorHistory") or {}
|
|
|
|
if #recent == 0 then
|
|
return ui.label({ text = tr("no_history"), fontSize = 12, color = "muted" })
|
|
end
|
|
|
|
local items = {}
|
|
for i = #recent, 1, -1 do
|
|
table.insert(items, renderRecentItem(recent[i].hex, i))
|
|
end
|
|
|
|
return ui.row({ gap = 8, align = "center" }, items)
|
|
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 = "onOpacityDragEnd" }),
|
|
}),
|
|
-- 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 onHistoryColorClicked1() handleHistoryClick(1) 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
|
|
|
|
--==============================================
|
|
-- 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
|
|
|
|
function onOpacityDragEnd(value)
|
|
local parsed = tonumber(value)
|
|
if parsed == nil then return end
|
|
|
|
local currentHex = getSelectedColor()
|
|
if currentHex then
|
|
setSelected(currentHex, parsed)
|
|
end
|
|
end
|
|
|
|
--==============================================
|
|
-- CALLBACKS: picking (delegates to the service)
|
|
--==============================================
|
|
|
|
-- Panel's own pick button: just closes the panel (so it doesn't cover the
|
|
-- area being sampled). The actual pick is triggered from onClose(), which
|
|
-- only fires once the close animation has actually finished -- firing the
|
|
-- pick here instead would race the animation and hyprpicker would freeze
|
|
-- mid-transition.
|
|
function onPickClickedFromPanel()
|
|
waitingForPanelPick = true
|
|
panel.close()
|
|
end
|
|
|
|
function onNativePickerClicked()
|
|
sendToService("picker-dialog")
|
|
end
|
|
|
|
function onCloseClicked()
|
|
panel.close()
|
|
end
|
|
|
|
-- Panel closed. Two cases:
|
|
-- - Technical close to hand off to hyprpicker (waitingForPanelPick): ask
|
|
-- the service to pick now that the close animation is done, then wait
|
|
-- for the selectedColor watch to reopen the panel.
|
|
--
|
|
-- - Real close (close button, Esc, click outside): save whatever is
|
|
-- currently selected to the service's SELECTED_FILE.
|
|
function onClose()
|
|
if waitingForPanelPick then
|
|
sendToService("pick-panel")
|
|
return
|
|
end
|
|
sendToService("save-selected")
|
|
end
|
|
|
|
function onOpen(context)
|
|
initialChanged = false
|
|
initialSessionColor = getSelectedColor()
|
|
initialSessionOpacity = getSelectedOpacity()
|
|
|
|
noctalia.state.watch("selectedColor", function(newHex: string?)
|
|
if waitingForPanelPick then
|
|
waitingForPanelPick = false
|
|
noctalia.togglePanel("oldirtty/color_picker:panel")
|
|
return
|
|
end
|
|
|
|
-- Covers changes the service made directly (panel pick button, native
|
|
-- picker dialog), which bypass this panel's setSelected().
|
|
commitInitialColorIfFirstChange(newHex)
|
|
|
|
render()
|
|
end)
|
|
|
|
noctalia.state.watch("colorHistory", function()
|
|
render()
|
|
end)
|
|
|
|
render()
|
|
end
|