feat: improve history push logic and update state persistence (#22)

This commit is contained in:
Ian Ribeiro
2026-07-16 07:57:14 -04:00
committed by GitHub
parent 6a8fa4aed9
commit 4c726442db
4 changed files with 191 additions and 164 deletions
-1
View File
@@ -37,7 +37,6 @@ Panel controls:
- **Left-click** the bar widget to open the panel. - **Left-click** the bar widget to open the panel.
- **Right-click** the bar widget to sample a color directly, without opening 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. - 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 ## Settings
+107 -90
View File
@@ -17,12 +17,13 @@ local SERVICE = "oldirtty/color_picker:service"
local swatch_radius = noctalia.getConfig("swatch-radius") local swatch_radius = noctalia.getConfig("swatch-radius")
local tr_color_picker = noctalia.tr("color_picker") local tr_color_picker = noctalia.tr("color_picker")
-- Set right before closing the panel to hand off to hyprpicker (so the --==============================================
-- panel doesn't cover the screen area being sampled). onClose() checks -- CONTROL VARIABLES
-- this to skip committing the stale selection on that technical close, --==============================================
-- and the selectedColor watch checks it to reopen the panel once the
-- service reports a result -- instead of just re-rendering a closed panel.
local waitingForPanelPick = false local waitingForPanelPick = false
local initialSessionColor = nil
local initialSessionOpacity = 1
local initialChanged = false
--============================================== --==============================================
-- TRANSLATION HELPER -- TRANSLATION HELPER
@@ -157,29 +158,6 @@ local function parseHexText(text: string): string?
return "#" .. clean return "#" .. clean
end 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 -- SERVICE DISPATCH
--============================================== --==============================================
@@ -187,49 +165,78 @@ end
local function sendToService(event: string, payload: string?) local function sendToService(event: string, payload: string?)
local cmd = "noctalia msg plugin " .. SERVICE .. " all " .. event local cmd = "noctalia msg plugin " .. SERVICE .. " all " .. event
if payload ~= nil then if payload ~= nil then
cmd = cmd .. " " .. payload cmd = cmd .. " '" .. payload .. "'"
end end
noctalia.runAsync(cmd, function() end) noctalia.runAsync(cmd, function() end)
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 -- 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() local function renderTitlebar()
return ui.row({ align = "center", justify = "space_between", gap = 8 }, { 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.label({ text = tr("color_picker"), fontSize = 18, fontWeight = "bold", color = "primary", flexGrow = 1 }),
ui.button({ glyph = "color-picker", onClick = "onPickClickedFromPanel" }), ui.button({ glyph = "color-picker", onClick = "onPickClickedFromPanel" }),
ui.button({ glyph = "close", onClick = "onCloseClicked" }), 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 end
local function renderColorspaceColumn(name: string, value: string, onSubmit: string, onCopy: string, keySuffix: string) local function renderColorspaceColumn(name: string, value: string, onSubmit: string, onCopy: string, keySuffix: string)
@@ -277,7 +284,7 @@ local function render()
}), }),
ui.column({ gap = 8, flexGrow = 2 }, { ui.column({ gap = 8, flexGrow = 2 }, {
ui.label({ text = tr("opacity"), fontSize = 14, color = "on_surface_variant" }), 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" }), ui.slider({ min = 0, max = 1, step = 0.01, value = opacity, onChange = "onOpacityChange", onDragEnd = "onOpacityDragEnd" }),
}), }),
-- Render current swatch -- Render current swatch
ui.box({ ui.box({
@@ -313,12 +320,12 @@ function handleHistoryClick(index: number)
end end
end end
function onHistoryColorClicked1() handleHistoryClick(1) end
function onHistoryColorClicked2() handleHistoryClick(2) end function onHistoryColorClicked2() handleHistoryClick(2) end
function onHistoryColorClicked3() handleHistoryClick(3) end function onHistoryColorClicked3() handleHistoryClick(3) end
function onHistoryColorClicked4() handleHistoryClick(4) end function onHistoryColorClicked4() handleHistoryClick(4) end
function onHistoryColorClicked5() handleHistoryClick(5) end function onHistoryColorClicked5() handleHistoryClick(5) end
function onHistoryColorClicked6() handleHistoryClick(6) end function onHistoryColorClicked6() handleHistoryClick(6) end
function onHistoryColorClicked7() handleHistoryClick(7) end
--============================================== --==============================================
-- CALLBACKS: colorspace fields (pure UI, no service call) -- CALLBACKS: colorspace fields (pure UI, no service call)
@@ -357,6 +364,16 @@ function onOpacityChange(value)
render() render()
end 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) -- CALLBACKS: picking (delegates to the service)
--============================================== --==============================================
@@ -382,40 +399,40 @@ end
-- Panel closed. Two cases: -- Panel closed. Two cases:
-- - Technical close to hand off to hyprpicker (waitingForPanelPick): ask -- - Technical close to hand off to hyprpicker (waitingForPanelPick): ask
-- the service to pick now that the close animation is done, then wait -- the service to pick now that the close animation is done, then wait
-- for the selectedColor watch to reopen the panel. No commit yet -- -- for the selectedColor watch to reopen the panel.
-- the selection isn't final until the panel closes for real.
-- --
-- - Real close (close button, Esc, click outside): commit whatever is -- - Real close (close button, Esc, click outside): save whatever is
-- currently selected to the service's history. -- currently selected to the service's SELECTED_FILE.
function onClose() function onClose()
if waitingForPanelPick then if waitingForPanelPick then
sendToService("pick-panel") sendToService("pick-panel")
return return
end end
sendToService("save-selected")
local hex = getSelectedColor()
if hex == nil then
return
end
local payload = hex .. ":" .. tostring(getSelectedOpacity())
sendToService("commit", payload)
end end
function onOpen(context) function onOpen(context)
-- Wait user pick a color to render panel initialChanged = false
-- when launching hyprpicker from panel's button initialSessionColor = getSelectedColor()
noctalia.state.watch("selectedColor", function() initialSessionOpacity = getSelectedOpacity()
if waitingForPanelPick then
waitingForPanelPick = false
noctalia.togglePanel("oldirtty/color_picker:panel")
else
render()
end
end)
noctalia.state.watch("colorHistory", function() noctalia.state.watch("selectedColor", function(newHex: string?)
render() if waitingForPanelPick then
end) waitingForPanelPick = false
render() 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 end
+1 -2
View File
@@ -3,13 +3,12 @@ id = "oldirtty/color_picker"
name = "Color Picker" name = "Color Picker"
description = "Pick a color from your screen with hyprpicker." description = "Pick a color from your screen with hyprpicker."
license = "MIT" license = "MIT"
version = "1.0.1" version = "1.0.2"
min_noctalia = "5.0.0" min_noctalia = "5.0.0"
icon = "palette" icon = "palette"
dependencies = ["hyprpicker"] dependencies = ["hyprpicker"]
tags = ["theming", "utility", "bar", "panel"] tags = ["theming", "utility", "bar", "panel"]
[[setting]] [[setting]]
key = "hyprpicker-format" key = "hyprpicker-format"
type = "select" type = "select"
+81 -69
View File
@@ -8,12 +8,15 @@
--============================================== --==============================================
-- CONSTANTS -- CONSTANTS
--============================================== --==============================================
local MAX_HISTORY = 7 -- selected color (index 1) + 6 recent local MAX_HISTORY = 6
local STATE_DIR = "~/.local/state/noctalia/plugin-cache/color_picker" local STATE_DIR = "~/.local/state/noctalia/plugin-cache/community/color_picker"
local HISTORY_FILE = STATE_DIR .. "/history.json" local HISTORY_FILE = STATE_DIR .. "/history.json"
local SELECTED_FILE = STATE_DIR .. "/selected.json"
local tr_color_picker = noctalia.tr("color_picker") local tr_color_picker = noctalia.tr("color_picker")
--==============================================
-- TRANSLATION HELPER
--==============================================
local function tr(key: string, subst: table?): string local function tr(key: string, subst: table?): string
if subst then if subst then
return noctalia.tr(key, subst) return noctalia.tr(key, subst)
@@ -103,7 +106,7 @@ end
-- itself for pure-UI interactions (history swatch click, field edit, -- itself for pure-UI interactions (history swatch click, field edit,
-- opacity slider) that don't touch hyprpicker or disk. -- opacity slider) that don't touch hyprpicker or disk.
-- --
-- colorHistory: persisted list (index 1 = most recently committed color). -- colorHistory: persisted list.
-- Only mutated by pushCurrentColor(), called on immediate-commit picks -- Only mutated by pushCurrentColor(), called on immediate-commit picks
-- (widget right-click) and on "commit" (panel close). -- (widget right-click) and on "commit" (panel close).
--============================================== --==============================================
@@ -153,6 +156,27 @@ local function saveHistoryToDisk()
end end
end end
local function loadSelectedFromDisk()
local content = noctalia.readFile(SELECTED_FILE)
if content == nil or content == "" then return end
local decoded = noctalia.json.decode(content)
if type(decoded) ~= "table" or decoded.hex == nil then return end
setSelected(decoded.hex, decoded.opacity or 1)
end
local function saveSelectedToDisk()
local hex = noctalia.state.get("selectedColor")
if hex == nil then return end
local opacity = tonumber(noctalia.state.get("selectedOpacity")) or 1
local encoded = noctalia.json.encode({ hex = hex, opacity = opacity })
if encoded ~= nil then
noctalia.writeFile(SELECTED_FILE, encoded)
end
end
--============================================== --==============================================
-- PICKING -- PICKING
--============================================== --==============================================
@@ -198,89 +222,77 @@ end
-- history, saves to disk right away. -- history, saves to disk right away.
-- "pick-panel" - panel's own pick button. Picks and updates the selection -- "pick-panel" - panel's own pick button. Picks and updates the selection
-- only; history/disk are untouched until "commit". -- only; history/disk are untouched until "commit".
-- "commit" - panel is closing. payload = "<hex>:<opacity>", the -- "push-to-history" - saves the initial panel color to history when it's first changed.
-- selection at close time. Pushes it to history and saves. -- "save-selected" - panel is closing for real. Saves the current draft to SELECTED_FILE.
--============================================== --==============================================
local function handlePick() local function handlePick()
runPicker(function(hex, copyText) runPicker(function(hex, copyText)
if hex == nil then return end if hex == nil then return end
setSelected(hex, 1) setSelected(hex, 1)
pushCurrentColor(hex, 1) pushCurrentColor(hex, 1)
saveHistoryToDisk() saveHistoryToDisk()
noctalia.notify(tr_color_picker, tr("color_picked", { color = copyText })) noctalia.notify(tr_color_picker, tr("color_picked", { color = copyText }))
end) end)
end end
local function handlePickFromPanel() local function handlePickFromPanel()
runPicker(function(hex, copyText) 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 handlePushToHistory(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 if hex == nil then return end
setSelected(hex, 1) pushCurrentColor(hex, tonumber(opacityStr) or 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() saveHistoryToDisk()
end) end
if not accepted then -- Opens Noctalia's native color picker dialog
noctalia.notify(tr_color_picker, tr("picker_already_open")) local function handleNativePicker()
end local current = noctalia.state.get("selectedColor") or "#FFFFFF"
local accepted = noctalia.openColorPicker(current, function(color)
if color == nil then
return
end
setSelected(color, noctalia.state.get("selectedOpacity") or 1)
end)
if not accepted then
noctalia.notify(tr_color_picker, tr("picker_already_open"))
end
end end
function onIpc(event: string, payload: string?) function onIpc(event: string, payload: string?)
if event == "pick" then if event == "pick" then
handlePick() handlePick()
elseif event == "picker-dialog" then elseif event == "picker-dialog" then
handleNativePicker() handleNativePicker()
elseif event == "pick-panel" then elseif event == "pick-panel" then
handlePickFromPanel() handlePickFromPanel()
elseif event == "commit" then elseif event == "push-to-history" then
handleCommit(payload) handlePushToHistory(payload)
end elseif event == "save-selected" then
saveSelectedToDisk()
end
end end
--============================================== --==============================================
-- BOOT (runs once at load) -- BOOT (runs once at load)
--============================================== --==============================================
noctalia.mkdirAll(STATE_DIR) function load()
loadHistoryFromDisk() noctalia.mkdirAll(STATE_DIR)
loadHistoryFromDisk()
local bootHistory = noctalia.state.get("colorHistory") or {} loadSelectedFromDisk()
local bootTop = bootHistory[1]
if bootTop ~= nil then
setSelected(bootTop.hex, bootTop.opacity or 1)
end end
load()