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:
@@ -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
|
||||||
|
|||||||
@@ -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<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;
|
||||||
@@ -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, ¶ms.album_ids).map_err(|e| e.to_string())
|
db::reorder_albums(&conn, ¶ms.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, ¶ms.album_ids).map_err(|e| e.to_string())
|
db::delete_albums(&conn, ¶ms.album_ids).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-8
@@ -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),
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user