commit 1d596530154196fd2b33ee4dc0baa8dd0eeefa8d Author: ShempError <689+shemperror@noreply.octowow.st> Date: Sat Sep 12 17:59:32 2026 +0200 TurtleColorPicker v1.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..65fe764 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [1.0.0] - 2026-02-17 + +Initial public release: a standalone HSV color picker library for TurtleWoW +(WoW 1.12) addon developers. + +### Added +- HSV saturation/value square with vertical hue bar. +- Optional hex color input (`#RRGGBB`). +- Old/new color preview swatches. +- Compact, draggable popup window (closable with ESC). +- Lazy initialization — no overhead until the picker is first opened. +- Public API with `onChange`, `onOk`, and `onCancel` callbacks. +- `opts.anchorFrame` option to position the picker next to a caller-supplied + frame (e.g. a color swatch button), with smart left/right and vertical + clamping so the popup stays fully on-screen. +- Frame strata raised to `FULLSCREEN_DIALOG` so the picker renders above + other addons' options panels. +- README with installation instructions, API reference, and integration + examples, including an in-game screenshot. + +### Fixed +- Removed the alpha slider (not needed for pure color picking, simplified the UI). +- Worked around the Lua 5.0 32-upvalue limit by packing shared state into a + single table. +- Removed use of `HasFocus()` / `IsMouseButtonDown()`, which are not + available in the WoW 1.12 client API; switched the saturation/value and + hue controls to `Button` frames with `OnMouseDown`/`OnMouseUp` instead. +- Added a guard so the picker frame is only shown after its UI has been + created. diff --git a/Core.lua b/Core.lua new file mode 100644 index 0000000..663604e --- /dev/null +++ b/Core.lua @@ -0,0 +1,207 @@ +-- TurtleColorPicker - Standalone HSV Color Picker Library +-- Version 1.0.0 | WoW 1.12 / Lua 5.0 + +TurtleColorPicker = {} +TurtleColorPicker.version = "1.0.0" +TurtleColorPicker._session = nil + +-- Pre-create UI namespace (UI.lua populates it) +TurtleColorPicker_UI = {} + +-- ============================================================ +-- MATH UTILITIES +-- ============================================================ + +local function Clamp(val, minV, maxV) + if val < minV then return minV end + if val > maxV then return maxV end + return val +end + +-- HSV to RGB (all values 0-1, h wraps at 1) +local function HSVtoRGB(h, s, v) + if s == 0 then return v, v, v end + if h >= 1 then h = 0 end + + local hSector = h * 6 + local sextant = math.floor(hSector) + local f = hSector - sextant + local p = v * (1 - s) + local q = v * (1 - s * f) + local t = v * (1 - s * (1 - f)) + + if sextant == 0 then return v, t, p + elseif sextant == 1 then return q, v, p + elseif sextant == 2 then return p, v, t + elseif sextant == 3 then return p, q, v + elseif sextant == 4 then return t, p, v + else return v, p, q end +end + +-- RGB to HSV (all values 0-1) +local function RGBtoHSV(r, g, b) + local maxC = math.max(r, g, b) + local minC = math.min(r, g, b) + local delta = maxC - minC + local h, s, vl + + vl = maxC + if maxC == 0 then + return 0, 0, 0 + end + + s = delta / maxC + if delta == 0 then + return 0, s, vl + end + + if maxC == r then + h = (g - b) / delta + if h < 0 then h = h + 6 end + elseif maxC == g then + h = 2 + (b - r) / delta + else + h = 4 + (r - g) / delta + end + h = h / 6 + + return h, s, vl +end + +-- RGB (0-1) to Hex string "RRGGBB" +local function RGBtoHex(r, g, b) + local ri = math.floor(r * 255 + 0.5) + local gi = math.floor(g * 255 + 0.5) + local bi = math.floor(b * 255 + 0.5) + return string.format("%02X%02X%02X", ri, gi, bi) +end + +-- Hex string "RRGGBB" to RGB (0-1), returns nil on invalid +local function HexToRGB(hex) + hex = string.gsub(hex, "^#", "") + if string.len(hex) ~= 6 then return nil, nil, nil end + local r = tonumber(string.sub(hex, 1, 2), 16) + local g = tonumber(string.sub(hex, 3, 4), 16) + local b = tonumber(string.sub(hex, 5, 6), 16) + if not r or not g or not b then return nil, nil, nil end + return r / 255, g / 255, b / 255 +end + +-- Expose utilities for other addons +TurtleColorPicker.HSVtoRGB = HSVtoRGB +TurtleColorPicker.RGBtoHSV = RGBtoHSV +TurtleColorPicker.RGBtoHex = RGBtoHex +TurtleColorPicker.HexToRGB = HexToRGB +TurtleColorPicker.Clamp = Clamp + +-- ============================================================ +-- PUBLIC API +-- ============================================================ + +function TurtleColorPicker:Open(opts) + opts = opts or {} + + local ir = 1 + local ig = 1 + local ib = 1 + if opts.color then + ir = opts.color.r or 1 + ig = opts.color.g or 1 + ib = opts.color.b or 1 + end + + local h, s, v = RGBtoHSV(ir, ig, ib) + + self._session = { + h = h, + s = s, + v = v, + origR = ir, + origG = ig, + origB = ib, + hasHexInput = opts.hasHexInput or false, + anchorFrame = opts.anchorFrame or nil, + onChange = opts.onChange, + onOk = opts.onOk, + onCancel = opts.onCancel, + _confirmed = false, + } + + if not TurtleColorPicker_UI.Show then + DEFAULT_CHAT_FRAME:AddMessage("|cffff0000[TCP] ERROR: UI.lua failed to load. Check for Lua errors at login.|r") + return + end + TurtleColorPicker_UI:Show(self._session) +end + +function TurtleColorPicker:Close() + if TurtleColorPicker_UI.Hide then + TurtleColorPicker_UI:Hide() + end +end + +function TurtleColorPicker:_NotifyChange() + local s = self._session + if not s then return end + if s.onChange then + local r, g, b = HSVtoRGB(s.h, s.s, s.v) + s.onChange(r, g, b) + end +end + +function TurtleColorPicker:_NotifyOk() + local s = self._session + if not s then return end + s._confirmed = true + if s.onOk then + local r, g, b = HSVtoRGB(s.h, s.s, s.v) + s.onOk(r, g, b) + end + self:Close() +end + +function TurtleColorPicker:_NotifyCancel() + local s = self._session + if not s then return end + if s._confirmed then return end + s._confirmed = true + if s.onCancel then + s.onCancel() + end +end + +-- ============================================================ +-- SLASH COMMANDS +-- ============================================================ + +SLASH_TURTLECOLORPICKER1 = "/tcp" +SLASH_TURTLECOLORPICKER2 = "/turtlecolor" +SlashCmdList["TURTLECOLORPICKER"] = function(msg) + if msg == "test" then + TurtleColorPicker:Open({ + color = {r = 0.4, g = 0.6, b = 0.85}, + hasHexInput = true, + onOk = function(r, g, b) + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ff00[TCP]|r OK: #%s", RGBtoHex(r, g, b)) + ) + end, + onCancel = function() + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[TCP]|r Cancelled.") + end, + }) + elseif msg == "minimal" then + TurtleColorPicker:Open({ + color = {r = 0, g = 0.5, b = 1}, + onOk = function(r, g, b) + DEFAULT_CHAT_FRAME:AddMessage( + string.format("|cff00ff00[TCP]|r OK: #%s", RGBtoHex(r, g, b)) + ) + end, + }) + else + DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[TurtleColorPicker]|r v" .. TurtleColorPicker.version) + DEFAULT_CHAT_FRAME:AddMessage(" /tcp test -- Full test (with hex input)") + DEFAULT_CHAT_FRAME:AddMessage(" /tcp minimal -- Minimal test (color only)") + end +end diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e1bc95c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ShempError + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e3e166 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# TurtleColorPicker + +Standalone HSV color picker library for [TurtleWoW](https://turtle-wow.org/) (WoW 1.12) addon developers. + +![TurtleColorPicker](screenshot.png) + +## Features + +- HSV saturation/value square + vertical hue bar +- Optional hex color input (`#RRGGBB`) +- Old/New color preview swatches +- Compact, draggable popup (ESC to close) +- Lazy initialization (no overhead until first use) +- Clean API with `onChange`, `onOk`, `onCancel` callbacks + +## Installation + +Download and extract into your `Interface/AddOns/` folder so the structure is: + +``` +Interface/AddOns/TurtleColorPicker/ + TurtleColorPicker.toc + Core.lua + UI.lua +``` + +## Integration + +Add the dependency to your addon's `.toc` file: + +``` +## RequiredDeps: TurtleColorPicker +``` + +Then call the API from your addon: + +```lua +TurtleColorPicker:Open({ + color = { r = 0.5, g = 0.8, b = 1.0 }, -- initial color (0-1) + hasHexInput = true, -- show hex input field + onChange = function(r, g, b) -- fires during drag + myFrame:SetBackdropColor(r, g, b, 1) + end, + onOk = function(r, g, b) -- fires on OK + SaveColor(r, g, b) + end, + onCancel = function() -- fires on Cancel/ESC + RevertColor() + end, +}) +``` + +All options are optional. Default color is white `(1, 1, 1)`. + +## API Reference + +### TurtleColorPicker:Open(opts) + +Opens the color picker popup. + +| Option | Type | Description | +|--------|------|-------------| +| `color` | `{r, g, b}` | Initial color, values 0-1. Default: white | +| `hasHexInput` | `boolean` | Show hex input field. Default: `false` | +| `anchorFrame` | `frame` | Position picker next to this frame. Default: screen center | +| `onChange` | `function(r, g, b)` | Called on every color change during interaction | +| `onOk` | `function(r, g, b)` | Called when OK is clicked | +| `onCancel` | `function()` | Called when Cancel is clicked or ESC pressed | + +### TurtleColorPicker:Close() + +Programmatically closes the picker. + +### Utility Functions + +```lua +local r, g, b = TurtleColorPicker.HSVtoRGB(h, s, v) +local h, s, v = TurtleColorPicker.RGBtoHSV(r, g, b) +local hex = TurtleColorPicker.RGBtoHex(r, g, b) -- "FF8000" +local r, g, b = TurtleColorPicker.HexToRGB("FF8000") -- returns 0-1 +local clamped = TurtleColorPicker.Clamp(val, min, max) +``` + +## Example: Color Swatch Button + +```lua +local swatch = CreateFrame("Button", nil, MyOptionsFrame) +swatch:SetWidth(20) +swatch:SetHeight(20) +swatch:RegisterForClicks("LeftButtonUp") + +local tex = swatch:CreateTexture(nil, "BACKGROUND") +tex:SetAllPoints(swatch) +tex:SetTexture(MySettings.r, MySettings.g, MySettings.b) + +swatch:SetScript("OnClick", function() + TurtleColorPicker:Open({ + color = MySettings, + hasHexInput = true, + anchorFrame = swatch, -- picker appears next to the swatch + onChange = function(r, g, b) + tex:SetTexture(r, g, b, 1) + end, + onOk = function(r, g, b) + MySettings.r = r + MySettings.g = g + MySettings.b = b + end, + onCancel = function() + tex:SetTexture(MySettings.r, MySettings.g, MySettings.b, 1) + end, + }) +end) +``` + +## Test Commands + +- `/tcp test` - Opens picker with hex input +- `/tcp minimal` - Opens picker without hex input +- `/tcp` - Shows version info + +## License + +MIT diff --git a/TurtleColorPicker.toc b/TurtleColorPicker.toc new file mode 100644 index 0000000..656dfe8 --- /dev/null +++ b/TurtleColorPicker.toc @@ -0,0 +1,8 @@ +## Interface: 11200 +## Title: TurtleColorPicker +## Notes: Standalone HSV color picker library for addon developers. +## Author: ShempError +## Version: 1.0.0 + +Core.lua +UI.lua diff --git a/UI.lua b/UI.lua new file mode 100644 index 0000000..be0f6c5 --- /dev/null +++ b/UI.lua @@ -0,0 +1,531 @@ +-- TurtleColorPicker UI +-- WoW 1.12 / Lua 5.0 +-- All state is stored in the F table to avoid 32-upvalue limit. + +local TCP = TurtleColorPicker + +local F = { + initialized = false, + session = nil, + draggingSV = false, + draggingHue = false, + hexHasFocus = false, + main = nil, + svFrame = nil, svBase = nil, svCursor = nil, + hueFrame = nil, hueCursor = nil, + oldSwatch = nil, newSwatch = nil, previewBorder = nil, + hexContainer = nil, hexEditBox = nil, + okBtn = nil, cancelBtn = nil, +} + +-- Layout constants +F.SV_SIZE = 150 +F.HUE_WIDTH = 20 +F.HUE_HEIGHT = 150 +F.PAD = 12 +F.SPACING = 8 +F.SWATCH_H = 22 +F.BTN_W = 55 +F.BTN_H = 22 + +-- Frame width: PAD | SV | GAP | HUE | PAD +F.HUE_GAP = 8 +F.CONTENT_W = F.SV_SIZE + F.HUE_GAP + F.HUE_WIDTH +F.FRAME_W = F.CONTENT_W + F.PAD * 2 + +F.HUE_COLORS = { + {1, 0, 0}, + {1, 1, 0}, + {0, 1, 0}, + {0, 1, 1}, + {0, 0, 1}, + {1, 0, 1}, + {1, 0, 0}, +} + +-- ============================================================ +-- CURSOR +-- ============================================================ + +local function CreateCrosshairCursor(parent, size) + local f = CreateFrame("Frame", nil, parent) + f:SetWidth(size) + f:SetHeight(size) + f:SetFrameLevel(parent:GetFrameLevel() + 5) + + local len = (size / 2) - 2 + + -- Black shadow (drawn first, behind white) + local dirs = { + {"RIGHT", "CENTER", -1, 0, len + 1, 3}, + {"LEFT", "CENTER", 1, 0, len + 1, 3}, + {"BOTTOM","CENTER", 0, 1, 3, len + 1}, + {"TOP", "CENTER", 0,-1, 3, len + 1}, + } + for i = 1, 4 do + local d = dirs[i] + local t = f:CreateTexture(nil, "ARTWORK") + t:SetWidth(d[5]) + t:SetHeight(d[6]) + t:SetPoint(d[1], f, d[2], d[3], d[4]) + t:SetTexture(0, 0, 0, 0.5) + end + + -- White cross + local wdirs = { + {"RIGHT", "CENTER", -2, 0, len, 1}, + {"LEFT", "CENTER", 2, 0, len, 1}, + {"BOTTOM","CENTER", 0, 2, 1, len}, + {"TOP", "CENTER", 0,-2, 1, len}, + } + for i = 1, 4 do + local d = wdirs[i] + local t = f:CreateTexture(nil, "OVERLAY") + t:SetWidth(d[5]) + t:SetHeight(d[6]) + t:SetPoint(d[1], f, d[2], d[3], d[4]) + t:SetTexture(1, 1, 1, 0.85) + end + + return f +end + +-- ============================================================ +-- UPDATE +-- ============================================================ + +local function UpdateColor() + local s = F.session + if not s then return end + + local r, g, b = TCP.HSVtoRGB(s.h, s.s, s.v) + + local hr, hg, hb = TCP.HSVtoRGB(s.h, 1, 1) + F.svBase:SetTexture(hr, hg, hb, 1) + + F.svCursor:ClearAllPoints() + F.svCursor:SetPoint("CENTER", F.svFrame, "BOTTOMLEFT", s.s * F.SV_SIZE, s.v * F.SV_SIZE) + + F.hueCursor:ClearAllPoints() + F.hueCursor:SetPoint("CENTER", F.hueFrame, "TOPLEFT", F.HUE_WIDTH / 2, -(s.h * F.HUE_HEIGHT)) + + F.newSwatch:SetTexture(r, g, b, 1) + + if F.hexEditBox and F.hexEditBox:IsShown() then + if not F.hexHasFocus then + F.hexEditBox:SetText(TCP.RGBtoHex(r, g, b)) + end + end + + TCP:_NotifyChange() +end + +local function UpdateSVFromMouse() + local s = F.session + if not s then return end + local cx, cy = GetCursorPosition() + local scale = F.svFrame:GetEffectiveScale() + cx = cx / scale + cy = cy / scale + + local newS = TCP.Clamp((cx - F.svFrame:GetLeft()) / F.SV_SIZE, 0, 1) + local newV = TCP.Clamp((cy - F.svFrame:GetBottom()) / F.SV_SIZE, 0, 1) + if newS == s.s and newV == s.v then return end + s.s = newS + s.v = newV + UpdateColor() +end + +local function UpdateHueFromMouse() + local s = F.session + if not s then return end + local cx, cy = GetCursorPosition() + local scale = F.hueFrame:GetEffectiveScale() + cy = cy / scale + + local newH = TCP.Clamp((F.hueFrame:GetTop() - cy) / F.HUE_HEIGHT, 0, 0.9999) + if newH == s.h then return end + s.h = newH + UpdateColor() +end + +-- ============================================================ +-- LAYOUT (forward declaration, defined after Init) +-- ============================================================ + +local RecalcLayout + +-- ============================================================ +-- INIT +-- ============================================================ + +local function Init() + if F.initialized then return end + F.initialized = true + + -- Main Frame + F.main = CreateFrame("Frame", "TurtleColorPickerFrame", UIParent) + F.main:SetFrameStrata("FULLSCREEN_DIALOG") + F.main:SetWidth(F.FRAME_W) + F.main:SetHeight(300) + F.main:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + F.main:SetMovable(true) + F.main:EnableMouse(true) + F.main:RegisterForDrag("LeftButton") + F.main:SetClampedToScreen(true) + F.main:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 12, + insets = { left = 3, right = 3, top = 3, bottom = 3 }, + }) + F.main:SetBackdropColor(0.06, 0.06, 0.06, 0.92) + F.main:SetBackdropBorderColor(0.45, 0.45, 0.45, 1) + F.main:Hide() + + F.main:SetScript("OnDragStart", function() this:StartMoving() end) + F.main:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + + table.insert(UISpecialFrames, "TurtleColorPickerFrame") + + F.main:SetScript("OnHide", function() + F.draggingSV = false + F.draggingHue = false + TCP:_NotifyCancel() + F.session = nil + TCP._session = nil + end) + + F.main:SetScript("OnUpdate", function() + if F.draggingSV then + UpdateSVFromMouse() + elseif F.draggingHue then + UpdateHueFromMouse() + end + end) + + -- Title bar + local title = F.main:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + title:SetPoint("TOP", F.main, "TOP", 0, -4) + title:SetText("Color Picker") + title:SetTextColor(0.8, 0.8, 0.8, 0.5) + + -- Offset top content below mini-title + local topOff = F.PAD + 8 + + -- SV Square + F.svFrame = CreateFrame("Button", nil, F.main) + F.svFrame:SetWidth(F.SV_SIZE) + F.svFrame:SetHeight(F.SV_SIZE) + F.svFrame:SetPoint("TOPLEFT", F.main, "TOPLEFT", F.PAD, -topOff) + F.svFrame:RegisterForClicks("LeftButtonDown", "LeftButtonUp") + + F.svBase = F.svFrame:CreateTexture(nil, "BACKGROUND") + F.svBase:SetAllPoints(F.svFrame) + F.svBase:SetTexture(1, 0, 0, 1) + + local svWhite = F.svFrame:CreateTexture(nil, "BORDER") + svWhite:SetAllPoints(F.svFrame) + svWhite:SetTexture(1, 1, 1, 1) + svWhite:SetGradientAlpha("HORIZONTAL", 1, 1, 1, 1, 1, 1, 1, 0) + + local svBlack = F.svFrame:CreateTexture(nil, "ARTWORK") + svBlack:SetAllPoints(F.svFrame) + svBlack:SetTexture(0, 0, 0, 1) + svBlack:SetGradientAlpha("VERTICAL", 0, 0, 0, 1, 0, 0, 0, 0) + + local svBorder = CreateFrame("Frame", nil, F.svFrame) + svBorder:SetPoint("TOPLEFT", F.svFrame, "TOPLEFT", -1, 1) + svBorder:SetPoint("BOTTOMRIGHT", F.svFrame, "BOTTOMRIGHT", 1, -1) + svBorder:SetFrameLevel(F.svFrame:GetFrameLevel() + 3) + svBorder:SetBackdrop({ + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + edgeSize = 9, + }) + svBorder:SetBackdropBorderColor(0.25, 0.25, 0.25, 0.7) + + F.svCursor = CreateCrosshairCursor(F.svFrame, 15) + + F.svFrame:SetScript("OnMouseDown", function() + F.draggingSV = true + UpdateSVFromMouse() + end) + F.svFrame:SetScript("OnMouseUp", function() + F.draggingSV = false + end) + + -- Hue Bar + F.hueFrame = CreateFrame("Button", nil, F.main) + F.hueFrame:SetWidth(F.HUE_WIDTH) + F.hueFrame:SetHeight(F.HUE_HEIGHT) + F.hueFrame:SetPoint("TOPLEFT", F.svFrame, "TOPRIGHT", F.HUE_GAP, 0) + F.hueFrame:RegisterForClicks("LeftButtonDown", "LeftButtonUp") + + local segH = F.HUE_HEIGHT / 6 + for i = 1, 6 do + local seg = F.hueFrame:CreateTexture(nil, "ARTWORK") + seg:SetWidth(F.HUE_WIDTH) + seg:SetHeight(segH) + seg:SetPoint("TOPLEFT", F.hueFrame, "TOPLEFT", 0, -(i - 1) * segH) + seg:SetTexture(1, 1, 1, 1) + local t = F.HUE_COLORS[i] + local b = F.HUE_COLORS[i + 1] + seg:SetGradient("VERTICAL", b[1], b[2], b[3], t[1], t[2], t[3]) + end + + local hueBorder = CreateFrame("Frame", nil, F.hueFrame) + hueBorder:SetPoint("TOPLEFT", F.hueFrame, "TOPLEFT", -1, 1) + hueBorder:SetPoint("BOTTOMRIGHT", F.hueFrame, "BOTTOMRIGHT", 1, -1) + hueBorder:SetFrameLevel(F.hueFrame:GetFrameLevel() + 3) + hueBorder:SetBackdrop({ + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + edgeSize = 9, + }) + hueBorder:SetBackdropBorderColor(0.25, 0.25, 0.25, 0.7) + + -- Hue Cursor (small arrows / bar) + F.hueCursor = CreateFrame("Frame", nil, F.hueFrame) + F.hueCursor:SetWidth(F.HUE_WIDTH + 4) + F.hueCursor:SetHeight(5) + F.hueCursor:SetFrameLevel(F.hueFrame:GetFrameLevel() + 5) + + local hcBg = F.hueCursor:CreateTexture(nil, "ARTWORK") + hcBg:SetAllPoints(F.hueCursor) + hcBg:SetTexture(0, 0, 0, 0.8) + + local hcInner = F.hueCursor:CreateTexture(nil, "OVERLAY") + hcInner:SetPoint("TOPLEFT", F.hueCursor, "TOPLEFT", 1, -1) + hcInner:SetPoint("BOTTOMRIGHT", F.hueCursor, "BOTTOMRIGHT", -1, 1) + hcInner:SetTexture(1, 1, 1, 0.85) + + F.hueFrame:SetScript("OnMouseDown", function() + F.draggingHue = true + UpdateHueFromMouse() + end) + F.hueFrame:SetScript("OnMouseUp", function() + F.draggingHue = false + end) + + F._topOff = topOff + + -- Preview Swatches + F.previewBorder = CreateFrame("Frame", nil, F.main) + F.previewBorder:SetHeight(F.SWATCH_H) + + F.oldSwatch = F.previewBorder:CreateTexture(nil, "ARTWORK") + F.oldSwatch:SetHeight(F.SWATCH_H) + F.oldSwatch:SetPoint("TOPLEFT", F.previewBorder, "TOPLEFT", 0, 0) + F.oldSwatch:SetTexture(1, 1, 1, 1) + + F.newSwatch = F.previewBorder:CreateTexture(nil, "ARTWORK") + F.newSwatch:SetHeight(F.SWATCH_H) + F.newSwatch:SetPoint("TOPRIGHT", F.previewBorder, "TOPRIGHT", 0, 0) + F.newSwatch:SetTexture(1, 0, 0, 1) + + -- Thin divider between swatches + local swatchDiv = F.previewBorder:CreateTexture(nil, "OVERLAY") + swatchDiv:SetWidth(1) + swatchDiv:SetHeight(F.SWATCH_H) + swatchDiv:SetPoint("CENTER", F.previewBorder, "CENTER", 0, 0) + swatchDiv:SetTexture(0.15, 0.15, 0.15, 1) + + -- Hex Input + F.hexContainer = CreateFrame("Frame", nil, F.main) + F.hexContainer:SetHeight(F.BTN_H) + F.hexContainer:Hide() + + local hexLabel = F.hexContainer:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + hexLabel:SetPoint("LEFT", F.hexContainer, "LEFT", 0, 0) + hexLabel:SetText("#") + hexLabel:SetTextColor(0.6, 0.6, 0.6, 1) + + F.hexEditBox = CreateFrame("EditBox", "TurtleColorPickerHexInput", F.hexContainer) + F.hexEditBox:SetWidth(48) + F.hexEditBox:SetHeight(F.BTN_H) + F.hexEditBox:SetPoint("LEFT", hexLabel, "RIGHT", 3, 0) + F.hexEditBox:SetFontObject(GameFontHighlightSmall) + F.hexEditBox:SetAutoFocus(false) + F.hexEditBox:SetMaxLetters(6) + F.hexEditBox:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + edgeSize = 8, + insets = { left = 2, right = 2, top = 2, bottom = 2 }, + }) + F.hexEditBox:SetBackdropColor(0.05, 0.05, 0.05, 0.9) + F.hexEditBox:SetBackdropBorderColor(0.35, 0.35, 0.35, 0.8) + F.hexEditBox:SetTextInsets(4, 4, 0, 0) + + F.hexEditBox:SetScript("OnEnterPressed", function() + local sess = F.session + if not sess then return end + local text = F.hexEditBox:GetText() + local r, g, b = TCP.HexToRGB(text) + if r then + sess.h, sess.s, sess.v = TCP.RGBtoHSV(r, g, b) + UpdateColor() + else + local cr, cg, cb = TCP.HSVtoRGB(sess.h, sess.s, sess.v) + F.hexEditBox:SetText(TCP.RGBtoHex(cr, cg, cb)) + end + F.hexEditBox:ClearFocus() + end) + + F.hexEditBox:SetScript("OnEscapePressed", function() + local sess = F.session + if not sess then return end + local r, g, b = TCP.HSVtoRGB(sess.h, sess.s, sess.v) + F.hexEditBox:SetText(TCP.RGBtoHex(r, g, b)) + F.hexEditBox:ClearFocus() + end) + + F.hexEditBox:SetScript("OnEditFocusGained", function() + F.hexHasFocus = true + end) + F.hexEditBox:SetScript("OnEditFocusLost", function() + F.hexHasFocus = false + end) + + F.hexEditBox:SetScript("OnTextChanged", function() + local sess = F.session + if not sess then return end + if not F.hexHasFocus then return end + local text = F.hexEditBox:GetText() + if string.len(text) == 6 then + local r, g, b = TCP.HexToRGB(text) + if r then + sess.h, sess.s, sess.v = TCP.RGBtoHSV(r, g, b) + UpdateColor() + end + end + end) + + -- OK / Cancel + F.okBtn = CreateFrame("Button", nil, F.main, "UIPanelButtonTemplate") + F.okBtn:SetWidth(F.BTN_W) + F.okBtn:SetHeight(F.BTN_H) + F.okBtn:SetText("OK") + F.okBtn:SetScript("OnClick", function() + TCP:_NotifyOk() + end) + + F.cancelBtn = CreateFrame("Button", nil, F.main, "UIPanelButtonTemplate") + F.cancelBtn:SetWidth(F.BTN_W) + F.cancelBtn:SetHeight(F.BTN_H) + F.cancelBtn:SetText("Cancel") + F.cancelBtn:SetScript("OnClick", function() + TCP:_NotifyCancel() + F.main:Hide() + end) +end + +-- ============================================================ +-- LAYOUT IMPLEMENTATION +-- ============================================================ + +RecalcLayout = function() + local s = F.session + if not s then return end + + local cw = F.CONTENT_W + local topOff = F._topOff or (F.PAD + 8) + local yOff = -topOff - F.SV_SIZE + + -- Preview swatches + yOff = yOff - F.SPACING + F.previewBorder:ClearAllPoints() + F.previewBorder:SetPoint("TOPLEFT", F.main, "TOPLEFT", F.PAD, yOff) + F.previewBorder:SetWidth(cw) + F.previewBorder:Show() + + local swatchW = math.floor((cw - 1) / 2) + F.oldSwatch:SetWidth(swatchW) + F.newSwatch:SetWidth(swatchW) + yOff = yOff - F.SWATCH_H + + -- Bottom row: hex input (left) + buttons (right) on same line + yOff = yOff - F.SPACING + local rowH = F.BTN_H + + F.okBtn:ClearAllPoints() + F.cancelBtn:ClearAllPoints() + local btnGap = 4 + -- Anchor buttons to right edge of content area (flush with swatches) + local rightEdge = F.PAD + cw + 2 + F.cancelBtn:SetPoint("TOPRIGHT", F.main, "TOPLEFT", rightEdge, yOff) + F.okBtn:SetPoint("TOPRIGHT", F.cancelBtn, "TOPLEFT", -btnGap, 0) + + if s.hasHexInput then + F.hexContainer:ClearAllPoints() + F.hexContainer:SetPoint("TOPLEFT", F.main, "TOPLEFT", F.PAD, yOff) + F.hexContainer:SetPoint("TOPRIGHT", F.okBtn, "TOPLEFT", -btnGap, 0) + F.hexContainer:Show() + else + F.hexContainer:Hide() + end + + yOff = yOff - rowH + + F.main:SetHeight(math.abs(yOff) + F.PAD) +end + +-- ============================================================ +-- PUBLIC +-- ============================================================ + +function TurtleColorPicker_UI:Show(sess) + Init() + F.session = sess + + F.oldSwatch:SetTexture(sess.origR, sess.origG, sess.origB, 1) + + if sess.hasHexInput then + local r, g, b = TCP.HSVtoRGB(sess.h, sess.s, sess.v) + F.hexEditBox:SetText(TCP.RGBtoHex(r, g, b)) + F.hexEditBox:ClearFocus() + end + + RecalcLayout() + UpdateColor() + + -- Position near anchor frame if provided + if sess.anchorFrame then + local anchor = sess.anchorFrame + local scale = F.main:GetEffectiveScale() + local aScale = anchor:GetEffectiveScale() + -- Get anchor position in screen coords + local aLeft = anchor:GetLeft() * aScale + local aRight = anchor:GetRight() * aScale + local aTop = anchor:GetTop() * aScale + -- Convert to our scale + local screenW = GetScreenWidth() * UIParent:GetEffectiveScale() + local fw = F.main:GetWidth() * scale + local fh = F.main:GetHeight() * scale + + -- Try to place to the right of the anchor with a small gap + local gap = 8 + local targetX = aRight + gap + local targetY = aTop + + -- If it would go off the right edge, place to the left instead + if (targetX + fw) > screenW then + targetX = aLeft - fw - gap + end + -- Clamp Y so it stays on screen + local screenH = GetScreenHeight() * UIParent:GetEffectiveScale() + if targetY > screenH then targetY = screenH end + if (targetY - fh) < 0 then targetY = fh end + + -- Convert back to our frame's scale and set position from BOTTOMLEFT of UIParent + F.main:ClearAllPoints() + F.main:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", targetX / scale, targetY / scale) + end + + F.main:Show() +end + +function TurtleColorPicker_UI:Hide() + if F.main then + F.main:Hide() + end +end diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..61a731c Binary files /dev/null and b/screenshot.png differ