From b22ec51d33820f5376f8f46018e670b93319a047 Mon Sep 17 00:00:00 2001 From: marclaliberte Date: Thu, 1 Oct 2026 09:50:12 -0400 Subject: [PATCH 01/19] feat(color): add expanded color mixer with targeted pipette Add an expanded Color Mixer view with Hue / Saturation / Luminance tabs, each showing all eight band sliders, plus a pipette for the global mixer. Press on the photo and drag up or down: every band present in the sampled area moves in proportion to its presence (dominant band 0.5 pt/px, Alt for fine control), mirroring the shader's band weighting. With the WGPU renderer the sample is read back from the displayed output texture via a new sample_display_area command; otherwise the JPEG preview is sampled. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit b5475ad8e3176386153d8c3a8fbdc7d21ba7c5a9) --- src-tauri/src/gpu_processing.rs | 43 ++++ src-tauri/src/lib.rs | 22 ++ src/App.tsx | 1 + src/components/adjustments/Color.tsx | 250 ++++++++++++------- src/components/panel/Editor.tsx | 4 +- src/components/panel/editor/ImageCanvas.tsx | 31 ++- src/components/panel/right/ControlsPanel.tsx | 2 +- src/components/ui/AppProperties.tsx | 1 + src/components/ui/Slider.tsx | 2 +- src/hooks/useAppNavigation.ts | 1 + src/hooks/useHslMixerPicker.ts | 210 ++++++++++++++++ src/hooks/useLibraryActions.ts | 1 + src/i18n/locales/ca.json | 2 + src/i18n/locales/de.json | 2 + src/i18n/locales/en.json | 2 + src/i18n/locales/es.json | 2 + src/i18n/locales/fr.json | 2 + src/i18n/locales/it.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/ko.json | 2 + src/i18n/locales/pl.json | 2 + src/i18n/locales/pt.json | 2 + src/i18n/locales/ru.json | 2 + src/i18n/locales/zh-CN.json | 2 + src/i18n/locales/zh-TW.json | 2 + src/store/useEditorStore.ts | 5 + src/store/useUIStore.ts | 2 + src/utils/hslMixer.ts | 77 ++++++ 28 files changed, 589 insertions(+), 89 deletions(-) create mode 100644 src/hooks/useHslMixerPicker.ts create mode 100644 src/utils/hslMixer.ts diff --git a/src-tauri/src/gpu_processing.rs b/src-tauri/src/gpu_processing.rs index 8ffbff7b7..d7246ce2b 100644 --- a/src-tauri/src/gpu_processing.rs +++ b/src-tauri/src/gpu_processing.rs @@ -1788,6 +1788,49 @@ impl GpuProcessor { } } +pub fn read_display_area( + context: &GpuContext, + state: &tauri::State, + center: (f32, f32), + radius: f32, +) -> Result, String> { + let [width, height] = context + .display + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .ok_or("WGPU display is not initialized")? + .latest_transform + .image_size; + let processor_lock = state + .gpu_processor + .lock() + .unwrap_or_else(|e| e.into_inner()); + let processor = &processor_lock + .as_ref() + .ok_or("GPU processor is not initialized")? + .processor; + + let half = (radius * width).max(1.0); + let x0 = (center.0 * width - half).clamp(0.0, width - 1.0) as u32; + let y0 = (center.1 * height - half).clamp(0.0, height - 1.0) as u32; + let x1 = ((center.0 * width + half).ceil() as u32).clamp(x0 + 1, width as u32); + let y1 = ((center.1 * height + half).ceil() as u32).clamp(y0 + 1, height as u32); + + read_texture_data_roi( + &context.device, + &context.queue, + &processor.output_texture, + wgpu::Origin3d { x: x0, y: y0, z: 0 }, + wgpu::Extent3d { + width: x1 - x0, + height: y1 - y0, + depth_or_array_layers: 1, + }, + 4, + ) +} + pub fn process_and_get_dynamic_image( context: &GpuContext, state: &tauri::State, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9a1dd26a8..4c51cc8d5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -407,6 +407,27 @@ async fn update_wgpu_transform( Ok(()) } +#[tauri::command] +async fn sample_display_area( + x: f32, + y: f32, + radius: f32, + app_handle: tauri::AppHandle, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + let state = app_handle.state::(); + let context = state + .gpu_context + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + .ok_or("GPU context is not initialized")?; + crate::gpu_processing::read_display_area(&context, &state, (x, y), radius) + }) + .await + .map_err(|e| format!("Task panicked: {}", e))? +} + #[allow(clippy::too_many_arguments)] fn process_preview_job( app_handle: &tauri::AppHandle, @@ -2247,6 +2268,7 @@ pub fn run() { frontend_ready, cancel_thumbnail_generation, update_wgpu_transform, + sample_display_area, android_integration::resolve_android_content_uri_name, cache_utils::clear_session_caches, cache_utils::clear_image_caches, diff --git a/src/App.tsx b/src/App.tsx index bfa50cd3e..1e1427950 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -515,6 +515,7 @@ function App() { useEffect(() => { setEditor({ isWbPickerActive: false, + mixerPickerProperty: null, isStraightenActive: false, isGuidedPerspectiveActive: false, activeMaskId: null, diff --git a/src/components/adjustments/Color.tsx b/src/components/adjustments/Color.tsx index 8a604073a..1964b38dc 100644 --- a/src/components/adjustments/Color.tsx +++ b/src/components/adjustments/Color.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useMemo, CSSProperties, ReactNode } from 'react'; import { Pipette, Sliders } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { useTranslation } from 'react-i18next'; @@ -10,6 +10,9 @@ import { AppSettings } from '../ui/AppProperties'; import Text from '../ui/Text'; import AdjustmentSubSection from './AdjustmentSubSection'; import { TextColors, TextWeights } from '../../types/typography'; +import { useEditorStore } from '../../store/useEditorStore'; +import { useUIStore } from '../../store/useUIStore'; +import { HSL_MIXER_BANDS, HslMixerBand, HslMixerProperty } from '../../utils/hslMixer'; interface ColorProps { color: string; @@ -27,6 +30,38 @@ interface ColorPanelProps { onDragStateChange?: (isDragging: boolean) => void; } +interface ToggleIconButtonProps { + isActive: boolean; + onClick: () => void; + tooltip: string; + children: ReactNode; +} + +const MIXER_SWATCHES: Record = { + reds: { color: '#f87171', hue: 0 }, + oranges: { color: '#fb923c', hue: 30 }, + yellows: { color: '#facc15', hue: 60 }, + greens: { color: '#4ade80', hue: 120 }, + aquas: { color: '#2dd4bf', hue: 180 }, + blues: { color: '#60a5fa', hue: 240 }, + purples: { color: '#a78bfa', hue: 300 }, + magentas: { color: '#f472b6', hue: 340 }, +}; + +const MIXER_TRACK_PREFIX: Record = { hue: 'hue', saturation: 'sat', luminance: 'lum' }; + +const ToggleIconButton = ({ isActive, onClick, tooltip, children }: ToggleIconButtonProps) => ( + +); + interface ColorSwatchProps { color: string; isActive: boolean; @@ -406,65 +441,88 @@ export default function ColorPanel({ const toolOrder = getAdjustmentToolOrder('color', appSettings?.adjustmentLayout?.toolOrder); const HSL_COLORS = useMemo>( - () => [ - { name: 'reds', color: '#f87171', label: t('adjustments.color.mixerColors.reds') }, - { name: 'oranges', color: '#fb923c', label: t('adjustments.color.mixerColors.oranges') }, - { name: 'yellows', color: '#facc15', label: t('adjustments.color.mixerColors.yellows') }, - { name: 'greens', color: '#4ade80', label: t('adjustments.color.mixerColors.greens') }, - { name: 'aquas', color: '#2dd4bf', label: t('adjustments.color.mixerColors.aquas') }, - { name: 'blues', color: '#60a5fa', label: t('adjustments.color.mixerColors.blues') }, - { name: 'purples', color: '#a78bfa', label: t('adjustments.color.mixerColors.purples') }, - { name: 'magentas', color: '#f472b6', label: t('adjustments.color.mixerColors.magentas') }, - ], + () => + HSL_MIXER_BANDS.map((name) => ({ + name, + color: MIXER_SWATCHES[name].color, + label: t(`adjustments.color.mixerColors.${name}`), + })), [t], ); - const colorHueMap = useMemo>( - () => ({ - reds: 0, - oranges: 30, - yellows: 60, - greens: 120, - aquas: 180, - blues: 240, - purples: 300, - magentas: 340, - }), - [], + const hsl = adjustments?.hsl || INITIAL_ADJUSTMENTS.hsl; + + const mixerTrackStyle = useMemo( + () => + Object.fromEntries( + HSL_MIXER_BANDS.flatMap((name) => [ + [`--hsl-mixer-hue-${name}`, `${(((MIXER_SWATCHES[name].hue + hsl[name].hue) % 360) + 360) % 360}`], + [`--hsl-mixer-sat-${name}`, `${(hsl[name].saturation + 100) / 2}%`], + ]), + ) as CSSProperties, + [hsl], ); - const currentHsl = adjustments?.hsl?.[activeColor] || { hue: 0, saturation: 0, luminance: 0 }; - const baseHue = colorHueMap[activeColor] || 0; - const effectiveHue = baseHue + (currentHsl.hue || 0); + const isMixerExpanded = useUIStore((state) => state.isColorMixerExpanded); + const setUI = useUIStore((state) => state.setUI); + const [mixerTab, setMixerTab] = useState('hue'); + const mixerPickerProperty = useEditorStore((state) => (isForMask ? null : state.mixerPickerProperty)); + const mixerPickerPresence = useEditorStore((state) => (isForMask ? null : state.mixerPickerPresence)); + const setEditor = useEditorStore((state) => state.setEditor); - useEffect(() => { - const normalizedHue = ((effectiveHue % 360) + 360) % 360; - const effectiveSaturation = (currentHsl.saturation + 100) / 2; + const mixerTabs = useMemo>( + () => [ + { id: 'hue', label: t('adjustments.color.hue') }, + { id: 'saturation', label: t('adjustments.color.saturation') }, + { id: 'luminance', label: t('adjustments.color.luminance') }, + ], + [t], + ); + + const toggleMixerExpanded = () => { + if (isMixerExpanded && mixerPickerProperty) setEditor({ mixerPickerProperty: null }); + setUI({ isColorMixerExpanded: !isMixerExpanded }); + }; - document.documentElement.style.setProperty(`--hsl-mixer-hue-${activeColor}`, normalizedHue.toString()); - document.documentElement.style.setProperty(`--hsl-mixer-sat-${activeColor}`, `${effectiveSaturation}%`); - }, [effectiveHue, currentHsl.saturation, activeColor]); + const toggleMixerPicker = () => { + setEditor({ mixerPickerProperty: mixerPickerProperty ? null : mixerTab, isWbPickerActive: false }); + }; + + const selectMixerTab = (tab: HslMixerProperty) => { + setMixerTab(tab); + if (mixerPickerProperty) setEditor({ mixerPickerProperty: tab }); + }; const handleAdjustmentChange = (key: ColorAdjustment, value: string) => { setAdjustments((prev: Partial) => ({ ...prev, [key]: parseFloat(value) })); }; - const handleHslChange = (key: ColorAdjustment, value: string) => { + const handleHslChange = (color: string, key: HslMixerProperty, value: string) => { setAdjustments((prev: Partial) => ({ ...prev, hsl: { ...(prev.hsl || {}), - [activeColor]: { - ...(prev.hsl?.[activeColor] || {}), + [color]: { + ...(prev.hsl?.[color] || {}), [key]: parseFloat(value), }, }, })); }; - const hue_slider = `hue-slider-${activeColor}`; - const saturation_slider = `sat-slider-${activeColor}`; - const luminance_slider = `lum-slider-${activeColor}`; + const renderMixerSlider = (color: string, property: HslMixerProperty, label: string) => ( + handleHslChange(color, property, e.target.value)} + step={1} + value={hsl[color][property]} + trackClassName={`${MIXER_TRACK_PREFIX[property]}-slider-${color}`} + onDragStateChange={onDragStateChange} + /> + ); return (
@@ -473,15 +531,13 @@ export default function ColorPanel({ actions={ !isForMask && toggleWbPicker && ( - + ) } id="whiteBalance" @@ -574,52 +630,76 @@ export default function ColorPanel({ {!hiddenTools.includes('colorMixer') && ( + {isMixerExpanded && !isForMask && ( + + + + )} + + + +
+ } id="colorMixer" order={toolOrder.indexOf('colorMixer')} title={t('adjustments.color.colorMixer')} > -
- {HSL_COLORS.map(({ name, color, label }) => ( - - ))} +
+ {isMixerExpanded ? ( + <> +
+ {mixerTabs.map(({ id, label }) => ( + + ))} +
+ {HSL_COLORS.map(({ name, label }) => ( +
+ {renderMixerSlider(name, mixerTab, label)} +
+ ))} + + ) : ( + <> +
+ {HSL_COLORS.map(({ name, color, label }) => ( + + ))} +
+ {mixerTabs.map(({ id, label }) => renderMixerSlider(activeColor, id, label))} + + )}
- handleHslChange(ColorAdjustment.Hue, e.target.value)} - step={1} - value={currentHsl.hue} - trackClassName={hue_slider} - onDragStateChange={onDragStateChange} - /> - handleHslChange(ColorAdjustment.Saturation, e.target.value)} - step={1} - value={currentHsl.saturation} - trackClassName={saturation_slider} - onDragStateChange={onDragStateChange} - /> - handleHslChange(ColorAdjustment.Luminance, e.target.value)} - step={1} - value={currentHsl.luminance} - trackClassName={luminance_slider} - onDragStateChange={onDragStateChange} - /> )} diff --git a/src/components/panel/Editor.tsx b/src/components/panel/Editor.tsx index fabbadc71..f934f636d 100644 --- a/src/components/panel/Editor.tsx +++ b/src/components/panel/Editor.tsx @@ -109,6 +109,7 @@ export default function Editor({ onBackToLibrary, onContextMenu, onImageSelect, const overlayRotation = useEditorStore((s) => s.overlayRotation); const isStraightenActive = useEditorStore((s) => s.isStraightenActive); const isWbPickerActive = useEditorStore((s) => s.isWbPickerActive); + const isMixerPickerActive = useEditorStore((s) => s.mixerPickerProperty !== null); const liveRotation = useEditorStore((s) => s.liveRotation); const brushSettings = useEditorStore((s) => s.brushSettings); const activeMaskContainerId = useEditorStore((s) => s.activeMaskContainerId); @@ -728,7 +729,8 @@ export default function Editor({ onBackToLibrary, onContextMenu, onImageSelect, activeSubMask?.type === Mask.Color || activeSubMask?.type === Mask.Luminance || activeSubMask?.parameters?.isInitialDraw)) || - isWbPickerActive; + isWbPickerActive || + isMixerPickerActive; useEffect(() => { const container = imageContainerRef.current; diff --git a/src/components/panel/editor/ImageCanvas.tsx b/src/components/panel/editor/ImageCanvas.tsx index edb1ae203..370cb7a7e 100644 --- a/src/components/panel/editor/ImageCanvas.tsx +++ b/src/components/panel/editor/ImageCanvas.tsx @@ -12,6 +12,7 @@ import { RenderSize } from '../../../hooks/useImageRenderSize'; import { useOsPlatform } from '../../../hooks/useOsPlatform'; import { useTranslation } from 'react-i18next'; import { useEditorStore } from '../../../store/useEditorStore'; +import { useHslMixerPicker } from '../../../hooks/useHslMixerPicker'; import type { OverlayMode } from '../right/CropPanel'; import CompositionOverlays from './overlays/CompositionOverlays'; import { calculateStraightenAngle } from '../../../utils/cropUtils'; @@ -1700,6 +1701,18 @@ const ImageCanvas = memo( : 0; const effectiveZoomScale = transformState.scale > 0 ? transformState.scale : 1; + + const { + isActive: isMixerPickerActive, + isDragging: isMixerPickerDragging, + start: startMixerPick, + } = useHslMixerPicker({ + getCanvasPointer, + imageRenderSize, + previewUrl: finalPreviewUrl, + zoomScale: effectiveZoomScale, + setAdjustments, + }); const brushStageSize = (brushSettings?.size ?? 0) / effectiveZoomScale; const brushImageSpaceSize = brushStageSize / (imageRenderSize.scale || 1); @@ -2229,6 +2242,11 @@ const ImageCanvas = memo( return; } + if (isMixerPickerActive) { + startMixerPick(e); + return; + } + if (isParametricActive && activeSubMask) { const pos = getCanvasPointer(e.target.getStage()); if (!pos) return; @@ -2425,6 +2443,8 @@ const ImageCanvas = memo( mapScreenToUv, isWbPickerActive, isInsideImage, + isMixerPickerActive, + startMixerPick, isInitialDrawing, isBrushActive, isCloneOrHealActive, @@ -2466,6 +2486,10 @@ const ImageCanvas = memo( return; } + if (isMixerPickerActive) { + return; + } + let pos; if (e && typeof e.target?.getStage === 'function') { const stage = e.target.getStage(); @@ -2694,6 +2718,7 @@ const ImageCanvas = memo( mapScreenToUv, isToolActive, isWbPickerActive, + isMixerPickerActive, isInitialDrawing, activeMaskId, activeAiSubMaskId, @@ -3108,6 +3133,8 @@ const ImageCanvas = memo( const effectiveCursor = useMemo(() => { if (isGuidedPerspectiveActive && isCropping) return 'crosshair'; if (isWbPickerActive) return 'crosshair'; + if (isMixerPickerDragging) return 'ns-resize'; + if (isMixerPickerActive) return 'crosshair'; if (isParametricActive) return 'crosshair'; if (isInitialDrawing) return 'crosshair'; @@ -3135,6 +3162,8 @@ const ImageCanvas = memo( isGuidedPerspectiveActive, isCropping, isWbPickerActive, + isMixerPickerDragging, + isMixerPickerActive, isInitialDrawing, isBrushActive, isCloneOrHealActive, @@ -3358,7 +3387,7 @@ const ImageCanvas = memo(
- {(isMasking || isAiEditing || isWbPickerActive) && ( + {(isMasking || isAiEditing || isWbPickerActive || isMixerPickerActive) && (
setEditor((state) => ({ isWbPickerActive: !state.isWbPickerActive })), + () => setEditor((state) => ({ isWbPickerActive: !state.isWbPickerActive, mixerPickerProperty: null })), [setEditor], ); diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index da61fc022..b77387aff 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -105,6 +105,7 @@ export enum Invokes { SaveFocusStack = 'save_focus_stack', MergeHdr = 'merge_hdr', TestAIConnectorConnection = 'test_ai_connector_connection', + SampleDisplayArea = 'sample_display_area', UpdateWgpuTransform = 'update_wgpu_transform', UpdateExifFields = 'update_exif_fields', FetchCommunityPresets = 'fetch_community_presets', diff --git a/src/components/ui/Slider.tsx b/src/components/ui/Slider.tsx index bf7202b18..b4ce157de 100644 --- a/src/components/ui/Slider.tsx +++ b/src/components/ui/Slider.tsx @@ -33,7 +33,7 @@ interface SliderProps { } const DOUBLE_CLICK_THRESHOLD_MS = 150; -const FINE_ADJUSTMENT_MULTIPLIER = 0.2; +export const FINE_ADJUSTMENT_MULTIPLIER = 0.2; const TOUCH_DRAG_THRESHOLD_PX = 10; const TOUCH_THUMB_HIT_RADIUS_PX = 24; diff --git a/src/hooks/useAppNavigation.ts b/src/hooks/useAppNavigation.ts index 6f8ecff5d..d5e493303 100644 --- a/src/hooks/useAppNavigation.ts +++ b/src/hooks/useAppNavigation.ts @@ -204,6 +204,7 @@ export function useAppNavigation({ clearThumbnailQueue, refs }: AppNavigationPro activeAiPatchContainerId: null, activeAiSubMaskId: null, isWbPickerActive: false, + mixerPickerProperty: null, previewOverride: null, }); diff --git a/src/hooks/useHslMixerPicker.ts b/src/hooks/useHslMixerPicker.ts new file mode 100644 index 000000000..8dcd1006d --- /dev/null +++ b/src/hooks/useHslMixerPicker.ts @@ -0,0 +1,210 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import type Konva from 'konva'; +import type { KonvaEventObject } from 'konva/lib/Node'; +import { useEditorStore } from '../store/useEditorStore'; +import { Adjustments, HueSatLum, INITIAL_ADJUSTMENTS } from '../utils/adjustments'; +import { HslMixerBand, HslMixerProperty, HslPresence, sampleHslPresence } from '../utils/hslMixer'; +import { RenderSize } from './useImageRenderSize'; +import { Invokes } from '../components/ui/AppProperties'; +import { FINE_ADJUSTMENT_MULTIPLIER } from '../components/ui/Slider'; + +const SAMPLE_SCREEN_SIZE = 16; +const DRAG_GAIN = 0.5; + +interface Coord { + x: number; + y: number; +} + +interface PickerDrag { + property: HslMixerProperty; + lastY: number; + offset: number; + appliedKey: string | null; + frame: number | null; + base: Record; + presence: HslPresence | null; + offsetRange: [number, number]; +} + +interface HslMixerPickerOptions { + getCanvasPointer(stage: Konva.Stage): Coord | null; + imageRenderSize: RenderSize; + previewUrl: string | null; + zoomScale: number; + setAdjustments(fn: (prev: Adjustments) => Adjustments): void; +} + +const clampOffset = ({ offsetRange: [min, max] }: PickerDrag, offset: number) => Math.max(min, Math.min(max, offset)); + +const getClientY = (e: MouseEvent | TouchEvent) => ('touches' in e ? e.touches[0]?.clientY : e.clientY); + +export function useHslMixerPicker({ + getCanvasPointer, + imageRenderSize, + previewUrl, + zoomScale, + setAdjustments, +}: HslMixerPickerOptions) { + const property = useEditorStore((state) => state.mixerPickerProperty); + const setEditor = useEditorStore((state) => state.setEditor); + const [isDragging, setIsDragging] = useState(false); + const dragRef = useRef(null); + const bitmapRef = useRef<{ url: string; bitmap: Promise } | null>(null); + const detachRef = useRef<(() => void) | null>(null); + + const getBitmap = useCallback((url: string) => { + if (bitmapRef.current?.url !== url) { + bitmapRef.current?.bitmap.then((bitmap) => bitmap.close()).catch(() => {}); + bitmapRef.current = { + url, + bitmap: fetch(url) + .then((res) => res.blob()) + .then((blob) => createImageBitmap(blob)), + }; + } + return bitmapRef.current.bitmap; + }, []); + + const sampleArea = useCallback( + async (u: number, v: number, radius: number): Promise => { + if (!previewUrl) { + return Uint8ClampedArray.from(await invoke(Invokes.SampleDisplayArea, { x: u, y: v, radius })); + } + const bitmap = await getBitmap(previewUrl); + const half = Math.max(1, radius * bitmap.width); + const x0 = Math.max(0, Math.floor(u * bitmap.width - half)); + const y0 = Math.max(0, Math.floor(v * bitmap.height - half)); + const w = Math.max(1, Math.min(bitmap.width, Math.ceil(u * bitmap.width + half)) - x0); + const h = Math.max(1, Math.min(bitmap.height, Math.ceil(v * bitmap.height + half)) - y0); + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return new Uint8ClampedArray(); + ctx.drawImage(bitmap, x0, y0, w, h, 0, 0, w, h); + return ctx.getImageData(0, 0, w, h).data; + }, + [previewUrl, getBitmap], + ); + + const applyDrag = useCallback( + (drag: PickerDrag) => { + const { presence, base, property: key } = drag; + if (!presence) return; + const values = Object.entries(presence).map(([band, weight]): [string, number] => [ + band, + Math.max(-100, Math.min(100, Math.round(base[band][key] + drag.offset * (weight as number)))), + ]); + const appliedKey = values.join(); + if (appliedKey === drag.appliedKey) return; + drag.appliedKey = appliedKey; + setAdjustments((prev) => { + const hsl = { ...(prev.hsl || INITIAL_ADJUSTMENTS.hsl) }; + values.forEach(([band, value]) => { + hsl[band] = { ...hsl[band], [key]: value }; + }); + return { ...prev, hsl }; + }); + }, + [setAdjustments], + ); + + const end = useCallback(() => { + detachRef.current?.(); + detachRef.current = null; + const drag = dragRef.current; + if (!drag) return; + if (drag.frame !== null) { + cancelAnimationFrame(drag.frame); + applyDrag(drag); + } + dragRef.current = null; + setIsDragging(false); + setEditor({ isSliderDragging: false, mixerPickerPresence: null }); + }, [applyDrag, setEditor]); + + const start = useCallback( + (e: KonvaEventObject) => { + const stage = e.target.getStage(); + const pos = stage && getCanvasPointer(stage); + const clientY = getClientY(e.evt); + if (!property || !pos || clientY === undefined) return; + if (pos.x < 0 || pos.y < 0 || pos.x > imageRenderSize.width || pos.y > imageRenderSize.height) return; + + end(); + const drag: PickerDrag = { + property, + lastY: clientY, + offset: 0, + appliedKey: null, + frame: null, + base: { ...INITIAL_ADJUSTMENTS.hsl, ...useEditorStore.getState().adjustments.hsl }, + presence: null, + offsetRange: [-Infinity, Infinity], + }; + dragRef.current = drag; + setIsDragging(true); + setEditor({ isSliderDragging: true }); + + const onMove = (ev: MouseEvent | TouchEvent) => { + const y = getClientY(ev); + if (y === undefined) return; + if (ev.cancelable) ev.preventDefault(); + const multiplier = ev.altKey ? FINE_ADJUSTMENT_MULTIPLIER : 1; + drag.offset = clampOffset(drag, drag.offset + (drag.lastY - y) * DRAG_GAIN * multiplier); + drag.lastY = y; + if (drag.frame === null) { + drag.frame = requestAnimationFrame(() => { + drag.frame = null; + applyDrag(drag); + }); + } + }; + window.addEventListener('mousemove', onMove); + window.addEventListener('touchmove', onMove, { passive: false }); + window.addEventListener('mouseup', end); + window.addEventListener('touchend', end); + window.addEventListener('touchcancel', end); + detachRef.current = () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('touchmove', onMove); + window.removeEventListener('mouseup', end); + window.removeEventListener('touchend', end); + window.removeEventListener('touchcancel', end); + }; + + const radius = SAMPLE_SCREEN_SIZE / 2 / zoomScale / imageRenderSize.width; + sampleArea(pos.x / imageRenderSize.width, pos.y / imageRenderSize.height, radius) + .then((pixels) => { + if (dragRef.current !== drag) return; + drag.presence = sampleHslPresence(pixels, drag.property); + const dominant = Object.keys(drag.presence).find((band) => drag.presence?.[band as HslMixerBand] === 1); + if (dominant) { + const value = drag.base[dominant][drag.property]; + drag.offsetRange = [-100 - value, 100 - value]; + drag.offset = clampOffset(drag, drag.offset); + } + setEditor({ mixerPickerPresence: drag.presence }); + applyDrag(drag); + }) + .catch((err) => console.error('Failed to sample preview for color mixer picker:', err)); + }, + [property, imageRenderSize, zoomScale, getCanvasPointer, sampleArea, setEditor, applyDrag, end], + ); + + useEffect(() => { + if (!property) end(); + }, [property, end]); + + useEffect( + () => () => { + end(); + bitmapRef.current?.bitmap.then((bitmap) => bitmap.close()).catch(() => {}); + }, + [end], + ); + + return { isActive: property !== null, isDragging, start }; +} diff --git a/src/hooks/useLibraryActions.ts b/src/hooks/useLibraryActions.ts index 1e506af83..5e1b8e2cf 100644 --- a/src/hooks/useLibraryActions.ts +++ b/src/hooks/useLibraryActions.ts @@ -171,6 +171,7 @@ export function useLibraryActions(handleImageSelect?: (path: string, openInEdito activeAiPatchContainerId: null, activeAiSubMaskId: null, isWbPickerActive: false, + mixerPickerProperty: null, }); } }, []); diff --git a/src/i18n/locales/ca.json b/src/i18n/locales/ca.json index 1525ab181..effadd016 100644 --- a/src/i18n/locales/ca.json +++ b/src/i18n/locales/ca.json @@ -55,10 +55,12 @@ "reds": "Vermells", "yellows": "Grocs" }, + "mixerPickerTooltip": "Selector del mesclador de colors: feu clic i arrossegueu amunt o avall a la foto", "presence": "Presència", "saturation": "Saturació", "temperature": "Temperatura", "tint": "Matís", + "toggleMixerExpanded": "Mesclador de colors ampliat", "toggleSliders": "Alternar controls", "vibrance": "Intensitat", "wbPickerTooltip": "Comptagotes de balanç de blancs", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 74481e58f..aebf33a4a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -55,10 +55,12 @@ "reds": "Rot", "yellows": "Gelb" }, + "mixerPickerTooltip": "Farbmischer-Pipette: Auf das Foto klicken und nach oben oder unten ziehen", "presence": "Präsenz", "saturation": "Sättigung", "temperature": "Temperatur", "tint": "Tonung", + "toggleMixerExpanded": "Erweiterter Farbmischer", "toggleSliders": "Regler umschalten", "vibrance": "Dynamik", "wbPickerTooltip": "Weißabgleich-Pipette", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index bda270d16..df263d5f3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -55,10 +55,12 @@ "reds": "Reds", "yellows": "Yellows" }, + "mixerPickerTooltip": "Color Mixer Picker: click and drag up or down on the photo", "presence": "Presence", "saturation": "Saturation", "temperature": "Temperature", "tint": "Tint", + "toggleMixerExpanded": "Expanded Color Mixer", "toggleSliders": "Toggle Sliders", "vibrance": "Vibrance", "wbPickerTooltip": "White Balance Picker", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ddfcfe659..4149b2b43 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -55,10 +55,12 @@ "reds": "Rojos", "yellows": "Amarillos" }, + "mixerPickerTooltip": "Selector del mezclador de colores: haz clic y arrastra hacia arriba o abajo en la foto", "presence": "Presencia", "saturation": "Saturación", "temperature": "Temperatura", "tint": "Matiz", + "toggleMixerExpanded": "Mezclador de colores ampliado", "toggleSliders": "Alternar controles", "vibrance": "Intensidad", "wbPickerTooltip": "Selector de balance de blancos", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 1d9be5552..329c05fc6 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -55,10 +55,12 @@ "reds": "Rouges", "yellows": "Jaunes" }, + "mixerPickerTooltip": "Pipette du mélangeur de couleurs : cliquez et faites glisser vers le haut ou le bas sur la photo", "presence": "Présence", "saturation": "Saturation", "temperature": "Température", "tint": "Teinte", + "toggleMixerExpanded": "Mélangeur de couleurs étendu", "toggleSliders": "Basculer les curseurs", "vibrance": "Vibrance", "wbPickerTooltip": "Pipette de balance des blancs", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 77d084292..c6665b3b0 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -55,10 +55,12 @@ "reds": "Rossi", "yellows": "Gialli" }, + "mixerPickerTooltip": "Contagocce del Mixer Colori: fai clic e trascina in alto o in basso sulla foto", "presence": "Presenza", "saturation": "Saturazione", "temperature": "Temperatura", "tint": "Tinta", + "toggleMixerExpanded": "Mixer Colori espanso", "toggleSliders": "Attiva/Disattiva Cursori", "vibrance": "Vividezza", "wbPickerTooltip": "Selettore Bilanciamento del Bianco", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index cf048f539..1e8177d9d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -55,10 +55,12 @@ "reds": "レッド", "yellows": "イエロー" }, + "mixerPickerTooltip": "カラーミキサーのスポイト:写真をクリックして上下にドラッグ", "presence": "外観", "saturation": "彩度", "temperature": "色温度", "tint": "色かぶり補正", + "toggleMixerExpanded": "カラーミキサーを展開", "toggleSliders": "スライダー切り替え", "vibrance": "自然な彩度", "wbPickerTooltip": "ホワイトバランススポイト", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index a8c515e7a..1f883c6f7 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -55,10 +55,12 @@ "reds": "레드", "yellows": "옐로우" }, + "mixerPickerTooltip": "컬러 믹서 스포이트: 사진을 클릭한 채 위아래로 드래그", "presence": "외관", "saturation": "채도", "temperature": "색온도", "tint": "틴트", + "toggleMixerExpanded": "확장 컬러 믹서", "toggleSliders": "슬라이더 전환", "vibrance": "생동감", "wbPickerTooltip": "화이트 밸런스 피커", diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json index 0a0c58b6f..2114b842b 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -55,10 +55,12 @@ "reds": "Czerwone", "yellows": "Żółte" }, + "mixerPickerTooltip": "Pipeta miksera kolorów: kliknij i przeciągnij w górę lub w dół na zdjęciu", "presence": "Prezencja", "saturation": "Nasycenie", "temperature": "Temperatura", "tint": "Zabarwienie", + "toggleMixerExpanded": "Rozszerzony mikser kolorów", "toggleSliders": "Przełącz suwaki", "vibrance": "Jaskrawość", "wbPickerTooltip": "Próbnik balansu bieli", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 0571774e8..a5b24052c 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -55,10 +55,12 @@ "reds": "Vermelhos", "yellows": "Amarelos" }, + "mixerPickerTooltip": "Seletor do Misturador de Cores: clique e arraste para cima ou para baixo na foto", "presence": "Presença", "saturation": "Saturação", "temperature": "Temperatura", "tint": "Tonalidade", + "toggleMixerExpanded": "Misturador de Cores expandido", "toggleSliders": "Alternar Controles", "vibrance": "Vibração", "wbPickerTooltip": "Seletor de Balanço de Branco", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 90b9eb867..db36f7531 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -55,10 +55,12 @@ "reds": "Красные", "yellows": "Желтые" }, + "mixerPickerTooltip": "Пипетка смешения цветов: нажмите на фото и перетащите вверх или вниз", "presence": "Внешний вид", "saturation": "Насыщенность", "temperature": "Температура", "tint": "Оттенок", + "toggleMixerExpanded": "Расширенное смешение цветов", "toggleSliders": "Переключить ползунки", "vibrance": "Красочность", "wbPickerTooltip": "Пипетка баланса белого", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index c53a64c5e..0016df6ed 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -55,10 +55,12 @@ "reds": "红色", "yellows": "黄色" }, + "mixerPickerTooltip": "颜色混合器吸管:在照片上按住并上下拖动", "presence": "质感", "saturation": "饱和度", "temperature": "色温", "tint": "色调", + "toggleMixerExpanded": "展开颜色混合器", "toggleSliders": "切换滑块", "vibrance": "鲜艳度", "wbPickerTooltip": "白平衡选取器", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index b36d4d6d7..ed2a3ae6f 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -55,10 +55,12 @@ "reds": "紅色", "yellows": "黃色" }, + "mixerPickerTooltip": "顏色混合器滴管:在相片上按住並上下拖曳", "presence": "質感", "saturation": "飽和度", "temperature": "色溫", "tint": "色調", + "toggleMixerExpanded": "展開顏色混合器", "toggleSliders": "切換滑桿", "vibrance": "鮮豔度", "wbPickerTooltip": "白平衡選取器", diff --git a/src/store/useEditorStore.ts b/src/store/useEditorStore.ts index f05b5238c..d50e26f4d 100644 --- a/src/store/useEditorStore.ts +++ b/src/store/useEditorStore.ts @@ -5,6 +5,7 @@ import { ChannelConfig } from '../components/adjustments/Curves'; import { ImageDimensions } from '../hooks/useImageRenderSize'; import { ToolType } from '../components/panel/right/Masks'; import { OverlayMode } from '../components/panel/right/CropPanel'; +import { HslMixerProperty, HslPresence } from '../utils/hslMixer'; interface InteractivePatch { url: string; @@ -58,6 +59,8 @@ interface EditorState { overlayRotation: number; isStraightenActive: boolean; isWbPickerActive: boolean; + mixerPickerProperty: HslMixerProperty | null; + mixerPickerPresence: HslPresence | null; isGuidedPerspectiveActive: boolean; liveRotation: number | null; brushSettings: BrushSettings | null; @@ -122,6 +125,8 @@ export const useEditorStore = create((set) => ({ overlayRotation: 0, isStraightenActive: false, isWbPickerActive: false, + mixerPickerProperty: null, + mixerPickerPresence: null, isGuidedPerspectiveActive: false, liveRotation: null, diff --git a/src/store/useUIStore.ts b/src/store/useUIStore.ts index f9396adbd..6e25c34ae 100644 --- a/src/store/useUIStore.ts +++ b/src/store/useUIStore.ts @@ -235,6 +235,7 @@ export interface UIState { renderedPanel: Panel | null; slideDirection: number; collapsibleSectionsState: CollapsibleSectionsState; + isColorMixerExpanded: boolean; cropSectionsState: CropSectionsState; isCreateFolderModalOpen: boolean; @@ -320,6 +321,7 @@ export const useUIStore = create((set, get) => ({ renderedPanel: Panel.Adjustments, slideDirection: 1, collapsibleSectionsState: { basic: true, color: false, curves: true, details: false, effects: false }, + isColorMixerExpanded: false, cropSectionsState: { transform: false, lens: false }, isCreateFolderModalOpen: false, diff --git a/src/utils/hslMixer.ts b/src/utils/hslMixer.ts new file mode 100644 index 000000000..cc626332e --- /dev/null +++ b/src/utils/hslMixer.ts @@ -0,0 +1,77 @@ +export const HSL_MIXER_BANDS = [ + 'reds', + 'oranges', + 'yellows', + 'greens', + 'aquas', + 'blues', + 'purples', + 'magentas', +] as const; + +export type HslMixerBand = (typeof HSL_MIXER_BANDS)[number]; +export type HslMixerProperty = 'hue' | 'saturation' | 'luminance'; +export type HslPresence = Partial>; + +const HSL_RANGES: Array<[number, number]> = [ + [358, 35], + [25, 45], + [60, 40], + [115, 90], + [180, 60], + [225, 60], + [280, 55], + [330, 50], +]; + +const MIN_PRESENCE = 0.03; + +const smoothstep = (edge0: number, edge1: number, x: number) => { + const t = Math.min(Math.max((x - edge0) / (edge1 - edge0), 0), 1); + return t * t * (3 - 2 * t); +}; + +const rgbToHueSat = (r: number, g: number, b: number): [number, number] => { + const max = Math.max(r, g, b); + const delta = max - Math.min(r, g, b); + let hue = 0; + if (delta > 0) { + if (max === r) hue = 60 * (((g - b) / delta) % 6); + else if (max === g) hue = 60 * ((b - r) / delta + 2); + else hue = 60 * ((r - g) / delta + 4); + } + if (hue < 0) hue += 360; + return [hue, max > 0 ? delta / max : 0]; +}; + +const bandInfluence = (hue: number, center: number, width: number) => { + const dist = Math.min(Math.abs(hue - center), 360 - Math.abs(hue - center)); + const falloff = dist / (width * 0.5); + return Math.exp(-1.5 * falloff * falloff); +}; + +export const hslBandWeights = (r: number, g: number, b: number, property: HslMixerProperty): number[] => { + if (Math.abs(r - g) < 0.001 && Math.abs(g - b) < 0.001) return HSL_RANGES.map(() => 0); + const [hue, saturation] = rgbToHueSat(Math.max(r, 0), Math.max(g, 0), Math.max(b, 0)); + const gate = property === 'luminance' ? smoothstep(0, 1, saturation) : smoothstep(0.05, 0.2, saturation); + const raw = HSL_RANGES.map(([center, width]) => bandInfluence(hue, center, width)); + const total = raw.reduce((sum, v) => sum + v, 0); + return raw.map((v) => (v / total) * gate); +}; + +export const sampleHslPresence = (rgba: Uint8ClampedArray, property: HslMixerProperty): HslPresence => { + const totals = HSL_RANGES.map(() => 0); + const count = rgba.length / 4; + for (let i = 0; i < rgba.length; i += 4) { + hslBandWeights(rgba[i] / 255, rgba[i + 1] / 255, rgba[i + 2] / 255, property).forEach((w, band) => { + totals[band] += w; + }); + } + const max = Math.max(...totals); + const presence: HslPresence = {}; + if (count === 0 || max / count < MIN_PRESENCE) return presence; + HSL_MIXER_BANDS.forEach((band, i) => { + if (totals[i] / max >= MIN_PRESENCE) presence[band] = totals[i] / max; + }); + return presence; +}; From edc753bab2098ff918477f516620a963ae96fbe7 Mon Sep 17 00:00:00 2001 From: marclaliberte Date: Thu, 1 Oct 2026 10:16:25 -0400 Subject: [PATCH 02/19] refactor(color): drop pipette slider highlight Remove the accent ring on the bands driven by the mixer pipette to keep the expanded view clean, along with the store state that fed it. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit dffc46a161f6a9c35037348d9d62b1a5267a99ac) --- src/components/adjustments/Color.tsx | 12 +----------- src/hooks/useHslMixerPicker.ts | 3 +-- src/store/useEditorStore.ts | 4 +--- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/src/components/adjustments/Color.tsx b/src/components/adjustments/Color.tsx index 1964b38dc..065876ef1 100644 --- a/src/components/adjustments/Color.tsx +++ b/src/components/adjustments/Color.tsx @@ -467,7 +467,6 @@ export default function ColorPanel({ const setUI = useUIStore((state) => state.setUI); const [mixerTab, setMixerTab] = useState('hue'); const mixerPickerProperty = useEditorStore((state) => (isForMask ? null : state.mixerPickerProperty)); - const mixerPickerPresence = useEditorStore((state) => (isForMask ? null : state.mixerPickerPresence)); const setEditor = useEditorStore((state) => state.setEditor); const mixerTabs = useMemo>( @@ -671,16 +670,7 @@ export default function ColorPanel({ ))}
- {HSL_COLORS.map(({ name, label }) => ( -
- {renderMixerSlider(name, mixerTab, label)} -
- ))} + {HSL_COLORS.map(({ name, label }) => renderMixerSlider(name, mixerTab, label))} ) : ( <> diff --git a/src/hooks/useHslMixerPicker.ts b/src/hooks/useHslMixerPicker.ts index 8dcd1006d..d98da3f2b 100644 --- a/src/hooks/useHslMixerPicker.ts +++ b/src/hooks/useHslMixerPicker.ts @@ -122,7 +122,7 @@ export function useHslMixerPicker({ } dragRef.current = null; setIsDragging(false); - setEditor({ isSliderDragging: false, mixerPickerPresence: null }); + setEditor({ isSliderDragging: false }); }, [applyDrag, setEditor]); const start = useCallback( @@ -186,7 +186,6 @@ export function useHslMixerPicker({ drag.offsetRange = [-100 - value, 100 - value]; drag.offset = clampOffset(drag, drag.offset); } - setEditor({ mixerPickerPresence: drag.presence }); applyDrag(drag); }) .catch((err) => console.error('Failed to sample preview for color mixer picker:', err)); diff --git a/src/store/useEditorStore.ts b/src/store/useEditorStore.ts index d50e26f4d..8e165b2c8 100644 --- a/src/store/useEditorStore.ts +++ b/src/store/useEditorStore.ts @@ -5,7 +5,7 @@ import { ChannelConfig } from '../components/adjustments/Curves'; import { ImageDimensions } from '../hooks/useImageRenderSize'; import { ToolType } from '../components/panel/right/Masks'; import { OverlayMode } from '../components/panel/right/CropPanel'; -import { HslMixerProperty, HslPresence } from '../utils/hslMixer'; +import { HslMixerProperty } from '../utils/hslMixer'; interface InteractivePatch { url: string; @@ -60,7 +60,6 @@ interface EditorState { isStraightenActive: boolean; isWbPickerActive: boolean; mixerPickerProperty: HslMixerProperty | null; - mixerPickerPresence: HslPresence | null; isGuidedPerspectiveActive: boolean; liveRotation: number | null; brushSettings: BrushSettings | null; @@ -126,7 +125,6 @@ export const useEditorStore = create((set) => ({ isStraightenActive: false, isWbPickerActive: false, mixerPickerProperty: null, - mixerPickerPresence: null, isGuidedPerspectiveActive: false, liveRotation: null, From 244d197dcbc7f8bdbf34ee173212697fe17e548e Mon Sep 17 00:00:00 2001 From: marclaliberte Date: Thu, 1 Oct 2026 12:47:04 -0400 Subject: [PATCH 03/19] perf(color): remove lag between pipette drag and mixer sliders Mixer sliders eased every external value change over 300 ms, so while the pipette drove them the thumbs trailed the mouse. Add an animateValueChanges prop to Slider (reusing the wheel path that already skips the easing) and turn it off for mixer sliders during a pipette drag. Also apply pipette moves directly instead of deferring them to the next animation frame, since mousemove is already frame-aligned. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 17173f2873b44f4f7d5f219df1e5e744b3e0dfa9) --- src/components/adjustments/Color.tsx | 2 ++ src/components/ui/Slider.tsx | 6 ++++-- src/hooks/useHslMixerPicker.ts | 18 +++--------------- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/components/adjustments/Color.tsx b/src/components/adjustments/Color.tsx index 065876ef1..8058894bf 100644 --- a/src/components/adjustments/Color.tsx +++ b/src/components/adjustments/Color.tsx @@ -467,6 +467,7 @@ export default function ColorPanel({ const setUI = useUIStore((state) => state.setUI); const [mixerTab, setMixerTab] = useState('hue'); const mixerPickerProperty = useEditorStore((state) => (isForMask ? null : state.mixerPickerProperty)); + const isMixerPickerDragging = useEditorStore((state) => mixerPickerProperty !== null && state.isSliderDragging); const setEditor = useEditorStore((state) => state.setEditor); const mixerTabs = useMemo>( @@ -511,6 +512,7 @@ export default function ColorPanel({ const renderMixerSlider = (color: string, property: HslMixerProperty, label: string) => ( { if (!isEditing || isDragging) { diff --git a/src/hooks/useHslMixerPicker.ts b/src/hooks/useHslMixerPicker.ts index d98da3f2b..f3eac7760 100644 --- a/src/hooks/useHslMixerPicker.ts +++ b/src/hooks/useHslMixerPicker.ts @@ -22,7 +22,6 @@ interface PickerDrag { lastY: number; offset: number; appliedKey: string | null; - frame: number | null; base: Record; presence: HslPresence | null; offsetRange: [number, number]; @@ -114,16 +113,11 @@ export function useHslMixerPicker({ const end = useCallback(() => { detachRef.current?.(); detachRef.current = null; - const drag = dragRef.current; - if (!drag) return; - if (drag.frame !== null) { - cancelAnimationFrame(drag.frame); - applyDrag(drag); - } + if (!dragRef.current) return; dragRef.current = null; setIsDragging(false); setEditor({ isSliderDragging: false }); - }, [applyDrag, setEditor]); + }, [setEditor]); const start = useCallback( (e: KonvaEventObject) => { @@ -139,7 +133,6 @@ export function useHslMixerPicker({ lastY: clientY, offset: 0, appliedKey: null, - frame: null, base: { ...INITIAL_ADJUSTMENTS.hsl, ...useEditorStore.getState().adjustments.hsl }, presence: null, offsetRange: [-Infinity, Infinity], @@ -155,12 +148,7 @@ export function useHslMixerPicker({ const multiplier = ev.altKey ? FINE_ADJUSTMENT_MULTIPLIER : 1; drag.offset = clampOffset(drag, drag.offset + (drag.lastY - y) * DRAG_GAIN * multiplier); drag.lastY = y; - if (drag.frame === null) { - drag.frame = requestAnimationFrame(() => { - drag.frame = null; - applyDrag(drag); - }); - } + applyDrag(drag); }; window.addEventListener('mousemove', onMove); window.addEventListener('touchmove', onMove, { passive: false }); From 67966c73ececbb6a1c17b887f132bcb8ac3494b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:03:28 +0000 Subject: [PATCH 04/19] Color mixer pipette: adapt to RapidRoom's shader, undo and image switching (#79) Adapts the harvested expanded Color Mixer and targeted pipette (from lalibertemarc/RapidRAW@17173f2) to RapidRoom: - Weight bands in linear RGB. RapidRoom's mixer derives hue and saturation from linear RGB (the fork's base also has the perceptual hue change, which RapidRoom doesn't), so the sRGB preview sample is linearised before it is weighted. - Read the WGPU display texture only when the canvas is actually drawn by WGPU, and otherwise the JPEG preview, instead of deciding by whether a preview URL happens to be set. - A drag is one undo step: moves update the editor state directly and history is recorded once on release, only if a value changed. - The preview sample never writes edits by itself: nothing is applied until the drag moves a value. - End the drag if the image changes or the window loses focus, and commit a slider number field still being typed in before the drag takes its starting values. - Turn the pipette off when the global Color panel unmounts. - Return an error instead of panicking when the display has no rendered image yet. HSL math, adjustment defaults and the render path are unchanged. Co-authored-by: marclaliberte Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016CdHB9AnmKmLftMu3oha5D --- src-tauri/src/gpu_processing.rs | 3 + src/components/adjustments/Color.tsx | 6 +- src/components/panel/editor/ImageCanvas.tsx | 1 + src/hooks/useHslMixerPicker.test.ts | 162 ++++++++++++++++++++ src/hooks/useHslMixerPicker.ts | 86 ++++++++--- src/utils/hslMixer.test.ts | 23 +++ src/utils/hslMixer.ts | 9 +- 7 files changed, 264 insertions(+), 26 deletions(-) create mode 100644 src/hooks/useHslMixerPicker.test.ts create mode 100644 src/utils/hslMixer.test.ts diff --git a/src-tauri/src/gpu_processing.rs b/src-tauri/src/gpu_processing.rs index d7246ce2b..541367a51 100644 --- a/src-tauri/src/gpu_processing.rs +++ b/src-tauri/src/gpu_processing.rs @@ -1802,6 +1802,9 @@ pub fn read_display_area( .ok_or("WGPU display is not initialized")? .latest_transform .image_size; + if width < 1.0 || height < 1.0 { + return Err("No image has been rendered to the display".to_string()); + } let processor_lock = state .gpu_processor .lock() diff --git a/src/components/adjustments/Color.tsx b/src/components/adjustments/Color.tsx index 8058894bf..9505ad586 100644 --- a/src/components/adjustments/Color.tsx +++ b/src/components/adjustments/Color.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, CSSProperties, ReactNode } from 'react'; +import { useEffect, useState, useMemo, CSSProperties, ReactNode } from 'react'; import { Pipette, Sliders } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { useTranslation } from 'react-i18next'; @@ -479,6 +479,10 @@ export default function ColorPanel({ [t], ); + useEffect(() => { + if (!isForMask) return () => setEditor({ mixerPickerProperty: null }); + }, [isForMask, setEditor]); + const toggleMixerExpanded = () => { if (isMixerExpanded && mixerPickerProperty) setEditor({ mixerPickerProperty: null }); setUI({ isColorMixerExpanded: !isMixerExpanded }); diff --git a/src/components/panel/editor/ImageCanvas.tsx b/src/components/panel/editor/ImageCanvas.tsx index 370cb7a7e..2b3963217 100644 --- a/src/components/panel/editor/ImageCanvas.tsx +++ b/src/components/panel/editor/ImageCanvas.tsx @@ -1710,6 +1710,7 @@ const ImageCanvas = memo( getCanvasPointer, imageRenderSize, previewUrl: finalPreviewUrl, + useDisplayTexture: !!isWgpuActive, zoomScale: effectiveZoomScale, setAdjustments, }); diff --git a/src/hooks/useHslMixerPicker.test.ts b/src/hooks/useHslMixerPicker.test.ts new file mode 100644 index 000000000..b9da6bd71 --- /dev/null +++ b/src/hooks/useHslMixerPicker.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment jsdom +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { KonvaEventObject } from 'konva/lib/Node'; +import { mockCommand } from '../test/tauriMock'; +import { useHslMixerPicker } from './useHslMixerPicker'; +import { useEditorStore } from '../store/useEditorStore'; +import { Invokes, SelectedImage } from '../components/ui/AppProperties'; +import { Adjustments, INITIAL_ADJUSTMENTS } from '../utils/adjustments'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const initialEditorState = useEditorStore.getState(); +const RENDER_SIZE = { + containerHeight: 100, + containerWidth: 100, + height: 100, + offsetX: 0, + offsetY: 0, + scale: 1, + width: 100, +}; +const RED_PIXELS = Array.from({ length: 16 }, () => [255, 0, 0, 255]).flat(); + +const image = (path: string) => ({ path, isReady: true }) as SelectedImage; + +const withRedSaturation = (saturation: number): Adjustments => ({ + ...INITIAL_ADJUSTMENTS, + hsl: { ...INITIAL_ADJUSTMENTS.hsl, reds: { ...INITIAL_ADJUSTMENTS.hsl.reds, saturation } }, +}); + +const pressEvent = (clientY: number) => + ({ target: { getStage: () => ({}) }, evt: new MouseEvent('mousedown', { clientY }) }) as unknown as KonvaEventObject< + MouseEvent | TouchEvent + >; + +const move = (clientY: number, altKey = false) => + act(async () => { + window.dispatchEvent(new MouseEvent('mousemove', { clientY, altKey, cancelable: true })); + }); + +const release = () => + act(async () => { + window.dispatchEvent(new MouseEvent('mouseup')); + }); + +async function mountPicker() { + // Mirrors Editor's setAdjustments; each call is what would reach the undo history. + const setAdjustments = vi.fn((fn: (prev: Adjustments) => Adjustments) => + useEditorStore.setState((state) => ({ adjustments: fn(state.adjustments) })), + ); + let picker!: ReturnType; + function Harness() { + picker = useHslMixerPicker({ + getCanvasPointer: () => ({ x: 50, y: 50 }), + imageRenderSize: RENDER_SIZE, + previewUrl: null, + useDisplayTexture: true, + zoomScale: 1, + setAdjustments, + }); + return null; + } + const root = createRoot(document.createElement('div')); + await act(async () => { + root.render(createElement(Harness)); + }); + const press = (clientY: number) => + act(async () => { + picker.start(pressEvent(clientY)); + }); + return { setAdjustments, press, unmount: () => act(async () => root.unmount()) }; +} + +const redSaturation = () => useEditorStore.getState().adjustments.hsl.reds.saturation; + +beforeEach(() => { + mockCommand(Invokes.SampleDisplayArea, () => RED_PIXELS); + useEditorStore.setState({ + selectedImage: image('/photos/a.raw'), + adjustments: withRedSaturation(10), + mixerPickerProperty: 'saturation', + }); +}); + +afterEach(() => { + useEditorStore.setState(initialEditorState, true); +}); + +describe('useHslMixerPicker', () => { + it('records a whole drag as one undo step', async () => { + const { setAdjustments, press, unmount } = await mountPicker(); + + await press(100); + expect(useEditorStore.getState().isSliderDragging).toBe(true); + await move(80); + await move(60); + expect(redSaturation()).toBe(30); + expect(setAdjustments).not.toHaveBeenCalled(); + + await release(); + expect(setAdjustments).toHaveBeenCalledTimes(1); + expect(redSaturation()).toBe(30); + expect(useEditorStore.getState().isSliderDragging).toBe(false); + await unmount(); + }); + + it('does not write the sampled preview back before the drag moves', async () => { + const { setAdjustments, press, unmount } = await mountPicker(); + const before = useEditorStore.getState().adjustments; + + await press(100); + await release(); + + expect(useEditorStore.getState().adjustments).toBe(before); + expect(setAdjustments).not.toHaveBeenCalled(); + await unmount(); + }); + + it('uses fine steps with Alt', async () => { + const { press, unmount } = await mountPicker(); + + await press(100); + await move(80, true); + expect(redSaturation()).toBe(12); + await release(); + await unmount(); + }); + + it('stops a drag when the image changes under it', async () => { + const { setAdjustments, press, unmount } = await mountPicker(); + + await press(100); + await move(80); + const other = withRedSaturation(-40); + useEditorStore.setState({ selectedImage: image('/photos/b.raw'), adjustments: other }); + await move(40); + await release(); + + expect(useEditorStore.getState().adjustments).toBe(other); + expect(setAdjustments).not.toHaveBeenCalled(); + expect(useEditorStore.getState().isSliderDragging).toBe(false); + await unmount(); + }); + + it('removes its window listeners when the picker is turned off mid-drag', async () => { + const { setAdjustments, press, unmount } = await mountPicker(); + + await press(100); + await move(80); + await act(async () => { + useEditorStore.setState({ mixerPickerProperty: null }); + }); + await move(40); + + expect(redSaturation()).toBe(20); + expect(setAdjustments).toHaveBeenCalledTimes(1); + expect(useEditorStore.getState().isSliderDragging).toBe(false); + await unmount(); + }); +}); diff --git a/src/hooks/useHslMixerPicker.ts b/src/hooks/useHslMixerPicker.ts index f3eac7760..02a80eb07 100644 --- a/src/hooks/useHslMixerPicker.ts +++ b/src/hooks/useHslMixerPicker.ts @@ -18,10 +18,12 @@ interface Coord { } interface PickerDrag { + path: string | undefined; property: HslMixerProperty; lastY: number; offset: number; - appliedKey: string | null; + appliedKey: string; + baseKey: string; base: Record; presence: HslPresence | null; offsetRange: [number, number]; @@ -31,6 +33,7 @@ interface HslMixerPickerOptions { getCanvasPointer(stage: Konva.Stage): Coord | null; imageRenderSize: RenderSize; previewUrl: string | null; + useDisplayTexture: boolean; zoomScale: number; setAdjustments(fn: (prev: Adjustments) => Adjustments): void; } @@ -39,10 +42,19 @@ const clampOffset = ({ offsetRange: [min, max] }: PickerDrag, offset: number) => const getClientY = (e: MouseEvent | TouchEvent) => ('touches' in e ? e.touches[0]?.clientY : e.clientY); +const getDragValues = ({ presence, base, property, offset }: PickerDrag): Array<[string, number]> => + Object.entries(presence ?? {}).map(([band, weight]) => [ + band, + offset === 0 + ? base[band][property] + : Math.max(-100, Math.min(100, Math.round(base[band][property] + offset * weight))), + ]); + export function useHslMixerPicker({ getCanvasPointer, imageRenderSize, previewUrl, + useDisplayTexture, zoomScale, setAdjustments, }: HslMixerPickerOptions) { @@ -52,6 +64,11 @@ export function useHslMixerPicker({ const dragRef = useRef(null); const bitmapRef = useRef<{ url: string; bitmap: Promise } | null>(null); const detachRef = useRef<(() => void) | null>(null); + const setAdjustmentsRef = useRef(setAdjustments); + + useEffect(() => { + setAdjustmentsRef.current = setAdjustments; + }, [setAdjustments]); const getBitmap = useCallback((url: string) => { if (bitmapRef.current?.url !== url) { @@ -68,9 +85,10 @@ export function useHslMixerPicker({ const sampleArea = useCallback( async (u: number, v: number, radius: number): Promise => { - if (!previewUrl) { + if (useDisplayTexture) { return Uint8ClampedArray.from(await invoke(Invokes.SampleDisplayArea, { x: u, y: v, radius })); } + if (!previewUrl) return new Uint8ClampedArray(); const bitmap = await getBitmap(previewUrl); const half = Math.max(1, radius * bitmap.width); const x0 = Math.max(0, Math.floor(u * bitmap.width - half)); @@ -85,40 +103,49 @@ export function useHslMixerPicker({ ctx.drawImage(bitmap, x0, y0, w, h, 0, 0, w, h); return ctx.getImageData(0, 0, w, h).data; }, - [previewUrl, getBitmap], + [useDisplayTexture, previewUrl, getBitmap], ); + const end = useCallback(() => { + detachRef.current?.(); + detachRef.current = null; + const drag = dragRef.current; + if (!drag) return; + dragRef.current = null; + setIsDragging(false); + setEditor({ isSliderDragging: false }); + // Drag moves skip the undo history; record the whole gesture as one step. + if (drag.appliedKey !== drag.baseKey && useEditorStore.getState().selectedImage?.path === drag.path) { + setAdjustmentsRef.current((prev) => prev); + } + }, [setEditor]); + const applyDrag = useCallback( (drag: PickerDrag) => { - const { presence, base, property: key } = drag; - if (!presence) return; - const values = Object.entries(presence).map(([band, weight]): [string, number] => [ - band, - Math.max(-100, Math.min(100, Math.round(base[band][key] + drag.offset * (weight as number)))), - ]); + if (!drag.presence) return; + if (useEditorStore.getState().selectedImage?.path !== drag.path) { + end(); + return; + } + const values = getDragValues(drag); const appliedKey = values.join(); if (appliedKey === drag.appliedKey) return; drag.appliedKey = appliedKey; - setAdjustments((prev) => { - const hsl = { ...(prev.hsl || INITIAL_ADJUSTMENTS.hsl) }; + const key = drag.property; + setEditor((state) => { + const hsl = { ...INITIAL_ADJUSTMENTS.hsl, ...state.adjustments.hsl }; values.forEach(([band, value]) => { hsl[band] = { ...hsl[band], [key]: value }; }); - return { ...prev, hsl }; + return { + adjustments: { ...state.adjustments, hsl }, + ...(state.showOriginal ? { showOriginal: false, previewOverride: null } : {}), + }; }); }, - [setAdjustments], + [setEditor, end], ); - const end = useCallback(() => { - detachRef.current?.(); - detachRef.current = null; - if (!dragRef.current) return; - dragRef.current = null; - setIsDragging(false); - setEditor({ isSliderDragging: false }); - }, [setEditor]); - const start = useCallback( (e: KonvaEventObject) => { const stage = e.target.getStage(); @@ -128,12 +155,19 @@ export function useHslMixerPicker({ if (pos.x < 0 || pos.y < 0 || pos.x > imageRenderSize.width || pos.y > imageRenderSize.height) return; end(); + // The canvas prevents default on press, so a slider still being typed into would keep focus + // and later commit its stale value over the drag. Commit it now, before taking the base. + const focused = document.activeElement; + if (focused instanceof HTMLInputElement || focused instanceof HTMLTextAreaElement) focused.blur(); + const { adjustments, selectedImage } = useEditorStore.getState(); const drag: PickerDrag = { + path: selectedImage?.path, property, lastY: clientY, offset: 0, - appliedKey: null, - base: { ...INITIAL_ADJUSTMENTS.hsl, ...useEditorStore.getState().adjustments.hsl }, + appliedKey: '', + baseKey: '', + base: { ...INITIAL_ADJUSTMENTS.hsl, ...adjustments.hsl }, presence: null, offsetRange: [-Infinity, Infinity], }; @@ -155,12 +189,14 @@ export function useHslMixerPicker({ window.addEventListener('mouseup', end); window.addEventListener('touchend', end); window.addEventListener('touchcancel', end); + window.addEventListener('blur', end); detachRef.current = () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('touchmove', onMove); window.removeEventListener('mouseup', end); window.removeEventListener('touchend', end); window.removeEventListener('touchcancel', end); + window.removeEventListener('blur', end); }; const radius = SAMPLE_SCREEN_SIZE / 2 / zoomScale / imageRenderSize.width; @@ -168,6 +204,8 @@ export function useHslMixerPicker({ .then((pixels) => { if (dragRef.current !== drag) return; drag.presence = sampleHslPresence(pixels, drag.property); + drag.baseKey = getDragValues({ ...drag, offset: 0 }).join(); + drag.appliedKey = drag.baseKey; const dominant = Object.keys(drag.presence).find((band) => drag.presence?.[band as HslMixerBand] === 1); if (dominant) { const value = drag.base[dominant][drag.property]; diff --git a/src/utils/hslMixer.test.ts b/src/utils/hslMixer.test.ts new file mode 100644 index 000000000..4699ffef5 --- /dev/null +++ b/src/utils/hslMixer.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { sampleHslPresence } from './hslMixer'; + +const pixels = (...rgb: Array<[number, number, number]>) => + Uint8ClampedArray.from(rgb.flatMap(([r, g, b]) => [r, g, b, 255])); + +describe('sampleHslPresence', () => { + it('ignores neutral pixels', () => { + expect(sampleHslPresence(pixels([128, 128, 128], [255, 255, 255]), 'saturation')).toEqual({}); + }); + + it('makes the sampled band dominant', () => { + expect(sampleHslPresence(pixels([255, 0, 0]), 'hue').reds).toBe(1); + expect(sampleHslPresence(pixels([0, 0, 255]), 'hue').blues).toBe(1); + }); + + it('weights bands by linear-RGB hue, as the shader does', () => { + // sRGB (255, 200, 0) has a 47° hue (yellow) as encoded, but 35° (orange) in linear light. + const presence = sampleHslPresence(pixels([255, 200, 0]), 'hue'); + expect(presence.oranges).toBe(1); + expect(presence.yellows ?? 0).toBeLessThan(0.5); + }); +}); diff --git a/src/utils/hslMixer.ts b/src/utils/hslMixer.ts index cc626332e..dc7899f3c 100644 --- a/src/utils/hslMixer.ts +++ b/src/utils/hslMixer.ts @@ -26,6 +26,11 @@ const HSL_RANGES: Array<[number, number]> = [ const MIN_PRESENCE = 0.03; +const SRGB_TO_LINEAR = Array.from({ length: 256 }, (_, i) => { + const c = i / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +}); + const smoothstep = (edge0: number, edge1: number, x: number) => { const t = Math.min(Math.max((x - edge0) / (edge1 - edge0), 0), 1); return t * t * (3 - 2 * t); @@ -59,11 +64,13 @@ export const hslBandWeights = (r: number, g: number, b: number, property: HslMix return raw.map((v) => (v / total) * gate); }; +// The preview is sRGB-encoded; the shader's mixer weights bands by hue and saturation in linear RGB. export const sampleHslPresence = (rgba: Uint8ClampedArray, property: HslMixerProperty): HslPresence => { const totals = HSL_RANGES.map(() => 0); const count = rgba.length / 4; for (let i = 0; i < rgba.length; i += 4) { - hslBandWeights(rgba[i] / 255, rgba[i + 1] / 255, rgba[i + 2] / 255, property).forEach((w, band) => { + const [r, g, b] = [rgba[i], rgba[i + 1], rgba[i + 2]].map((v) => SRGB_TO_LINEAR[v]); + hslBandWeights(r, g, b, property).forEach((w, band) => { totals[band] += w; }); } From 487f43baf09036bc85c690bcf34a6b8545436129 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:03:28 +0000 Subject: [PATCH 05/19] Change list and credits: expanded Color Mixer with targeted pipette (#79) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016CdHB9AnmKmLftMu3oha5D --- .github/README.md | 3 ++- CHANGES.md | 12 +++++++++++- CREDITS.md | 1 + rapidroom/changes.json | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.github/README.md b/.github/README.md index d40fceb12..0dc32837f 100644 --- a/.github/README.md +++ b/.github/README.md @@ -12,6 +12,7 @@ RapidRAW is a free, open-source raw editor by [Timon Käch (CyberTimon)](https:/ +- **Targeted colour adjustment.** Pick Hue, Saturation or Luminance in the expanded Color Mixer, then drag up or down on a colour in the photo to change just the bands it contains. - **A white balance picker you can trust.** It reads the untouched raw data, averages a square or a dragged area, and shows a live preview before you click, which helps with mixed-light night shots. - **At home on Linux tiling desktops.** The native title bar works with Wayland compositors like Hyprland instead of fighting them. - **Colour-managed exports.** JPEG, PNG and TIFF files carry an sRGB profile, so browsers, other apps and print labs show your colours as intended. @@ -26,7 +27,7 @@ RapidRAW is a free, open-source raw editor by [Timon Käch (CyberTimon)](https:/ - **Faster culling:** rate with 0–5 and jump straight to the next photo, and filter for exactly N stars. - **Bring your Lightroom edits along:** import XMP sidecars -38 improvements on top of RapidRAW so far, including fixes for 17 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). +39 improvements on top of RapidRAW so far, including fixes for 17 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). ## Why RapidRoom diff --git a/CHANGES.md b/CHANGES.md index 6af8a2f85..bb8a4d492 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,10 +4,11 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber Generated from [rapidroom/changes.json](rapidroom/changes.json) by `node rapidroom/status.mjs`; don't edit by hand. -**38 changes on top of RapidRAW.** 18 fix upstream issues that had been open a median of 68 days when RapidRoom shipped the fix; 17 of them still open upstream. 13 offered upstream as PRs, 3 merged so far. +**39 changes on top of RapidRAW.** 18 fix upstream issues that had been open a median of 68 days when RapidRoom shipped the fix; 17 of them still open upstream. 13 offered upstream as PRs, 3 merged so far. | Change | Type | By | Upstream | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Expanded Color Mixer: Hue, Saturation and Luminance tabs with all eight bands, and a pipette that adjusts the bands under the cursor as you drag up or down on the photo | feature | [@lalibertemarc](https://github.com/lalibertemarc) | not yet offered | | White balance picker samples the original linear image (click for a small square, drag for an area) instead of an edited thumbnail, so picks are stable and correct | fix | [@lalibertemarc](https://github.com/lalibertemarc) | [#1251](https://github.com/CyberTimon/RapidRAW/issues/1251) open 120 d; [#746](https://github.com/CyberTimon/RapidRAW/issues/746) open 219 d; [#1768](https://github.com/CyberTimon/RapidRAW/issues/1768) open 11 d | | Tauri 2.12: Native Titlebar works with tiling Wayland compositors (Hyprland) | platform | [@yojen7](https://github.com/yojen7) | PR [#1813](https://github.com/CyberTimon/RapidRAW/pull/1813) merged | | No abort or hang when stdout/stderr is a closed pipe (e.g. `rapidraw … \| head`) | fix | [@yojen7](https://github.com/yojen7) | PR [#1819](https://github.com/CyberTimon/RapidRAW/pull/1819) open | @@ -54,6 +55,15 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber ## Details +### Expanded Color Mixer: Hue, Saturation and Luminance tabs with all eight bands, and a pipette that adjusts the bands under the cursor as you drag up or down on the photo + +- **Type:** feature +- **Landed in RapidRoom:** 2026-10-04 +- **By:** [@lalibertemarc](https://github.com/lalibertemarc), from lalibertemarc/RapidRAW +- **Upstream:** not yet offered +- **Commits:** [b5475ad](https://github.com/lalibertemarc/RapidRAW/commit/b5475ad8e3176386153d8c3a8fbdc7d21ba7c5a9), [dffc46a](https://github.com/lalibertemarc/RapidRAW/commit/dffc46a161f6a9c35037348d9d62b1a5267a99ac), [17173f2](https://github.com/lalibertemarc/RapidRAW/commit/17173f2873b44f4f7d5f219df1e5e744b3e0dfa9) +- **Notes:** HSL math and defaults are unchanged; the pipette only sets the existing mixer sliders. Each band moves in proportion to how much of it is in the sampled square (dominant band 0.5 per pixel, Alt for fine steps), weighted in linear RGB like RapidRoom's shader. A drag is one undo step. Global mixer only, not masks. Adapted with Claude Code. + ### White balance picker samples the original linear image (click for a small square, drag for an area) instead of an edited thumbnail, so picks are stable and correct - **Type:** fix diff --git a/CREDITS.md b/CREDITS.md index 3b2ef11a0..6b1ef0bfc 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -26,6 +26,7 @@ RapidRoom is built on other people's work. This file lists where every non-upstr | Lightroom/Camera Raw XMP sidecar import | [@dimafa](https://github.com/dimafa), StephenMasseur | [#1465](https://github.com/CyberTimon/RapidRAW/pull/1465): [7a6913f](https://github.com/dimafa/RapidRAW/commit/7a6913f563a7ed6a3e9493d8f49471622c8caf76), [b3d0c52](https://github.com/dimafa/RapidRAW/commit/b3d0c52472c77b7d93c7774ec663df0341a6a772); builds on [#1280](https://github.com/CyberTimon/RapidRAW/pull/1280) | | Keep pending window/DPI preview resizes | [@subbajeu](https://github.com/subbajeu), adapted by @yojen7 with Codex | [1084ca9](https://github.com/SandeepSubba/RapidRAW/commit/1084ca96fbad8ea1c64dd58d6a26df06fdf3df60) in SandeepSubba/RapidRAW | | Render caches keyed by virtual image identity | [@mlauziertr](https://github.com/mlauziertr) (Maxime Lauzier), adapted by @yojen7 with Codex | [8ec31d4](https://github.com/mlauziertr/picportal-editor/commit/8ec31d4f4fcddc50b6217d9d6419d7278d2937ec) | +| Expanded Color Mixer with a targeted pipette | [@lalibertemarc](https://github.com/lalibertemarc), adapted with Claude Code | [lalibertemarc/RapidRAW@b5475ad](https://github.com/lalibertemarc/RapidRAW/commit/b5475ad8e3176386153d8c3a8fbdc7d21ba7c5a9), [@dffc46a](https://github.com/lalibertemarc/RapidRAW/commit/dffc46a161f6a9c35037348d9d62b1a5267a99ac), [@17173f2](https://github.com/lalibertemarc/RapidRAW/commit/17173f2873b44f4f7d5f219df1e5e744b3e0dfa9) | ## RapidRoom changes diff --git a/rapidroom/changes.json b/rapidroom/changes.json index 4e2919672..89cca7bbf 100644 --- a/rapidroom/changes.json +++ b/rapidroom/changes.json @@ -1,6 +1,24 @@ { "$comment": "Everything RapidRoom adds on top of upstream RapidRAW. Edit this file in the same PR as the change; `node rapidroom/status.mjs` renders the README table and CHANGES.md. Fields: id, title, category (fix | feature | performance | privacy | platform | ci), landed (YYYY-MM-DD, date it reached RapidRoom main), authors (GitHub logins), source (rapidroom, or the fork it was harvested from), commits (URLs), upstream_issues and upstream_prs ('owner/repo#n'), rendering_change, notes, highlight (optional: a short, user-facing Markdown line; entries with one are listed under Highlights in the README, everything is in CHANGES.md).", "changes": [ + { + "id": "hsl-mixer-targeted", + "title": "Expanded Color Mixer: Hue, Saturation and Luminance tabs with all eight bands, and a pipette that adjusts the bands under the cursor as you drag up or down on the photo", + "category": "feature", + "landed": "2026-10-04", + "authors": ["lalibertemarc"], + "source": "lalibertemarc/RapidRAW", + "commits": [ + "https://github.com/lalibertemarc/RapidRAW/commit/b5475ad8e3176386153d8c3a8fbdc7d21ba7c5a9", + "https://github.com/lalibertemarc/RapidRAW/commit/dffc46a161f6a9c35037348d9d62b1a5267a99ac", + "https://github.com/lalibertemarc/RapidRAW/commit/17173f2873b44f4f7d5f219df1e5e744b3e0dfa9" + ], + "upstream_issues": [], + "upstream_prs": [], + "rendering_change": false, + "notes": "HSL math and defaults are unchanged; the pipette only sets the existing mixer sliders. Each band moves in proportion to how much of it is in the sampled square (dominant band 0.5 per pixel, Alt for fine steps), weighted in linear RGB like RapidRoom's shader. A drag is one undo step. Global mixer only, not masks. Adapted with Claude Code.", + "highlight": "**Targeted colour adjustment.** Pick Hue, Saturation or Luminance in the expanded Color Mixer, then drag up or down on a colour in the photo to change just the bands it contains." + }, { "id": "wb-picker-original", "title": "White balance picker samples the original linear image (click for a small square, drag for an area) instead of an edited thumbnail, so picks are stable and correct", From 8830dc0f711d2953b94f3c49b4e97fe04e0998d1 Mon Sep 17 00:00:00 2001 From: marclaliberte Date: Wed, 30 Sep 2026 21:31:50 -0400 Subject: [PATCH 06/19] feat(adjustments): add visibility toggles for color grading and mixer The Color panel eye bypasses every color tool at once. Color Grading and Color Mixer now get their own eye toggle, stored per image and per mask as sectionVisibility.colorGrading / colorMixer (default on, so existing sidecars render unchanged). The renderer applies each only when both the Color panel and the tool are visible. Resetting or pasting a section re-enables its tool toggles. The eye button is extracted into a shared VisibilityToggle used by both CollapsibleSection and AdjustmentSubSection, and the Rust section visibility lookup is shared between the global and mask builders. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 879b4146fa4d87ecfa3580261cfc0554ffb1555f) --- src-tauri/src/image_processing.rs | 62 ++++++++++--------- .../adjustments/AdjustmentSubSection.tsx | 37 +++++++++-- src/components/adjustments/Color.tsx | 16 ++++- src/components/panel/right/ControlsPanel.tsx | 19 +++--- src/components/panel/right/MasksPanel.tsx | 19 +++--- src/components/ui/CollapsibleSection.tsx | 26 +++----- src/components/ui/VisibilityToggle.tsx | 29 +++++++++ src/utils/adjustments.ts | 16 +++++ 8 files changed, 156 insertions(+), 68 deletions(-) create mode 100644 src/components/ui/VisibilityToggle.tsx diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index 71d37986b..5bfc18100 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -2086,18 +2086,24 @@ pub fn is_image_edited( bytemuck::bytes_of(¤t_adj) != bytemuck::bytes_of(&default_adj) } +fn is_section_visible(adjustments: &serde_json::Value, section: &str) -> bool { + adjustments + .get("sectionVisibility") + .and_then(|v| v.get(section)) + .and_then(|s| s.as_bool()) + .unwrap_or(true) +} + +fn is_color_tool_visible(adjustments: &serde_json::Value, tool: &str) -> bool { + is_section_visible(adjustments, "color") && is_section_visible(adjustments, tool) +} + fn get_global_adjustments_from_json( js_adjustments: &serde_json::Value, is_raw: bool, tonemapper_override: Option, ) -> GlobalAdjustments { - let visibility = js_adjustments.get("sectionVisibility"); - let is_visible = |section: &str| -> bool { - visibility - .and_then(|v| v.get(section)) - .and_then(|s| s.as_bool()) - .unwrap_or(true) - }; + let is_visible = |section: &str| is_section_visible(js_adjustments, section); let get_val = |section: &str, key: &str, scale: f32, default: Option| -> f32 { if is_visible(section) { @@ -2158,6 +2164,9 @@ fn get_global_adjustments_from_json( Vec::new() }; + let color_grading_visible = is_color_tool_visible(js_adjustments, "colorGrading"); + let color_mixer_visible = is_color_tool_visible(js_adjustments, "colorMixer"); + let cg_obj = js_adjustments .get("colorGrading") .cloned() @@ -2318,32 +2327,32 @@ fn get_global_adjustments_from_json( _pad_cg2: 0.0, _pad_cg3: 0.0, _pad_cg4: 0.0, - color_grading_shadows: if is_visible("color") { + color_grading_shadows: if color_grading_visible { parse_color_grade_settings(&cg_obj["shadows"]) } else { ColorGradeSettings::default() }, - color_grading_midtones: if is_visible("color") { + color_grading_midtones: if color_grading_visible { parse_color_grade_settings(&cg_obj["midtones"]) } else { ColorGradeSettings::default() }, - color_grading_highlights: if is_visible("color") { + color_grading_highlights: if color_grading_visible { parse_color_grade_settings(&cg_obj["highlights"]) } else { ColorGradeSettings::default() }, - color_grading_global: if is_visible("color") { + color_grading_global: if color_grading_visible { parse_color_grade_settings(&cg_obj["global"]) } else { ColorGradeSettings::default() }, - color_grading_blending: if is_visible("color") { + color_grading_blending: if color_grading_visible { cg_obj["blending"].as_f64().unwrap_or(50.0) as f32 / SCALES.color_grading_blending } else { 0.5 }, - color_grading_balance: if is_visible("color") { + color_grading_balance: if color_grading_visible { cg_obj["balance"].as_f64().unwrap_or(0.0) as f32 / SCALES.color_grading_balance } else { 0.0 @@ -2353,7 +2362,7 @@ fn get_global_adjustments_from_json( color_calibration: color_cal_settings, - hsl: if is_visible("color") { + hsl: if color_mixer_visible { parse_hsl_adjustments(&js_adjustments.get("hsl").cloned().unwrap_or_default()) } else { [HslColor::default(); 8] @@ -2388,13 +2397,7 @@ fn get_mask_adjustments_from_json(adj: &serde_json::Value) -> MaskAdjustments { return MaskAdjustments::default(); } - let visibility = adj.get("sectionVisibility"); - let is_visible = |section: &str| -> bool { - visibility - .and_then(|v| v.get(section)) - .and_then(|s| s.as_bool()) - .unwrap_or(true) - }; + let is_visible = |section: &str| is_section_visible(adj, section); let get_val = |section: &str, key: &str, scale: f32| -> f32 { if is_visible(section) { @@ -2425,6 +2428,9 @@ fn get_mask_adjustments_from_json(adj: &serde_json::Value) -> MaskAdjustments { } else { Vec::new() }; + let color_grading_visible = is_color_tool_visible(adj, "colorGrading"); + let color_mixer_visible = is_color_tool_visible(adj, "colorMixer"); + let cg_obj = adj.get("colorGrading").cloned().unwrap_or_default(); MaskAdjustments { @@ -2461,32 +2467,32 @@ fn get_mask_adjustments_from_json(adj: &serde_json::Value) -> MaskAdjustments { hue: get_val("color", "hue", 1.0), _pad_cg1: 0.0, _pad_cg2: 0.0, - color_grading_shadows: if is_visible("color") { + color_grading_shadows: if color_grading_visible { parse_color_grade_settings(&cg_obj["shadows"]) } else { ColorGradeSettings::default() }, - color_grading_midtones: if is_visible("color") { + color_grading_midtones: if color_grading_visible { parse_color_grade_settings(&cg_obj["midtones"]) } else { ColorGradeSettings::default() }, - color_grading_highlights: if is_visible("color") { + color_grading_highlights: if color_grading_visible { parse_color_grade_settings(&cg_obj["highlights"]) } else { ColorGradeSettings::default() }, - color_grading_global: if is_visible("color") { + color_grading_global: if color_grading_visible { parse_color_grade_settings(&cg_obj["global"]) } else { ColorGradeSettings::default() }, - color_grading_blending: if is_visible("color") { + color_grading_blending: if color_grading_visible { cg_obj["blending"].as_f64().unwrap_or(50.0) as f32 / SCALES.color_grading_blending } else { 0.5 }, - color_grading_balance: if is_visible("color") { + color_grading_balance: if color_grading_visible { cg_obj["balance"].as_f64().unwrap_or(0.0) as f32 / SCALES.color_grading_balance } else { 0.0 @@ -2494,7 +2500,7 @@ fn get_mask_adjustments_from_json(adj: &serde_json::Value) -> MaskAdjustments { _pad5: 0.0, _pad6: 0.0, - hsl: if is_visible("color") { + hsl: if color_mixer_visible { parse_hsl_adjustments(&adj.get("hsl").cloned().unwrap_or_default()) } else { [HslColor::default(); 8] diff --git a/src/components/adjustments/AdjustmentSubSection.tsx b/src/components/adjustments/AdjustmentSubSection.tsx index 5f2cf80bd..4ebd2f05a 100644 --- a/src/components/adjustments/AdjustmentSubSection.tsx +++ b/src/components/adjustments/AdjustmentSubSection.tsx @@ -3,6 +3,7 @@ import { ChevronDown } from 'lucide-react'; import clsx from 'clsx'; import { useShallow } from 'zustand/react/shallow'; import Text from '../ui/Text'; +import VisibilityToggle from '../ui/VisibilityToggle'; import { TextVariants } from '../../types/typography'; import { useSettingsStore } from '../../store/useSettingsStore'; import { useCollapsibleHeight } from '../../hooks/useCollapsibleHeight'; @@ -12,11 +13,21 @@ interface AdjustmentSubSectionProps { actions?: ReactNode; children: ReactNode; id: string; + isContentVisible?: boolean; + onToggleVisibility?: () => void; order: number; title: string; } -export default function AdjustmentSubSection({ actions, children, id, order, title }: AdjustmentSubSectionProps) { +export default function AdjustmentSubSection({ + actions, + children, + id, + isContentVisible = true, + onToggleVisibility, + order, + title, +}: AdjustmentSubSectionProps) { const { appSettings, handleSettingsChange } = useSettingsStore( useShallow((state) => ({ appSettings: state.appSettings, @@ -48,10 +59,22 @@ export default function AdjustmentSubSection({ actions, children, id, order, tit return (
-
- +
+ {title} + {onToggleVisibility && ( +
+ +
+ )} {actions &&
e.stopPropagation()}>{actions}
}
-
+
{children}
diff --git a/src/components/adjustments/Color.tsx b/src/components/adjustments/Color.tsx index 8a604073a..b0caf055f 100644 --- a/src/components/adjustments/Color.tsx +++ b/src/components/adjustments/Color.tsx @@ -4,7 +4,13 @@ import { motion, AnimatePresence } from 'framer-motion'; import { useTranslation } from 'react-i18next'; import Slider from '../ui/Slider'; import ColorWheel from '../ui/ColorWheel'; -import { ColorAdjustment, ColorCalibration, HueSatLum, INITIAL_ADJUSTMENTS } from '../../utils/adjustments'; +import { + ColorAdjustment, + ColorCalibration, + HueSatLum, + INITIAL_ADJUSTMENTS, + SectionVisibility, +} from '../../utils/adjustments'; import { Adjustments, ColorGrading, getAdjustmentToolOrder, getHiddenAdjustmentTools } from '../../utils/adjustments'; import { AppSettings } from '../ui/AppProperties'; import Text from '../ui/Text'; @@ -23,6 +29,8 @@ interface ColorPanelProps { appSettings: AppSettings | null; isForMask?: boolean; isWbPickerActive?: boolean; + onToggleVisibility?: (key: string) => void; + sectionVisibility?: SectionVisibility; toggleWbPicker?: () => void; onDragStateChange?: (isDragging: boolean) => void; } @@ -399,6 +407,8 @@ export default function ColorPanel({ isWbPickerActive = false, toggleWbPicker, onDragStateChange, + onToggleVisibility, + sectionVisibility, }: ColorPanelProps) { const { t } = useTranslation(); const [activeColor, setActiveColor] = useState('reds'); @@ -560,6 +570,8 @@ export default function ColorPanel({ {!hiddenTools.includes('colorGrading') && ( onToggleVisibility('colorGrading'))} order={toolOrder.indexOf('colorGrading')} title={t('adjustments.color.colorGrading')} > @@ -575,6 +587,8 @@ export default function ColorPanel({ {!hiddenTools.includes('colorMixer') && ( onToggleVisibility('colorMixer'))} order={toolOrder.indexOf('colorMixer')} title={t('adjustments.color.colorMixer')} > diff --git a/src/components/panel/right/ControlsPanel.tsx b/src/components/panel/right/ControlsPanel.tsx index c065eea07..713b777f3 100644 --- a/src/components/panel/right/ControlsPanel.tsx +++ b/src/components/panel/right/ControlsPanel.tsx @@ -18,6 +18,7 @@ import { INITIAL_ADJUSTMENTS, ADJUSTMENT_SECTIONS, getVisibleAdjustmentSections, + showSectionAndTools, } from '../../../utils/adjustments'; import { useContextMenu } from '../../../context/ContextMenuContext'; import { OPTION_SEPARATOR, Orientation } from '../../ui/AppProperties'; @@ -169,10 +170,10 @@ export default function Controls() { setAdjustments((prev: Adjustments) => ({ ...prev, ...copiedSectionAdjustments.values, - sectionVisibility: { - ...(prev.sectionVisibility || INITIAL_ADJUSTMENTS.sectionVisibility), - [sectionName]: true, - }, + sectionVisibility: showSectionAndTools( + prev.sectionVisibility || INITIAL_ADJUSTMENTS.sectionVisibility, + sectionName, + ), })); }; @@ -184,10 +185,10 @@ export default function Controls() { setAdjustments((prev: Adjustments) => ({ ...prev, ...resetValues, - sectionVisibility: { - ...(prev.sectionVisibility || INITIAL_ADJUSTMENTS.sectionVisibility), - [sectionName]: true, - }, + sectionVisibility: showSectionAndTools( + prev.sectionVisibility || INITIAL_ADJUSTMENTS.sectionVisibility, + sectionName, + ), })); }; @@ -321,6 +322,8 @@ export default function Controls() { isWbPickerActive={isWbPickerActive} toggleWbPicker={toggleWbPicker} onDragStateChange={onDragStateChange} + onToggleVisibility={handleToggleVisibility} + sectionVisibility={sectionVisibility} />
diff --git a/src/components/panel/right/MasksPanel.tsx b/src/components/panel/right/MasksPanel.tsx index c28645bf9..ab48b882e 100644 --- a/src/components/panel/right/MasksPanel.tsx +++ b/src/components/panel/right/MasksPanel.tsx @@ -80,6 +80,7 @@ import { MaskContainer, ADJUSTMENT_SECTIONS, getVisibleAdjustmentSections, + showSectionAndTools, } from '../../../utils/adjustments'; import { useContextMenu } from '../../../context/ContextMenuContext'; import { OPTION_SEPARATOR, Orientation, Panel } from '../../ui/AppProperties'; @@ -2059,10 +2060,10 @@ function SettingsPanel({ setMaskContainerAdjustments((prev: any) => ({ ...prev, ...copiedSectionAdjustments.values, - sectionVisibility: { - ...(prev.sectionVisibility || INITIAL_MASK_ADJUSTMENTS.sectionVisibility), - [sectionName]: true, - }, + sectionVisibility: showSectionAndTools( + prev.sectionVisibility || INITIAL_MASK_ADJUSTMENTS.sectionVisibility, + sectionName, + ), })); }; @@ -2076,10 +2077,10 @@ function SettingsPanel({ setMaskContainerAdjustments((prev: any) => ({ ...prev, ...resetValues, - sectionVisibility: { - ...(prev.sectionVisibility || INITIAL_MASK_ADJUSTMENTS.sectionVisibility), - [sectionName]: true, - }, + sectionVisibility: showSectionAndTools( + prev.sectionVisibility || INITIAL_MASK_ADJUSTMENTS.sectionVisibility, + sectionName, + ), })); }; @@ -2292,6 +2293,8 @@ function SettingsPanel({ isForMask={true} appSettings={appSettings} onDragStateChange={onDragStateChange} + onToggleVisibility={handleToggleVisibility} + sectionVisibility={sectionVisibility} /> ); diff --git a/src/components/ui/CollapsibleSection.tsx b/src/components/ui/CollapsibleSection.tsx index 3296621e3..d35f77972 100644 --- a/src/components/ui/CollapsibleSection.tsx +++ b/src/components/ui/CollapsibleSection.tsx @@ -1,8 +1,8 @@ import { useRef, useState } from 'react'; -import { ChevronDown, Eye, EyeOff } from 'lucide-react'; +import { ChevronDown } from 'lucide-react'; import clsx from 'clsx'; -import { useTranslation } from 'react-i18next'; import Text from './Text'; +import VisibilityToggle from './VisibilityToggle'; import { TextVariants, TextWeights } from '../../types/typography'; import { useCollapsibleHeight } from '../../hooks/useCollapsibleHeight'; @@ -27,7 +27,6 @@ export default function CollapsibleSection({ onToggleVisibility = () => {}, title, }: CollapsibleSectionProps) { - const { t } = useTranslation(); const { contentRef, wrapperRef } = useCollapsibleHeight(isOpen); const [isHovering, setIsHovering] = useState(false); const hoverTimeoutRef = useRef(null); @@ -49,11 +48,6 @@ export default function CollapsibleSection({ setIsHovering(false); }; - const handleVisibilityClick = (e: any) => { - e.stopPropagation(); - onToggleVisibility(); - }; - return (
{canToggleVisibility && (
- + isVisible={isContentVisible} + onToggle={onToggleVisibility} + />
)}
diff --git a/src/components/ui/VisibilityToggle.tsx b/src/components/ui/VisibilityToggle.tsx new file mode 100644 index 000000000..85a87c2e1 --- /dev/null +++ b/src/components/ui/VisibilityToggle.tsx @@ -0,0 +1,29 @@ +import { Eye, EyeOff } from 'lucide-react'; +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; + +interface VisibilityToggleProps { + className?: string; + isVisible: boolean; + onToggle(): void; +} + +export default function VisibilityToggle({ className, isVisible, onToggle }: VisibilityToggleProps) { + const { t } = useTranslation(); + + return ( + + ); +} diff --git a/src/utils/adjustments.ts b/src/utils/adjustments.ts index e02aa5b75..15c737a85 100644 --- a/src/utils/adjustments.ts +++ b/src/utils/adjustments.ts @@ -385,6 +385,8 @@ export interface SectionVisibility { basic: boolean; curves: boolean; color: boolean; + colorGrading: boolean; + colorMixer: boolean; details: boolean; effects: boolean; } @@ -490,6 +492,8 @@ export const INITIAL_MASK_ADJUSTMENTS: MaskAdjustments = { basic: true, curves: true, color: true, + colorGrading: true, + colorMixer: true, details: true, effects: true, }, @@ -587,6 +591,8 @@ export const INITIAL_ADJUSTMENTS: Adjustments = { basic: true, curves: true, color: true, + colorGrading: true, + colorMixer: true, details: true, effects: true, }, @@ -1020,5 +1026,15 @@ export const ADJUSTMENT_SECTION_TOOLS: Record (ADJUSTMENT_SECTION_TOOLS[section] ?? []).map((tool) => tool.id); +export const showSectionAndTools = (visibility: SectionVisibility, section: string): SectionVisibility => ({ + ...visibility, + ...Object.fromEntries( + getAdjustmentSectionToolIds(section) + .filter((id) => id in visibility) + .map((id) => [id, true]), + ), + [section]: true, +}); + export const getAdjustmentToolOrder = (section: string, toolOrder?: Record): string[] => reconcileOrder(getAdjustmentSectionToolIds(section), toolOrder?.[section]); From 2c46f15eb3d0647f1125c71eb824adcb310a3c0c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:26:48 +0000 Subject: [PATCH 07/19] Treat a missing section visibility key as visible when toggling A sectionVisibility object without the new colorGrading/colorMixer keys (an older or partial one) made the first eye click a no-op, because !undefined is true. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SHAmUdMPzKcgwDMEftFCrN --- src/components/panel/right/ControlsPanel.tsx | 2 +- src/components/panel/right/MasksPanel.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/panel/right/ControlsPanel.tsx b/src/components/panel/right/ControlsPanel.tsx index 713b777f3..ad5fd92a2 100644 --- a/src/components/panel/right/ControlsPanel.tsx +++ b/src/components/panel/right/ControlsPanel.tsx @@ -110,7 +110,7 @@ export default function Controls() { ...prev, sectionVisibility: { ...currentVisibility, - [sectionName]: !currentVisibility[sectionName], + [sectionName]: !(currentVisibility[sectionName] ?? true), }, }; }); diff --git a/src/components/panel/right/MasksPanel.tsx b/src/components/panel/right/MasksPanel.tsx index ab48b882e..e8f1bd87d 100644 --- a/src/components/panel/right/MasksPanel.tsx +++ b/src/components/panel/right/MasksPanel.tsx @@ -2032,7 +2032,7 @@ function SettingsPanel({ const cur = container.adjustments; const vis = cur.sectionVisibility || INITIAL_MASK_ADJUSTMENTS.sectionVisibility; updateContainer(container.id, { - adjustments: { ...cur, sectionVisibility: { ...vis, [sectionName]: !vis[sectionName] } }, + adjustments: { ...cur, sectionVisibility: { ...vis, [sectionName]: !(vis[sectionName] ?? true) } }, }); }; From ba8c6f276dce07764985466e3d06dec98cc23308 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:27:08 +0000 Subject: [PATCH 08/19] Change list and credits: Color Grading / Color Mixer visibility toggles Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SHAmUdMPzKcgwDMEftFCrN --- .github/README.md | 2 +- CHANGES.md | 12 +++++++++++- CREDITS.md | 1 + rapidroom/changes.json | 13 +++++++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/README.md b/.github/README.md index d40fceb12..5399bad2f 100644 --- a/.github/README.md +++ b/.github/README.md @@ -26,7 +26,7 @@ RapidRAW is a free, open-source raw editor by [Timon Käch (CyberTimon)](https:/ - **Faster culling:** rate with 0–5 and jump straight to the next photo, and filter for exactly N stars. - **Bring your Lightroom edits along:** import XMP sidecars -38 improvements on top of RapidRAW so far, including fixes for 17 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). +39 improvements on top of RapidRAW so far, including fixes for 17 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). ## Why RapidRoom diff --git a/CHANGES.md b/CHANGES.md index 6af8a2f85..8f0e2189a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,7 +4,7 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber Generated from [rapidroom/changes.json](rapidroom/changes.json) by `node rapidroom/status.mjs`; don't edit by hand. -**38 changes on top of RapidRAW.** 18 fix upstream issues that had been open a median of 68 days when RapidRoom shipped the fix; 17 of them still open upstream. 13 offered upstream as PRs, 3 merged so far. +**39 changes on top of RapidRAW.** 18 fix upstream issues that had been open a median of 68 days when RapidRoom shipped the fix; 17 of them still open upstream. 13 offered upstream as PRs, 3 merged so far. | Change | Type | By | Upstream | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -49,6 +49,7 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber | Linux release packaging: a .deb and an AppImage named RapidRoom that install side by side with RapidRAW | platform | [@yojen7](https://github.com/yojen7) | not yet offered | | Denoise dialogs stay open on busy backdrop clicks; Cancel stops waiting and discards the eventual UI result | fix | [@yojen7](https://github.com/yojen7) | [#1697](https://github.com/CyberTimon/RapidRAW/issues/1697) open 28 d | | DxO compressed DNG highlights no longer wrap to black dots when lookup-table dithering exceeds 16 bits ⚑ | fix | [@yojen7](https://github.com/yojen7) | [#1119](https://github.com/CyberTimon/RapidRAW/issues/1119) open 156 d | +| Color Grading and Color Mixer get their own visibility eye, globally and in masks; the Color panel eye still bypasses every colour tool | feature | [@lalibertemarc](https://github.com/lalibertemarc) | PR [#1833](https://github.com/CyberTimon/RapidRAW/issues/1833) | ⚑ changes rendered output on purpose. Upstream status as of 2026-10-04. @@ -383,3 +384,12 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber - **Upstream:** [#1119](https://github.com/CyberTimon/RapidRAW/issues/1119) open 156 d - **Commits:** [5a44585](https://github.com/yojen7/RapidRAW-DngLab/commit/5a44585da25c7ff783c83a4bff25281f0cf28159), [4c96062](https://github.com/yojen7/RapidRAW-DngLab/commit/4c960626978287ab8e4aabe70a6cfa60b4582273) - **Notes:** Dither values above 65535 now saturate instead of wrapping. Unsaturated values and the random-state update are preserved. The fork retains Kheil-Z's Sony/Canon default-crop fixes; the extra tile-fixture change is test-only. + +### Color Grading and Color Mixer get their own visibility eye, globally and in masks; the Color panel eye still bypasses every colour tool + +- **Type:** feature +- **Landed in RapidRoom:** 2026-10-04 +- **By:** [@lalibertemarc](https://github.com/lalibertemarc), from lalibertemarc/RapidRAW +- **Upstream:** PR [#1833](https://github.com/CyberTimon/RapidRAW/issues/1833) +- **Commits:** [879b414](https://github.com/lalibertemarc/RapidRAW/commit/879b4146fa4d87ecfa3580261cfc0554ffb1555f) +- **Notes:** New sectionVisibility.colorGrading/colorMixer keys default to on, so existing sidecars and presets render unchanged; turning a tool off intentionally skips it. Resetting or pasting the Color section re-enables both tools. diff --git a/CREDITS.md b/CREDITS.md index 3b2ef11a0..a3bd78001 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -26,6 +26,7 @@ RapidRoom is built on other people's work. This file lists where every non-upstr | Lightroom/Camera Raw XMP sidecar import | [@dimafa](https://github.com/dimafa), StephenMasseur | [#1465](https://github.com/CyberTimon/RapidRAW/pull/1465): [7a6913f](https://github.com/dimafa/RapidRAW/commit/7a6913f563a7ed6a3e9493d8f49471622c8caf76), [b3d0c52](https://github.com/dimafa/RapidRAW/commit/b3d0c52472c77b7d93c7774ec663df0341a6a772); builds on [#1280](https://github.com/CyberTimon/RapidRAW/pull/1280) | | Keep pending window/DPI preview resizes | [@subbajeu](https://github.com/subbajeu), adapted by @yojen7 with Codex | [1084ca9](https://github.com/SandeepSubba/RapidRAW/commit/1084ca96fbad8ea1c64dd58d6a26df06fdf3df60) in SandeepSubba/RapidRAW | | Render caches keyed by virtual image identity | [@mlauziertr](https://github.com/mlauziertr) (Maxime Lauzier), adapted by @yojen7 with Codex | [8ec31d4](https://github.com/mlauziertr/picportal-editor/commit/8ec31d4f4fcddc50b6217d9d6419d7278d2937ec) | +| Separate visibility toggles for Color Grading and Color Mixer | [@lalibertemarc](https://github.com/lalibertemarc) with Claude | [lalibertemarc/RapidRAW@879b414](https://github.com/lalibertemarc/RapidRAW/commit/879b4146fa4d87ecfa3580261cfc0554ffb1555f) (upstream PR `CyberTimon/RapidRAW#1833`) | ## RapidRoom changes diff --git a/rapidroom/changes.json b/rapidroom/changes.json index 4e2919672..9c50075d1 100644 --- a/rapidroom/changes.json +++ b/rapidroom/changes.json @@ -525,6 +525,19 @@ "upstream_prs": [], "rendering_change": true, "notes": "Dither values above 65535 now saturate instead of wrapping. Unsaturated values and the random-state update are preserved. The fork retains Kheil-Z's Sony/Canon default-crop fixes; the extra tile-fixture change is test-only." + }, + { + "id": "color-tool-visibility", + "title": "Color Grading and Color Mixer get their own visibility eye, globally and in masks; the Color panel eye still bypasses every colour tool", + "category": "feature", + "landed": "2026-10-04", + "authors": ["lalibertemarc"], + "source": "lalibertemarc/RapidRAW", + "commits": ["https://github.com/lalibertemarc/RapidRAW/commit/879b4146fa4d87ecfa3580261cfc0554ffb1555f"], + "upstream_issues": [], + "upstream_prs": ["CyberTimon/RapidRAW#1833"], + "rendering_change": false, + "notes": "New sectionVisibility.colorGrading/colorMixer keys default to on, so existing sidecars and presets render unchanged; turning a tool off intentionally skips it. Resetting or pasting the Color section re-enables both tools." } ] } From 9fea0f584cfa62d5fe70e618be61c914888d56fc Mon Sep 17 00:00:00 2001 From: Emil Zimmermann Date: Sun, 4 Oct 2026 01:31:30 +0000 Subject: [PATCH 09/19] Lights Out viewing in Library and Editor L cycles Normal -> Dim -> Black, Shift+L cycles backward (both configurable). Escape restores normal lighting before its existing action. Dim fades the surrounding chrome to black; Black hides it, flattens the layout and puts the window in fullscreen until Lights Out is turned off again. The photo, thumbnails and editing overlays are not dimmed, and exports are unaffected. (from zeromeridian/RapidRAW-Enhanced@69bdb283246c7c6746f88c7c0c22d88b7e90ae94) Adapted for RapidRoom's panel layout: - Dim uses opacity on the chrome instead of a CSS filter, so fixed popovers inside panels keep their positioning. - Backgrounds go black through the components and the wgpu clear colours, so the transparent wgpu preview area is never painted over. - Toggle folder tree moves from L to N; RapidRoom already bound L to it. - Lights Out only cycles in the library and editor views; the window fullscreen switch is skipped on Android. - Catalan and Dutch strings added; tests for the cycle and for duplicate default shortcuts. - Dropped the donor's FEATURES.md, RELEASE_NOTES.MD and tauri.conf.json version change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WKn2QWXeYf2rr2z39dZggU --- src/App.tsx | 60 ++++++++++++++++++++++---- src/components/panel/Editor.tsx | 25 +++++++---- src/components/panel/MainLibrary.tsx | 11 ++++- src/components/panel/SidePanelArea.tsx | 4 +- src/components/views/EditorView.tsx | 4 +- src/components/views/LibraryView.tsx | 48 +++++++++++---------- src/hooks/useKeyboardShortcuts.ts | 17 +++++++- src/i18n/locales/ca.json | 2 + src/i18n/locales/de.json | 2 + src/i18n/locales/en.json | 2 + src/i18n/locales/es.json | 2 + src/i18n/locales/fr.json | 2 + src/i18n/locales/it.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/ko.json | 2 + src/i18n/locales/nl.json | 2 + src/i18n/locales/pl.json | 2 + src/i18n/locales/pt.json | 2 + src/i18n/locales/ru.json | 2 + src/i18n/locales/zh-CN.json | 2 + src/i18n/locales/zh-TW.json | 2 + src/store/useUIStore.test.ts | 29 +++++++++++++ src/store/useUIStore.ts | 10 +++++ src/styles.css | 14 ++++++ src/utils/keyboardUtils.test.ts | 19 ++++++++ src/utils/keyboardUtils.ts | 14 +++++- 26 files changed, 235 insertions(+), 48 deletions(-) create mode 100644 src/store/useUIStore.test.ts create mode 100644 src/utils/keyboardUtils.test.ts diff --git a/src/App.tsx b/src/App.tsx index bfa50cd3e..175ddc027 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -163,6 +163,7 @@ function App() { activePanel, activeLayoutDragItem, isSettingsOpen, + lightsOutMode, setUI, setPanel, setLayoutDragItem, @@ -181,6 +182,7 @@ function App() { activePanel: state.activePanel, activeLayoutDragItem: state.activeLayoutDragItem, isSettingsOpen: state.isSettingsOpen, + lightsOutMode: state.lightsOutMode, setUI: state.setUI, setPanel: state.setPanel, setLayoutDragItem: state.setLayoutDragItem, @@ -657,6 +659,43 @@ function App() { }; }, [setUI]); + const wasWindowFullscreenBeforeBlackRef = useRef(null); + const lightsOutFullscreenTransitionRef = useRef(0); + useEffect(() => { + if (isAndroid) return; + const transition = ++lightsOutFullscreenTransitionRef.current; + const appWindow = getCurrentWindow(); + + const synchronizeBlackFullscreen = async () => { + if (lightsOutMode === 'black') { + if (wasWindowFullscreenBeforeBlackRef.current !== null) return; + + const wasFullscreen = await appWindow.isFullscreen(); + if (transition !== lightsOutFullscreenTransitionRef.current) return; + + wasWindowFullscreenBeforeBlackRef.current = wasFullscreen; + if (!wasFullscreen) { + await appWindow.setFullscreen(true); + if (useUIStore.getState().lightsOutMode !== 'black') { + await appWindow.setFullscreen(false); + } + } + return; + } + + const wasFullscreen = wasWindowFullscreenBeforeBlackRef.current; + if (wasFullscreen === null) return; + + wasWindowFullscreenBeforeBlackRef.current = null; + if (!wasFullscreen && (await appWindow.isFullscreen())) { + if (transition !== lightsOutFullscreenTransitionRef.current) return; + await appWindow.setFullscreen(false); + } + }; + + synchronizeBlackFullscreen().catch((err) => console.error('Failed to sync Lights Out fullscreen:', err)); + }, [lightsOutMode, isAndroid]); + const handlePanelSelect = useCallback( (panelId: Panel) => { setPanel(panelId); @@ -851,14 +890,15 @@ function App() {
{!isAndroid && (
{selectedImage && externalEditSession && ( - +
+ +
)}
s.isFullScreen); const activePanel = useUIStore((s) => s.activePanel); const isInstantTransition = useUIStore((s) => s.isInstantTransition); + const isLightsOut = useUIStore((s) => s.lightsOutMode !== 'off'); const setUI = useUIStore((s) => s.setUI); const isLoading = useLibraryStore((s) => s.isViewLoading); const selectedImage = useEditorStore((s) => s.selectedImage); @@ -1415,8 +1416,8 @@ export default function Editor({ onBackToLibrary, onContextMenu, onImageSelect, isCropping, uncroppedAdjustedPreviewUrl, showOriginal, - bgPrimary: parseRgb(bgPrimaryStr), - bgSecondary: isNeutralGrey ? NEUTRAL_GREY_RGB : parseRgb(bgSecondaryStr), + bgPrimary: isLightsOut ? [0, 0, 0, 1] : parseRgb(bgPrimaryStr), + bgSecondary: isLightsOut ? [0, 0, 0, 1] : isNeutralGrey ? NEUTRAL_GREY_RGB : parseRgb(bgSecondaryStr), }; }, [ appSettings?.useWgpuRenderer, @@ -1426,6 +1427,7 @@ export default function Editor({ onBackToLibrary, onContextMenu, onImageSelect, isCropping, uncroppedAdjustedPreviewUrl, showOriginal, + isLightsOut, appSettings?.theme, finalPreviewUrl, ]); @@ -1435,6 +1437,7 @@ export default function Editor({ onBackToLibrary, onContextMenu, onImageSelect, }, [ appSettings?.useWgpuRenderer, appSettings?.editorNeutralGreyBg, + isLightsOut, selectedImage?.isReady, hasRenderedFirstFrame, isCropping, @@ -2265,17 +2268,20 @@ export default function Editor({ onBackToLibrary, onContextMenu, onImageSelect, return (
{hasRenderedAnyPreview &&
}
state.isSettingsOpen); + const lightsOutMode = useUIStore((state) => state.lightsOutMode); const libraryDisplayMode = props.appSettings?.libraryDisplayMode || LibraryDisplayMode.Grid; @@ -540,9 +542,14 @@ export default function MainLibrary(props: MainLibraryProps) { } return ( -
+
setIsProgressHovered(true)} onMouseLeave={() => setIsProgressHovered(false)} > diff --git a/src/components/panel/SidePanelArea.tsx b/src/components/panel/SidePanelArea.tsx index 2e47d4547..102eb1cdf 100644 --- a/src/components/panel/SidePanelArea.tsx +++ b/src/components/panel/SidePanelArea.tsx @@ -397,7 +397,7 @@ export default function SidePanelArea({ return (
)} {rootPaths && rootPaths.length > 0 && ( - - setUI((state) => ({ isLibraryExportPanelVisible: !state.isLibraryExportPanelVisible })) - } - onOpenCopyPasteSettings={() => setUI({ isCopyPasteSettingsModalOpen: true })} - onPaste={() => handlePasteAdjustments()} - onRate={handleRate} - onReset={() => handleResetAdjustments()} - rating={imageRatings[libraryActivePath || ''] || 0} - thumbnailAspectRatio={thumbnailAspectRatio} - totalImages={imageList.length} - /> +
+ + setUI((state) => ({ isLibraryExportPanelVisible: !state.isLibraryExportPanelVisible })) + } + onOpenCopyPasteSettings={() => setUI({ isCopyPasteSettingsModalOpen: true })} + onPaste={() => handlePasteAdjustments()} + onRate={handleRate} + onReset={() => handleResetAdjustments()} + rating={imageRatings[libraryActivePath || ''] || 0} + thumbnailAspectRatio={thumbnailAspectRatio} + totalImages={imageList.length} + /> +
)}
diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index a55673588..ea954fb72 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -319,6 +319,20 @@ export const useKeyboardShortcuts = ({ s.ui.toggleFullScreen(); }, }, + cycle_lights_out: { + shouldFire: (s: any) => s.ui.activeView === 'library' || s.ui.activeView === 'editor', + execute: (e: KeyboardEvent, s: ReturnType) => { + e.preventDefault(); + s.ui.cycleLightsOut(1); + }, + }, + cycle_lights_out_reverse: { + shouldFire: (s: any) => s.ui.activeView === 'library' || s.ui.activeView === 'editor', + execute: (e: KeyboardEvent, s: ReturnType) => { + e.preventDefault(); + s.ui.cycleLightsOut(-1); + }, + }, show_original: { shouldFire: (s: any) => s.ui.activeView === 'editor' && !!s.editor.selectedImage, execute: (e: any) => { @@ -583,7 +597,8 @@ export const useKeyboardShortcuts = ({ match: (e: KeyboardEvent) => e.code === 'Escape', execute: (e: KeyboardEvent, s: any) => { e.preventDefault(); - if (s.editor.isStraightenActive) s.editor.setEditor({ isStraightenActive: false }); + if (s.ui.lightsOutMode !== 'off') s.ui.setUI({ lightsOutMode: 'off' }); + else if (s.editor.isStraightenActive) s.editor.setEditor({ isStraightenActive: false }); else if (s.ui.customEscapeHandler) s.ui.customEscapeHandler(); else if (s.editor.activeAiSubMaskId) s.editor.setEditor({ activeAiSubMaskId: null }); else if (s.editor.activeAiPatchContainerId) s.editor.setEditor({ activeAiPatchContainerId: null }); diff --git a/src/i18n/locales/ca.json b/src/i18n/locales/ca.json index 1525ab181..f79d9a16c 100644 --- a/src/i18n/locales/ca.json +++ b/src/i18n/locales/ca.json @@ -1684,6 +1684,8 @@ "toggle_export": "Alternar tauler d'Exportació", "toggle_folder_tree": "Alternar tauler d'arbre de carpetes", "toggle_fullscreen": "Alternar pantalla completa", + "cycle_lights_out": "Canvia el mode Lights Out", + "cycle_lights_out_reverse": "Canvia el mode Lights Out enrere", "toggle_left_panel": "Commutar el panell esquerre", "toggle_library_exif": "Alternar superposició EXIF", "toggle_masks": "Alternar tauler de Màscares", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 74481e58f..78b3f9250 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1682,6 +1682,8 @@ "toggle_export": "Exportieren-Panel ein-/ausblenden", "toggle_folder_tree": "Ordnerstruktur-Panel ein-/ausblenden", "toggle_fullscreen": "Vollbild umschalten", + "cycle_lights_out": "Lights Out durchschalten", + "cycle_lights_out_reverse": "Lights Out rückwärts durchschalten", "toggle_left_panel": "Linkes Panel ein-/ausblenden", "toggle_library_exif": "EXIF-Überlagerung ein-/ausblenden", "toggle_masks": "Maskieren-Panel ein-/ausblenden", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index bda270d16..0c25fa3f5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1697,6 +1697,8 @@ "toggle_export": "Toggle Export panel", "toggle_folder_tree": "Toggle Folder Tree panel", "toggle_fullscreen": "Toggle fullscreen", + "cycle_lights_out": "Cycle Lights Out", + "cycle_lights_out_reverse": "Cycle Lights Out backward", "toggle_left_panel": "Toggle Left Panel", "toggle_library_exif": "Toggle EXIF overlay", "toggle_masks": "Toggle Masks panel", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ddfcfe659..59455d152 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1717,6 +1717,8 @@ "toggle_export": "Alternar panel de Exportación", "toggle_folder_tree": "Alternar panel de árbol de carpetas", "toggle_fullscreen": "Alternar pantalla completa", + "cycle_lights_out": "Recorrer Lights Out", + "cycle_lights_out_reverse": "Recorrer Lights Out hacia atrás", "toggle_left_panel": "Alternar panel izquierdo", "toggle_library_exif": "Alternar superposición EXIF", "toggle_masks": "Alternar panel de Máscaras", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 1d9be5552..a3335d253 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1717,6 +1717,8 @@ "toggle_export": "Basculer le panneau d'exportation", "toggle_folder_tree": "Afficher/Masquer le panneau d'arborescence des dossiers", "toggle_fullscreen": "Basculer en plein écran", + "cycle_lights_out": "Parcourir le mode Lights Out", + "cycle_lights_out_reverse": "Parcourir Lights Out en sens inverse", "toggle_left_panel": "Afficher/Masquer le panneau gauche", "toggle_library_exif": "Basculer la superposition EXIF", "toggle_masks": "Basculer le panneau des masques", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 77d084292..2d8dd0c80 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1717,6 +1717,8 @@ "toggle_export": "Attiva/Disattiva pannello Esportazione", "toggle_folder_tree": "Mostra/Nascondi pannello albero delle cartelle", "toggle_fullscreen": "Attiva/Disattiva schermo intero", + "cycle_lights_out": "Cambia modalità Lights Out", + "cycle_lights_out_reverse": "Cambia Lights Out all'indietro", "toggle_left_panel": "Mostra/Nascondi pannello sinistro", "toggle_library_exif": "Attiva/Disattiva overlay EXIF", "toggle_masks": "Attiva/Disattiva pannello Maschere", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index cf048f539..bda0c759c 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1678,6 +1678,8 @@ "toggle_export": "書き出しパネルの表示/非表示", "toggle_folder_tree": "フォルダーツリーパネルの切り替え", "toggle_fullscreen": "全画面表示の切り替え", + "cycle_lights_out": "ライトアウトを切り替え", + "cycle_lights_out_reverse": "ライトアウトを逆方向に切り替え", "toggle_left_panel": "左パネルの表示/非表示", "toggle_library_exif": "EXIF重ね合わせ表示の表示/非表示", "toggle_masks": "マスクパネルの表示/非表示", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index a8c515e7a..506442daa 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1678,6 +1678,8 @@ "toggle_export": "내보내기 패널 전환", "toggle_folder_tree": "폴더 트리 패널 토글", "toggle_fullscreen": "전체 화면 전환", + "cycle_lights_out": "Lights Out 순환", + "cycle_lights_out_reverse": "Lights Out 역순 순환", "toggle_left_panel": "왼쪽 패널 토글", "toggle_library_exif": "EXIF 오버레이 전환", "toggle_masks": "마스크 패널 전환", diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index daf1b3341..8e8c3e9dd 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -1647,6 +1647,8 @@ "toggle_export": "Exportpaneel aan/uit", "toggle_folder_tree": "Mappenstructuurpaneel aan/uit", "toggle_fullscreen": "Volledig scherm aan/uit", + "cycle_lights_out": "Lights Out doorlopen", + "cycle_lights_out_reverse": "Lights Out achteruit doorlopen", "toggle_left_panel": "Linkerpaneel aan/uit", "toggle_library_exif": "EXIF-overlay aan/uit", "toggle_masks": "Maskerpaneel aan/uit", diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json index 0a0c58b6f..bb749b264 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -1754,6 +1754,8 @@ "toggle_export": "Przełącz panel Eksport", "toggle_folder_tree": "Przełącz panel drzewa folderów", "toggle_fullscreen": "Przełącz tryb pełnoekranowy", + "cycle_lights_out": "Przełączaj Lights Out", + "cycle_lights_out_reverse": "Przełączaj Lights Out wstecz", "toggle_left_panel": "Przełącz lewy panel", "toggle_library_exif": "Przełącz nakładkę EXIF", "toggle_masks": "Przełącz panel Maski", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 0571774e8..8cdace026 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1718,6 +1718,8 @@ "toggle_export": "Alternar painel de Exportação", "toggle_folder_tree": "Alternar painel da árvore de pastas", "toggle_fullscreen": "Alternar tela cheia", + "cycle_lights_out": "Alternar Lights Out", + "cycle_lights_out_reverse": "Alternar Lights Out para trás", "toggle_left_panel": "Alternar painel esquerdo", "toggle_library_exif": "Alternar sobreposição EXIF", "toggle_masks": "Alternar painel de Máscaras", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 90b9eb867..e54062e45 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1754,6 +1754,8 @@ "toggle_export": "Панель экспорта", "toggle_folder_tree": "Показать/скрыть панель дерева папок", "toggle_fullscreen": "Полноэкранный режим", + "cycle_lights_out": "Переключить Lights Out", + "cycle_lights_out_reverse": "Переключить Lights Out назад", "toggle_left_panel": "Показать/скрыть левую панель", "toggle_library_exif": "Информация EXIF поверх сетки", "toggle_masks": "Панель масок", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index c53a64c5e..ce3a411e7 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1678,6 +1678,8 @@ "toggle_export": "切换导出面板", "toggle_folder_tree": "切换文件夹树面板", "toggle_fullscreen": "切换全屏", + "cycle_lights_out": "循环切换熄灯模式", + "cycle_lights_out_reverse": "反向循环切换熄灯模式", "toggle_left_panel": "切换左侧面板", "toggle_library_exif": "切换 EXIF 叠加", "toggle_masks": "切换蒙版面板", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index b36d4d6d7..74f5cdbb9 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1678,6 +1678,8 @@ "toggle_export": "切換匯出面板", "toggle_folder_tree": "切換資料夾樹面板", "toggle_fullscreen": "切換全屏", + "cycle_lights_out": "循環切換熄燈模式", + "cycle_lights_out_reverse": "反向循環切換熄燈模式", "toggle_left_panel": "切換左側面板", "toggle_library_exif": "切換 EXIF 疊加", "toggle_masks": "切換遮色片面板", diff --git a/src/store/useUIStore.test.ts b/src/store/useUIStore.test.ts new file mode 100644 index 000000000..d8d30351a --- /dev/null +++ b/src/store/useUIStore.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { useUIStore } from './useUIStore'; + +const initialState = useUIStore.getState(); + +afterEach(() => { + useUIStore.setState(initialState, true); +}); + +describe('useUIStore.cycleLightsOut', () => { + it('cycles Normal, Dim, Black and back to Normal', () => { + const { cycleLightsOut } = useUIStore.getState(); + expect(useUIStore.getState().lightsOutMode).toBe('off'); + cycleLightsOut(); + expect(useUIStore.getState().lightsOutMode).toBe('dim'); + cycleLightsOut(); + expect(useUIStore.getState().lightsOutMode).toBe('black'); + cycleLightsOut(); + expect(useUIStore.getState().lightsOutMode).toBe('off'); + }); + + it('cycles backward', () => { + const { cycleLightsOut } = useUIStore.getState(); + cycleLightsOut(-1); + expect(useUIStore.getState().lightsOutMode).toBe('black'); + cycleLightsOut(-1); + expect(useUIStore.getState().lightsOutMode).toBe('dim'); + }); +}); diff --git a/src/store/useUIStore.ts b/src/store/useUIStore.ts index f9396adbd..0a5d6f44d 100644 --- a/src/store/useUIStore.ts +++ b/src/store/useUIStore.ts @@ -11,6 +11,7 @@ import { import { useEditorStore } from './useEditorStore'; export type SwitcherPlacement = 'bottom' | 'right' | 'left' | 'top'; +export type LightsOutMode = 'off' | 'dim' | 'black'; export interface CropSectionsState { transform: boolean; @@ -212,6 +213,7 @@ export interface UIState { uiVisibility: UiVisibility; isLibraryExportPanelVisible: boolean; isSettingsOpen: boolean; + lightsOutMode: LightsOutMode; leftPanelWidth: number; rightPanelWidth: number; @@ -270,6 +272,7 @@ export interface UIState { setCustomEscapeHandler: (handler: (() => void) | null) => void; searchFocusRequest: number; requestSearchFocus: () => void; + cycleLightsOut: (direction?: 1 | -1) => void; toggleFullScreen: () => void; resetWorkspaceLayout: (isTetheringSupported?: boolean) => WorkspaceState; } @@ -283,6 +286,7 @@ export const useUIStore = create((set, get) => ({ uiVisibility: { filmstrip: true, leftPanel: true, rightPanel: true, quickFilter: false }, isLibraryExportPanelVisible: false, isSettingsOpen: false, + lightsOutMode: 'off', leftPanelWidth: DEFAULT_PANEL_WIDTH, rightPanelWidth: DEFAULT_PANEL_WIDTH, @@ -561,4 +565,10 @@ export const useUIStore = create((set, get) => ({ setCustomEscapeHandler: (handler) => set({ customEscapeHandler: handler }), searchFocusRequest: 0, requestSearchFocus: () => set((state) => ({ searchFocusRequest: state.searchFocusRequest + 1 })), + cycleLightsOut: (direction = 1) => + set((state) => { + const modes: LightsOutMode[] = ['off', 'dim', 'black']; + const currentIndex = modes.indexOf(state.lightsOutMode); + return { lightsOutMode: modes[(currentIndex + direction + modes.length) % modes.length] }; + }), })); diff --git a/src/styles.css b/src/styles.css index 7237cd83d..769c34b07 100644 --- a/src/styles.css +++ b/src/styles.css @@ -31,6 +31,20 @@ } } +.lights-out-dim .lights-out-chrome { + opacity: 0.25 !important; +} + +.lights-out-black .lights-out-chrome { + display: none !important; +} + +.lights-out-black .lights-out-content { + gap: 0; + padding: 0; + border-radius: 0; +} + @utility slider-input { &::-webkit-slider-thumb { @apply h-4 w-4 appearance-none rounded-full bg-accent; diff --git a/src/utils/keyboardUtils.test.ts b/src/utils/keyboardUtils.test.ts new file mode 100644 index 000000000..21840294f --- /dev/null +++ b/src/utils/keyboardUtils.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { KEYBIND_DEFINITIONS } from './keyboardUtils'; + +describe('KEYBIND_DEFINITIONS', () => { + it('has no two actions sharing a default shortcut', () => { + const seen = new Map(); + for (const def of KEYBIND_DEFINITIONS) { + const combo = def.defaultCombo.join('+'); + expect(seen.get(combo), `${def.action} reuses ${combo}`).toBeUndefined(); + seen.set(combo, def.action); + } + }); + + it('binds Lights Out to L and Shift+L', () => { + const combo = (action: string) => KEYBIND_DEFINITIONS.find((def) => def.action === action)?.defaultCombo; + expect(combo('cycle_lights_out')).toEqual(['KeyL']); + expect(combo('cycle_lights_out_reverse')).toEqual(['shift', 'KeyL']); + }); +}); diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts index 7ef8d651a..9692630b4 100644 --- a/src/utils/keyboardUtils.ts +++ b/src/utils/keyboardUtils.ts @@ -115,6 +115,18 @@ export const KEYBIND_DEFINITIONS: KeybindDefinition[] = [ defaultCombo: ['KeyF'], section: 'view', }, + { + action: 'cycle_lights_out', + description: 'settings.keybinds.actions.cycle_lights_out', + defaultCombo: ['KeyL'], + section: 'view', + }, + { + action: 'cycle_lights_out_reverse', + description: 'settings.keybinds.actions.cycle_lights_out_reverse', + defaultCombo: ['shift', 'KeyL'], + section: 'view', + }, { action: 'show_original', description: 'settings.keybinds.actions.show_original', @@ -202,7 +214,7 @@ export const KEYBIND_DEFINITIONS: KeybindDefinition[] = [ { action: 'toggle_folder_tree', description: 'settings.keybinds.actions.toggle_folder_tree', - defaultCombo: ['KeyL'], + defaultCombo: ['KeyN'], section: 'panels', }, { From bbcab3098609f41a8918ff0362c94c57f88fc645 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:31:30 +0000 Subject: [PATCH 10/19] Change list and credits: Lights Out (#83) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WKn2QWXeYf2rr2z39dZggU --- .github/README.md | 3 ++- CHANGES.md | 12 +++++++++++- CREDITS.md | 1 + rapidroom/changes.json | 14 ++++++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/README.md b/.github/README.md index d40fceb12..1c84d5ed6 100644 --- a/.github/README.md +++ b/.github/README.md @@ -12,6 +12,7 @@ RapidRAW is a free, open-source raw editor by [Timon Käch (CyberTimon)](https:/ +- **Lights Out, Lightroom-style.** Press L to dim everything but the photo, again for pure black, Escape to come back. Good for judging night shots. - **A white balance picker you can trust.** It reads the untouched raw data, averages a square or a dragged area, and shows a live preview before you click, which helps with mixed-light night shots. - **At home on Linux tiling desktops.** The native title bar works with Wayland compositors like Hyprland instead of fighting them. - **Colour-managed exports.** JPEG, PNG and TIFF files carry an sRGB profile, so browsers, other apps and print labs show your colours as intended. @@ -26,7 +27,7 @@ RapidRAW is a free, open-source raw editor by [Timon Käch (CyberTimon)](https:/ - **Faster culling:** rate with 0–5 and jump straight to the next photo, and filter for exactly N stars. - **Bring your Lightroom edits along:** import XMP sidecars -38 improvements on top of RapidRAW so far, including fixes for 17 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). +39 improvements on top of RapidRAW so far, including fixes for 17 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). ## Why RapidRoom diff --git a/CHANGES.md b/CHANGES.md index 6af8a2f85..7f78827af 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,10 +4,11 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber Generated from [rapidroom/changes.json](rapidroom/changes.json) by `node rapidroom/status.mjs`; don't edit by hand. -**38 changes on top of RapidRAW.** 18 fix upstream issues that had been open a median of 68 days when RapidRoom shipped the fix; 17 of them still open upstream. 13 offered upstream as PRs, 3 merged so far. +**39 changes on top of RapidRAW.** 18 fix upstream issues that had been open a median of 68 days when RapidRoom shipped the fix; 17 of them still open upstream. 13 offered upstream as PRs, 3 merged so far. | Change | Type | By | Upstream | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Lights Out viewing in Library and Editor: L cycles Normal → Dim → Black, Shift+L goes back, Escape restores | feature | [@zeromeridian](https://github.com/zeromeridian) | not yet offered | | White balance picker samples the original linear image (click for a small square, drag for an area) instead of an edited thumbnail, so picks are stable and correct | fix | [@lalibertemarc](https://github.com/lalibertemarc) | [#1251](https://github.com/CyberTimon/RapidRAW/issues/1251) open 120 d; [#746](https://github.com/CyberTimon/RapidRAW/issues/746) open 219 d; [#1768](https://github.com/CyberTimon/RapidRAW/issues/1768) open 11 d | | Tauri 2.12: Native Titlebar works with tiling Wayland compositors (Hyprland) | platform | [@yojen7](https://github.com/yojen7) | PR [#1813](https://github.com/CyberTimon/RapidRAW/pull/1813) merged | | No abort or hang when stdout/stderr is a closed pipe (e.g. `rapidraw … \| head`) | fix | [@yojen7](https://github.com/yojen7) | PR [#1819](https://github.com/CyberTimon/RapidRAW/pull/1819) open | @@ -54,6 +55,15 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber ## Details +### Lights Out viewing in Library and Editor: L cycles Normal → Dim → Black, Shift+L goes back, Escape restores + +- **Type:** feature +- **Landed in RapidRoom:** 2026-10-04 +- **By:** [@zeromeridian](https://github.com/zeromeridian), from zeromeridian/RapidRAW-Enhanced +- **Upstream:** not yet offered +- **Commits:** [69bdb28](https://github.com/zeromeridian/RapidRAW-Enhanced/commit/69bdb283246c7c6746f88c7c0c22d88b7e90ae94) +- **Notes:** Only the app chrome around the photo is dimmed or hidden; the preview and exports are untouched. Toggle folder tree moved from L to N to free the key. Black also switches the window to fullscreen and restores it afterwards. + ### White balance picker samples the original linear image (click for a small square, drag for an area) instead of an edited thumbnail, so picks are stable and correct - **Type:** fix diff --git a/CREDITS.md b/CREDITS.md index 3b2ef11a0..6ba18cfd2 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -26,6 +26,7 @@ RapidRoom is built on other people's work. This file lists where every non-upstr | Lightroom/Camera Raw XMP sidecar import | [@dimafa](https://github.com/dimafa), StephenMasseur | [#1465](https://github.com/CyberTimon/RapidRAW/pull/1465): [7a6913f](https://github.com/dimafa/RapidRAW/commit/7a6913f563a7ed6a3e9493d8f49471622c8caf76), [b3d0c52](https://github.com/dimafa/RapidRAW/commit/b3d0c52472c77b7d93c7774ec663df0341a6a772); builds on [#1280](https://github.com/CyberTimon/RapidRAW/pull/1280) | | Keep pending window/DPI preview resizes | [@subbajeu](https://github.com/subbajeu), adapted by @yojen7 with Codex | [1084ca9](https://github.com/SandeepSubba/RapidRAW/commit/1084ca96fbad8ea1c64dd58d6a26df06fdf3df60) in SandeepSubba/RapidRAW | | Render caches keyed by virtual image identity | [@mlauziertr](https://github.com/mlauziertr) (Maxime Lauzier), adapted by @yojen7 with Codex | [8ec31d4](https://github.com/mlauziertr/picportal-editor/commit/8ec31d4f4fcddc50b6217d9d6419d7278d2937ec) | +| Lights Out viewing (L / Shift+L) | Emil Zimmermann ([zeromeridian](https://github.com/zeromeridian)), adapted with Claude Code | [zeromeridian/RapidRAW-Enhanced@69bdb28](https://github.com/zeromeridian/RapidRAW-Enhanced/commit/69bdb283246c7c6746f88c7c0c22d88b7e90ae94) | ## RapidRoom changes diff --git a/rapidroom/changes.json b/rapidroom/changes.json index 4e2919672..0316b224d 100644 --- a/rapidroom/changes.json +++ b/rapidroom/changes.json @@ -1,6 +1,20 @@ { "$comment": "Everything RapidRoom adds on top of upstream RapidRAW. Edit this file in the same PR as the change; `node rapidroom/status.mjs` renders the README table and CHANGES.md. Fields: id, title, category (fix | feature | performance | privacy | platform | ci), landed (YYYY-MM-DD, date it reached RapidRoom main), authors (GitHub logins), source (rapidroom, or the fork it was harvested from), commits (URLs), upstream_issues and upstream_prs ('owner/repo#n'), rendering_change, notes, highlight (optional: a short, user-facing Markdown line; entries with one are listed under Highlights in the README, everything is in CHANGES.md).", "changes": [ + { + "id": "lights-out", + "title": "Lights Out viewing in Library and Editor: L cycles Normal → Dim → Black, Shift+L goes back, Escape restores", + "category": "feature", + "landed": "2026-10-04", + "authors": ["zeromeridian"], + "source": "zeromeridian/RapidRAW-Enhanced", + "commits": ["https://github.com/zeromeridian/RapidRAW-Enhanced/commit/69bdb283246c7c6746f88c7c0c22d88b7e90ae94"], + "upstream_issues": [], + "upstream_prs": [], + "rendering_change": false, + "notes": "Only the app chrome around the photo is dimmed or hidden; the preview and exports are untouched. Toggle folder tree moved from L to N to free the key. Black also switches the window to fullscreen and restores it afterwards.", + "highlight": "**Lights Out, Lightroom-style.** Press L to dim everything but the photo, again for pure black, Escape to come back. Good for judging night shots." + }, { "id": "wb-picker-original", "title": "White balance picker samples the original linear image (click for a small square, drag for an area) instead of an edited thumbnail, so picks are stable and correct", From a7825a5bf7faa52a519ad1ae63effb07155d896d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:35:25 +0000 Subject: [PATCH 11/19] Test colour tool visibility: absent keys, independent tools, parent eye Absent or partial sectionVisibility must produce byte-identical uniforms to an explicit all-on one, for global and mask adjustments. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SHAmUdMPzKcgwDMEftFCrN --- src-tauri/src/image_processing.rs | 89 +++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index 5bfc18100..7be0c84a1 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -3743,3 +3743,92 @@ mod white_balance_sample_tests { assert!(s.temperature.abs() < 1e-4); } } + +#[cfg(test)] +mod color_tool_visibility_tests { + use super::*; + use serde_json::json; + + fn edited(visibility: Option) -> serde_json::Value { + let mut adj = json!({ + "saturation": 20, + "colorGrading": { + "shadows": { "hue": 200, "saturation": 40, "luminance": 0 }, + "midtones": { "hue": 30, "saturation": 10, "luminance": 5 }, + "highlights": { "hue": 60, "saturation": 25, "luminance": 0 }, + "global": { "hue": 0, "saturation": 0, "luminance": 0 }, + "blending": 70, + "balance": 15 + }, + "hsl": { "reds": { "hue": 10, "saturation": -20, "luminance": 5 } } + }); + if let Some(v) = visibility { + adj["sectionVisibility"] = v; + } + adj + } + + fn global(adj: &serde_json::Value) -> GlobalAdjustments { + get_global_adjustments_from_json(adj, true, None) + } + + fn has_grading(a: &GlobalAdjustments) -> bool { + a.color_grading_shadows.saturation != 0.0 && a.color_grading_balance != 0.0 + } + + fn has_mixer(a: &GlobalAdjustments) -> bool { + a.hsl[0].hue != 0.0 + } + + #[test] + fn absent_tool_keys_render_like_all_visible() { + let all_on = global(&edited(Some(json!({ + "basic": true, "curves": true, "color": true, + "colorGrading": true, "colorMixer": true, "details": true, "effects": true + })))); + for vis in [None, Some(json!({})), Some(json!({ "color": true }))] { + let a = global(&edited(vis)); + assert_eq!(bytemuck::bytes_of(&a), bytemuck::bytes_of(&all_on)); + } + assert!(has_grading(&all_on) && has_mixer(&all_on)); + } + + #[test] + fn tools_toggle_independently() { + let no_grading = global(&edited(Some(json!({ "colorGrading": false })))); + assert!(!has_grading(&no_grading) && has_mixer(&no_grading)); + assert_eq!(no_grading.color_grading_blending, 0.5); + + let no_mixer = global(&edited(Some(json!({ "colorMixer": false })))); + assert!(has_grading(&no_mixer) && !has_mixer(&no_mixer)); + assert!(no_mixer.saturation != 0.0); + } + + #[test] + fn color_panel_eye_bypasses_both_tools() { + let a = global(&edited(Some(json!({ + "color": false, "colorGrading": true, "colorMixer": true + })))); + assert!(!has_grading(&a) && !has_mixer(&a)); + assert_eq!(a.saturation, 0.0); + } + + #[test] + fn mask_tools_follow_the_same_rules() { + let on = get_mask_adjustments_from_json(&edited(None)); + let explicit = get_mask_adjustments_from_json(&edited(Some(json!({ "color": true })))); + assert_eq!(bytemuck::bytes_of(&on), bytemuck::bytes_of(&explicit)); + assert!(on.color_grading_shadows.saturation != 0.0 && on.hsl[0].hue != 0.0); + + let no_grading = + get_mask_adjustments_from_json(&edited(Some(json!({ "colorGrading": false })))); + assert!(no_grading.color_grading_shadows.saturation == 0.0 && no_grading.hsl[0].hue != 0.0); + + let no_mixer = + get_mask_adjustments_from_json(&edited(Some(json!({ "colorMixer": false })))); + assert!(no_mixer.color_grading_shadows.saturation != 0.0 && no_mixer.hsl[0].hue == 0.0); + + let parent_off = get_mask_adjustments_from_json(&edited(Some(json!({ "color": false })))); + assert!(parent_off.color_grading_shadows.saturation == 0.0 && parent_off.hsl[0].hue == 0.0); + } +} From 57b1453f2564c873dfae858891385769e48a7cd1 Mon Sep 17 00:00:00 2001 From: Yojen <86065973+yojen7@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:08:40 -1000 Subject: [PATCH 12/19] Expose visibility controls to keyboard and screen-reader users Give the shared eye button a translated accessible label and pressed state; reveal focused eyes in subsection and section headers. Co-authored-by: marclaliberte --- src/components/adjustments/AdjustmentSubSection.tsx | 2 +- src/components/ui/CollapsibleSection.tsx | 4 +++- src/components/ui/VisibilityToggle.tsx | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/adjustments/AdjustmentSubSection.tsx b/src/components/adjustments/AdjustmentSubSection.tsx index 4ebd2f05a..f00678bc2 100644 --- a/src/components/adjustments/AdjustmentSubSection.tsx +++ b/src/components/adjustments/AdjustmentSubSection.tsx @@ -68,7 +68,7 @@ export default function AdjustmentSubSection({ Date: Sun, 4 Oct 2026 03:25:00 -1000 Subject: [PATCH 13/19] Regenerate status docs after catalog and color-tool integration --- .github/README.md | 2 +- CHANGES.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/README.md b/.github/README.md index ab57e1b39..e7d7fa849 100644 --- a/.github/README.md +++ b/.github/README.md @@ -31,7 +31,7 @@ RapidRAW is a free, open-source raw editor by [Timon Käch (CyberTimon)](https:/ - **Pick and reject flags for culling,** separate from stars: P, X and U as in Lightroom, a flag filter, and rejects that darktable, Bridge and Lightroom understand. - **Presets at your fingertips.** Presets sit in the left sidebar as compact rows: hover to preview one on your photo, click to apply, and star favorites to pin them on top. -53 improvements on top of RapidRAW so far, including fixes for 18 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). +54 improvements on top of RapidRAW so far, including fixes for 18 upstream issues that are still open there. Every change, with its source and upstream status, is in the [changelog](../CHANGES.md). ## Why RapidRoom diff --git a/CHANGES.md b/CHANGES.md index 9ad5c0334..dbd39eba3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,7 +4,7 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber Generated from [rapidroom/changes.json](rapidroom/changes.json) by `node rapidroom/status.mjs`; don't edit by hand. -**53 changes on top of RapidRAW.** 19 fix upstream issues that had been open a median of 63 days when RapidRoom shipped the fix; 18 of them still open upstream. 14 offered upstream as PRs, 3 merged so far. +**54 changes on top of RapidRAW.** 19 fix upstream issues that had been open a median of 63 days when RapidRoom shipped the fix; 18 of them still open upstream. 14 offered upstream as PRs, 3 merged so far. | Change | Type | By | Upstream | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | From cb721d107daba9455aee95457c3685ac5b72f3a8 Mon Sep 17 00:00:00 2001 From: Yojen <86065973+yojen7@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:31:01 -1000 Subject: [PATCH 14/19] Serialize Lights Out fullscreen changes to preserve window state Complete restoration before capturing a new Black session; preserve existing fullscreen and recover from failed native requests. Add six asynchronous behavioral tests for cancelled entry, entry/restore races and failures. Co-authored-by: Emil Zimmermann --- CHANGES.md | 6 +- rapidroom/changes.json | 4 +- src/App.tsx | 40 ++-------- src/utils/lightsOutFullscreen.test.ts | 106 ++++++++++++++++++++++++++ src/utils/lightsOutFullscreen.ts | 43 +++++++++++ 5 files changed, 161 insertions(+), 38 deletions(-) create mode 100644 src/utils/lightsOutFullscreen.test.ts create mode 100644 src/utils/lightsOutFullscreen.ts diff --git a/CHANGES.md b/CHANGES.md index 8b1450347..7f1329d23 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -64,7 +64,7 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber | Pick and reject flags, separate from star ratings: P / X / U shortcuts, badges, a flag filter (picked, unflagged, hide rejected, rejected) and Delete rejected | feature | [@lalibertemarc](https://github.com/lalibertemarc), [@yojen7](https://github.com/yojen7) | [#1599](https://github.com/CyberTimon/RapidRAW/issues/1599); [#994](https://github.com/CyberTimon/RapidRAW/issues/994); [#1057](https://github.com/CyberTimon/RapidRAW/issues/1057) | | Presets move to the left sidebar with compact rows, hover previews, favorites and folder reordering | feature | [@laurensiusadi](https://github.com/laurensiusadi) | not yet offered | | Color Grading and Color Mixer get their own visibility eye, globally and in masks; the Color panel eye still bypasses every colour tool | feature | [@lalibertemarc](https://github.com/lalibertemarc) | PR [#1833](https://github.com/CyberTimon/RapidRAW/issues/1833) | -| Lights Out viewing in Library and Editor: L cycles Normal → Dim → Black, Shift+L goes back, Escape restores | feature | [@zeromeridian](https://github.com/zeromeridian) | not yet offered | +| Lights Out viewing in Library and Editor: L cycles Normal → Dim → Black, Shift+L goes back, Escape restores | feature | [@zeromeridian](https://github.com/zeromeridian), [@yojen7](https://github.com/yojen7) | not yet offered | ⚑ changes rendered output on purpose. Upstream status as of 2026-10-04. @@ -536,7 +536,7 @@ Everything RapidRoom adds on top of upstream [RapidRAW](https://github.com/Cyber - **Type:** feature - **Landed in RapidRoom:** 2026-10-04 -- **By:** [@zeromeridian](https://github.com/zeromeridian), from zeromeridian/RapidRAW-Enhanced +- **By:** [@zeromeridian](https://github.com/zeromeridian), [@yojen7](https://github.com/yojen7), from zeromeridian/RapidRAW-Enhanced - **Upstream:** not yet offered - **Commits:** [69bdb28](https://github.com/zeromeridian/RapidRAW-Enhanced/commit/69bdb283246c7c6746f88c7c0c22d88b7e90ae94) -- **Notes:** Only the app chrome around the photo is dimmed or hidden; the preview and exports are untouched. Toggle folder tree moved from L to N to free the key. Black also switches the window to fullscreen and restores it afterwards. +- **Notes:** Only the app chrome around the photo is dimmed or hidden; the preview and exports are untouched. Toggle folder tree moved from L to N to free the key. Black also switches the window to fullscreen and restores it afterwards. Local Codex review integrates Reference View and pick/reject controls, and serializes native fullscreen entry/restore to preserve the original window state during rapid mode changes; six async tests cover cancellation, restore races and native failures. diff --git a/rapidroom/changes.json b/rapidroom/changes.json index 8c9997c2b..c6895cb8c 100644 --- a/rapidroom/changes.json +++ b/rapidroom/changes.json @@ -745,13 +745,13 @@ "title": "Lights Out viewing in Library and Editor: L cycles Normal → Dim → Black, Shift+L goes back, Escape restores", "category": "feature", "landed": "2026-10-04", - "authors": ["zeromeridian"], + "authors": ["zeromeridian", "yojen7"], "source": "zeromeridian/RapidRAW-Enhanced", "commits": ["https://github.com/zeromeridian/RapidRAW-Enhanced/commit/69bdb283246c7c6746f88c7c0c22d88b7e90ae94"], "upstream_issues": [], "upstream_prs": [], "rendering_change": false, - "notes": "Only the app chrome around the photo is dimmed or hidden; the preview and exports are untouched. Toggle folder tree moved from L to N to free the key. Black also switches the window to fullscreen and restores it afterwards.", + "notes": "Only the app chrome around the photo is dimmed or hidden; the preview and exports are untouched. Toggle folder tree moved from L to N to free the key. Black also switches the window to fullscreen and restores it afterwards. Local Codex review integrates Reference View and pick/reject controls, and serializes native fullscreen entry/restore to preserve the original window state during rapid mode changes; six async tests cover cancellation, restore races and native failures.", "highlight": "**Lights Out, Lightroom-style.** Press L to dim everything but the photo, again for pure black, Escape to come back. Good for judging night shots." } ] diff --git a/src/App.tsx b/src/App.tsx index 0d2ff019e..0a9fd266e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,4 @@ +import { createLightsOutFullscreenController } from './utils/lightsOutFullscreen'; import { type PointerEvent as ReactPointerEvent, useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; @@ -663,41 +664,14 @@ function App() { }; }, [setUI]); - const wasWindowFullscreenBeforeBlackRef = useRef(null); - const lightsOutFullscreenTransitionRef = useRef(0); + const lightsOutFullscreenRef = useRef | null>(null); useEffect(() => { if (isAndroid) return; - const transition = ++lightsOutFullscreenTransitionRef.current; - const appWindow = getCurrentWindow(); - - const synchronizeBlackFullscreen = async () => { - if (lightsOutMode === 'black') { - if (wasWindowFullscreenBeforeBlackRef.current !== null) return; - - const wasFullscreen = await appWindow.isFullscreen(); - if (transition !== lightsOutFullscreenTransitionRef.current) return; - - wasWindowFullscreenBeforeBlackRef.current = wasFullscreen; - if (!wasFullscreen) { - await appWindow.setFullscreen(true); - if (useUIStore.getState().lightsOutMode !== 'black') { - await appWindow.setFullscreen(false); - } - } - return; - } - - const wasFullscreen = wasWindowFullscreenBeforeBlackRef.current; - if (wasFullscreen === null) return; - - wasWindowFullscreenBeforeBlackRef.current = null; - if (!wasFullscreen && (await appWindow.isFullscreen())) { - if (transition !== lightsOutFullscreenTransitionRef.current) return; - await appWindow.setFullscreen(false); - } - }; - - synchronizeBlackFullscreen().catch((err) => console.error('Failed to sync Lights Out fullscreen:', err)); + const controller = lightsOutFullscreenRef.current ?? createLightsOutFullscreenController(getCurrentWindow()); + lightsOutFullscreenRef.current = controller; + controller + .setBlack(lightsOutMode === 'black') + .catch((err) => console.error('Failed to sync Lights Out fullscreen:', err)); }, [lightsOutMode, isAndroid]); const handlePanelSelect = useCallback( diff --git a/src/utils/lightsOutFullscreen.test.ts b/src/utils/lightsOutFullscreen.test.ts new file mode 100644 index 000000000..1b36bcae4 --- /dev/null +++ b/src/utils/lightsOutFullscreen.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createLightsOutFullscreenController } from './lightsOutFullscreen'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function nativeWindow(initial = false) { + let state = initial; + return { + isFullscreen: vi.fn(async () => state), + setFullscreen: vi.fn(async (fullscreen: boolean) => { + state = fullscreen; + }), + state: () => state, + }; +} + +describe('Lights Out native fullscreen restoration', () => { + it('restores a windowed window after Black and leaves existing fullscreen intact', async () => { + for (const initial of [false, true]) { + const window = nativeWindow(initial), + controller = createLightsOutFullscreenController(window); + await controller.setBlack(true); + expect(window.state()).toBe(true); + await controller.setBlack(false); + expect(window.state()).toBe(initial); + expect(window.setFullscreen.mock.calls).toEqual(initial ? [] : [[true], [false]]); + } + }); + it('coalesces a request cancelled before its native query resolves', async () => { + const gate = deferred(), + window = nativeWindow(); + window.isFullscreen.mockImplementationOnce(async () => { + await gate.promise; + return false; + }); + const controller = createLightsOutFullscreenController(window); + const entering = controller.setBlack(true); + await Promise.resolve(); + await Promise.resolve(); + const leaving = controller.setBlack(false); + gate.resolve(); + await Promise.all([entering, leaving]); + expect(window.setFullscreen).not.toHaveBeenCalled(); + }); + it('restores after an in-flight fullscreen entry finishes', async () => { + const gate = deferred(), + window = nativeWindow(); + const set = window.setFullscreen.getMockImplementation()!; + window.setFullscreen.mockImplementationOnce(async (value) => { + await gate.promise; + await set(value); + }); + const controller = createLightsOutFullscreenController(window); + const entering = controller.setBlack(true); + await vi.waitFor(() => expect(window.setFullscreen).toHaveBeenCalledWith(true)); + const leaving = controller.setBlack(false); + gate.resolve(); + await Promise.all([entering, leaving]); + expect(window.state()).toBe(false); + expect(window.setFullscreen.mock.calls).toEqual([[true], [false]]); + }); + it('preserves the original window state when Black resumes during a restore', async () => { + const gate = deferred(), + window = nativeWindow(); + const controller = createLightsOutFullscreenController(window); + await controller.setBlack(true); + const set = window.setFullscreen.getMockImplementation()!; + window.setFullscreen.mockImplementationOnce(async (value) => { + await gate.promise; + await set(value); + }); + const leaving = controller.setBlack(false); + await vi.waitFor(() => expect(window.setFullscreen).toHaveBeenCalledWith(false)); + const resuming = controller.setBlack(true); + gate.resolve(); + await Promise.all([leaving, resuming]); + expect(window.state()).toBe(true); + await controller.setBlack(false); + expect(window.state()).toBe(false); + expect(window.setFullscreen.mock.calls).toEqual([[true], [false], [true], [false]]); + }); + it('recovers after a failed entry instead of leaving the transition queue rejected', async () => { + const window = nativeWindow(), + controller = createLightsOutFullscreenController(window); + window.setFullscreen.mockRejectedValueOnce(new Error('native failure')); + await expect(controller.setBlack(true)).rejects.toThrow('native failure'); + await controller.setBlack(true); + expect(window.state()).toBe(true); + await controller.setBlack(false); + expect(window.state()).toBe(false); + }); + it('retries a failed restore without forgetting the original state', async () => { + const window = nativeWindow(), + controller = createLightsOutFullscreenController(window); + await controller.setBlack(true); + window.setFullscreen.mockRejectedValueOnce(new Error('native failure')); + await expect(controller.setBlack(false)).rejects.toThrow('native failure'); + await controller.setBlack(false); + expect(window.state()).toBe(false); + }); +}); diff --git a/src/utils/lightsOutFullscreen.ts b/src/utils/lightsOutFullscreen.ts new file mode 100644 index 000000000..c4222a576 --- /dev/null +++ b/src/utils/lightsOutFullscreen.ts @@ -0,0 +1,43 @@ +interface FullscreenWindow { + isFullscreen(): Promise; + setFullscreen(fullscreen: boolean): Promise; +} + +// Serialize native transitions. A rapid Black -> Normal -> Black sequence must +// finish any restore before recording the next session's original window state. +export function createLightsOutFullscreenController(window: FullscreenWindow) { + let requestedBlack = false; + let originalFullscreen: boolean | null = null; + let pending: Promise = Promise.resolve(); + + return { + setBlack(black: boolean): Promise { + requestedBlack = black; + pending = pending + .catch(() => {}) + .then(async () => { + if (requestedBlack) { + if (originalFullscreen !== null) return; + const original = await window.isFullscreen(); + if (!requestedBlack) return; + originalFullscreen = original; + if (!original) { + try { + await window.setFullscreen(true); + } catch (error) { + originalFullscreen = null; + throw error; + } + } + } else { + if (originalFullscreen === null) return; + if (!originalFullscreen && (await window.isFullscreen())) { + await window.setFullscreen(false); + } + originalFullscreen = null; + } + }); + return pending; + }, + }; +} From 998e7ed5e092a20254ad86a866a7bbb349fec6ba Mon Sep 17 00:00:00 2001 From: Yojen <86065973+yojen7@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:56:00 -1000 Subject: [PATCH 15/19] Apply Lights Out to Reference View backgrounds and chrome Keep both photos visible while dimming or hiding reference labels, chooser controls and the active-image badge; use black around the reference photo when Lights Out is enabled. --- src/components/panel/Editor.tsx | 2 +- src/components/panel/editor/ReferencePane.tsx | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/panel/Editor.tsx b/src/components/panel/Editor.tsx index b0917589a..0917e2467 100644 --- a/src/components/panel/Editor.tsx +++ b/src/components/panel/Editor.tsx @@ -2428,7 +2428,7 @@ export default function Editor({ onBackToLibrary, onContextMenu, onImageSelect, )} {isReferenceViewOn && ( -
+
{t('editor.referenceView.active')} diff --git a/src/components/panel/editor/ReferencePane.tsx b/src/components/panel/editor/ReferencePane.tsx index 68db67a9c..eaf271f89 100644 --- a/src/components/panel/editor/ReferencePane.tsx +++ b/src/components/panel/editor/ReferencePane.tsx @@ -4,6 +4,7 @@ import { ImageOff, Loader2, Replace, Trash2, X } from 'lucide-react'; import clsx from 'clsx'; import { useTranslation } from 'react-i18next'; import { Invokes } from '../../ui/AppProperties'; +import { useUIStore } from '../../../store/useUIStore'; import { useProcessStore } from '../../../store/useProcessStore'; import { ReferenceViewImage } from '../../../utils/referenceView'; @@ -38,6 +39,7 @@ export default function ReferencePane({ onExit, }: ReferencePaneProps) { const { t } = useTranslation(); + const isLightsOut = useUIStore((state) => state.lightsOutMode !== 'off'); const referencePath = reference?.path ?? null; const thumbnailUrl = useProcessStore((s) => (referencePath ? s.thumbnails[referencePath] : undefined)); const [previewUrl, setPreviewUrl] = useState(null); @@ -178,7 +180,10 @@ export default function ReferencePane({ return (
@@ -209,7 +214,7 @@ export default function ReferencePane({
{reference && status === 'loading' && ( -
+
)} @@ -222,7 +227,7 @@ export default function ReferencePane({ )} {(isChooserOpen || !reference) && ( -
+

{t('editor.referenceView.chooseHint')}