diff --git a/src-tauri/src/app_settings.rs b/src-tauri/src/app_settings.rs index a48aeee0d..860398934 100644 --- a/src-tauri/src/app_settings.rs +++ b/src-tauri/src/app_settings.rs @@ -19,15 +19,22 @@ pub struct SortCriteria { #[serde(rename_all = "camelCase")] pub struct FilterCriteria { pub rating: u8, + #[serde(default = "default_rejected_status")] + pub rejected_status: String, pub raw_status: String, #[serde(default)] pub colors: Vec, } +fn default_rejected_status() -> String { + "all".to_string() +} + impl Default for FilterCriteria { fn default() -> Self { Self { rating: 0, + rejected_status: default_rejected_status(), raw_status: "all".to_string(), colors: Vec::new(), } diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 8d60821ee..6489b6fa2 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -129,7 +129,7 @@ pub struct ImageFile { path: String, modified: u64, is_edited: bool, - rating: u8, + rating: i8, tags: Option>, exif: Option>, is_virtual_copy: bool, @@ -1058,7 +1058,7 @@ fn generate_single_thumbnail_and_cache( preloaded_image: Option<&DynamicImage>, force_regenerate: bool, app_handle: &AppHandle, -) -> Option<(String, u8)> { +) -> Option<(String, i8)> { let (source_path, sidecar_path) = parse_virtual_path(path_str); let img_mod_time = fs::metadata(source_path) @@ -2012,7 +2012,7 @@ pub fn set_color_label_for_paths( #[tauri::command] pub fn set_rating_for_paths( paths: Vec, - rating: u8, + rating: i8, app_handle: AppHandle, ) -> Result<(), String> { let settings = load_settings(app_handle.clone()).unwrap_or_default(); @@ -2970,7 +2970,7 @@ pub fn create_virtual_copy(source_virtual_path: String) -> Result Option { +pub fn extract_xmp_rating(content: &str) -> Option { if let Some(idx) = content.find("xmp:Rating=\"") { let start = idx + 12; let end = content[start..].find('"').map(|i| start + i)?; diff --git a/src-tauri/src/image_processing.rs b/src-tauri/src/image_processing.rs index 3e6d43271..abdc46af6 100644 --- a/src-tauri/src/image_processing.rs +++ b/src-tauri/src/image_processing.rs @@ -51,7 +51,7 @@ impl<'a> IntoCowImage<'a> for &'a std::sync::Arc { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ImageMetadata { pub version: u32, - pub rating: u8, + pub rating: i8, pub adjustments: Value, #[serde(default)] pub tags: Option>, diff --git a/src/App.tsx b/src/App.tsx index df7a63a61..610268e15 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,7 +46,7 @@ import { } from 'lucide-react'; import TitleBar from './window/TitleBar'; import CommunityPage from './components/panel/CommunityPage'; -import MainLibrary, { ColumnWidths } from './components/panel/MainLibrary'; +import MainLibrary, { ColumnWidths, SelectByCriteria } from './components/panel/MainLibrary'; import FolderTree from './components/panel/FolderTree'; import Editor from './components/panel/Editor'; import Controls from './components/panel/right/ControlsPanel'; @@ -119,6 +119,8 @@ import { ThumbnailSize, ThumbnailAspectRatio, CullingSuggestions, + RejectedFilterStatus, + REJECTED_RATING, } from './components/ui/AppProperties'; import { ChannelConfig } from './components/adjustments/Curves'; import HdrModal from './components/modals/HdrModal'; @@ -307,6 +309,7 @@ function App() { const [filterCriteria, setFilterCriteria] = useState({ colors: [], rating: 0, + rejectedStatus: RejectedFilterStatus.All, rawStatus: RawStatus.All, }); const [supportedTypes, setSupportedTypes] = useState(null); @@ -1284,8 +1287,9 @@ function App() { } const filteredList = processedList.filter((image) => { + const rating = imageRatings[image.path] ?? 0; + if (filterCriteria.rating > 0) { - const rating = imageRatings[image.path] || 0; if (filterCriteria.rating === 5) { if (rating !== 5) return false; } else { @@ -1293,6 +1297,14 @@ function App() { } } + if (filterCriteria.rejectedStatus === RejectedFilterStatus.RejectedOnly && rating !== REJECTED_RATING) { + return false; + } + + if (filterCriteria.rejectedStatus === RejectedFilterStatus.UnrejectedOnly && rating === REJECTED_RATING) { + return false; + } + if ( filterCriteria.rawStatus && filterCriteria.rawStatus !== RawStatus.All && @@ -1929,6 +1941,7 @@ function App() { setFilterCriteria((prev: FilterCriteria) => ({ ...prev, ...settings.filterCriteria, + rejectedStatus: settings.filterCriteria.rejectedStatus || RejectedFilterStatus.All, rawStatus: settings.filterCriteria.rawStatus || RawStatus.All, colors: settings.filterCriteria.colors || [], })); @@ -2909,10 +2922,41 @@ function App() { } }; + const getRatingTargetPaths = useCallback( + (paths?: Array) => + paths || + (multiSelectedPaths.length > 0 + ? multiSelectedPaths + : selectedImage + ? [selectedImage.path] + : libraryActivePath + ? [libraryActivePath] + : []), + [libraryActivePath, multiSelectedPaths, selectedImage], + ); + + const applyRatingToPaths = useCallback((pathsToRate: string[], nextRating: number) => { + if (pathsToRate.length === 0) { + return; + } + + setImageRatings((prev: Record) => { + const newRatings = { ...prev }; + pathsToRate.forEach((path: string) => { + newRatings[path] = nextRating; + }); + return newRatings; + }); + + invoke(Invokes.SetRatingForPaths, { paths: pathsToRate, rating: nextRating }).catch((err) => { + console.error('Failed to apply rating to paths:', err); + setError(`Failed to apply rating: ${err}`); + }); + }, []); + const handleRate = useCallback( (newRating: number, paths?: Array) => { - const pathsToRate = - paths || (multiSelectedPaths.length > 0 ? multiSelectedPaths : selectedImage ? [selectedImage.path] : []); + const pathsToRate = getRatingTargetPaths(paths); if (pathsToRate.length === 0) { return; } @@ -2920,20 +2964,24 @@ function App() { const currentRating = imageRatings[pathsToRate[0]] || 0; const finalRating = newRating === currentRating ? 0 : newRating; - setImageRatings((prev: Record) => { - const newRatings = { ...prev }; - pathsToRate.forEach((path: string) => { - newRatings[path] = finalRating; - }); - return newRatings; - }); + applyRatingToPaths(pathsToRate, finalRating); + }, + [applyRatingToPaths, getRatingTargetPaths, imageRatings], + ); - invoke(Invokes.SetRatingForPaths, { paths: pathsToRate, rating: finalRating }).catch((err) => { - console.error('Failed to apply rating to paths:', err); - setError(`Failed to apply rating: ${err}`); - }); + const handleToggleRejected = useCallback( + (paths?: Array) => { + const pathsToRate = getRatingTargetPaths(paths); + if (pathsToRate.length === 0) { + return; + } + + const allRejected = pathsToRate.every((path: string) => (imageRatings[path] ?? 0) === REJECTED_RATING); + const finalRating = allRejected ? 0 : REJECTED_RATING; + + applyRatingToPaths(pathsToRate, finalRating); }, - [multiSelectedPaths, selectedImage, imageRatings], + [applyRatingToPaths, getRatingTargetPaths, imageRatings], ); const handleUpdateExif = useCallback( @@ -3421,6 +3469,7 @@ function App() { handlePasteAdjustments, handlePasteFiles, handleRate, + handleToggleRejected, handleRightPanelSelect, handleRotate, handleSetColorLabel, @@ -4291,6 +4340,57 @@ function App() { } }; + const handleSelectBy = useCallback( + (criteria: SelectByCriteria) => { + const matchingPaths = sortedImageList + .filter((image: ImageFile) => { + if (criteria.type === 'rating') { + const rating = imageRatings[image.path] ?? 0; + + if (criteria.mode === 'rejected') { + return rating === REJECTED_RATING; + } + + if (criteria.mode === 'notRejected') { + return rating !== REJECTED_RATING; + } + + if (criteria.mode === 'unrated') { + return rating === 0; + } + + const threshold = criteria.value ?? 0; + if (rating === REJECTED_RATING) { + return false; + } + return threshold === 5 ? rating === 5 : rating >= threshold; + } + + const imageColor = (image.tags || []).find((tag: string) => tag.startsWith('color:'))?.substring(6); + return imageColor === criteria.color; + }) + .map((image: ImageFile) => image.path); + + setMultiSelectedPaths(matchingPaths); + + if (selectedImage) { + if (matchingPaths.length > 0) { + handleImageSelect(matchingPaths[0]); + setSelectionAnchorPath(matchingPaths[0]); + } else { + handleBackToLibrary(); + setSelectionAnchorPath(null); + } + return; + } + + const nextActivePath = matchingPaths[0] ?? null; + setLibraryActivePath(nextActivePath); + setSelectionAnchorPath(nextActivePath); + }, + [sortedImageList, imageRatings, selectedImage, handleImageSelect, handleBackToLibrary], + ); + const handleRenameFiles = useCallback(async (paths: Array) => { if (paths && paths.length > 0) { setRenameTargetPaths(paths); @@ -4611,6 +4711,11 @@ function App() { onClick: () => handleRate(rating), })), }, + { + label: 'Rejected', + icon: X, + onClick: () => handleToggleRejected(), + }, { label: 'Color Label', icon: Palette, @@ -5008,6 +5113,11 @@ function App() { onClick: () => handleRate(rating, finalSelection), })), }, + { + label: 'Rejected', + icon: X, + onClick: () => handleToggleRejected(finalSelection), + }, { label: 'Color Label', icon: Palette, @@ -5369,6 +5479,7 @@ function App() { onImportClick={() => handleImportClick(currentFolderPath as string)} onLibraryRefresh={handleLibraryRefresh} onOpenFolder={handleOpenFolder} + onSelectBy={handleSelectBy} onSettingsChange={handleSettingsChange} onThumbnailAspectRatioChange={setThumbnailAspectRatio} onThumbnailSizeChange={setThumbnailSize} @@ -6015,7 +6126,7 @@ function App() { thumbnails={thumbnails} onApply={(action, paths) => { if (action === 'reject') { - handleSetColorLabel('red', paths); + applyRatingToPaths(paths, REJECTED_RATING); } else if (action === 'rate_zero') { handleRate(1, paths); } else if (action === 'delete') { diff --git a/src/components/modals/CullingModal.tsx b/src/components/modals/CullingModal.tsx index a68ade58f..c57d19a2e 100644 --- a/src/components/modals/CullingModal.tsx +++ b/src/components/modals/CullingModal.tsx @@ -29,7 +29,7 @@ const CULL_ACTIONS: { label: string; icon: React.ReactNode; }[] = [ - { value: 'reject', label: 'Mark as Rejected (Red Label)', icon: }, + { value: 'reject', label: 'Mark as Rejected', icon: }, { value: 'rate_zero', label: 'Set Rating to 1 Stars', icon: }, { value: 'delete', label: 'Move to Trash', icon: }, ]; diff --git a/src/components/panel/Filmstrip.tsx b/src/components/panel/Filmstrip.tsx index 04165e8bc..76f6fc8fd 100644 --- a/src/components/panel/Filmstrip.tsx +++ b/src/components/panel/Filmstrip.tsx @@ -1,15 +1,16 @@ import React, { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react'; -import { Image as ImageIcon, Star } from 'lucide-react'; +import { Image as ImageIcon, Star, X } from 'lucide-react'; import { motion } from 'framer-motion'; import clsx from 'clsx'; import { Grid, useGridCallbackRef } from 'react-window'; -import { ImageFile, SelectedImage, ThumbnailAspectRatio } from '../ui/AppProperties'; +import { ImageFile, SelectedImage, ThumbnailAspectRatio, REJECTED_RATING } from '../ui/AppProperties'; import { Color, COLOR_LABELS } from '../../utils/adjustments'; import Text from '../ui/Text'; import { TextColors, TextVariants, TextWeights } from '../../types/typography'; const HORIZONTAL_PADDING = 4; const ITEM_GAP = 8; +const isRejectedRating = (rating: number) => rating === REJECTED_RATING; interface ImageLayer { id: string; @@ -68,6 +69,8 @@ const FilmstripThumbnail = memo( const rating = imageRatings?.[path] || 0; const colorTag = tags?.find((t: string) => t.startsWith('color:'))?.substring(6); const colorLabel = COLOR_LABELS.find((c: Color) => c.name === colorTag); + const isRejected = isRejectedRating(rating); + const contentOpacityClass = isRejected ? 'opacity-35' : ''; const isVirtualCopy = path.includes('?vc='); const cleanPath = path.split('?')[0]; @@ -166,49 +169,54 @@ const FilmstripThumbnail = memo( }} data-tooltip={truncatedTitle} > - {layers.length > 0 ? ( -
- {layers.map((layer) => ( -
handleTransitionEnd(layer.id)} - > - {thumbnailAspectRatio === ThumbnailAspectRatio.Contain && ( +
+ {layers.length > 0 ? ( +
+ {layers.map((layer) => ( +
handleTransitionEnd(layer.id)} + > + {thumbnailAspectRatio === ThumbnailAspectRatio.Contain && ( + + )} - )} - {truncatedTitle} -
- ))} -
- ) : ( -
- -
- )} +
+ ))} +
+ ) : ( +
+ +
+ )} +
- {(colorLabel || rating > 0) && ( + {(colorLabel || rating > 0 || isRejected) && ( <>
-
+
+ {isRejected && ( + + )} {colorLabel && (
)} - {rating > 0 && ( + {!isRejected && rating > 0 && ( <> {rating} diff --git a/src/components/panel/MainLibrary.tsx b/src/components/panel/MainLibrary.tsx index c383daba8..5749a212d 100644 --- a/src/components/panel/MainLibrary.tsx +++ b/src/components/panel/MainLibrary.tsx @@ -16,6 +16,7 @@ import { RefreshCw, Settings, SlidersHorizontal, + SquareDashedMousePointer, Star as StarIcon, Search, Users, @@ -34,6 +35,8 @@ import { LibraryViewMode, Progress, RawStatus, + RejectedFilterStatus, + REJECTED_RATING, SortCriteria, SortDirection, SupportedTypes, @@ -53,6 +56,10 @@ export interface ColumnWidths { color: number; } +export type SelectByCriteria = + | { type: 'rating'; mode: 'notRejected' | 'rejected' | 'unrated' | 'atLeast'; value?: number } + | { type: 'color'; color: string }; + interface DropdownMenuProps { buttonContent: any; buttonTitle: string; @@ -104,6 +111,7 @@ interface MainLibraryProps { onImportClick(): void; onLibraryRefresh(): void; onOpenFolder(): void; + onSelectBy(criteria: SelectByCriteria): void; onSettingsChange(settings: AppSettings): Promise; onThumbnailAspectRatioChange(aspectRatio: ThumbnailAspectRatio): void; onThumbnailSizeChange(size: ThumbnailSize): void; @@ -215,6 +223,63 @@ const rawStatusOptions: Array = [ { key: RawStatus.RawOverNonRaw, label: 'Prefer RAW' }, ]; +const rejectedStatusOptions: Array = [ + { key: RejectedFilterStatus.All, label: 'All Images' }, + { key: RejectedFilterStatus.UnrejectedOnly, label: 'Not Rejected Only' }, + { key: RejectedFilterStatus.RejectedOnly, label: 'Rejected Only' }, +]; + +const selectByRatingOptions: Array<{ + label: string; + criteria: SelectByCriteria; + starValue?: number; + showEmptyStar?: boolean; + icon?: 'check' | 'x'; +}> = [ + { label: 'Not Rejected', criteria: { type: 'rating', mode: 'notRejected' }, icon: 'check' }, + { label: 'Rejected', criteria: { type: 'rating', mode: 'rejected' }, icon: 'x' }, + { label: 'Unrated', criteria: { type: 'rating', mode: 'unrated' }, showEmptyStar: true }, + { label: '1 & up', criteria: { type: 'rating', mode: 'atLeast', value: 1 }, starValue: 1 }, + { label: '2 & up', criteria: { type: 'rating', mode: 'atLeast', value: 2 }, starValue: 2 }, + { label: '3 & up', criteria: { type: 'rating', mode: 'atLeast', value: 3 }, starValue: 3 }, + { label: '4 & up', criteria: { type: 'rating', mode: 'atLeast', value: 4 }, starValue: 4 }, + { label: '5 only', criteria: { type: 'rating', mode: 'atLeast', value: 5 }, starValue: 5 }, + ]; + +const isRejectedRating = (rating: number) => rating === REJECTED_RATING; + +const matchesSelectByCriteria = ( + image: ImageFile, + criteria: SelectByCriteria, + imageRatings: Record, +): boolean => { + if (criteria.type === 'rating') { + const rating = imageRatings[image.path] ?? 0; + + if (criteria.mode === 'rejected') { + return rating === REJECTED_RATING; + } + + if (criteria.mode === 'notRejected') { + return rating !== REJECTED_RATING; + } + + if (criteria.mode === 'unrated') { + return rating === 0; + } + + const threshold = criteria.value ?? 0; + if (rating === REJECTED_RATING) { + return false; + } + + return threshold === 5 ? rating === 5 : rating >= threshold; + } + + const imageColor = (image.tags || []).find((tag: string) => tag.startsWith('color:'))?.substring(6); + return imageColor === criteria.color; +}; + const thumbnailSizeOptions: Array = [ { id: ThumbnailSize.Small, label: 'Small', size: 160 }, { id: ThumbnailSize.Medium, label: 'Medium', size: 240 }, @@ -324,9 +389,8 @@ function ListHeader({ return (
sortKey && onSortChange(sortKey)} > onSelectSize(option.id)} role="menuitem" @@ -737,9 +800,8 @@ function ThumbnailAspectRatioOptions({ selectedAspectRatio, onSelectAspectRatio const isSelected = selectedAspectRatio === option.id; return (
+
+ + Filter by Status + + {rejectedStatusOptions.map((option: KeyValueLabel) => { + const isSelected = (filterCriteria.rejectedStatus || RejectedFilterStatus.All) === option.key; + return ( + + ); + })} +
+
Filter by File Type @@ -810,9 +905,8 @@ function FilterOptions({ filterCriteria, setFilterCriteria }: FilterOptionProps) const isSelected = (filterCriteria.rawStatus || RawStatus.All) === option.key; return ( + ))} + + + Color Label + +
+ {COLOR_LABELS.map((label: Color) => { + const isDisabled = (optionCounts.get(label.name) || 0) === 0; + + return ( + + ); + })} +
+
+ + ); +} + function ListItem({ data, isActive, @@ -1061,6 +1247,8 @@ function ListItem({ const colorTag = tags?.find((t: string) => t.startsWith('color:'))?.substring(6); const colorLabel = COLOR_LABELS.find((c: Color) => c.name === colorTag); + const isRejected = isRejectedRating(rating); + const contentOpacityClass = isRejected ? 'opacity-35' : ''; const dateObj = new Date(modified > 1e11 ? modified : modified * 1000); const dateStr = @@ -1086,7 +1274,7 @@ function ListItem({ >
{layers.length > 0 && ( @@ -1100,9 +1288,8 @@ function ListItem({ > {baseName} {/* Name */} -
+
{baseName} @@ -1147,14 +1337,22 @@ function ListItem({ )}
-
+
{dateStr}
- {rating > 0 && ( + {isRejected && ( +
+ +
+ )} + {!isRejected && rating > 0 && (
@@ -1164,7 +1362,10 @@ function ListItem({ )}
-
+
{colorLabel && (
t.startsWith('color:'))?.substring(6); const colorLabel = COLOR_LABELS.find((c: Color) => c.name === colorTag); + const isRejected = isRejectedRating(rating); + const contentOpacityClass = isRejected ? 'opacity-35' : ''; return (
onImageDoubleClick(path)} > - {layers.length > 0 && ( -
- {layers.map((layer) => ( -
handleTransitionEnd(layer.id)} +
+ {layers.length > 0 && ( +
+ {layers.map((layer) => ( +
handleTransitionEnd(layer.id)} + > + {path.split(/[\\/]/).pop()} +
+ ))} +
+ )} + + + {layers.length === 0 && showPlaceholder && ( + - {path.split(/[\\/]/).pop()} -
- ))} -
- )} + + + )} + - - {layers.length === 0 && showPlaceholder && ( - - - - )} - +
+ + {baseName} + + {isVirtualCopy && ( + + VC + + )} +
+
- {(colorLabel || rating > 0) && ( + {(colorLabel || rating > 0 || isRejected) && ( <>
-
+
+ {isRejected && ( + + )} {colorLabel && (
)} - {rating > 0 && ( + {!isRejected && rating > 0 && ( <> {rating} @@ -1339,23 +1564,6 @@ function Thumbnail({
)} -
- - {baseName} - - {isVirtualCopy && ( - - VC - - )} -
); } @@ -1530,6 +1738,7 @@ export default function MainLibrary({ onImportClick, onLibraryRefresh, onOpenFolder, + onSelectBy, onSettingsChange, onThumbnailAspectRatioChange, onThumbnailSizeChange, @@ -2196,6 +2405,7 @@ export default function MainLibrary({ /> {!isAndroid && ( <> + ))} +
diff --git a/src/components/ui/AppProperties.tsx b/src/components/ui/AppProperties.tsx index 2f1e8697f..80db82460 100644 --- a/src/components/ui/AppProperties.tsx +++ b/src/components/ui/AppProperties.tsx @@ -19,6 +19,7 @@ export const GLOBAL_KEYS = [ 'p', 'i', 'e', + 'x', '0', '1', '2', @@ -125,6 +126,14 @@ export enum RawStatus { RawOverNonRaw = 'rawOverNonRaw', } +export enum RejectedFilterStatus { + All = 'all', + RejectedOnly = 'rejectedOnly', + UnrejectedOnly = 'unrejectedOnly', +} + +export const REJECTED_RATING = -1; + export enum SortDirection { Ascending = 'asc', Descening = 'desc', @@ -206,6 +215,7 @@ export enum LibraryViewMode { export interface FilterCriteria { colors: Array; rating: number; + rejectedStatus: RejectedFilterStatus; rawStatus: RawStatus; } diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index 4e9d0940b..561e3546b 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -26,6 +26,7 @@ interface KeyboardShortcutsProps { handleRightPanelSelect(panel: Panel): void; handleRotate(degrees: number): void; handleSetColorLabel(label: string | null): void; + handleToggleRejected(): void; handleToggleFullScreen(): void; handleZoomChange(zoomValue: number, fitToWindow?: boolean): void; isFullScreen: boolean; @@ -79,6 +80,7 @@ export const useKeyboardShortcuts = ({ handleRightPanelSelect, handleRotate, handleSetColorLabel, + handleToggleRejected, handleToggleFullScreen, handleZoomChange, isFullScreen, @@ -166,6 +168,10 @@ export const useKeyboardShortcuts = ({ }, execute: (event) => { event.preventDefault(); handleDeleteSelected(); }, }, + toggle_rejected: { + shouldFire: () => multiSelectedPaths.length > 0 || !!selectedImage, + execute: (event) => { event.preventDefault(); handleToggleRejected(); }, + }, preview_prev: { shouldFire: () => !!selectedImage, execute: (event) => { @@ -458,6 +464,15 @@ export const useKeyboardShortcuts = ({ } }, }, + { + match: (e) => !selectedImage && ['Enter', 'Space'].includes(e.code), + execute: (e) => { + e.preventDefault(); + const activePath = libraryActivePath; + if (!activePath) return; + handleImageSelect(activePath); + }, + }, ]; const handleKeyDown = (event: KeyboardEvent) => { @@ -511,6 +526,7 @@ export const useKeyboardShortcuts = ({ handleRightPanelSelect, handleRotate, handleSetColorLabel, + handleToggleRejected, handleToggleFullScreen, handleZoomChange, isFullScreen, diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts index 37733a1e4..c6d53a831 100644 --- a/src/utils/keyboardUtils.ts +++ b/src/utils/keyboardUtils.ts @@ -24,6 +24,7 @@ export const KEYBIND_DEFINITIONS: KeybindDefinition[] = [ { action: 'paste_files', description: 'Paste file(s) to current folder', defaultCombo: ['ctrl', 'shift', 'KeyV'], section: 'library' }, { action: 'select_all', description: 'Select all images', defaultCombo: ['ctrl', 'KeyA'], section: 'library' }, { action: 'delete_selected', description: 'Delete selected file(s)', defaultCombo: ['Delete'], section: 'library' }, + { action: 'toggle_rejected', description: 'Toggle rejected status', defaultCombo: ['KeyX'], section: 'library' }, { action: 'preview_prev', description: 'Previous image', defaultCombo: ['ArrowLeft'], section: 'library' }, { action: 'preview_next', description: 'Next image', defaultCombo: ['ArrowRight'], section: 'library' }, { action: 'zoom_in_step', description: 'Zoom in (by step)', defaultCombo: ['ArrowUp'], section: 'view' },