From 4c726442db8672d4bb00f6b48c779b2e3ec96de7 Mon Sep 17 00:00:00 2001 From: Ian Ribeiro <133624505+oldirtty@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:57:14 -0300 Subject: [PATCH] feat: improve history push logic and update state persistence (#22) --- color_picker/README.md | 1 - color_picker/panel.luau | 197 +++++++++++++++++++++----------------- color_picker/plugin.toml | 5 +- color_picker/service.luau | 152 +++++++++++++++-------------- 4 files changed, 191 insertions(+), 164 deletions(-) diff --git a/color_picker/README.md b/color_picker/README.md index 8e26623..ba5a55a 100644 --- a/color_picker/README.md +++ b/color_picker/README.md @@ -37,7 +37,6 @@ Panel controls: - **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 diff --git a/color_picker/panel.luau b/color_picker/panel.luau index fe1472f..201a578 100644 --- a/color_picker/panel.luau +++ b/color_picker/panel.luau @@ -17,12 +17,13 @@ local SERVICE = "oldirtty/color_picker:service" local swatch_radius = noctalia.getConfig("swatch-radius") 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 --- 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. +--============================================== +-- CONTROL VARIABLES +--============================================== local waitingForPanelPick = false +local initialSessionColor = nil +local initialSessionOpacity = 1 +local initialChanged = false --============================================== -- TRANSLATION HELPER @@ -157,29 +158,6 @@ local function parseHexText(text: string): string? 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 --============================================== @@ -187,49 +165,78 @@ end local function sendToService(event: string, payload: string?) local cmd = "noctalia msg plugin " .. SERVICE .. " all " .. event if payload ~= nil then - cmd = cmd .. " " .. payload + 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 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" }), - }) + 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) @@ -277,7 +284,7 @@ local function render() }), 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" }), + ui.slider({ min = 0, max = 1, step = 0.01, value = opacity, onChange = "onOpacityChange", onDragEnd = "onOpacityDragEnd" }), }), -- Render current swatch ui.box({ @@ -313,12 +320,12 @@ function handleHistoryClick(index: number) 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 -function onHistoryColorClicked7() handleHistoryClick(7) end --============================================== -- CALLBACKS: colorspace fields (pure UI, no service call) @@ -357,6 +364,16 @@ function onOpacityChange(value) 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) --============================================== @@ -382,40 +399,40 @@ 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. No commit yet -- --- the selection isn't final until the panel closes for real. +-- for the selectedColor watch to reopen the panel. -- --- - Real close (close button, Esc, click outside): commit whatever is --- currently selected to the service's history. +-- - 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 - - local hex = getSelectedColor() - if hex == nil then - return - end - - local payload = hex .. ":" .. tostring(getSelectedOpacity()) - sendToService("commit", payload) + sendToService("save-selected") end function onOpen(context) - -- Wait user pick a color to render panel - -- when launching hyprpicker from panel's button - noctalia.state.watch("selectedColor", function() - if waitingForPanelPick then - waitingForPanelPick = false - noctalia.togglePanel("oldirtty/color_picker:panel") - else - render() - end - end) + initialChanged = false + initialSessionColor = getSelectedColor() + initialSessionOpacity = getSelectedOpacity() - noctalia.state.watch("colorHistory", function() - render() - end) - render() + 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 diff --git a/color_picker/plugin.toml b/color_picker/plugin.toml index a5feeab..bb95c00 100644 --- a/color_picker/plugin.toml +++ b/color_picker/plugin.toml @@ -3,12 +3,11 @@ id = "oldirtty/color_picker" name = "Color Picker" description = "Pick a color from your screen with hyprpicker." license = "MIT" -version = "1.0.1" +version = "1.0.2" min_noctalia = "5.0.0" icon = "palette" dependencies = ["hyprpicker"] -tags = ["theming", "utility", "bar", "panel"] - +tags = ["theming", "utility", "bar", "panel"] [[setting]] key = "hyprpicker-format" diff --git a/color_picker/service.luau b/color_picker/service.luau index 5aacd0e..f0a0d77 100644 --- a/color_picker/service.luau +++ b/color_picker/service.luau @@ -8,12 +8,15 @@ --============================================== -- CONSTANTS --============================================== -local MAX_HISTORY = 7 -- selected color (index 1) + 6 recent -local STATE_DIR = "~/.local/state/noctalia/plugin-cache/color_picker" +local MAX_HISTORY = 6 +local STATE_DIR = "~/.local/state/noctalia/plugin-cache/community/color_picker" local HISTORY_FILE = STATE_DIR .. "/history.json" - +local SELECTED_FILE = STATE_DIR .. "/selected.json" 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) @@ -103,7 +106,7 @@ end -- 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). +-- colorHistory: persisted list. -- Only mutated by pushCurrentColor(), called on immediate-commit picks -- (widget right-click) and on "commit" (panel close). --============================================== @@ -153,6 +156,27 @@ local function saveHistoryToDisk() 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 --============================================== @@ -198,89 +222,77 @@ end -- 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 = ":", the --- selection at close time. Pushes it to history and saves. +-- "push-to-history" - saves the initial panel color to history when it's first changed. +-- "save-selected" - panel is closing for real. Saves the current draft to SELECTED_FILE. --============================================== local function handlePick() - runPicker(function(hex, copyText) - if hex == nil then return end + 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) + 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) + 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 - 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) + pushCurrentColor(hex, tonumber(opacityStr) or 1) saveHistoryToDisk() - end) +end - if not accepted then - noctalia.notify(tr_color_picker, tr("picker_already_open")) - end +-- Opens Noctalia's native color picker dialog +local function handleNativePicker() + 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 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 + if event == "pick" then + handlePick() + elseif event == "picker-dialog" then + handleNativePicker() + elseif event == "pick-panel" then + handlePickFromPanel() + elseif event == "push-to-history" then + handlePushToHistory(payload) + elseif event == "save-selected" then + saveSelectedToDisk() + 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 \ No newline at end of file +function load() + noctalia.mkdirAll(STATE_DIR) + loadHistoryFromDisk() + loadSelectedFromDisk() +end +load()