From 90dec3b21292d4e9ada7be17bcb8040e97505af6 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 28 Jun 2026 10:25:52 +0100 Subject: [PATCH] feat: add color search and reusable tooltips Add dominant-color palette extraction, storage, filtering, and startup backfill so the gallery and Timeline can be filtered from the toolbar color picker. Introduce a reusable tooltip component and migrate the color filter, update indicator, and gallery filename hover affordances to it. --- CHANGELOG.md | 3 + src-tauri/src/color.rs | 91 +++++++++++++++++++ src-tauri/src/commands.rs | 55 +++++++----- src-tauri/src/db.rs | 114 ++++++++++++++++++++++-- src-tauri/src/indexer.rs | 84 ++++++++++++++++++ src-tauri/src/lib.rs | 3 + src-tauri/src/thumbnail.rs | 23 +++++ src/components/ColorFilter.tsx | 158 +++++++++++++++++++++++++++++++++ src/components/Gallery.tsx | 4 +- src/components/TitleBar.tsx | 29 +++--- src/components/Toolbar.tsx | 2 + src/components/Tooltip.tsx | 141 +++++++++++++++++++++++++++++ src/store.ts | 21 ++++- 13 files changed, 682 insertions(+), 46 deletions(-) create mode 100644 src-tauri/src/color.rs create mode 100644 src/components/ColorFilter.tsx create mode 100644 src/components/Tooltip.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd0a3c..b70b9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Added +- **Color search** — filter the gallery (and Timeline) by dominant color via a + collapsible swatch palette plus a custom color picker in the toolbar; existing + libraries are backfilled automatically in the background. - **Albums** — curate your own collections. A new Albums section in the sidebar (with cover thumbnails, kept visually distinct from Libraries) lets you create, rename, and open albums; albums can span multiple folders. Add images from the diff --git a/src-tauri/src/color.rs b/src-tauri/src/color.rs new file mode 100644 index 0000000..375c647 --- /dev/null +++ b/src-tauri/src/color.rs @@ -0,0 +1,91 @@ +//! Dominant-color palette extraction for color search. +//! +//! Colors are sampled from the already-generated thumbnail (small, fast) rather +//! than the full image. We coarse-quantize pixels into an RGB histogram, then +//! return the most populated bins as representative colors with their weight +//! (fraction of sampled pixels). Search then filters images whose palette has a +//! color within a distance threshold of the query color. + +use image::RgbImage; +use std::collections::HashMap; +use std::path::Path; + +#[derive(Debug, Clone, Copy)] +pub struct PaletteColor { + pub r: u8, + pub g: u8, + pub b: u8, + /// Fraction of sampled pixels (0.0–1.0) that fell in this color's bin. + pub weight: f32, +} + +/// Bits kept per channel when binning. 4 bits → 16 levels/channel → 4096 bins: +/// coarse enough to group near-identical shades, fine enough to separate hues. +const QUANT_BITS: u32 = 4; +/// Cap on sampled pixels so very large frames stay cheap; thumbnails are tiny so +/// this rarely bites, but the backfill may read arbitrary thumbnail sizes. +const MAX_SAMPLES: usize = 50_000; + +/// Extract up to `k` dominant colors from an RGB image, most-common first. +pub fn extract_palette(img: &RgbImage, k: usize) -> Vec { + let pixels = img.as_raw(); + let pixel_count = pixels.len() / 3; + if pixel_count == 0 { + return Vec::new(); + } + let step = (pixel_count / MAX_SAMPLES).max(1); + let shift = 8 - QUANT_BITS; + + // bin key → (sum_r, sum_g, sum_b, count); summing lets us return the bin's + // average color rather than the quantized corner. + let mut bins: HashMap = HashMap::new(); + let mut total: u64 = 0; + for pixel in pixels.chunks_exact(3).step_by(step) { + let (r, g, b) = (pixel[0], pixel[1], pixel[2]); + let key = (((r as u16) >> shift) << (QUANT_BITS * 2)) + | (((g as u16) >> shift) << QUANT_BITS) + | ((b as u16) >> shift); + let entry = bins.entry(key).or_insert((0, 0, 0, 0)); + entry.0 += r as u64; + entry.1 += g as u64; + entry.2 += b as u64; + entry.3 += 1; + total += 1; + } + if total == 0 { + return Vec::new(); + } + + let mut entries: Vec<(u64, u64, u64, u64)> = bins.into_values().collect(); + entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.3)); + entries + .into_iter() + .take(k) + .map(|(sum_r, sum_g, sum_b, count)| PaletteColor { + r: (sum_r / count) as u8, + g: (sum_g / count) as u8, + b: (sum_b / count) as u8, + weight: count as f32 / total as f32, + }) + .collect() +} + +/// Decode a thumbnail file and extract its palette. Used by the backfill pass. +pub fn extract_palette_from_file(thumbnail_path: &Path, k: usize) -> Option> { + let img = image::ImageReader::open(thumbnail_path) + .ok()? + .decode() + .ok()?; + Some(extract_palette(&img.into_rgb8(), k)) +} + +/// Number of palette colors stored per image. +pub const PALETTE_SIZE: usize = 5; + +/// Max squared RGB distance for a palette color to count as matching a query +/// color (~70 units in RGB space). Tunable feel/precision of color search. +pub const MATCH_DISTANCE_SQ: i64 = 4900; + +/// Minimum weight (fraction of pixels) a palette color must have to match, so +/// trivial specks of a color don't trigger a match. +pub const MATCH_MIN_WEIGHT: f64 = 0.05; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7ec7dce..c724283 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -42,6 +42,9 @@ pub struct GetImagesParams { pub rating_min: Option, pub embedding_failed_only: Option, pub tagging_failed_only: Option, + /// Optional `[r, g, b]` color filter — matches images whose palette contains + /// a prominent color near this one. + pub color: Option<[u8; 3]>, pub sort: Option, pub offset: Option, pub limit: Option, @@ -564,6 +567,7 @@ pub async fn get_images( let rating_min = params.rating_min.unwrap_or(0); let embedding_failed_only = params.embedding_failed_only.unwrap_or(false); let tagging_failed_only = params.tagging_failed_only.unwrap_or(false); + let color = params.color.map(|[r, g, b]| (r, g, b)); let total = db::count_images( &conn, @@ -574,6 +578,7 @@ pub async fn get_images( rating_min, embedding_failed_only, tagging_failed_only, + color, ) .map_err(|e| e.to_string())?; @@ -586,6 +591,7 @@ pub async fn get_images( rating_min, embedding_failed_only, tagging_failed_only, + color, sort, offset, limit, @@ -768,23 +774,23 @@ pub async fn find_similar_by_region( .map_err(|e| e.to_string())? } else { match params.folder_id { - Some(folder_id) => vector::search_image_ids_by_embedding_in_folder( - &conn, - &embedding, - folder_id, - Some(params.image_id), - offset + limit + 1, - ) - .map_err(|e| e.to_string())?, - None => { - // Fetch one extra candidate to compensate for the source image that - // will be removed, so has_more is accurate and results span multiple pages. - let mut ids = - vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2) - .map_err(|e| e.to_string())?; - ids.retain(|&id| id != params.image_id); - ids - } + Some(folder_id) => vector::search_image_ids_by_embedding_in_folder( + &conn, + &embedding, + folder_id, + Some(params.image_id), + offset + limit + 1, + ) + .map_err(|e| e.to_string())?, + None => { + // Fetch one extra candidate to compensate for the source image that + // will be removed, so has_more is accurate and results span multiple pages. + let mut ids = + vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2) + .map_err(|e| e.to_string())?; + ids.retain(|&id| id != params.image_id); + ids + } } }; @@ -2268,7 +2274,10 @@ pub async fn list_albums(db: State<'_, DbState>) -> Result, String> { } #[tauri::command] -pub async fn create_album(db: State<'_, DbState>, params: CreateAlbumParams) -> Result { +pub async fn create_album( + db: State<'_, DbState>, + params: CreateAlbumParams, +) -> Result { let conn = db.get().map_err(|e| e.to_string())?; let name = params.name.trim(); if name.is_empty() { @@ -2294,13 +2303,19 @@ pub async fn delete_album(db: State<'_, DbState>, params: DeleteAlbumParams) -> } #[tauri::command] -pub async fn reorder_albums(db: State<'_, DbState>, params: ReorderAlbumsParams) -> Result<(), String> { +pub async fn reorder_albums( + db: State<'_, DbState>, + params: ReorderAlbumsParams, +) -> Result<(), String> { let conn = db.get().map_err(|e| e.to_string())?; db::reorder_albums(&conn, ¶ms.album_ids).map_err(|e| e.to_string()) } #[tauri::command] -pub async fn delete_albums(db: State<'_, DbState>, params: DeleteAlbumsParams) -> Result<(), String> { +pub async fn delete_albums( + db: State<'_, DbState>, + params: DeleteAlbumsParams, +) -> Result<(), String> { let conn = db.get().map_err(|e| e.to_string())?; db::delete_albums(&conn, ¶ms.album_ids).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index acaab7b..f6b2bd6 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -321,6 +321,17 @@ pub fn migrate(conn: &Connection) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_album_images_album ON album_images(album_id); CREATE INDEX IF NOT EXISTS idx_album_images_image ON album_images(image_id); + + CREATE TABLE IF NOT EXISTS image_colors ( + image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE, + idx INTEGER NOT NULL, + r INTEGER NOT NULL, + g INTEGER NOT NULL, + b INTEGER NOT NULL, + weight REAL NOT NULL, + PRIMARY KEY (image_id, idx) + ); + CREATE INDEX IF NOT EXISTS idx_image_colors_image ON image_colors(image_id); ", )?; @@ -1741,11 +1752,7 @@ pub fn add_images_to_album(conn: &Connection, album_id: i64, image_ids: &[i64]) Ok(added) } -pub fn remove_images_from_album( - conn: &Connection, - album_id: i64, - image_ids: &[i64], -) -> Result<()> { +pub fn remove_images_from_album(conn: &Connection, album_id: i64, image_ids: &[i64]) -> Result<()> { let tx = conn.unchecked_transaction()?; for image_id in image_ids { tx.execute( @@ -1872,6 +1879,63 @@ pub fn bulk_remove_tag_by_name(conn: &Connection, image_ids: &[i64], tag: &str) Ok(()) } +// ── Color palettes (color search) ────────────────────────────────────────────── + +/// Replace an image's stored color palette. `colors` is `(r, g, b, weight)`. +pub fn replace_image_colors( + conn: &Connection, + image_id: i64, + colors: &[(u8, u8, u8, f32)], +) -> Result<()> { + conn.execute("DELETE FROM image_colors WHERE image_id = ?1", [image_id])?; + for (idx, (r, g, b, weight)) in colors.iter().enumerate() { + conn.execute( + "INSERT INTO image_colors (image_id, idx, r, g, b, weight) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + image_id, + idx as i64, + *r as i64, + *g as i64, + *b as i64, + *weight as f64 + ], + )?; + } + Ok(()) +} + +/// Images (with thumbnails) that have no stored palette yet — the backfill set. +/// Returns `(image_id, thumbnail_path)`. +pub fn get_images_missing_colors(conn: &Connection, limit: i64) -> Result> { + let mut stmt = conn.prepare( + "SELECT i.id, i.thumbnail_path + FROM images i + WHERE i.media_kind = 'image' + AND i.thumbnail_path IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM image_colors c WHERE c.image_id = i.id) + LIMIT ?1", + )?; + let rows = stmt.query_map([limit], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })?; + Ok(rows.collect::>>()?) +} + +/// Count of images still awaiting palette extraction (for backfill progress). +pub fn count_images_missing_colors(conn: &Connection) -> Result { + let count = conn.query_row( + "SELECT COUNT(*) + FROM images i + WHERE i.media_kind = 'image' + AND i.thumbnail_path IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM image_colors c WHERE c.image_id = i.id)", + [], + |row| row.get(0), + )?; + Ok(count) +} + pub fn repair_deferred_embedding_jobs(conn: &Connection) -> Result { let pattern = "No thumbnail available yet for%"; let repaired = conn.execute( @@ -1999,6 +2063,7 @@ pub fn get_images( rating_min: i64, embedding_failed_only: bool, tagging_failed_only: bool, + color: Option<(u8, u8, u8)>, sort: &str, offset: i64, limit: i64, @@ -2023,6 +2088,10 @@ pub fn get_images( let favorites_flag = i64::from(favorites_only); let embedding_failed_flag = i64::from(embedding_failed_only); let tagging_failed_flag = i64::from(tagging_failed_only); + let (color_flag, qr, qg, qb) = match color { + Some((r, g, b)) => (1i64, r as i64, g as i64, b as i64), + None => (0, 0, 0, 0), + }; let sql = format!( "SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, @@ -2037,6 +2106,12 @@ pub fn get_images( AND rating >= ?5 AND (?6 = 0 OR embedding_status = 'failed') AND (?7 = 0 OR ai_tagger_error IS NOT NULL) + AND (?10 = 0 OR EXISTS ( + SELECT 1 FROM image_colors c + WHERE c.image_id = images.id + AND c.weight >= ?15 + AND ((c.r - ?11)*(c.r - ?11) + (c.g - ?12)*(c.g - ?12) + (c.b - ?13)*(c.b - ?13)) <= ?14 + )) ORDER BY {order} LIMIT ?8 OFFSET ?9" ); @@ -2051,7 +2126,13 @@ pub fn get_images( embedding_failed_flag, tagging_failed_flag, limit, - offset + offset, + color_flag, + qr, + qg, + qb, + crate::color::MATCH_DISTANCE_SQ, + crate::color::MATCH_MIN_WEIGHT ], map_image_row, )?; @@ -2068,12 +2149,17 @@ pub fn count_images( rating_min: i64, embedding_failed_only: bool, tagging_failed_only: bool, + color: Option<(u8, u8, u8)>, ) -> Result { let search_pattern = search.map(|value| format!("%{value}%")); let favorites_flag = i64::from(favorites_only); let embedding_failed_flag = i64::from(embedding_failed_only); let tagging_failed_flag = i64::from(tagging_failed_only); + let (color_flag, qr, qg, qb) = match color { + Some((r, g, b)) => (1i64, r as i64, g as i64, b as i64), + None => (0, 0, 0, 0), + }; let count = conn.query_row( "SELECT COUNT(*) FROM images WHERE (?1 IS NULL OR folder_id = ?1) @@ -2082,7 +2168,13 @@ pub fn count_images( AND (?4 = 0 OR favorite = 1) AND rating >= ?5 AND (?6 = 0 OR embedding_status = 'failed') - AND (?7 = 0 OR ai_tagger_error IS NOT NULL)", + AND (?7 = 0 OR ai_tagger_error IS NOT NULL) + AND (?8 = 0 OR EXISTS ( + SELECT 1 FROM image_colors c + WHERE c.image_id = images.id + AND c.weight >= ?13 + AND ((c.r - ?9)*(c.r - ?9) + (c.g - ?10)*(c.g - ?10) + (c.b - ?11)*(c.b - ?11)) <= ?12 + ))", params![ folder_id, search_pattern, @@ -2090,7 +2182,13 @@ pub fn count_images( favorites_flag, rating_min, embedding_failed_flag, - tagging_failed_flag + tagging_failed_flag, + color_flag, + qr, + qg, + qb, + crate::color::MATCH_DISTANCE_SQ, + crate::color::MATCH_MIN_WEIGHT ], |row| row.get(0), )?; diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index 199fad8..789d34e 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -190,6 +190,13 @@ pub struct MediaUpdateBatch { pub images: Vec, } +#[derive(Clone, Serialize)] +pub struct ColorBackfillProgress { + pub processed: i64, + pub total: i64, + pub done: bool, +} + #[derive(Clone, Serialize)] pub struct MediaJobProgressEvent { pub progress: Vec, @@ -737,6 +744,13 @@ fn persist_thumbnail_results( width, height, )?); + + // Store the dominant-color palette sampled during resizing. + if let Some(thumb) = &generated { + if !thumb.palette.is_empty() { + db::replace_image_colors(&tx, image_id, &thumb.palette)?; + } + } } tx.commit()?; @@ -767,6 +781,76 @@ fn is_avif_path(path: &Path) -> bool { .is_some_and(|ext| ext.eq_ignore_ascii_case("avif")) } +/// One-shot background pass that samples a color palette from the (already +/// generated) thumbnails of images indexed before color search existed. New +/// images get their palette during thumbnail generation, so this only fills the +/// historical gap. Emits `color-backfill-progress` so the UI can show a count. +pub fn start_color_backfill(app: AppHandle, pool: DbPool) { + std::thread::spawn(move || { + let total = match pool.get() { + Ok(conn) => db::count_images_missing_colors(&conn).unwrap_or(0), + Err(_) => return, + }; + if total == 0 { + return; + } + log::info!("Color backfill: sampling palettes for {total} images."); + + let mut processed: i64 = 0; + loop { + let batch = match pool.get() { + Ok(conn) => db::get_images_missing_colors(&conn, 64).unwrap_or_default(), + Err(_) => break, + }; + if batch.is_empty() { + break; + } + + for (image_id, thumbnail_path) in batch { + let palette = crate::color::extract_palette_from_file( + Path::new(&thumbnail_path), + crate::color::PALETTE_SIZE, + ); + let colors: Vec<(u8, u8, u8, f32)> = match palette { + Some(colors) if !colors.is_empty() => colors + .into_iter() + .map(|c| (c.r, c.g, c.b, c.weight)) + .collect(), + // Unreadable thumbnail: store a zero-weight sentinel so the + // image isn't reprocessed forever (it just won't match). + _ => vec![(0, 0, 0, 0.0)], + }; + let _ = with_db_write_lock(|| { + let conn = pool.get()?; + db::replace_image_colors(&conn, image_id, &colors) + }); + processed += 1; + } + + let _ = app.emit( + "color-backfill-progress", + ColorBackfillProgress { + processed, + total, + done: false, + }, + ); + // Yield so the backfill stays in the background under active use. + std::thread::sleep(Duration::from_millis(15)); + } + + log::info!("Color backfill complete: {processed} images sampled."); + let _ = app.emit( + "color-backfill-progress", + ColorBackfillProgress { + processed, + total, + done: true, + }, + ); + }); +} + /// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if /// the queue was empty. fn process_metadata_batch( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 592202e..fe0cd94 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod captioner; +mod color; mod commands; mod db; mod embedder; @@ -122,6 +123,8 @@ pub fn run() { // Caption worker disabled — UI removed; keeping backend code intact for future use. // indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone()); indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone()); + // Backfill color palettes for images indexed before color search existed. + indexer::start_color_backfill(app.handle().clone(), pool.clone()); let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone(), thumb_dir.clone()); diff --git a/src-tauri/src/thumbnail.rs b/src-tauri/src/thumbnail.rs index cb45785..af29b9c 100644 --- a/src-tauri/src/thumbnail.rs +++ b/src-tauri/src/thumbnail.rs @@ -12,6 +12,9 @@ pub struct GeneratedThumbnail { pub path: PathBuf, pub width: Option, pub height: Option, + /// Dominant-color palette `(r, g, b, weight)` sampled while resizing. Empty + /// when the thumbnail already existed (the color backfill handles those). + pub palette: Vec<(u8, u8, u8, f32)>, } pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result { @@ -28,6 +31,7 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result Result Result n.toString(16).padStart(2, "0")).join("")}`; +} + +function fromHex(hex: string): Rgb { + const n = parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +export function ColorFilter() { + const colorFilter = useGalleryStore((state) => state.colorFilter); + const setColorFilter = useGalleryStore((state) => state.setColorFilter); + const colorBackfill = useGalleryStore((state) => state.colorBackfill); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const isActive = colorFilter !== null; + const isCustom = isActive && !SWATCHES.some((swatch) => rgbEquals(colorFilter, swatch.rgb)); + + // Collapse the panel when clicking elsewhere. + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false); + }; + window.addEventListener("pointerdown", onPointerDown); + return () => window.removeEventListener("pointerdown", onPointerDown); + }, [open]); + + return ( +
+ {/* Trigger — a single palette icon; shows the active color as a dot when a + filter is applied so the collapsed state still communicates it. */} + + + + + + {open ? ( + + {SWATCHES.map((swatch) => { + const active = rgbEquals(colorFilter, swatch.rgb); + return ( + + ) : null} + + {colorBackfill && colorBackfill.total > 0 ? ( + + sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()} + + ) : null} + + ) : null} + +
+ ); +} diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index d4ff5e4..5612157 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -3,6 +3,7 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { convertFileSrc } from "@tauri-apps/api/core"; import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store"; import { BulkActionBar } from "./BulkActionBar"; +import { Tooltip } from "./Tooltip"; const GAP = 6; @@ -125,6 +126,7 @@ export function ImageTile({ const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null; return ( + + ); } diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index cfd7e5a..1ebc005 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useGalleryStore } from "../store"; import { PhokusMark } from "./PhokusMark"; +import { Tooltip } from "./Tooltip"; // SVG icons for window controls function MinimizeIcon() { @@ -79,22 +80,18 @@ export function TitleBar() { up its focal point and the chip becomes a button that re-opens the prompt. */}
{updatePending ? ( -
- - {/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */} - - Click to update — v{updateVersion} - +
+ {/* Instant tooltip (delay 0) — this affordance should read immediately. */} + + +
) : (
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 73ab370..7eb4d2e 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; +import { ColorFilter } from "./ColorFilter"; const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ { value: "date_desc", label: "Newest first" }, @@ -478,6 +479,7 @@ export function Toolbar() { onClick={() => setFailedTaggingOnly(!failedTaggingOnly)} /> ) : null} + {isSimilarResults ? Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"} : null}
diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx new file mode 100644 index 0000000..a31d78f --- /dev/null +++ b/src/components/Tooltip.tsx @@ -0,0 +1,141 @@ +import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react"; +import { motion } from "framer-motion"; + +type TooltipSide = "top" | "bottom" | "left" | "right"; +type TooltipAlign = "center" | "start" | "end"; + +// Horizontal alignment only applies to top/bottom; left/right center vertically. +function positionClasses(side: TooltipSide, align: TooltipAlign): string { + if (side === "left") return "right-full top-1/2 mr-1.5 -translate-y-1/2"; + if (side === "right") return "left-full top-1/2 ml-1.5 -translate-y-1/2"; + const vertical = side === "top" ? "bottom-full mb-1.5" : "top-full mt-1.5"; + const horizontal = align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2"; + return `${vertical} ${horizontal}`; +} + +const BASE_CLASSES = + "pointer-events-none z-50 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 shadow-lg"; +const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`; + +/** + * Lightweight custom tooltip — fades in (faster than the native one) after a + * tunable hover `delay`, and hides instantly on leave or click. By default it + * anchors to a `side` of the wrapper; with `followCursor` it tracks the pointer + * (fixed-positioned, so it escapes scroll-container clipping). + */ +export function Tooltip({ + label, + delay = 400, + side = "bottom", + align = "center", + block = false, + followCursor = false, + className = "", + children, +}: { + label: ReactNode; + /** Milliseconds the pointer must hover before the tooltip appears (0 = instant). */ + delay?: number; + side?: TooltipSide; + /** Horizontal alignment for top/bottom tooltips. */ + align?: TooltipAlign; + /** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */ + block?: boolean; + /** Track the cursor (fixed position) instead of anchoring to a side. */ + followCursor?: boolean; + /** Extra classes for the wrapper (e.g. layout). */ + className?: string; + children: ReactNode; +}) { + const [visible, setVisible] = useState(false); + const [coords, setCoords] = useState({ x: 0, y: 0 }); + const timer = useRef | undefined>(undefined); + const frame = useRef(undefined); + const tooltipRef = useRef(null); + + // Resolve a viewport-safe position near the cursor: below-right by default, + // but flipped above the cursor if it would overflow the bottom edge, and + // clamped so it never runs off the right/left. + const place = (clientX: number, clientY: number) => { + const tip = tooltipRef.current; + const tipWidth = tip?.offsetWidth ?? 0; + const tipHeight = tip?.offsetHeight ?? 24; + const gap = 16; + let top = clientY + gap; + if (top + tipHeight > window.innerHeight - 4) { + top = clientY - gap - tipHeight; + } + let left = clientX + 14; + if (left + tipWidth > window.innerWidth - 4) left = window.innerWidth - 4 - tipWidth; + if (left < 4) left = 4; + setCoords({ x: left, y: top }); + }; + + const clear = () => { + if (timer.current) clearTimeout(timer.current); + timer.current = undefined; + if (frame.current !== undefined) cancelAnimationFrame(frame.current); + frame.current = undefined; + }; + const show = (event: MouseEvent) => { + clear(); + if (followCursor) place(event.clientX, event.clientY); + if (delay <= 0) { + setVisible(true); + return; + } + timer.current = setTimeout(() => setVisible(true), delay); + }; + const hide = () => { + clear(); + setVisible(false); + }; + const move = (event: MouseEvent) => { + if (!followCursor) return; + const { clientX, clientY } = event; + if (frame.current !== undefined) cancelAnimationFrame(frame.current); + frame.current = requestAnimationFrame(() => { + frame.current = undefined; + place(clientX, clientY); + }); + }; + + useEffect(() => clear, []); + + return ( + + {children} + {followCursor ? ( + + {label} + + ) : ( + + {label} + + )} + + ); +} diff --git a/src/store.ts b/src/store.ts index 3dbcbd7..dfae7b1 100644 --- a/src/store.ts +++ b/src/store.ts @@ -354,6 +354,8 @@ interface GalleryState { minimumRating: number; failedEmbeddingsOnly: boolean; failedTaggingOnly: boolean; + colorFilter: [number, number, number] | null; // [r,g,b] dominant-color filter + colorBackfill: { processed: number; total: number; done: boolean } | null; zoomPreset: ZoomPreset; selectedImage: ImageRecord | null; collectionTitle: string | null; @@ -469,6 +471,7 @@ interface GalleryState { setMinimumRating: (minimumRating: number) => void; setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void; setFailedTaggingOnly: (failedTaggingOnly: boolean) => void; + setColorFilter: (color: [number, number, number] | null) => void; showFailedTagging: (folderId: number) => void; setZoomPreset: (zoomPreset: ZoomPreset) => void; openImage: (image: ImageRecord) => void; @@ -834,6 +837,8 @@ export const useGalleryStore = create((set, get) => ({ minimumRating: 0, failedEmbeddingsOnly: false, failedTaggingOnly: false, + colorFilter: null, + colorBackfill: null, zoomPreset: "comfortable", selectedImage: null, collectionTitle: null, @@ -1062,7 +1067,7 @@ export const useGalleryStore = create((set, get) => ({ }, loadImages: async (reset = false) => { - const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, failedTaggingOnly, activeView } = get(); + const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, failedTaggingOnly, colorFilter, activeView } = get(); const parsedSearch = parseSearchValue(search); const requestToken = ++galleryRequestToken; // Any fresh collection load invalidates a selection that referenced the @@ -1176,6 +1181,7 @@ export const useGalleryStore = create((set, get) => ({ rating_min: minimumRating > 0 ? minimumRating : null, embedding_failed_only: failedEmbeddingsOnly, tagging_failed_only: failedTaggingOnly, + color: colorFilter, sort, offset, limit: activeView === "timeline" ? TIMELINE_PAGE_SIZE : PAGE_SIZE, @@ -1317,6 +1323,11 @@ export const useGalleryStore = create((set, get) => ({ void get().loadImages(true); }, + setColorFilter: (colorFilter) => { + set({ colorFilter, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); + void get().loadImages(true); + }, + showFailedTagging: (folderId) => { set({ selectedFolderId: folderId, @@ -2939,6 +2950,13 @@ export const useGalleryStore = create((set, get) => ({ } }); + const unlistenColorBackfill = await listen<{ processed: number; total: number; done: boolean }>( + "color-backfill-progress", + (event) => { + set({ colorBackfill: event.payload.done ? null : event.payload }); + }, + ); + return () => { unlistenProgress(); unlistenMediaJobs(); @@ -2949,6 +2967,7 @@ export const useGalleryStore = create((set, get) => ({ unlistenWatcherDeleted(); unlistenFolderCounts(); unlistenFfmpegProgress(); + unlistenColorBackfill(); }; }, }));