diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5359d9d..a083efd 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import { CommandApplications } from '@renderer/components/CommandApplications' import { CommandShortcuts } from '@renderer/components/CommandShortcuts' import { useScrollToTop } from '@renderer/hooks' import { Settings } from '@renderer/components/Settings' +import { CommandColor } from './components/CommandColor' const App = () => { const [selectedCommand, setSelectedCommand] = useState(null) @@ -50,6 +51,7 @@ const App = () => { + diff --git a/src/renderer/src/components/CommandColor.tsx b/src/renderer/src/components/CommandColor.tsx new file mode 100644 index 0000000..83b15e7 --- /dev/null +++ b/src/renderer/src/components/CommandColor.tsx @@ -0,0 +1,57 @@ +import { evaluateColorExpression, ResultColorCodes } from '@renderer/lib/color'; +import { useMemo } from 'react' + +type CommandColorProps = { commandSearch: string } + +type MemoResults = { + isColorCommand: false, + result: null +} | { + isColorCommand: true, + result: ResultColorCodes +} + +export const CommandColor = ({ commandSearch }: CommandColorProps) => { + const { isColorCommand, result } = useMemo(() => { + const result = evaluateColorExpression(commandSearch) + + if (result === null) return { isColorCommand: false, result: null } + + return { + isColorCommand: true, + result + } + }, [commandSearch]) + + return ( + <> + {isColorCommand && ( +
+
+

HEX: #{result.hex}

+

RGB: rgb({result.rgb.r}, {result.rgb.g}, {result.rgb.b})

+

HSL: hsl({result.hsl.h}, {result.hsl.s}, {result.hsl.l})

+
+
+
+
+ Color: {result.colorDescription} + Lightness: {result.hsl.l}% + Saturation: {result.hsl.s}% +
+
+
+ )} + + ) +} \ No newline at end of file diff --git a/src/renderer/src/lib/color.ts b/src/renderer/src/lib/color.ts new file mode 100644 index 0000000..3accf6e --- /dev/null +++ b/src/renderer/src/lib/color.ts @@ -0,0 +1,103 @@ +import { normalizeHex, hexToRgb } from './colorHelper/hex' +import { parseHsl, hslToRgb } from './colorHelper/hsl' +import { parseRgb, rgbToHex, rgbToHsl } from './colorHelper/rgb' + +export type ResultColorCodes = { + hex: string + rgb: { r: number; g: number; b: number } + hsl: { h: number; s: number; l: number } + colorDescription: string +} + +export function evaluateColorExpression(str: string): ResultColorCodes | null { + // Check for HEX + if (str.startsWith('#') && str.length > 3) { + const hex = normalizeHex(str) + + if (hex === null) return null + + const rgb = hexToRgb(hex) + const hsl = rgbToHsl(rgb) + + return { + hex, + rgb, + hsl, + colorDescription: getColorInfo(hex, hsl) + } + } + + // Check for RGB + if (str.startsWith('rgb(')) { + const rgb = parseRgb(str) + + if (rgb === null) return null + + const hex = rgbToHex(rgb) + const hsl = rgbToHsl(rgb) + + return { + hex, + rgb, + hsl, + colorDescription: getColorInfo(hex, hsl) + } + } + + // Check for HSL + // TODO: check for other hsl formats too + if (str.startsWith('hsl(')) { + const hsl = parseHsl(str) + + if (hsl === null) return null + + const rgb = hslToRgb(hsl) + const hex = rgbToHex(rgb) + + if (normalizeHex !== null) { + return { + hex, + rgb, + hsl, + colorDescription: getColorInfo(hex, hsl) + } + } + } + + return null +} + +// Helper: Get color name/description +export function getColorInfo(hex: string, hsl: { h: number; s: number; l: number }) { + const { h, s, l } = hsl + + // Exact match for white and black + if (hex.toLowerCase() === 'ffffff') return 'White' + if (hex.toLowerCase() === '000000') return 'Black' + + let description = '' + + // Lightness description + if (l < 20) description += 'Very Dark ' + else if (l < 40) description += 'Dark ' + else if (l > 80) description += 'Light ' + else if (l > 60) description += 'Bright ' + + // Saturation description + if (s < 10) description += 'Gray' + else if (s < 30) description += 'Muted ' + + // Hue description + if (s >= 10) { + if (h >= 0 && h < 15) description += 'Red' + else if (h >= 15 && h < 45) description += 'Orange' + else if (h >= 45 && h < 75) description += 'Yellow' + else if (h >= 75 && h < 150) description += 'Green' + else if (h >= 150 && h < 210) description += 'Cyan' + else if (h >= 210 && h < 270) description += 'Blue' + else if (h >= 270 && h < 330) description += 'Purple' + else description += 'Red' + } + + return description.trim() || 'Neutral' +} diff --git a/src/renderer/src/lib/colorHelper/hex.ts b/src/renderer/src/lib/colorHelper/hex.ts new file mode 100644 index 0000000..5395cec --- /dev/null +++ b/src/renderer/src/lib/colorHelper/hex.ts @@ -0,0 +1,25 @@ +// Helper: Validate and normalize hex color +export function normalizeHex(hex: string) { + hex = hex.trim().replace(/^#/, '') + if (/^[0-9a-fA-F]{3}$/.test(hex)) { + // Expand shorthand (e.g. "abc" -> "aabbcc") + hex = hex + .split('') + .map((x: string) => x + x) + .join('') + } + if (/^[0-9a-fA-F]{6}$/.test(hex)) { + return hex.toLowerCase() + } + return null +} + +// Helper: Convert hex to RGB +export function hexToRgb(hex: string) { + const n = parseInt(hex, 16) + return { + r: (n >> 16) & 255, + g: (n >> 8) & 255, + b: n & 255 + } +} diff --git a/src/renderer/src/lib/colorHelper/hsl.ts b/src/renderer/src/lib/colorHelper/hsl.ts new file mode 100644 index 0000000..f5583dd --- /dev/null +++ b/src/renderer/src/lib/colorHelper/hsl.ts @@ -0,0 +1,65 @@ +// Helper: Validate and parse HSL input +export function parseHsl(input: string) { + if (typeof input !== 'string') return null + + // Remove whitespace and convert to lowercase + const cleaned = input.trim().toLowerCase() + + // Match various HSL formats + const hslMatch = cleaned.match(/^hsl\s*\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*\)$/) + const spaceSeparated = cleaned.match(/^(\d+)\s+(\d+)%?\s+(\d+)%?$/) + const commaSeparated = cleaned.match(/^(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?$/) + + let h: number, s: number, l: number + + if (hslMatch) { + ;[, h, s, l] = hslMatch.map(Number) + } else if (spaceSeparated) { + ;[, h, s, l] = spaceSeparated.map(Number) + } else if (commaSeparated) { + ;[, h, s, l] = commaSeparated.map(Number) + } else { + return null + } + + // Validate HSL values + if (h < 0 || h > 360 || s < 0 || s > 100 || l < 0 || l > 100) { + return null + } + + return { h, s, l } +} + +// Helper: Convert HSL to RGB +export function hslToRgb({ h, s, l }) { + h /= 360 + s /= 100 + l /= 100 + + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1 + if (t > 1) t -= 1 + if (t < 1 / 6) return p + (q - p) * 6 * t + if (t < 1 / 2) return q + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 + return p + } + + let r: number, g: number, b: number + + if (s === 0) { + r = g = b = l // achromatic + } else { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s + const p = 2 * l - q + r = hue2rgb(p, q, h + 1 / 3) + g = hue2rgb(p, q, h) + b = hue2rgb(p, q, h - 1 / 3) + } + + return { + r: Math.round(r * 255), + g: Math.round(g * 255), + b: Math.round(b * 255) + } +} diff --git a/src/renderer/src/lib/colorHelper/rgb.ts b/src/renderer/src/lib/colorHelper/rgb.ts new file mode 100644 index 0000000..5daf98a --- /dev/null +++ b/src/renderer/src/lib/colorHelper/rgb.ts @@ -0,0 +1,77 @@ +// Helper: Validate and parse RGB input +export function parseRgb(input: string) { + if (typeof input !== 'string') return null + + // Remove whitespace and convert to lowercase + const cleaned = input.trim().toLowerCase() + + // Match various RGB formats + const rgbMatch = cleaned.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/) + const spaceSeparated = cleaned.match(/^(\d+)\s+(\d+)\s+(\d+)$/) + const commaSeparated = cleaned.match(/^(\d+)\s*,\s*(\d+)\s*,\s*(\d+)$/) + + let r: number, g: number, b: number + + if (rgbMatch) { + ;[, r, g, b] = rgbMatch.map(Number) + } else if (spaceSeparated) { + ;[, r, g, b] = spaceSeparated.map(Number) + } else if (commaSeparated) { + ;[, r, g, b] = commaSeparated.map(Number) + } else { + return null + } + + // Validate RGB values are in range 0-255 + if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) { + return null + } + + return { r, g, b } +} + +// Helper: Convert RGB to hex +export function rgbToHex({ r, g, b }) { + const toHex = (n: number) => { + const hex = Math.round(n).toString(16) + return hex.length === 1 ? '0' + hex : hex + } + return toHex(r) + toHex(g) + toHex(b) +} + +// Helper: Convert RGB to HSL +export function rgbToHsl({ r, g, b }) { + r /= 255 + g /= 255 + b /= 255 + const max = Math.max(r, g, b), + min = Math.min(r, g, b) + let h = 0 + let s = 0 + let l = (max + min) / 2 + + if (max === min) { + h = s = 0 // achromatic + } else { + const d = max - min + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0) + break + case g: + h = (b - r) / d + 2 + break + case b: + h = (r - g) / d + 4 + break + } + h = h / 6 + } + + return { + h: Math.round(h * 360), + s: Math.round(s * 100), + l: Math.round(l * 100) + } +}