Skip to content

Commit 6f824c2

Browse files
committed
Add interactive tone curves with histogram to image and color editors
- Monotone cubic spline (Fritsch-Carlson) curves editor with draggable control points and luminance histogram background - Applied in both the wallpaper image editor (pixel-level tone curve) and the palette color editor (HSL lightness remapping) - Fix reset not clearing curve state in theme store and color editor
1 parent f84aedc commit 6f824c2

6 files changed

Lines changed: 459 additions & 6 deletions

File tree

frontend/src/lib/components/sidebar/ColorAdjustments.svelte

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import AdjustmentSlider from './AdjustmentSlider.svelte';
33
import ExpandableSection from '$lib/components/shared/ExpandableSection.svelte';
4+
import CurvesEditor from '$lib/components/wallpaper-editor/CurvesEditor.svelte';
45
import {
56
getAdjustments,
67
setAdjustments,
@@ -17,14 +18,49 @@
1718
getExtendedColors,
1819
getBaseExtendedColors,
1920
setAdjustedExtendedColors,
21+
getPaletteCurvePoints,
22+
setPaletteCurvePoints,
2023
} from '$lib/stores/theme.svelte';
2124
import {pushState} from '$lib/stores/history.svelte';
2225
import {ADJUSTMENT_LIMITS} from '$lib/constants/colors';
2326
import {DEFAULT_ADJUSTMENTS, type Adjustments} from '$lib/types/theme';
2427
import {debounce} from '$lib/utils/debounce';
28+
import {buildCurveLUT, applyCurveToColors} from '$lib/utils/canvas-filters';
29+
import {hexToRgb} from '$lib/utils/color';
2530
2631
let adj = $derived(getAdjustments());
2732
let expanded = $state(true);
33+
let curvePoints = $state<[number, number][]>([]);
34+
35+
// Sync store → local on external changes (undo/redo)
36+
$effect(() => {
37+
const stored = getPaletteCurvePoints();
38+
if (JSON.stringify(stored) !== JSON.stringify(curvePoints)) {
39+
curvePoints = stored;
40+
}
41+
});
42+
43+
// Build a palette luminance histogram (Gaussian bumps at each color's L)
44+
let paletteHistogram = $derived.by(() => {
45+
const pal = getPalette();
46+
const hist = new Array(256).fill(0);
47+
const sigma = 8;
48+
for (const hex of pal) {
49+
if (!hex || hex.length < 7) continue;
50+
const {r, g, b} = hexToRgb(hex);
51+
const lum = Math.round((Math.max(r, g, b) + Math.min(r, g, b)) / 2);
52+
for (let i = 0; i < 256; i++) {
53+
const d = i - lum;
54+
hist[i] += Math.exp((-d * d) / (2 * sigma * sigma));
55+
}
56+
}
57+
return hist;
58+
});
59+
60+
function handleCurveChange() {
61+
setPaletteCurvePoints(curvePoints);
62+
applyAdjustments(getAdjustments());
63+
}
2864
2965
const sliderDefs = [
3066
{key: 'vibrance', label: 'Vibrance'},
@@ -42,7 +78,7 @@
4278
] as const;
4379
4480
// Always adjust from basePalette so changes are non-destructive
45-
// Respects locked colors and color selection
81+
// Respects locked colors, color selection, and palette curve
4682
const applyAdjustments = debounce(async (adj: Adjustments) => {
4783
const base = getBasePalette();
4884
const locked = getLockedColors();
@@ -52,6 +88,8 @@
5288
const extSelActive = hasExtColorSelection();
5389
const anySelection = hasAnySelection();
5490
const baseExt = getBaseExtendedColors();
91+
const curveLUT =
92+
curvePoints.length > 0 ? buildCurveLUT(curvePoints) : null;
5593
try {
5694
const {AdjustPaletteColors} = await import(
5795
'../../../../wailsjs/go/main/App'
@@ -61,11 +99,14 @@
6199
if (!(anySelection && !paletteSelActive && extSelActive)) {
62100
const result = await AdjustPaletteColors(base, adj);
63101
if (result && Array.isArray(result) && result.length >= 16) {
64-
const final = result.map((c: string, i: number) => {
102+
let final = result.map((c: string, i: number) => {
65103
if (locked[i]) return base[i];
66104
if (paletteSelActive && !selected[i]) return base[i];
67105
return c;
68106
});
107+
if (curveLUT) {
108+
final = applyCurveToColors(final, curveLUT);
109+
}
69110
setAdjustedPalette(final);
70111
}
71112
}
@@ -78,11 +119,21 @@
78119
const extKeys = Object.keys(baseExt);
79120
const adjusted: Record<string, string> = {};
80121
extKeys.forEach((key, i) => {
81-
adjusted[key] =
122+
const val =
82123
extSelActive && !selectedExt[key]
83124
? baseExt[key]
84125
: extResult[i];
126+
adjusted[key] = val;
85127
});
128+
if (curveLUT) {
129+
const curvedExt = applyCurveToColors(
130+
Object.values(adjusted),
131+
curveLUT
132+
);
133+
Object.keys(adjusted).forEach((key, i) => {
134+
adjusted[key] = curvedExt[i];
135+
});
136+
}
86137
setAdjustedExtendedColors(adjusted);
87138
}
88139
}
@@ -125,11 +176,21 @@
125176
function resetAll() {
126177
pushState(getPalette(), getExtendedColors(), getAdjustments());
127178
setAdjustments({...DEFAULT_ADJUSTMENTS});
179+
curvePoints = [];
180+
setPaletteCurvePoints([]);
128181
setPalette(getBasePalette(), true);
129182
}
130183
</script>
131184

132185
<ExpandableSection title="Color Adjustments" bind:expanded>
186+
<div class="mb-3">
187+
<CurvesEditor
188+
bind:points={curvePoints}
189+
histogram={paletteHistogram}
190+
onchange={handleCurveChange}
191+
/>
192+
</div>
193+
133194
<button
134195
class="text-fg-dimmed hover:text-fg-secondary mb-2 text-[10px]"
135196
onclick={resetAll}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<script lang="ts">
2+
import {buildCurveLUT} from '$lib/utils/canvas-filters';
3+
4+
const W = 256;
5+
const H = 192;
6+
const PAD = 0;
7+
8+
let {
9+
points = $bindable<[number, number][]>([]),
10+
histogram = [] as number[],
11+
onchange = () => {},
12+
}: {
13+
points: [number, number][];
14+
histogram: number[];
15+
onchange: () => void;
16+
} = $props();
17+
18+
let canvasEl = $state<HTMLCanvasElement | null>(null);
19+
let dragging = $state<number | null>(null);
20+
21+
// Redraw whenever points or histogram change. JSON.stringify ensures
22+
// coordinate mutations (not just length changes) trigger a redraw.
23+
$effect(() => {
24+
const _ = JSON.stringify(points);
25+
const __ = histogram.length;
26+
draw();
27+
});
28+
29+
function toCanvas(px: number, py: number): [number, number] {
30+
return [PAD + px * W, PAD + (1 - py) * H];
31+
}
32+
33+
function fromCanvas(cx: number, cy: number): [number, number] {
34+
return [
35+
Math.max(0, Math.min(1, (cx - PAD) / W)),
36+
Math.max(0, Math.min(1, 1 - (cy - PAD) / H)),
37+
];
38+
}
39+
40+
function draw() {
41+
if (!canvasEl) return;
42+
const ctx = canvasEl.getContext('2d');
43+
if (!ctx) return;
44+
const cw = canvasEl.width;
45+
const ch = canvasEl.height;
46+
ctx.clearRect(0, 0, cw, ch);
47+
48+
// Histogram bars
49+
if (histogram.length === 256) {
50+
const max = Math.max(...histogram);
51+
if (max > 0) {
52+
ctx.fillStyle = 'rgba(255,255,255,0.08)';
53+
for (let i = 0; i < 256; i++) {
54+
const barH = (histogram[i] / max) * H;
55+
ctx.fillRect(PAD + i, PAD + H - barH, 1, barH);
56+
}
57+
}
58+
}
59+
60+
// Grid lines
61+
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
62+
ctx.lineWidth = 1;
63+
for (let i = 1; i < 4; i++) {
64+
const x = PAD + (W * i) / 4;
65+
const y = PAD + (H * i) / 4;
66+
ctx.beginPath();
67+
ctx.moveTo(x, PAD);
68+
ctx.lineTo(x, PAD + H);
69+
ctx.stroke();
70+
ctx.beginPath();
71+
ctx.moveTo(PAD, y);
72+
ctx.lineTo(PAD + W, y);
73+
ctx.stroke();
74+
}
75+
76+
// Diagonal reference (identity)
77+
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
78+
ctx.setLineDash([4, 4]);
79+
ctx.beginPath();
80+
ctx.moveTo(...toCanvas(0, 0));
81+
ctx.lineTo(...toCanvas(1, 1));
82+
ctx.stroke();
83+
ctx.setLineDash([]);
84+
85+
// Curve from LUT
86+
const lut = buildCurveLUT(points);
87+
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
88+
ctx.lineWidth = 1.5;
89+
ctx.beginPath();
90+
for (let i = 0; i < 256; i++) {
91+
const x = PAD + i;
92+
const val = lut ? lut[i] / 255 : i / 255;
93+
const y = PAD + (1 - val) * H;
94+
if (i === 0) ctx.moveTo(x, y);
95+
else ctx.lineTo(x, y);
96+
}
97+
ctx.stroke();
98+
99+
// Control points
100+
const allPts: {x: number; y: number; fixed: boolean}[] = [
101+
{x: 0, y: 0, fixed: true},
102+
...points.map(([x, y]) => ({x, y, fixed: false})),
103+
{x: 1, y: 1, fixed: true},
104+
];
105+
106+
for (const pt of allPts) {
107+
const [cx, cy] = toCanvas(pt.x, pt.y);
108+
ctx.beginPath();
109+
ctx.arc(cx, cy, pt.fixed ? 3 : 5, 0, Math.PI * 2);
110+
ctx.fillStyle = pt.fixed
111+
? 'rgba(255,255,255,0.3)'
112+
: 'rgba(255,255,255,0.9)';
113+
ctx.fill();
114+
if (!pt.fixed) {
115+
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
116+
ctx.lineWidth = 1;
117+
ctx.stroke();
118+
}
119+
}
120+
}
121+
122+
function getMousePos(e: MouseEvent): [number, number] {
123+
const rect = canvasEl!.getBoundingClientRect();
124+
const scaleX = canvasEl!.width / rect.width;
125+
const scaleY = canvasEl!.height / rect.height;
126+
return [
127+
(e.clientX - rect.left) * scaleX,
128+
(e.clientY - rect.top) * scaleY,
129+
];
130+
}
131+
132+
function findNearestPoint(cx: number, cy: number): number | null {
133+
let best = -1;
134+
let bestDist = 15; // max grab distance in canvas pixels
135+
for (let i = 0; i < points.length; i++) {
136+
const [px, py] = toCanvas(points[i][0], points[i][1]);
137+
const dist = Math.sqrt((cx - px) ** 2 + (cy - py) ** 2);
138+
if (dist < bestDist) {
139+
bestDist = dist;
140+
best = i;
141+
}
142+
}
143+
return best >= 0 ? best : null;
144+
}
145+
146+
function handleMouseDown(e: MouseEvent) {
147+
if (!canvasEl) return;
148+
const [cx, cy] = getMousePos(e);
149+
150+
// Right-click or ctrl+click removes nearest point
151+
if (e.button === 2 || (e.button === 0 && e.ctrlKey)) {
152+
e.preventDefault();
153+
const idx = findNearestPoint(cx, cy);
154+
if (idx !== null) {
155+
points = points.filter((_, i) => i !== idx);
156+
onchange();
157+
}
158+
return;
159+
}
160+
161+
// Left-click: try to grab existing point, else add new
162+
const idx = findNearestPoint(cx, cy);
163+
if (idx !== null) {
164+
dragging = idx;
165+
} else {
166+
const [x, y] = fromCanvas(cx, cy);
167+
points = [...points, [x, y]];
168+
dragging = points.length - 1;
169+
onchange();
170+
}
171+
}
172+
173+
function handleMouseMove(e: MouseEvent) {
174+
if (dragging === null || !canvasEl) return;
175+
const [cx, cy] = getMousePos(e);
176+
const [x, y] = fromCanvas(cx, cy);
177+
// Clamp x to avoid overlapping endpoints
178+
const clampedX = Math.max(0.01, Math.min(0.99, x));
179+
points = points.map((p, i) =>
180+
i === dragging ? ([clampedX, y] as [number, number]) : p
181+
);
182+
onchange();
183+
}
184+
185+
function handleMouseUp() {
186+
dragging = null;
187+
}
188+
</script>
189+
190+
<div class="space-y-1.5">
191+
<!-- svelte-ignore a11y_no_static_element_interactions -->
192+
<canvas
193+
bind:this={canvasEl}
194+
width={W}
195+
height={H}
196+
class="border-border w-full cursor-crosshair border"
197+
style="height: {H}px; image-rendering: auto;"
198+
onmousedown={handleMouseDown}
199+
onmousemove={handleMouseMove}
200+
onmouseup={handleMouseUp}
201+
onmouseleave={handleMouseUp}
202+
oncontextmenu={e => e.preventDefault()}
203+
></canvas>
204+
<div class="flex items-center justify-between">
205+
<span class="text-fg-dimmed text-[9px]">
206+
Click to add · drag to adjust · ctrl+click to remove
207+
</span>
208+
{#if points.length > 0}
209+
<button
210+
type="button"
211+
class="text-fg-dimmed hover:text-fg-secondary text-[10px] transition-colors"
212+
onclick={() => {
213+
points = [];
214+
onchange();
215+
}}>Reset</button
216+
>
217+
{/if}
218+
</div>
219+
</div>

0 commit comments

Comments
 (0)