Files
community-plugins/color_picker/panel.luau
T

432 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 = function() handleHistoryClick(index) end,
})
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
--==============================================
-- 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;charset=utf-8")
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