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.
This commit is contained in:
2026-06-28 10:25:52 +01:00
parent e3fde46e91
commit 90dec3b212
13 changed files with 682 additions and 46 deletions
+3
View File
@@ -9,6 +9,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
### Added ### 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 - **Albums** — curate your own collections. A new Albums section in the sidebar
(with cover thumbnails, kept visually distinct from Libraries) lets you create, (with cover thumbnails, kept visually distinct from Libraries) lets you create,
rename, and open albums; albums can span multiple folders. Add images from the rename, and open albums; albums can span multiple folders. Add images from the
+91
View File
@@ -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.01.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<PaletteColor> {
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<u16, (u64, u64, u64, u64)> = 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<Vec<PaletteColor>> {
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;
+18 -3
View File
@@ -42,6 +42,9 @@ pub struct GetImagesParams {
pub rating_min: Option<i64>, pub rating_min: Option<i64>,
pub embedding_failed_only: Option<bool>, pub embedding_failed_only: Option<bool>,
pub tagging_failed_only: Option<bool>, pub tagging_failed_only: Option<bool>,
/// 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<String>, pub sort: Option<String>,
pub offset: Option<i64>, pub offset: Option<i64>,
pub limit: Option<i64>, pub limit: Option<i64>,
@@ -564,6 +567,7 @@ pub async fn get_images(
let rating_min = params.rating_min.unwrap_or(0); let rating_min = params.rating_min.unwrap_or(0);
let embedding_failed_only = params.embedding_failed_only.unwrap_or(false); let embedding_failed_only = params.embedding_failed_only.unwrap_or(false);
let tagging_failed_only = params.tagging_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( let total = db::count_images(
&conn, &conn,
@@ -574,6 +578,7 @@ pub async fn get_images(
rating_min, rating_min,
embedding_failed_only, embedding_failed_only,
tagging_failed_only, tagging_failed_only,
color,
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -586,6 +591,7 @@ pub async fn get_images(
rating_min, rating_min,
embedding_failed_only, embedding_failed_only,
tagging_failed_only, tagging_failed_only,
color,
sort, sort,
offset, offset,
limit, limit,
@@ -2268,7 +2274,10 @@ pub async fn list_albums(db: State<'_, DbState>) -> Result<Vec<Album>, String> {
} }
#[tauri::command] #[tauri::command]
pub async fn create_album(db: State<'_, DbState>, params: CreateAlbumParams) -> Result<Album, String> { pub async fn create_album(
db: State<'_, DbState>,
params: CreateAlbumParams,
) -> Result<Album, String> {
let conn = db.get().map_err(|e| e.to_string())?; let conn = db.get().map_err(|e| e.to_string())?;
let name = params.name.trim(); let name = params.name.trim();
if name.is_empty() { if name.is_empty() {
@@ -2294,13 +2303,19 @@ pub async fn delete_album(db: State<'_, DbState>, params: DeleteAlbumParams) ->
} }
#[tauri::command] #[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())?; let conn = db.get().map_err(|e| e.to_string())?;
db::reorder_albums(&conn, &params.album_ids).map_err(|e| e.to_string()) db::reorder_albums(&conn, &params.album_ids).map_err(|e| e.to_string())
} }
#[tauri::command] #[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())?; let conn = db.get().map_err(|e| e.to_string())?;
db::delete_albums(&conn, &params.album_ids).map_err(|e| e.to_string()) db::delete_albums(&conn, &params.album_ids).map_err(|e| e.to_string())
} }
+106 -8
View File
@@ -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_album ON album_images(album_id);
CREATE INDEX IF NOT EXISTS idx_album_images_image ON album_images(image_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) Ok(added)
} }
pub fn remove_images_from_album( pub fn remove_images_from_album(conn: &Connection, album_id: i64, image_ids: &[i64]) -> Result<()> {
conn: &Connection,
album_id: i64,
image_ids: &[i64],
) -> Result<()> {
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
for image_id in image_ids { for image_id in image_ids {
tx.execute( tx.execute(
@@ -1872,6 +1879,63 @@ pub fn bulk_remove_tag_by_name(conn: &Connection, image_ids: &[i64], tag: &str)
Ok(()) 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<Vec<(i64, String)>> {
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::<rusqlite::Result<Vec<_>>>()?)
}
/// Count of images still awaiting palette extraction (for backfill progress).
pub fn count_images_missing_colors(conn: &Connection) -> Result<i64> {
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<usize> { pub fn repair_deferred_embedding_jobs(conn: &Connection) -> Result<usize> {
let pattern = "No thumbnail available yet for%"; let pattern = "No thumbnail available yet for%";
let repaired = conn.execute( let repaired = conn.execute(
@@ -1999,6 +2063,7 @@ pub fn get_images(
rating_min: i64, rating_min: i64,
embedding_failed_only: bool, embedding_failed_only: bool,
tagging_failed_only: bool, tagging_failed_only: bool,
color: Option<(u8, u8, u8)>,
sort: &str, sort: &str,
offset: i64, offset: i64,
limit: i64, limit: i64,
@@ -2023,6 +2088,10 @@ pub fn get_images(
let favorites_flag = i64::from(favorites_only); let favorites_flag = i64::from(favorites_only);
let embedding_failed_flag = i64::from(embedding_failed_only); let embedding_failed_flag = i64::from(embedding_failed_only);
let tagging_failed_flag = i64::from(tagging_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!( let sql = format!(
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type, "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, 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 rating >= ?5
AND (?6 = 0 OR embedding_status = 'failed') 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 (?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} ORDER BY {order}
LIMIT ?8 OFFSET ?9" LIMIT ?8 OFFSET ?9"
); );
@@ -2051,7 +2126,13 @@ pub fn get_images(
embedding_failed_flag, embedding_failed_flag,
tagging_failed_flag, tagging_failed_flag,
limit, limit,
offset offset,
color_flag,
qr,
qg,
qb,
crate::color::MATCH_DISTANCE_SQ,
crate::color::MATCH_MIN_WEIGHT
], ],
map_image_row, map_image_row,
)?; )?;
@@ -2068,12 +2149,17 @@ pub fn count_images(
rating_min: i64, rating_min: i64,
embedding_failed_only: bool, embedding_failed_only: bool,
tagging_failed_only: bool, tagging_failed_only: bool,
color: Option<(u8, u8, u8)>,
) -> Result<i64> { ) -> Result<i64> {
let search_pattern = search.map(|value| format!("%{value}%")); let search_pattern = search.map(|value| format!("%{value}%"));
let favorites_flag = i64::from(favorites_only); let favorites_flag = i64::from(favorites_only);
let embedding_failed_flag = i64::from(embedding_failed_only); let embedding_failed_flag = i64::from(embedding_failed_only);
let tagging_failed_flag = i64::from(tagging_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( let count = conn.query_row(
"SELECT COUNT(*) FROM images "SELECT COUNT(*) FROM images
WHERE (?1 IS NULL OR folder_id = ?1) WHERE (?1 IS NULL OR folder_id = ?1)
@@ -2082,7 +2168,13 @@ pub fn count_images(
AND (?4 = 0 OR favorite = 1) AND (?4 = 0 OR favorite = 1)
AND rating >= ?5 AND rating >= ?5
AND (?6 = 0 OR embedding_status = 'failed') 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![ params![
folder_id, folder_id,
search_pattern, search_pattern,
@@ -2090,7 +2182,13 @@ pub fn count_images(
favorites_flag, favorites_flag,
rating_min, rating_min,
embedding_failed_flag, 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), |row| row.get(0),
)?; )?;
+84
View File
@@ -190,6 +190,13 @@ pub struct MediaUpdateBatch {
pub images: Vec<ImageRecord>, pub images: Vec<ImageRecord>,
} }
#[derive(Clone, Serialize)]
pub struct ColorBackfillProgress {
pub processed: i64,
pub total: i64,
pub done: bool,
}
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
pub struct MediaJobProgressEvent { pub struct MediaJobProgressEvent {
pub progress: Vec<FolderJobProgress>, pub progress: Vec<FolderJobProgress>,
@@ -737,6 +744,13 @@ fn persist_thumbnail_results(
width, width,
height, 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()?; tx.commit()?;
@@ -767,6 +781,76 @@ fn is_avif_path(path: &Path) -> bool {
.is_some_and(|ext| ext.eq_ignore_ascii_case("avif")) .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 /// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if
/// the queue was empty. /// the queue was empty.
fn process_metadata_batch( fn process_metadata_batch(
+3
View File
@@ -1,4 +1,5 @@
mod captioner; mod captioner;
mod color;
mod commands; mod commands;
mod db; mod db;
mod embedder; mod embedder;
@@ -122,6 +123,8 @@ pub fn run() {
// Caption worker disabled — UI removed; keeping backend code intact for future use. // 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_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone());
indexer::start_tagging_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()); let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone(), thumb_dir.clone());
+23
View File
@@ -12,6 +12,9 @@ pub struct GeneratedThumbnail {
pub path: PathBuf, pub path: PathBuf,
pub width: Option<i64>, pub width: Option<i64>,
pub height: Option<i64>, pub height: Option<i64>,
/// 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<GeneratedThumbnail> { pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<GeneratedThumbnail> {
@@ -28,6 +31,7 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
path: out_path, path: out_path,
width: original_dimensions.0, width: original_dimensions.0,
height: original_dimensions.1, height: original_dimensions.1,
palette: Vec::new(),
}); });
} }
@@ -47,6 +51,12 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
let thumb = image::RgbImage::from_raw(dst_width, dst_height, dst.buffer().to_vec()) let thumb = image::RgbImage::from_raw(dst_width, dst_height, dst.buffer().to_vec())
.ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?; .ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?;
// Sample the dominant-color palette from the resized buffer before encoding.
let palette = crate::color::extract_palette(&thumb, crate::color::PALETTE_SIZE)
.into_iter()
.map(|color| (color.r, color.g, color.b, color.weight))
.collect();
if let Some(parent) = out_path.parent() { if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
@@ -58,6 +68,7 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
path: out_path, path: out_path,
width: original_dimensions.0, width: original_dimensions.0,
height: original_dimensions.1, height: original_dimensions.1,
palette,
}) })
} }
@@ -166,6 +177,7 @@ pub fn generate_video_thumbnail(
path: out_path, path: out_path,
width: None, width: None,
height: None, height: None,
palette: Vec::new(),
}); });
} }
@@ -231,6 +243,7 @@ pub fn generate_video_thumbnail(
path: out_path, path: out_path,
width: None, width: None,
height: None, height: None,
palette: Vec::new(),
}); });
} }
last_error = String::from_utf8_lossy(&output.stderr).to_string(); last_error = String::from_utf8_lossy(&output.stderr).to_string();
@@ -255,6 +268,7 @@ pub fn generate_avif_thumbnail(
path: out_path, path: out_path,
width: original_dimensions.0, width: original_dimensions.0,
height: original_dimensions.1, height: original_dimensions.1,
palette: Vec::new(),
}); });
} }
@@ -282,10 +296,19 @@ pub fn generate_avif_thumbnail(
.output()?; .output()?;
if output.status.success() && out_path.exists() { if output.status.success() && out_path.exists() {
// AVIF is decoded by FFmpeg, so there's no in-memory RGB buffer here;
// sample the palette by reading the JPEG thumbnail we just wrote.
let palette =
crate::color::extract_palette_from_file(&out_path, crate::color::PALETTE_SIZE)
.unwrap_or_default()
.into_iter()
.map(|color| (color.r, color.g, color.b, color.weight))
.collect();
return Ok(GeneratedThumbnail { return Ok(GeneratedThumbnail {
path: out_path, path: out_path,
width: original_dimensions.0, width: original_dimensions.0,
height: original_dimensions.1, height: original_dimensions.1,
palette,
}); });
} }
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
type Rgb = [number, number, number];
// Representative colors for the quick-pick swatches. Each is just an RGB the
// distance filter matches against — not a hard bucket.
const SWATCHES: { name: string; rgb: Rgb }[] = [
{ name: "Red", rgb: [226, 59, 59] },
{ name: "Orange", rgb: [232, 134, 46] },
{ name: "Yellow", rgb: [242, 207, 46] },
{ name: "Green", rgb: [76, 175, 80] },
{ name: "Teal", rgb: [31, 182, 166] },
{ name: "Blue", rgb: [59, 125, 216] },
{ name: "Purple", rgb: [139, 92, 246] },
{ name: "Pink", rgb: [236, 72, 153] },
{ name: "Brown", rgb: [139, 90, 43] },
{ name: "Black", rgb: [26, 26, 26] },
{ name: "White", rgb: [245, 245, 245] },
{ name: "Gray", rgb: [154, 160, 166] },
];
function rgbEquals(a: Rgb | null, b: Rgb): boolean {
return a !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
}
function toHex([r, g, b]: Rgb): string {
return `#${[r, g, b].map((n) => 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<HTMLDivElement>(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 (
<div ref={ref} className="ml-1 flex items-center border-l border-white/[0.06] pl-2">
{/* Trigger — a single palette icon; shows the active color as a dot when a
filter is applied so the collapsed state still communicates it. */}
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}>
<button
className={`relative flex items-center gap-1.5 rounded-lg px-2 py-1.5 transition-colors ${
open || isActive ? "bg-white/10 text-white" : "text-gray-500 hover:bg-white/5 hover:text-gray-200"
}`}
onClick={() => setOpen((value) => !value)}
aria-label="Filter by color"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6}
d="M12 3a9 9 0 100 18c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.39-.61-.39-1 0-.83.67-1.5 1.5-1.5H16a5 5 0 005-5c0-4.42-4.03-8-9-8z" />
<circle cx="7.5" cy="11.5" r="1" fill="currentColor" stroke="none" />
<circle cx="11.5" cy="7.5" r="1" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="9.5" r="1" fill="currentColor" stroke="none" />
</svg>
{isActive ? (
<span
className="h-3 w-3 rounded-full border border-white/30"
style={{ backgroundColor: toHex(colorFilter as Rgb) }}
/>
) : null}
</button>
</Tooltip>
<AnimatePresence initial={false}>
{open ? (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: "auto", opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
// Horizontal/vertical padding gives the active swatch's ring + scale
// room inside the overflow-hidden clip box (needed for the width slide).
// py-1 keeps the panel shorter than the always-present filter pills so
// opening it never changes the row height (no 1px toolbar shift).
className="flex items-center gap-1 overflow-hidden whitespace-nowrap px-1.5 py-1"
>
{SWATCHES.map((swatch) => {
const active = rgbEquals(colorFilter, swatch.rgb);
return (
<button
key={swatch.name}
title={swatch.name}
aria-label={`Filter by ${swatch.name}`}
className={`h-4 w-4 shrink-0 rounded-full border transition-transform ${
active ? "scale-110 border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
}`}
style={{ backgroundColor: toHex(swatch.rgb) }}
onClick={() => setColorFilter(active ? null : swatch.rgb)}
/>
);
})}
{/* Custom color picker — rainbow until a custom color is chosen. */}
<label
title="Custom color"
className={`relative h-4 w-4 shrink-0 cursor-pointer overflow-hidden rounded-full border ${
isCustom ? "border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
}`}
style={
isCustom
? { backgroundColor: toHex(colorFilter as Rgb) }
: { background: "conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)" }
}
>
<input
type="color"
className="absolute inset-0 cursor-pointer opacity-0"
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"}
onChange={(event) => setColorFilter(fromHex(event.target.value))}
/>
</label>
{isActive ? (
<button
className="ml-0.5 shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200"
onClick={() => setColorFilter(null)}
title="Clear color filter"
>
Clear
</button>
) : null}
{colorBackfill && colorBackfill.total > 0 ? (
<span
className="ml-0.5 shrink-0 text-[10px] text-gray-600"
title="Sampling colors from existing thumbnails — color search fills in as this runs"
>
sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()}
</span>
) : null}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
+3 -1
View File
@@ -3,6 +3,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store"; import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
import { BulkActionBar } from "./BulkActionBar"; import { BulkActionBar } from "./BulkActionBar";
import { Tooltip } from "./Tooltip";
const GAP = 6; const GAP = 6;
@@ -125,6 +126,7 @@ export function ImageTile({
const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null; const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null;
return ( return (
<Tooltip label={image.filename} delay={500} block followCursor>
<button <button
className={`media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none transition-shadow ${ className={`media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none transition-shadow ${
selected ? "ring-2 ring-inset ring-blue-400/80" : "" selected ? "ring-2 ring-inset ring-blue-400/80" : ""
@@ -141,7 +143,6 @@ export function ImageTile({
onClick(); onClick();
}} }}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
title={image.filename}
> >
{/* Selection corner — a top-left zone that reveals the checkbox only when {/* Selection corner — a top-left zone that reveals the checkbox only when
hovered (not the whole tile) and toggles selection on click. The hovered (not the whole tile) and toggles selection on click. The
@@ -281,6 +282,7 @@ export function ImageTile({
</div> </div>
</div> </div>
</button> </button>
</Tooltip>
); );
} }
+5 -8
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { useGalleryStore } from "../store"; import { useGalleryStore } from "../store";
import { PhokusMark } from "./PhokusMark"; import { PhokusMark } from "./PhokusMark";
import { Tooltip } from "./Tooltip";
// SVG icons for window controls // SVG icons for window controls
function MinimizeIcon() { function MinimizeIcon() {
@@ -79,10 +80,9 @@ export function TitleBar() {
up its focal point and the chip becomes a button that re-opens the prompt. */} up its focal point and the chip becomes a button that re-opens the prompt. */}
<div className="flex items-center gap-2 pl-3 pr-4"> <div className="flex items-center gap-2 pl-3 pr-4">
{updatePending ? ( {updatePending ? (
<div <div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}>
className="group relative" {/* Instant tooltip (delay 0) — this affordance should read immediately. */}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} <Tooltip label={`Click to update — v${updateVersion}`} delay={0} align="start">
>
<button <button
onClick={() => void installUpdate()} onClick={() => void installUpdate()}
aria-label={`Update available — click to update to Phokus v${updateVersion}`} aria-label={`Update available — click to update to Phokus v${updateVersion}`}
@@ -91,10 +91,7 @@ export function TitleBar() {
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" /> <span className="pointer-events-none absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" />
<PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" /> <PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" />
</button> </button>
{/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */} </Tooltip>
<span className="pointer-events-none absolute left-0 top-full z-50 mt-1.5 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 opacity-0 shadow-lg transition-opacity duration-100 group-hover:opacity-100">
Click to update v{updateVersion}
</span>
</div> </div>
) : ( ) : (
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300"> <div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300">
+2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store"; import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ColorFilter } from "./ColorFilter";
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" }, { value: "date_desc", label: "Newest first" },
@@ -478,6 +479,7 @@ export function Toolbar() {
onClick={() => setFailedTaggingOnly(!failedTaggingOnly)} onClick={() => setFailedTaggingOnly(!failedTaggingOnly)}
/> />
) : null} ) : null}
<ColorFilter />
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"}</span> : null} {isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
</div> </div>
</div> </div>
+141
View File
@@ -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<ReturnType<typeof setTimeout> | undefined>(undefined);
const frame = useRef<number | undefined>(undefined);
const tooltipRef = useRef<HTMLSpanElement>(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<HTMLSpanElement>) => {
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<HTMLSpanElement>) => {
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 (
<span
className={`${block ? "relative block w-full" : "relative inline-flex"} ${className}`}
onMouseEnter={show}
onMouseMove={followCursor ? move : undefined}
onMouseLeave={hide}
onPointerDown={hide}
>
{children}
{followCursor ? (
<motion.span
ref={tooltipRef}
role="tooltip"
// Fixed (so the scroll container's overflow doesn't clip it) and
// positioned by `place()` near the cursor with viewport-edge flipping.
className={`fixed ${BASE_CLASSES}`}
initial={false}
animate={{ left: coords.x, top: coords.y, opacity: visible ? 1 : 0 }}
transition={{
left: { type: "spring", stiffness: 700, damping: 45, mass: 0.35 },
top: { type: "spring", stiffness: 520, damping: 34, mass: 0.45 },
opacity: { duration: visible ? 0.1 : 0.05 },
}}
>
{label}
</motion.span>
) : (
<span
role="tooltip"
className={`absolute ${STATIC_CLASSES} ${positionClasses(side, align)} ${visible ? "opacity-100" : "opacity-0"}`}
>
{label}
</span>
)}
</span>
);
}
+20 -1
View File
@@ -354,6 +354,8 @@ interface GalleryState {
minimumRating: number; minimumRating: number;
failedEmbeddingsOnly: boolean; failedEmbeddingsOnly: boolean;
failedTaggingOnly: boolean; failedTaggingOnly: boolean;
colorFilter: [number, number, number] | null; // [r,g,b] dominant-color filter
colorBackfill: { processed: number; total: number; done: boolean } | null;
zoomPreset: ZoomPreset; zoomPreset: ZoomPreset;
selectedImage: ImageRecord | null; selectedImage: ImageRecord | null;
collectionTitle: string | null; collectionTitle: string | null;
@@ -469,6 +471,7 @@ interface GalleryState {
setMinimumRating: (minimumRating: number) => void; setMinimumRating: (minimumRating: number) => void;
setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void; setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void;
setFailedTaggingOnly: (failedTaggingOnly: boolean) => void; setFailedTaggingOnly: (failedTaggingOnly: boolean) => void;
setColorFilter: (color: [number, number, number] | null) => void;
showFailedTagging: (folderId: number) => void; showFailedTagging: (folderId: number) => void;
setZoomPreset: (zoomPreset: ZoomPreset) => void; setZoomPreset: (zoomPreset: ZoomPreset) => void;
openImage: (image: ImageRecord) => void; openImage: (image: ImageRecord) => void;
@@ -834,6 +837,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
minimumRating: 0, minimumRating: 0,
failedEmbeddingsOnly: false, failedEmbeddingsOnly: false,
failedTaggingOnly: false, failedTaggingOnly: false,
colorFilter: null,
colorBackfill: null,
zoomPreset: "comfortable", zoomPreset: "comfortable",
selectedImage: null, selectedImage: null,
collectionTitle: null, collectionTitle: null,
@@ -1062,7 +1067,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}, },
loadImages: async (reset = false) => { 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 parsedSearch = parseSearchValue(search);
const requestToken = ++galleryRequestToken; const requestToken = ++galleryRequestToken;
// Any fresh collection load invalidates a selection that referenced the // Any fresh collection load invalidates a selection that referenced the
@@ -1176,6 +1181,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
rating_min: minimumRating > 0 ? minimumRating : null, rating_min: minimumRating > 0 ? minimumRating : null,
embedding_failed_only: failedEmbeddingsOnly, embedding_failed_only: failedEmbeddingsOnly,
tagging_failed_only: failedTaggingOnly, tagging_failed_only: failedTaggingOnly,
color: colorFilter,
sort, sort,
offset, offset,
limit: activeView === "timeline" ? TIMELINE_PAGE_SIZE : PAGE_SIZE, limit: activeView === "timeline" ? TIMELINE_PAGE_SIZE : PAGE_SIZE,
@@ -1317,6 +1323,11 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void get().loadImages(true); 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) => { showFailedTagging: (folderId) => {
set({ set({
selectedFolderId: folderId, selectedFolderId: folderId,
@@ -2939,6 +2950,13 @@ export const useGalleryStore = create<GalleryState>((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 () => { return () => {
unlistenProgress(); unlistenProgress();
unlistenMediaJobs(); unlistenMediaJobs();
@@ -2949,6 +2967,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
unlistenWatcherDeleted(); unlistenWatcherDeleted();
unlistenFolderCounts(); unlistenFolderCounts();
unlistenFfmpegProgress(); unlistenFfmpegProgress();
unlistenColorBackfill();
}; };
}, },
})); }));