schenesort 2.3.0__py3-none-any.whl → 2.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schenesort/cli.py +128 -7
- schenesort/thumbnails.py +127 -0
- schenesort/tui/__init__.py +11 -1
- schenesort/tui/grid_app.py +302 -0
- schenesort/tui/widgets/filter_panel.py +249 -0
- schenesort/tui/widgets/thumbnail_grid.py +291 -0
- {schenesort-2.3.0.dist-info → schenesort-2.4.1.dist-info}/METADATA +87 -14
- schenesort-2.4.1.dist-info/RECORD +19 -0
- schenesort-2.3.0.dist-info/RECORD +0 -15
- {schenesort-2.3.0.dist-info → schenesort-2.4.1.dist-info}/WHEEL +0 -0
- {schenesort-2.3.0.dist-info → schenesort-2.4.1.dist-info}/entry_points.txt +0 -0
- {schenesort-2.3.0.dist-info → schenesort-2.4.1.dist-info}/licenses/LICENSE +0 -0
schenesort/cli.py
CHANGED
|
@@ -412,6 +412,85 @@ def index(
|
|
|
412
412
|
typer.echo(f"With metadata: {stats.get('with_metadata', 0)}")
|
|
413
413
|
|
|
414
414
|
|
|
415
|
+
@app.command()
|
|
416
|
+
def thumbnail(
|
|
417
|
+
path: Annotated[Path, typer.Argument(help="Directory to generate thumbnails for")],
|
|
418
|
+
recursive: Annotated[
|
|
419
|
+
bool, typer.Option("--recursive", "-r", help="Process directories recursively")
|
|
420
|
+
] = True,
|
|
421
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Regenerate all thumbnails")] = False,
|
|
422
|
+
clear: Annotated[
|
|
423
|
+
bool, typer.Option("--clear", help="Clear thumbnail cache before generating")
|
|
424
|
+
] = False,
|
|
425
|
+
) -> None:
|
|
426
|
+
"""Generate thumbnails for gallery view."""
|
|
427
|
+
from rich.progress import BarColumn, Progress, TaskProgressColumn, TextColumn
|
|
428
|
+
|
|
429
|
+
from schenesort.thumbnails import (
|
|
430
|
+
clear_cache,
|
|
431
|
+
generate_thumbnail,
|
|
432
|
+
get_cache_stats,
|
|
433
|
+
thumbnail_exists,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
path = path.resolve()
|
|
437
|
+
|
|
438
|
+
if not path.exists():
|
|
439
|
+
typer.echo(f"Error: Path '{path}' does not exist.", err=True)
|
|
440
|
+
raise typer.Exit(1)
|
|
441
|
+
|
|
442
|
+
if not path.is_dir():
|
|
443
|
+
typer.echo(f"Error: Path '{path}' is not a directory.", err=True)
|
|
444
|
+
raise typer.Exit(1)
|
|
445
|
+
|
|
446
|
+
if clear:
|
|
447
|
+
cleared = clear_cache()
|
|
448
|
+
typer.echo(f"Cleared {cleared} cached thumbnail(s).")
|
|
449
|
+
|
|
450
|
+
pattern = "**/*" if recursive else "*"
|
|
451
|
+
image_files = [
|
|
452
|
+
f for f in path.glob(pattern) if f.is_file() and f.suffix.lower() in VALID_IMAGE_EXTENSIONS
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
if not image_files:
|
|
456
|
+
typer.echo("No image files found.")
|
|
457
|
+
raise typer.Exit(0)
|
|
458
|
+
|
|
459
|
+
generated = 0
|
|
460
|
+
skipped = 0
|
|
461
|
+
failed = 0
|
|
462
|
+
|
|
463
|
+
with Progress(
|
|
464
|
+
TextColumn("[progress.description]{task.description}"),
|
|
465
|
+
BarColumn(),
|
|
466
|
+
TaskProgressColumn(),
|
|
467
|
+
TextColumn("{task.completed}/{task.total}"),
|
|
468
|
+
) as progress:
|
|
469
|
+
task = progress.add_task("Generating thumbnails", total=len(image_files))
|
|
470
|
+
|
|
471
|
+
for filepath in image_files:
|
|
472
|
+
if not force and thumbnail_exists(filepath):
|
|
473
|
+
skipped += 1
|
|
474
|
+
progress.advance(task)
|
|
475
|
+
continue
|
|
476
|
+
|
|
477
|
+
result = generate_thumbnail(filepath, force=force)
|
|
478
|
+
if result:
|
|
479
|
+
generated += 1
|
|
480
|
+
else:
|
|
481
|
+
failed += 1
|
|
482
|
+
|
|
483
|
+
progress.advance(task)
|
|
484
|
+
|
|
485
|
+
typer.echo(f"\nGenerated: {generated}, Skipped: {skipped}, Failed: {failed}")
|
|
486
|
+
|
|
487
|
+
# Show cache stats
|
|
488
|
+
stats = get_cache_stats()
|
|
489
|
+
typer.echo(f"\nCache: {stats['path']}")
|
|
490
|
+
typer.echo(f"Total thumbnails: {stats['count']}")
|
|
491
|
+
typer.echo(f"Cache size: {stats['size_mb']:.1f} MB")
|
|
492
|
+
|
|
493
|
+
|
|
415
494
|
@app.command()
|
|
416
495
|
def get(
|
|
417
496
|
tag: Annotated[str | None, typer.Option("--tag", "-t", help="Filter by tag")] = None,
|
|
@@ -581,6 +660,48 @@ def browse(
|
|
|
581
660
|
app_instance.run()
|
|
582
661
|
|
|
583
662
|
|
|
663
|
+
@app.command()
|
|
664
|
+
def gallery(
|
|
665
|
+
tag: Annotated[str | None, typer.Option("--tag", "-t", help="Filter by tag")] = None,
|
|
666
|
+
mood: Annotated[str | None, typer.Option("--mood", "-m", help="Filter by mood")] = None,
|
|
667
|
+
color: Annotated[str | None, typer.Option("--color", "-c", help="Filter by color")] = None,
|
|
668
|
+
style: Annotated[str | None, typer.Option("--style", "-s", help="Filter by style")] = None,
|
|
669
|
+
subject: Annotated[str | None, typer.Option("--subject", help="Filter by subject")] = None,
|
|
670
|
+
time: Annotated[str | None, typer.Option("--time", help="Filter by time of day")] = None,
|
|
671
|
+
screen: Annotated[
|
|
672
|
+
str | None, typer.Option("--screen", help="Filter by recommended screen (4K, 1440p, etc)")
|
|
673
|
+
] = None,
|
|
674
|
+
min_width: Annotated[
|
|
675
|
+
int | None, typer.Option("--min-width", help="Minimum width in pixels")
|
|
676
|
+
] = None,
|
|
677
|
+
min_height: Annotated[
|
|
678
|
+
int | None, typer.Option("--min-height", help="Minimum height in pixels")
|
|
679
|
+
] = None,
|
|
680
|
+
search: Annotated[
|
|
681
|
+
str | None, typer.Option("--search", "-q", help="Search description, scene, style, subject")
|
|
682
|
+
] = None,
|
|
683
|
+
) -> None:
|
|
684
|
+
"""Browse wallpapers in a thumbnail grid with filter sidebar."""
|
|
685
|
+
from schenesort.tui.grid_app import GridBrowser
|
|
686
|
+
from schenesort.tui.widgets.filter_panel import FilterValues
|
|
687
|
+
|
|
688
|
+
initial_filters = FilterValues(
|
|
689
|
+
search=search or "",
|
|
690
|
+
tag=tag or "",
|
|
691
|
+
mood=mood or "",
|
|
692
|
+
color=color or "",
|
|
693
|
+
style=style or "",
|
|
694
|
+
subject=subject or "",
|
|
695
|
+
time=time or "",
|
|
696
|
+
screen=screen or "",
|
|
697
|
+
min_width=min_width,
|
|
698
|
+
min_height=min_height,
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
app_instance = GridBrowser(initial_filters=initial_filters)
|
|
702
|
+
app_instance.run()
|
|
703
|
+
|
|
704
|
+
|
|
584
705
|
@app.command()
|
|
585
706
|
def config(
|
|
586
707
|
create: Annotated[
|
|
@@ -1246,8 +1367,8 @@ def collage(
|
|
|
1246
1367
|
search: Annotated[
|
|
1247
1368
|
str | None, typer.Option("--search", "-q", help="Search description, scene, style, subject")
|
|
1248
1369
|
] = None,
|
|
1249
|
-
cols: Annotated[int, typer.Option("--cols", help="Number of columns
|
|
1250
|
-
rows: Annotated[int, typer.Option("--rows", help="Number of rows
|
|
1370
|
+
cols: Annotated[int, typer.Option("--cols", help="Number of columns")] = 6,
|
|
1371
|
+
rows: Annotated[int, typer.Option("--rows", help="Number of rows")] = 6,
|
|
1251
1372
|
tile_width: Annotated[
|
|
1252
1373
|
int, typer.Option("--tile-width", "-w", help="Width of each tile in pixels")
|
|
1253
1374
|
] = 480,
|
|
@@ -1256,17 +1377,17 @@ def collage(
|
|
|
1256
1377
|
] = 270,
|
|
1257
1378
|
random: Annotated[bool, typer.Option("--random", "-R", help="Select images randomly")] = True,
|
|
1258
1379
|
) -> None:
|
|
1259
|
-
"""Create a collage of wallpapers matching the given criteria
|
|
1380
|
+
"""Create a collage of wallpapers matching the given criteria."""
|
|
1260
1381
|
from PIL import Image
|
|
1261
1382
|
|
|
1262
1383
|
from schenesort.db import WallpaperDB
|
|
1263
1384
|
|
|
1264
1385
|
# Validate grid size
|
|
1265
|
-
if cols < 1
|
|
1266
|
-
typer.echo("Error: --cols must be
|
|
1386
|
+
if cols < 1:
|
|
1387
|
+
typer.echo("Error: --cols must be at least 1.", err=True)
|
|
1267
1388
|
raise typer.Exit(1)
|
|
1268
|
-
if rows < 1
|
|
1269
|
-
typer.echo("Error: --rows must be
|
|
1389
|
+
if rows < 1:
|
|
1390
|
+
typer.echo("Error: --rows must be at least 1.", err=True)
|
|
1270
1391
|
raise typer.Exit(1)
|
|
1271
1392
|
|
|
1272
1393
|
num_images = cols * rows
|
schenesort/thumbnails.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Thumbnail cache management for gallery view."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
APP_NAME = "schenesort"
|
|
10
|
+
|
|
11
|
+
# Thumbnail dimensions - larger for better quality when rendered in terminal
|
|
12
|
+
# Terminal cells are ~32x14 chars, but textual-image benefits from more source pixels
|
|
13
|
+
THUMBNAIL_WIDTH = 320
|
|
14
|
+
THUMBNAIL_HEIGHT = 200
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_cache_dir() -> Path:
|
|
18
|
+
"""Get the XDG cache directory for schenesort thumbnails."""
|
|
19
|
+
xdg_cache = os.environ.get("XDG_CACHE_HOME", "")
|
|
20
|
+
if xdg_cache:
|
|
21
|
+
cache_dir = Path(xdg_cache) / APP_NAME / "thumbnails"
|
|
22
|
+
else:
|
|
23
|
+
cache_dir = Path.home() / ".cache" / APP_NAME / "thumbnails"
|
|
24
|
+
return cache_dir
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_thumbnail_path(image_path: Path) -> Path:
|
|
28
|
+
"""Get the thumbnail path for an image.
|
|
29
|
+
|
|
30
|
+
Uses MD5 hash of the absolute path to create a unique filename.
|
|
31
|
+
"""
|
|
32
|
+
# Use absolute path for consistent hashing
|
|
33
|
+
abs_path = str(image_path.resolve())
|
|
34
|
+
path_hash = hashlib.md5(abs_path.encode()).hexdigest()
|
|
35
|
+
return get_cache_dir() / f"{path_hash}.jpg"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def thumbnail_exists(image_path: Path) -> bool:
|
|
39
|
+
"""Check if a thumbnail exists for the given image."""
|
|
40
|
+
thumb_path = get_thumbnail_path(image_path)
|
|
41
|
+
if not thumb_path.exists():
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
# Check if thumbnail is newer than original
|
|
45
|
+
try:
|
|
46
|
+
orig_mtime = image_path.stat().st_mtime
|
|
47
|
+
thumb_mtime = thumb_path.stat().st_mtime
|
|
48
|
+
return thumb_mtime >= orig_mtime
|
|
49
|
+
except OSError:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def generate_thumbnail(image_path: Path, force: bool = False) -> Path | None:
|
|
54
|
+
"""Generate a thumbnail for the given image.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
image_path: Path to the original image
|
|
58
|
+
force: If True, regenerate even if thumbnail exists
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Path to the thumbnail, or None if generation failed
|
|
62
|
+
"""
|
|
63
|
+
if not force and thumbnail_exists(image_path):
|
|
64
|
+
return get_thumbnail_path(image_path)
|
|
65
|
+
|
|
66
|
+
thumb_path = get_thumbnail_path(image_path)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
# Ensure cache directory exists
|
|
70
|
+
thumb_path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
|
|
72
|
+
with Image.open(image_path) as img:
|
|
73
|
+
# Convert to RGB if necessary (handles RGBA, palette, etc.)
|
|
74
|
+
if img.mode not in ("RGB", "L"):
|
|
75
|
+
img = img.convert("RGB")
|
|
76
|
+
|
|
77
|
+
# Calculate size preserving aspect ratio
|
|
78
|
+
img.thumbnail((THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT), Image.Resampling.LANCZOS)
|
|
79
|
+
|
|
80
|
+
# Save as JPEG with high quality for better terminal rendering
|
|
81
|
+
img.save(thumb_path, "JPEG", quality=95, optimize=True)
|
|
82
|
+
|
|
83
|
+
return thumb_path
|
|
84
|
+
|
|
85
|
+
except Exception:
|
|
86
|
+
# Clean up partial file if it exists
|
|
87
|
+
if thumb_path.exists():
|
|
88
|
+
thumb_path.unlink()
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def clear_cache() -> int:
|
|
93
|
+
"""Clear all cached thumbnails.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Number of thumbnails deleted
|
|
97
|
+
"""
|
|
98
|
+
cache_dir = get_cache_dir()
|
|
99
|
+
if not cache_dir.exists():
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
count = 0
|
|
103
|
+
for thumb in cache_dir.glob("*.jpg"):
|
|
104
|
+
try:
|
|
105
|
+
thumb.unlink()
|
|
106
|
+
count += 1
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
return count
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_cache_stats() -> dict:
|
|
114
|
+
"""Get statistics about the thumbnail cache."""
|
|
115
|
+
cache_dir = get_cache_dir()
|
|
116
|
+
if not cache_dir.exists():
|
|
117
|
+
return {"count": 0, "size_bytes": 0, "path": str(cache_dir)}
|
|
118
|
+
|
|
119
|
+
thumbnails = list(cache_dir.glob("*.jpg"))
|
|
120
|
+
total_size = sum(t.stat().st_size for t in thumbnails)
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
"count": len(thumbnails),
|
|
124
|
+
"size_bytes": total_size,
|
|
125
|
+
"size_mb": total_size / (1024 * 1024),
|
|
126
|
+
"path": str(cache_dir),
|
|
127
|
+
}
|
schenesort/tui/__init__.py
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
"""Schenesort TUI - Terminal UI for browsing wallpapers."""
|
|
2
2
|
|
|
3
3
|
from schenesort.tui.app import WallpaperBrowser
|
|
4
|
+
from schenesort.tui.grid_app import GridBrowser
|
|
5
|
+
from schenesort.tui.widgets.filter_panel import FilterPanel, FilterValues
|
|
6
|
+
from schenesort.tui.widgets.thumbnail_grid import ThumbnailGrid, ThumbnailText
|
|
4
7
|
|
|
5
|
-
__all__ = [
|
|
8
|
+
__all__ = [
|
|
9
|
+
"FilterPanel",
|
|
10
|
+
"FilterValues",
|
|
11
|
+
"GridBrowser",
|
|
12
|
+
"ThumbnailGrid",
|
|
13
|
+
"ThumbnailText",
|
|
14
|
+
"WallpaperBrowser",
|
|
15
|
+
]
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Gallery Grid Browser TUI application."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import Horizontal
|
|
8
|
+
from textual.screen import Screen
|
|
9
|
+
from textual.widgets import Footer, Header, Static
|
|
10
|
+
|
|
11
|
+
from schenesort.db import WallpaperDB
|
|
12
|
+
from schenesort.tui.widgets.filter_panel import FilterPanel, FilterValues
|
|
13
|
+
from schenesort.tui.widgets.image_preview import ImagePreview
|
|
14
|
+
from schenesort.tui.widgets.metadata_panel import MetadataPanel
|
|
15
|
+
from schenesort.tui.widgets.thumbnail_grid import ThumbnailGrid
|
|
16
|
+
from schenesort.xmp import read_xmp
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DetailScreen(Screen):
|
|
20
|
+
"""Screen for viewing a single image with metadata."""
|
|
21
|
+
|
|
22
|
+
BINDINGS = [
|
|
23
|
+
Binding("escape", "pop_screen", "Back", show=True),
|
|
24
|
+
Binding("q", "pop_screen", "Back", show=False),
|
|
25
|
+
Binding("j", "next_image", "Next", show=True),
|
|
26
|
+
Binding("k", "prev_image", "Previous", show=True),
|
|
27
|
+
Binding("down", "next_image", "Next", show=False),
|
|
28
|
+
Binding("up", "prev_image", "Previous", show=False),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
CSS = """
|
|
32
|
+
DetailScreen {
|
|
33
|
+
layout: vertical;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
DetailScreen #detail-container {
|
|
37
|
+
width: 100%;
|
|
38
|
+
height: 1fr;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
DetailScreen #image-panel {
|
|
42
|
+
width: 60%;
|
|
43
|
+
height: 100%;
|
|
44
|
+
border: solid $primary;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
DetailScreen #metadata-panel {
|
|
48
|
+
width: 40%;
|
|
49
|
+
height: 100%;
|
|
50
|
+
border: solid $secondary;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
DetailScreen #detail-status {
|
|
54
|
+
dock: bottom;
|
|
55
|
+
height: 1;
|
|
56
|
+
background: $surface;
|
|
57
|
+
padding: 0 1;
|
|
58
|
+
}
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, images: list[Path], start_index: int = 0, **kwargs) -> None:
|
|
62
|
+
super().__init__(**kwargs)
|
|
63
|
+
self._images = images
|
|
64
|
+
self._current_index = start_index
|
|
65
|
+
|
|
66
|
+
def compose(self) -> ComposeResult:
|
|
67
|
+
yield Header()
|
|
68
|
+
with Horizontal(id="detail-container"):
|
|
69
|
+
yield ImagePreview(id="image-panel")
|
|
70
|
+
yield MetadataPanel(id="metadata-panel")
|
|
71
|
+
yield Static("", id="detail-status")
|
|
72
|
+
yield Footer()
|
|
73
|
+
|
|
74
|
+
def on_mount(self) -> None:
|
|
75
|
+
"""Show the initial image."""
|
|
76
|
+
self._show_current_image()
|
|
77
|
+
|
|
78
|
+
def _show_current_image(self) -> None:
|
|
79
|
+
"""Display the current image and metadata."""
|
|
80
|
+
if not self._images or not (0 <= self._current_index < len(self._images)):
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
current = self._images[self._current_index]
|
|
84
|
+
|
|
85
|
+
# Update image
|
|
86
|
+
preview = self.query_one("#image-panel", ImagePreview)
|
|
87
|
+
preview.load_image(current)
|
|
88
|
+
|
|
89
|
+
# Update metadata
|
|
90
|
+
metadata = read_xmp(current)
|
|
91
|
+
panel = self.query_one("#metadata-panel", MetadataPanel)
|
|
92
|
+
panel.update_metadata(metadata, current.name)
|
|
93
|
+
|
|
94
|
+
# Update status
|
|
95
|
+
status = self.query_one("#detail-status", Static)
|
|
96
|
+
status.update(f"{current.name} [{self._current_index + 1}/{len(self._images)}]")
|
|
97
|
+
|
|
98
|
+
def action_next_image(self) -> None:
|
|
99
|
+
"""Show next image."""
|
|
100
|
+
if self._current_index < len(self._images) - 1:
|
|
101
|
+
self._current_index += 1
|
|
102
|
+
self._show_current_image()
|
|
103
|
+
|
|
104
|
+
def action_prev_image(self) -> None:
|
|
105
|
+
"""Show previous image."""
|
|
106
|
+
if self._current_index > 0:
|
|
107
|
+
self._current_index -= 1
|
|
108
|
+
self._show_current_image()
|
|
109
|
+
|
|
110
|
+
def action_pop_screen(self) -> None:
|
|
111
|
+
"""Return to the grid view."""
|
|
112
|
+
self.app.pop_screen()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class GridBrowser(App):
|
|
116
|
+
"""A TUI application for browsing wallpapers in a thumbnail grid with filters."""
|
|
117
|
+
|
|
118
|
+
TITLE = "Schenesort - Gallery"
|
|
119
|
+
|
|
120
|
+
CSS = """
|
|
121
|
+
Screen {
|
|
122
|
+
layout: vertical;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#main-container {
|
|
126
|
+
width: 100%;
|
|
127
|
+
height: 1fr;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#filter-panel {
|
|
131
|
+
width: 22;
|
|
132
|
+
height: 100%;
|
|
133
|
+
border: solid $secondary;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#grid-panel {
|
|
137
|
+
width: 1fr;
|
|
138
|
+
height: 100%;
|
|
139
|
+
border: solid $primary;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#status-bar {
|
|
143
|
+
dock: bottom;
|
|
144
|
+
height: 1;
|
|
145
|
+
background: $surface;
|
|
146
|
+
color: $text;
|
|
147
|
+
padding: 0 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#status-left {
|
|
151
|
+
width: 1fr;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#status-right {
|
|
155
|
+
width: auto;
|
|
156
|
+
}
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
BINDINGS = [
|
|
160
|
+
Binding("q", "quit", "Quit", show=True),
|
|
161
|
+
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
|
|
162
|
+
Binding("ctrl+q", "quit", "Quit", show=False, priority=True),
|
|
163
|
+
Binding("tab", "focus_next_panel", "Switch Panel", show=True),
|
|
164
|
+
Binding("shift+tab", "focus_prev_panel", "Switch Panel", show=False),
|
|
165
|
+
Binding("enter", "open_detail", "Open", show=True),
|
|
166
|
+
Binding("escape", "clear_filters", "Clear Filters", show=True),
|
|
167
|
+
Binding("r", "refresh", "Refresh", show=True),
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
def __init__(self, initial_filters: FilterValues | None = None, **kwargs) -> None:
|
|
171
|
+
super().__init__(**kwargs)
|
|
172
|
+
self._initial_filters = initial_filters or FilterValues()
|
|
173
|
+
self._images: list[Path] = []
|
|
174
|
+
self._db_path: Path | None = None
|
|
175
|
+
|
|
176
|
+
def compose(self) -> ComposeResult:
|
|
177
|
+
yield Header()
|
|
178
|
+
with Horizontal(id="main-container"):
|
|
179
|
+
yield FilterPanel(initial_filters=self._initial_filters, id="filter-panel")
|
|
180
|
+
yield ThumbnailGrid(id="grid-panel")
|
|
181
|
+
with Horizontal(id="status-bar"):
|
|
182
|
+
yield Static("Loading...", id="status-left")
|
|
183
|
+
yield Static("", id="status-right")
|
|
184
|
+
yield Footer()
|
|
185
|
+
|
|
186
|
+
def on_mount(self) -> None:
|
|
187
|
+
"""Initialize the browser when mounted."""
|
|
188
|
+
# Set initial focus to the grid
|
|
189
|
+
self.query_one("#grid-panel", ThumbnailGrid).focus()
|
|
190
|
+
# Load initial data
|
|
191
|
+
self._query_database(self._initial_filters)
|
|
192
|
+
|
|
193
|
+
def _query_database(self, filters: FilterValues) -> None:
|
|
194
|
+
"""Query the database with the given filters."""
|
|
195
|
+
try:
|
|
196
|
+
with WallpaperDB() as db:
|
|
197
|
+
self._db_path = db.db_path
|
|
198
|
+
results = db.query(
|
|
199
|
+
search=filters.search or None,
|
|
200
|
+
tag=filters.tag or None,
|
|
201
|
+
mood=filters.mood or None,
|
|
202
|
+
color=filters.color or None,
|
|
203
|
+
style=filters.style or None,
|
|
204
|
+
subject=filters.subject or None,
|
|
205
|
+
time_of_day=filters.time or None,
|
|
206
|
+
screen=filters.screen or None,
|
|
207
|
+
min_width=filters.min_width,
|
|
208
|
+
min_height=filters.min_height,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Extract paths and filter for existing files
|
|
212
|
+
self._images = [Path(r["path"]) for r in results if Path(r["path"]).exists()]
|
|
213
|
+
|
|
214
|
+
# Update grid
|
|
215
|
+
grid = self.query_one("#grid-panel", ThumbnailGrid)
|
|
216
|
+
grid.set_images(self._images)
|
|
217
|
+
|
|
218
|
+
# Update status
|
|
219
|
+
self._update_status()
|
|
220
|
+
|
|
221
|
+
except Exception as e:
|
|
222
|
+
self._update_status(error=str(e))
|
|
223
|
+
|
|
224
|
+
def _update_status(self, error: str | None = None) -> None:
|
|
225
|
+
"""Update the status bar."""
|
|
226
|
+
left_status = self.query_one("#status-left", Static)
|
|
227
|
+
right_status = self.query_one("#status-right", Static)
|
|
228
|
+
|
|
229
|
+
if error:
|
|
230
|
+
left_status.update(f"[red]Error: {error}[/red]")
|
|
231
|
+
right_status.update("")
|
|
232
|
+
else:
|
|
233
|
+
count = len(self._images)
|
|
234
|
+
if count == 0:
|
|
235
|
+
left_status.update("[dim]No images matching filters[/dim]")
|
|
236
|
+
else:
|
|
237
|
+
left_status.update(f"{count} image{'s' if count != 1 else ''} matching")
|
|
238
|
+
|
|
239
|
+
grid = self.query_one("#grid-panel", ThumbnailGrid)
|
|
240
|
+
if grid.image_count > 0:
|
|
241
|
+
right_status.update(f"{grid.selected_index + 1}/{grid.image_count}")
|
|
242
|
+
else:
|
|
243
|
+
right_status.update("")
|
|
244
|
+
|
|
245
|
+
def on_filter_panel_filters_changed(self, event: FilterPanel.FiltersChanged) -> None:
|
|
246
|
+
"""Handle filter changes from the filter panel."""
|
|
247
|
+
self._query_database(event.filters)
|
|
248
|
+
|
|
249
|
+
def on_thumbnail_grid_selection_changed(
|
|
250
|
+
self,
|
|
251
|
+
event: ThumbnailGrid.SelectionChanged, # noqa: ARG002
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Handle selection changes in the grid."""
|
|
254
|
+
self._update_status()
|
|
255
|
+
|
|
256
|
+
def on_thumbnail_grid_image_selected(self, event: ThumbnailGrid.ImageSelected) -> None:
|
|
257
|
+
"""Handle image selection (Enter pressed) - open detail view."""
|
|
258
|
+
self._open_detail_view(event.index)
|
|
259
|
+
|
|
260
|
+
def _open_detail_view(self, start_index: int = 0) -> None:
|
|
261
|
+
"""Open the detail view with the current image list."""
|
|
262
|
+
if not self._images:
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
# Push detail screen onto the stack
|
|
266
|
+
self.push_screen(DetailScreen(self._images, start_index))
|
|
267
|
+
|
|
268
|
+
def action_focus_next_panel(self) -> None:
|
|
269
|
+
"""Switch focus to the next panel."""
|
|
270
|
+
current = self.focused
|
|
271
|
+
filter_panel = self.query_one("#filter-panel", FilterPanel)
|
|
272
|
+
grid_panel = self.query_one("#grid-panel", ThumbnailGrid)
|
|
273
|
+
|
|
274
|
+
# Check if focus is in the filter panel area
|
|
275
|
+
if current is not None and filter_panel in current.ancestors_with_self:
|
|
276
|
+
grid_panel.focus()
|
|
277
|
+
else:
|
|
278
|
+
# Focus the first input in the filter panel
|
|
279
|
+
try:
|
|
280
|
+
first_input = filter_panel.query("Input").first()
|
|
281
|
+
first_input.focus()
|
|
282
|
+
except Exception:
|
|
283
|
+
filter_panel.focus()
|
|
284
|
+
|
|
285
|
+
def action_focus_prev_panel(self) -> None:
|
|
286
|
+
"""Switch focus to the previous panel (reverse of next)."""
|
|
287
|
+
self.action_focus_next_panel()
|
|
288
|
+
|
|
289
|
+
def action_open_detail(self) -> None:
|
|
290
|
+
"""Open detail view for the selected image."""
|
|
291
|
+
grid = self.query_one("#grid-panel", ThumbnailGrid)
|
|
292
|
+
self._open_detail_view(grid.selected_index)
|
|
293
|
+
|
|
294
|
+
def action_clear_filters(self) -> None:
|
|
295
|
+
"""Clear all filters."""
|
|
296
|
+
filter_panel = self.query_one("#filter-panel", FilterPanel)
|
|
297
|
+
filter_panel.clear_filters()
|
|
298
|
+
|
|
299
|
+
def action_refresh(self) -> None:
|
|
300
|
+
"""Refresh the grid with current filters."""
|
|
301
|
+
filter_panel = self.query_one("#filter-panel", FilterPanel)
|
|
302
|
+
self._query_database(filter_panel.filters)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Filter panel widget for gallery grid view."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import VerticalScroll
|
|
7
|
+
from textual.message import Message
|
|
8
|
+
from textual.widgets import Input, Label, Static
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class FilterValues:
|
|
13
|
+
"""Container for all filter values."""
|
|
14
|
+
|
|
15
|
+
search: str = ""
|
|
16
|
+
tag: str = ""
|
|
17
|
+
mood: str = ""
|
|
18
|
+
color: str = ""
|
|
19
|
+
style: str = ""
|
|
20
|
+
subject: str = ""
|
|
21
|
+
time: str = ""
|
|
22
|
+
screen: str = ""
|
|
23
|
+
min_width: int | None = None
|
|
24
|
+
min_height: int | None = None
|
|
25
|
+
|
|
26
|
+
def is_empty(self) -> bool:
|
|
27
|
+
"""Check if all filters are empty."""
|
|
28
|
+
return (
|
|
29
|
+
not self.search
|
|
30
|
+
and not self.tag
|
|
31
|
+
and not self.mood
|
|
32
|
+
and not self.color
|
|
33
|
+
and not self.style
|
|
34
|
+
and not self.subject
|
|
35
|
+
and not self.time
|
|
36
|
+
and not self.screen
|
|
37
|
+
and self.min_width is None
|
|
38
|
+
and self.min_height is None
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FilterPanel(VerticalScroll):
|
|
43
|
+
"""Sidebar panel with filter inputs for gallery view."""
|
|
44
|
+
|
|
45
|
+
class FiltersChanged(Message):
|
|
46
|
+
"""Emitted when any filter value changes."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, filters: FilterValues) -> None:
|
|
49
|
+
self.filters = filters
|
|
50
|
+
super().__init__()
|
|
51
|
+
|
|
52
|
+
DEFAULT_CSS = """
|
|
53
|
+
FilterPanel {
|
|
54
|
+
width: 100%;
|
|
55
|
+
height: 100%;
|
|
56
|
+
background: $surface;
|
|
57
|
+
padding: 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
FilterPanel .filter-title {
|
|
61
|
+
text-style: bold;
|
|
62
|
+
color: $text;
|
|
63
|
+
margin-bottom: 1;
|
|
64
|
+
text-align: center;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
FilterPanel .filter-label {
|
|
68
|
+
color: $text-muted;
|
|
69
|
+
margin-top: 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
FilterPanel Input {
|
|
73
|
+
margin-bottom: 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
FilterPanel Input:focus {
|
|
77
|
+
border: tall $accent;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
FilterPanel .filter-section {
|
|
81
|
+
margin-top: 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
FilterPanel .filter-hint {
|
|
85
|
+
color: $text-disabled;
|
|
86
|
+
text-style: italic;
|
|
87
|
+
margin-top: 1;
|
|
88
|
+
}
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, initial_filters: FilterValues | None = None, **kwargs) -> None:
|
|
92
|
+
super().__init__(**kwargs)
|
|
93
|
+
self._filters = initial_filters or FilterValues()
|
|
94
|
+
self._pending_change: int = 0
|
|
95
|
+
|
|
96
|
+
def compose(self) -> ComposeResult:
|
|
97
|
+
yield Static("Filters", classes="filter-title")
|
|
98
|
+
|
|
99
|
+
yield Label("Search", classes="filter-label")
|
|
100
|
+
yield Input(
|
|
101
|
+
value=self._filters.search,
|
|
102
|
+
placeholder="description, scene...",
|
|
103
|
+
id="filter-search",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
yield Label("Tag", classes="filter-label")
|
|
107
|
+
yield Input(
|
|
108
|
+
value=self._filters.tag,
|
|
109
|
+
placeholder="e.g. nature",
|
|
110
|
+
id="filter-tag",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
yield Label("Mood", classes="filter-label")
|
|
114
|
+
yield Input(
|
|
115
|
+
value=self._filters.mood,
|
|
116
|
+
placeholder="e.g. peaceful",
|
|
117
|
+
id="filter-mood",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
yield Label("Color", classes="filter-label")
|
|
121
|
+
yield Input(
|
|
122
|
+
value=self._filters.color,
|
|
123
|
+
placeholder="e.g. blue",
|
|
124
|
+
id="filter-color",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
yield Label("Style", classes="filter-label")
|
|
128
|
+
yield Input(
|
|
129
|
+
value=self._filters.style,
|
|
130
|
+
placeholder="e.g. photography",
|
|
131
|
+
id="filter-style",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
yield Label("Subject", classes="filter-label")
|
|
135
|
+
yield Input(
|
|
136
|
+
value=self._filters.subject,
|
|
137
|
+
placeholder="e.g. landscape",
|
|
138
|
+
id="filter-subject",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
yield Label("Time", classes="filter-label")
|
|
142
|
+
yield Input(
|
|
143
|
+
value=self._filters.time,
|
|
144
|
+
placeholder="e.g. sunset",
|
|
145
|
+
id="filter-time",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
yield Label("Screen", classes="filter-label")
|
|
149
|
+
yield Input(
|
|
150
|
+
value=self._filters.screen,
|
|
151
|
+
placeholder="e.g. 4K, 1440p",
|
|
152
|
+
id="filter-screen",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
yield Label("Min Width", classes="filter-label")
|
|
156
|
+
yield Input(
|
|
157
|
+
value=str(self._filters.min_width) if self._filters.min_width else "",
|
|
158
|
+
placeholder="pixels",
|
|
159
|
+
id="filter-min-width",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
yield Label("Min Height", classes="filter-label")
|
|
163
|
+
yield Input(
|
|
164
|
+
value=str(self._filters.min_height) if self._filters.min_height else "",
|
|
165
|
+
placeholder="pixels",
|
|
166
|
+
id="filter-min-height",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
yield Static("Press Tab to switch to grid", classes="filter-hint")
|
|
170
|
+
|
|
171
|
+
def on_input_changed(self, event: Input.Changed) -> None: # noqa: ARG002
|
|
172
|
+
"""Handle input changes with debouncing."""
|
|
173
|
+
# Increment counter to track which change this is
|
|
174
|
+
self._pending_change += 1
|
|
175
|
+
current_change = self._pending_change
|
|
176
|
+
|
|
177
|
+
# Schedule the filter update with a small delay
|
|
178
|
+
self.set_timer(0.3, lambda: self._emit_filter_change(current_change))
|
|
179
|
+
|
|
180
|
+
def _emit_filter_change(self, change_id: int) -> None:
|
|
181
|
+
"""Gather all filter values and emit the change message."""
|
|
182
|
+
# Only process if this is the most recent change
|
|
183
|
+
if change_id != self._pending_change:
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
# Read all input values
|
|
187
|
+
search = self.query_one("#filter-search", Input).value.strip()
|
|
188
|
+
tag = self.query_one("#filter-tag", Input).value.strip()
|
|
189
|
+
mood = self.query_one("#filter-mood", Input).value.strip()
|
|
190
|
+
color = self.query_one("#filter-color", Input).value.strip()
|
|
191
|
+
style = self.query_one("#filter-style", Input).value.strip()
|
|
192
|
+
subject = self.query_one("#filter-subject", Input).value.strip()
|
|
193
|
+
time = self.query_one("#filter-time", Input).value.strip()
|
|
194
|
+
screen = self.query_one("#filter-screen", Input).value.strip()
|
|
195
|
+
|
|
196
|
+
# Parse numeric values
|
|
197
|
+
min_width_str = self.query_one("#filter-min-width", Input).value.strip()
|
|
198
|
+
min_height_str = self.query_one("#filter-min-height", Input).value.strip()
|
|
199
|
+
|
|
200
|
+
min_width = None
|
|
201
|
+
min_height = None
|
|
202
|
+
try:
|
|
203
|
+
if min_width_str:
|
|
204
|
+
min_width = int(min_width_str)
|
|
205
|
+
except ValueError:
|
|
206
|
+
pass
|
|
207
|
+
try:
|
|
208
|
+
if min_height_str:
|
|
209
|
+
min_height = int(min_height_str)
|
|
210
|
+
except ValueError:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
self._filters = FilterValues(
|
|
214
|
+
search=search,
|
|
215
|
+
tag=tag,
|
|
216
|
+
mood=mood,
|
|
217
|
+
color=color,
|
|
218
|
+
style=style,
|
|
219
|
+
subject=subject,
|
|
220
|
+
time=time,
|
|
221
|
+
screen=screen,
|
|
222
|
+
min_width=min_width,
|
|
223
|
+
min_height=min_height,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
self.post_message(self.FiltersChanged(self._filters))
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def filters(self) -> FilterValues:
|
|
230
|
+
"""Get the current filter values."""
|
|
231
|
+
return self._filters
|
|
232
|
+
|
|
233
|
+
def clear_filters(self) -> None:
|
|
234
|
+
"""Clear all filter inputs."""
|
|
235
|
+
for input_id in [
|
|
236
|
+
"#filter-search",
|
|
237
|
+
"#filter-tag",
|
|
238
|
+
"#filter-mood",
|
|
239
|
+
"#filter-color",
|
|
240
|
+
"#filter-style",
|
|
241
|
+
"#filter-subject",
|
|
242
|
+
"#filter-time",
|
|
243
|
+
"#filter-screen",
|
|
244
|
+
"#filter-min-width",
|
|
245
|
+
"#filter-min-height",
|
|
246
|
+
]:
|
|
247
|
+
self.query_one(input_id, Input).value = ""
|
|
248
|
+
self._filters = FilterValues()
|
|
249
|
+
self.post_message(self.FiltersChanged(self._filters))
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Thumbnail grid widget for gallery view."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import Container, VerticalScroll
|
|
8
|
+
from textual.message import Message
|
|
9
|
+
from textual.widgets import Static
|
|
10
|
+
from textual_image.widget import Image
|
|
11
|
+
|
|
12
|
+
from schenesort.thumbnails import get_thumbnail_path, thumbnail_exists
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ThumbnailText(Static):
|
|
16
|
+
"""Text fallback for when no thumbnail exists."""
|
|
17
|
+
|
|
18
|
+
DEFAULT_CSS = """
|
|
19
|
+
ThumbnailText {
|
|
20
|
+
width: 32;
|
|
21
|
+
height: 14;
|
|
22
|
+
content-align: center middle;
|
|
23
|
+
text-align: center;
|
|
24
|
+
color: $text-muted;
|
|
25
|
+
background: $surface;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ThumbnailText.selected {
|
|
29
|
+
border: solid $primary;
|
|
30
|
+
}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, image_path: Path, index: int, **kwargs) -> None:
|
|
34
|
+
name = image_path.stem
|
|
35
|
+
if len(name) > 28:
|
|
36
|
+
name = name[:25] + "..."
|
|
37
|
+
super().__init__(name, **kwargs)
|
|
38
|
+
self.image_path = image_path
|
|
39
|
+
self.index = index
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Type alias for cell widgets
|
|
43
|
+
CellWidget = Image | ThumbnailText
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_thumbnail_cell(image_path: Path, index: int) -> CellWidget:
|
|
47
|
+
"""Create a cell widget for the given image using its thumbnail."""
|
|
48
|
+
if thumbnail_exists(image_path):
|
|
49
|
+
thumb_path = get_thumbnail_path(image_path)
|
|
50
|
+
img = Image(thumb_path)
|
|
51
|
+
# Store metadata on the widget
|
|
52
|
+
img.image_path = image_path # type: ignore[attr-defined]
|
|
53
|
+
img.index = index # type: ignore[attr-defined]
|
|
54
|
+
return img
|
|
55
|
+
return ThumbnailText(image_path, index)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ThumbnailGrid(VerticalScroll, can_focus=True):
|
|
59
|
+
"""Scrollable grid of image thumbnails with keyboard navigation."""
|
|
60
|
+
|
|
61
|
+
BINDINGS = [
|
|
62
|
+
Binding("up", "move_up", "Up", show=False),
|
|
63
|
+
Binding("down", "move_down", "Down", show=False),
|
|
64
|
+
Binding("left", "move_left", "Left", show=False),
|
|
65
|
+
Binding("right", "move_right", "Right", show=False),
|
|
66
|
+
Binding("k", "move_up", "Up", show=False),
|
|
67
|
+
Binding("j", "move_down", "Down", show=False),
|
|
68
|
+
Binding("h", "move_left", "Left", show=False),
|
|
69
|
+
Binding("l", "move_right", "Right", show=False),
|
|
70
|
+
Binding("enter", "select", "Select", show=False),
|
|
71
|
+
Binding("home", "first", "First", show=False),
|
|
72
|
+
Binding("end", "last", "Last", show=False),
|
|
73
|
+
Binding("g", "first", "First", show=False),
|
|
74
|
+
Binding("G", "last", "Last", show=False, key_display="shift+g"),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
class ImageSelected(Message):
|
|
78
|
+
"""Emitted when an image is selected (Enter pressed)."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, image_path: Path, index: int) -> None:
|
|
81
|
+
self.image_path = image_path
|
|
82
|
+
self.index = index
|
|
83
|
+
super().__init__()
|
|
84
|
+
|
|
85
|
+
class SelectionChanged(Message):
|
|
86
|
+
"""Emitted when the selection changes."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, image_path: Path, index: int) -> None:
|
|
89
|
+
self.image_path = image_path
|
|
90
|
+
self.index = index
|
|
91
|
+
super().__init__()
|
|
92
|
+
|
|
93
|
+
DEFAULT_CSS = """
|
|
94
|
+
ThumbnailGrid {
|
|
95
|
+
width: 100%;
|
|
96
|
+
height: 100%;
|
|
97
|
+
background: $background;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
ThumbnailGrid:focus {
|
|
101
|
+
border: solid $accent;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
ThumbnailGrid #grid-container {
|
|
105
|
+
layout: grid;
|
|
106
|
+
grid-gutter: 0;
|
|
107
|
+
width: 100%;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
ThumbnailGrid #grid-container Image {
|
|
111
|
+
width: 32;
|
|
112
|
+
height: 14;
|
|
113
|
+
margin: 0;
|
|
114
|
+
padding: 0;
|
|
115
|
+
border: solid $background;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
ThumbnailGrid #grid-container Image.selected {
|
|
119
|
+
border: solid $primary;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
ThumbnailGrid .empty-message {
|
|
123
|
+
color: $text-disabled;
|
|
124
|
+
text-style: italic;
|
|
125
|
+
text-align: center;
|
|
126
|
+
width: 100%;
|
|
127
|
+
padding: 4;
|
|
128
|
+
}
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
CELL_WIDTH = 32
|
|
132
|
+
CELL_HEIGHT = 14
|
|
133
|
+
|
|
134
|
+
def __init__(self, images: list[Path] | None = None, **kwargs) -> None:
|
|
135
|
+
super().__init__(**kwargs)
|
|
136
|
+
self._images: list[Path] = images or []
|
|
137
|
+
self._selected_index: int = 0
|
|
138
|
+
self._columns: int = 1
|
|
139
|
+
self._cells: list[CellWidget] = []
|
|
140
|
+
self._grid_container: Container | None = None
|
|
141
|
+
|
|
142
|
+
def compose(self) -> ComposeResult:
|
|
143
|
+
self._grid_container = Container(id="grid-container")
|
|
144
|
+
yield self._grid_container
|
|
145
|
+
|
|
146
|
+
def on_mount(self) -> None:
|
|
147
|
+
"""Initialize the grid on mount."""
|
|
148
|
+
# Defer initial build to after layout
|
|
149
|
+
self.call_after_refresh(self._initial_build)
|
|
150
|
+
|
|
151
|
+
def _initial_build(self) -> None:
|
|
152
|
+
"""Build grid after initial layout."""
|
|
153
|
+
self._calculate_columns()
|
|
154
|
+
self._rebuild_grid()
|
|
155
|
+
|
|
156
|
+
def on_resize(self) -> None:
|
|
157
|
+
"""Recalculate columns when resized."""
|
|
158
|
+
old_columns = self._columns
|
|
159
|
+
self._calculate_columns()
|
|
160
|
+
if old_columns != self._columns:
|
|
161
|
+
self._rebuild_grid()
|
|
162
|
+
|
|
163
|
+
def _calculate_columns(self) -> None:
|
|
164
|
+
"""Calculate number of columns based on container width."""
|
|
165
|
+
available_width = max(self.size.width - 2, self.CELL_WIDTH)
|
|
166
|
+
self._columns = max(1, available_width // self.CELL_WIDTH)
|
|
167
|
+
|
|
168
|
+
def _rebuild_grid(self) -> None:
|
|
169
|
+
"""Rebuild the grid with current images and column count."""
|
|
170
|
+
if self._grid_container is None:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
container = self._grid_container
|
|
174
|
+
|
|
175
|
+
# Clear all children from container
|
|
176
|
+
container.remove_children()
|
|
177
|
+
self._cells.clear()
|
|
178
|
+
|
|
179
|
+
if not self._images:
|
|
180
|
+
container.mount(Static("No images to display", classes="empty-message"))
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
# Update grid columns CSS
|
|
184
|
+
container.styles.grid_size_columns = self._columns
|
|
185
|
+
|
|
186
|
+
# Calculate and set container height based on rows
|
|
187
|
+
# (textual-image needs explicit height, not height: auto, to render in scroll containers)
|
|
188
|
+
num_rows = (len(self._images) + self._columns - 1) // self._columns
|
|
189
|
+
container_height = num_rows * self.CELL_HEIGHT
|
|
190
|
+
container.styles.height = container_height
|
|
191
|
+
|
|
192
|
+
# Create cells
|
|
193
|
+
for idx, image_path in enumerate(self._images):
|
|
194
|
+
cell = create_thumbnail_cell(image_path, idx)
|
|
195
|
+
self._cells.append(cell)
|
|
196
|
+
container.mount(cell)
|
|
197
|
+
|
|
198
|
+
# Update selection
|
|
199
|
+
self._update_selection()
|
|
200
|
+
|
|
201
|
+
def set_images(self, images: list[Path]) -> None:
|
|
202
|
+
"""Update the images displayed in the grid."""
|
|
203
|
+
self._images = images
|
|
204
|
+
self._selected_index = 0 if images else -1
|
|
205
|
+
self._calculate_columns()
|
|
206
|
+
self._rebuild_grid()
|
|
207
|
+
|
|
208
|
+
def _update_selection(self) -> None:
|
|
209
|
+
"""Update the visual selection state."""
|
|
210
|
+
for idx, cell in enumerate(self._cells):
|
|
211
|
+
if idx == self._selected_index:
|
|
212
|
+
cell.add_class("selected")
|
|
213
|
+
else:
|
|
214
|
+
cell.remove_class("selected")
|
|
215
|
+
|
|
216
|
+
# Scroll selected cell into view
|
|
217
|
+
if 0 <= self._selected_index < len(self._cells):
|
|
218
|
+
self._cells[self._selected_index].scroll_visible()
|
|
219
|
+
|
|
220
|
+
def _move_selection(self, delta: int) -> None:
|
|
221
|
+
"""Move selection by delta amount."""
|
|
222
|
+
if not self._images:
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
new_index = self._selected_index + delta
|
|
226
|
+
new_index = max(0, min(len(self._images) - 1, new_index))
|
|
227
|
+
|
|
228
|
+
if new_index != self._selected_index:
|
|
229
|
+
self._selected_index = new_index
|
|
230
|
+
self._update_selection()
|
|
231
|
+
self.post_message(
|
|
232
|
+
self.SelectionChanged(self._images[self._selected_index], self._selected_index)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def action_move_up(self) -> None:
|
|
236
|
+
"""Move selection up by one row."""
|
|
237
|
+
self._move_selection(-self._columns)
|
|
238
|
+
|
|
239
|
+
def action_move_down(self) -> None:
|
|
240
|
+
"""Move selection down by one row."""
|
|
241
|
+
self._move_selection(self._columns)
|
|
242
|
+
|
|
243
|
+
def action_move_left(self) -> None:
|
|
244
|
+
"""Move selection left by one cell."""
|
|
245
|
+
self._move_selection(-1)
|
|
246
|
+
|
|
247
|
+
def action_move_right(self) -> None:
|
|
248
|
+
"""Move selection right by one cell."""
|
|
249
|
+
self._move_selection(1)
|
|
250
|
+
|
|
251
|
+
def action_first(self) -> None:
|
|
252
|
+
"""Move selection to first image."""
|
|
253
|
+
if self._images:
|
|
254
|
+
self._selected_index = 0
|
|
255
|
+
self._update_selection()
|
|
256
|
+
self.post_message(
|
|
257
|
+
self.SelectionChanged(self._images[self._selected_index], self._selected_index)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def action_last(self) -> None:
|
|
261
|
+
"""Move selection to last image."""
|
|
262
|
+
if self._images:
|
|
263
|
+
self._selected_index = len(self._images) - 1
|
|
264
|
+
self._update_selection()
|
|
265
|
+
self.post_message(
|
|
266
|
+
self.SelectionChanged(self._images[self._selected_index], self._selected_index)
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def action_select(self) -> None:
|
|
270
|
+
"""Select the current image (open in detail view)."""
|
|
271
|
+
if 0 <= self._selected_index < len(self._images):
|
|
272
|
+
self.post_message(
|
|
273
|
+
self.ImageSelected(self._images[self._selected_index], self._selected_index)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def selected_image(self) -> Path | None:
|
|
278
|
+
"""Get the currently selected image path."""
|
|
279
|
+
if 0 <= self._selected_index < len(self._images):
|
|
280
|
+
return self._images[self._selected_index]
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def selected_index(self) -> int:
|
|
285
|
+
"""Get the currently selected index."""
|
|
286
|
+
return self._selected_index
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def image_count(self) -> int:
|
|
290
|
+
"""Get the number of images in the grid."""
|
|
291
|
+
return len(self._images)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: schenesort
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.1
|
|
4
4
|
Summary: Wallpaper collection management CLI tool
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.13
|
|
@@ -12,12 +12,14 @@ Requires-Dist: textual>=0.95.0
|
|
|
12
12
|
Requires-Dist: typer>=0.21.1
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
14
|
|
|
15
|
-
# Schenesort v2.
|
|
15
|
+
# Schenesort v2.4.1
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
A cli tool for managing wallpaper collections with model generated metadata, sweet tui, and sql metadata querying.
|
|
20
|
+
|
|
21
|
+
schenesort takes a directory of random wallpapers, with random filenames, and uses olama with a decent vision model to:
|
|
19
22
|
|
|
20
|
-
schenesort takes a directory of random wallpapers, with random filenames, and uses olama with a decent vision model to
|
|
21
23
|
- look at each wallpaper
|
|
22
24
|
- rename the wallpaper to something sensible
|
|
23
25
|
- drop a XMP sidecar with metadata about the file
|
|
@@ -51,12 +53,18 @@ setup](./ollama-setup.md).
|
|
|
51
53
|
# Generate metadata for images
|
|
52
54
|
schenesort metadata generate ~/wallpapers -r
|
|
53
55
|
|
|
54
|
-
# Browse with TUI
|
|
55
|
-
schenesort browse ~/wallpapers
|
|
56
|
-
|
|
57
56
|
# Index the collection
|
|
58
57
|
schenesort index ~/wallpapers
|
|
59
58
|
|
|
59
|
+
# Generate thumbnails for gallery
|
|
60
|
+
schenesort thumbnail ~/wallpapers -r
|
|
61
|
+
|
|
62
|
+
# Browse with gallery (thumbnail grid with filters)
|
|
63
|
+
schenesort gallery
|
|
64
|
+
|
|
65
|
+
# Browse single images with TUI
|
|
66
|
+
schenesort browse ~/wallpapers
|
|
67
|
+
|
|
60
68
|
# Query wallpapers
|
|
61
69
|
schenesort get --mood peaceful --screen 4K
|
|
62
70
|
schenesort get -1 -p | xargs feh # random wallpaper
|
|
@@ -67,6 +75,8 @@ schenesort get -1 -p | xargs feh # random wallpaper
|
|
|
67
75
|
| Command | Description |
|
|
68
76
|
|------------------------------|-----------------------------------------------------|
|
|
69
77
|
| `browse` | Terminal UI browser with image preview and metadata |
|
|
78
|
+
| `gallery` | Thumbnail grid browser with filters |
|
|
79
|
+
| `thumbnail` | Generate thumbnail cache for gallery |
|
|
70
80
|
| `index` | Build SQLite index for fast querying |
|
|
71
81
|
| `get` | Query wallpapers by metadata attributes |
|
|
72
82
|
| `stats` | Show collection statistics from index |
|
|
@@ -100,6 +110,7 @@ schenesort get --mood peaceful -b # browse query results
|
|
|
100
110
|

|
|
101
111
|
|
|
102
112
|
**Keyboard shortcuts:**
|
|
113
|
+
|
|
103
114
|
| Key | Action |
|
|
104
115
|
|--------------|----------------|
|
|
105
116
|
| `j` / `Down` | Next image |
|
|
@@ -111,6 +122,65 @@ schenesort get --mood peaceful -b # browse query results
|
|
|
111
122
|
|
|
112
123
|
The TUI uses textual-image for rendering, which auto-detects terminal graphics support (Sixel, iTerm2, Kitty).
|
|
113
124
|
|
|
125
|
+
## Gallery Browser
|
|
126
|
+
|
|
127
|
+

|
|
128
|
+
|
|
129
|
+
Browse your indexed collection with a thumbnail grid and filter panel:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# Open gallery browser
|
|
133
|
+
schenesort gallery
|
|
134
|
+
|
|
135
|
+
# Pre-filter results
|
|
136
|
+
schenesort gallery --mood peaceful
|
|
137
|
+
schenesort gallery --tag nature --style photography
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The gallery requires an indexed collection (`schenesort index`) and cached thumbnails for fast loading.
|
|
141
|
+
|
|
142
|
+
### Generate Thumbnails
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# Generate thumbnails for a directory
|
|
146
|
+
schenesort thumbnail ~/wallpapers
|
|
147
|
+
|
|
148
|
+
# Recursive with progress bar
|
|
149
|
+
schenesort thumbnail ~/wallpapers -r
|
|
150
|
+
|
|
151
|
+
# Force regenerate all thumbnails
|
|
152
|
+
schenesort thumbnail ~/wallpapers --force
|
|
153
|
+
|
|
154
|
+
# Clear thumbnail cache
|
|
155
|
+
schenesort thumbnail ~/wallpapers --clear
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Thumbnails are cached at `~/.cache/schenesort/thumbnails/` (320x200 JPEG).
|
|
159
|
+
|
|
160
|
+
**Gallery keyboard shortcuts:**
|
|
161
|
+
|
|
162
|
+
| Key | Action |
|
|
163
|
+
|------------------|-----------------------|
|
|
164
|
+
| `j` / `Down` | Move down |
|
|
165
|
+
| `k` / `Up` | Move up |
|
|
166
|
+
| `h` / `Left` | Move left |
|
|
167
|
+
| `l` / `Right` | Move right |
|
|
168
|
+
| `g` / `Home` | First image |
|
|
169
|
+
| `G` / `End` | Last image |
|
|
170
|
+
| `Enter` | Open detail view |
|
|
171
|
+
| `Tab` | Switch panel |
|
|
172
|
+
| `Escape` | Clear filters |
|
|
173
|
+
| `r` | Refresh |
|
|
174
|
+
| `q` / `Ctrl+C` | Quit |
|
|
175
|
+
|
|
176
|
+
**Detail view shortcuts:**
|
|
177
|
+
|
|
178
|
+
| Key | Action |
|
|
179
|
+
|------------------|-----------------------|
|
|
180
|
+
| `j` / `Down` | Next image |
|
|
181
|
+
| `k` / `Up` | Previous image |
|
|
182
|
+
| `Escape` / `q` | Back to grid |
|
|
183
|
+
|
|
114
184
|
## Collection Indexing and Querying
|
|
115
185
|
|
|
116
186
|
Build a SQLite index for fast querying across your entire collection:
|
|
@@ -151,10 +221,10 @@ The database is stored at `$XDG_DATA_HOME/schenesort/index.db` (default: `~/.loc
|
|
|
151
221
|
|
|
152
222
|
## Collage Generation
|
|
153
223
|
|
|
154
|
-
Create a collage grid
|
|
224
|
+
Create a collage grid from wallpapers matching query criteria:
|
|
155
225
|
|
|
156
226
|
```bash
|
|
157
|
-
# Create a
|
|
227
|
+
# Create a 6x6 collage (default)
|
|
158
228
|
schenesort collage output.png --mood peaceful
|
|
159
229
|
|
|
160
230
|
# Create a 4x4 collage of landscape images
|
|
@@ -167,12 +237,14 @@ schenesort collage collage.png --tile-width 640 --tile-height 360
|
|
|
167
237
|
schenesort collage night_cities.png --time night --subject urban --cols 3 --rows 2
|
|
168
238
|
```
|
|
169
239
|
|
|
170
|
-
|
|
240
|
+
`schenesort collage landscape.png --cols 4 --rows 4 --tile-width 640 --tile-height 360 --tag landscape`
|
|
241
|
+
|
|
242
|
+

|
|
171
243
|
|
|
172
244
|
| Option | Description | Default |
|
|
173
245
|
|-----------------|--------------------------------------|---------|
|
|
174
|
-
| `--cols` | Number of columns
|
|
175
|
-
| `--rows` | Number of rows
|
|
246
|
+
| `--cols` | Number of columns | 6 |
|
|
247
|
+
| `--rows` | Number of rows | 6 |
|
|
176
248
|
| `--tile-width` | Width of each tile in pixels | 480 |
|
|
177
249
|
| `--tile-height` | Height of each tile in pixels | 270 |
|
|
178
250
|
| `--random` | Select images randomly | True |
|
|
@@ -277,6 +349,7 @@ schenesort cleanup ~/wallpapers -r
|
|
|
277
349
|
## Configuration
|
|
278
350
|
|
|
279
351
|
Schenesort follows XDG Base Directory spec:
|
|
352
|
+
|
|
280
353
|
- Config: `$XDG_CONFIG_HOME/schenesort/config.toml` (default: `~/.config/schenesort/config.toml`)
|
|
281
354
|
- Data: `$XDG_DATA_HOME/schenesort/index.db` (default: `~/.local/share/schenesort/index.db`)
|
|
282
355
|
|
|
@@ -354,7 +427,7 @@ See [schenesort.yazi/README.md](schenesort.yazi/README.md) for details.
|
|
|
354
427
|
|
|
355
428
|
## XMP Sidecar Format
|
|
356
429
|
|
|
357
|
-
```
|
|
430
|
+
```text
|
|
358
431
|
~/wallpapers/
|
|
359
432
|
├── mountain_sunset.jpg
|
|
360
433
|
├── mountain_sunset.jpg.xmp ← metadata stored here
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
schenesort/__init__.py,sha256=Z7bmXIWiFUCKj1j4JRlPZvmfzc4RC10Lh1WCKNsJn20,61
|
|
2
|
+
schenesort/cli.py,sha256=tBsZ9TUI-UI6vSjSs98_WiHHR0g-RpvXeboCD4SaXI4,55641
|
|
3
|
+
schenesort/config.py,sha256=8EuYv2nma3QKnLPSAgAsaOHZlORzinHjy-Bj_LKPfJA,2739
|
|
4
|
+
schenesort/db.py,sha256=RbfZN6d5O5MkTRPbu51fA7tQLOJf1huIjxFdoT4sESk,11398
|
|
5
|
+
schenesort/thumbnails.py,sha256=-B4vlh2w_NdUkO0tshcpZPUSs6M8S6G_abiBPp3xVOY,3631
|
|
6
|
+
schenesort/xmp.py,sha256=1VS_I4akY8Dv_KLPOdzPgBCFy0280oSCsMmo-_A9cNE,9749
|
|
7
|
+
schenesort/tui/__init__.py,sha256=BsOc1PedYBguvYBpnny_T9ST-dIa_52xYWlLdNemha4,449
|
|
8
|
+
schenesort/tui/app.py,sha256=HGKBnszTJXtLu9Mo7eJY4wiO2qkB7dFftBoM4GC_js4,6202
|
|
9
|
+
schenesort/tui/grid_app.py,sha256=hrS-dEwQ9wFmcQz2ZT7jy6lYOdSCidEY5Zlfmpv8sI8,10032
|
|
10
|
+
schenesort/tui/widgets/__init__.py,sha256=3cm7vfXG5-xC_UhIbgEtuMxq5I5tXg6okokJ4ecjGIE,202
|
|
11
|
+
schenesort/tui/widgets/filter_panel.py,sha256=BZf8T1H4u4eTUj2bt6ILvQ3m_hwx20DRfc02YVJ4zQk,7273
|
|
12
|
+
schenesort/tui/widgets/image_preview.py,sha256=j_KTQBJk0vbO18FzTo-GYkRlgerBe1K3VGQwMPbEuUw,2846
|
|
13
|
+
schenesort/tui/widgets/metadata_panel.py,sha256=8DnsvDdKf3jzqHOo7dDmxJxL0Mjx5mX7HNwwcMUcJn0,4860
|
|
14
|
+
schenesort/tui/widgets/thumbnail_grid.py,sha256=-81CL4pBI7Q7J_PD2MbeaI-SNckEdfuNZdA14m7q348,9407
|
|
15
|
+
schenesort-2.4.1.dist-info/METADATA,sha256=Drs4ynmh9VhrN9gHmFkmapj3T_g7VH4bcRIyFP6ysuo,14724
|
|
16
|
+
schenesort-2.4.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
17
|
+
schenesort-2.4.1.dist-info/entry_points.txt,sha256=J5lS-N6KgmzjutFi5bG1jv-4Wszbz3MfcOHcbznBVcw,50
|
|
18
|
+
schenesort-2.4.1.dist-info/licenses/LICENSE,sha256=sMw3SMb9ec9dbM2twEMVeunsGwuljza-9kEXg4kSJpg,1070
|
|
19
|
+
schenesort-2.4.1.dist-info/RECORD,,
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
schenesort/__init__.py,sha256=Z7bmXIWiFUCKj1j4JRlPZvmfzc4RC10Lh1WCKNsJn20,61
|
|
2
|
-
schenesort/cli.py,sha256=LU_s4c7Eg_uozxP5I599TV9Y-WirQxx0h-8LvFNYlqM,51550
|
|
3
|
-
schenesort/config.py,sha256=8EuYv2nma3QKnLPSAgAsaOHZlORzinHjy-Bj_LKPfJA,2739
|
|
4
|
-
schenesort/db.py,sha256=RbfZN6d5O5MkTRPbu51fA7tQLOJf1huIjxFdoT4sESk,11398
|
|
5
|
-
schenesort/xmp.py,sha256=1VS_I4akY8Dv_KLPOdzPgBCFy0280oSCsMmo-_A9cNE,9749
|
|
6
|
-
schenesort/tui/__init__.py,sha256=bqSyRlefPfsYhUxsub4Rltz7yjCQVPXvhzj9n2bn370,141
|
|
7
|
-
schenesort/tui/app.py,sha256=HGKBnszTJXtLu9Mo7eJY4wiO2qkB7dFftBoM4GC_js4,6202
|
|
8
|
-
schenesort/tui/widgets/__init__.py,sha256=3cm7vfXG5-xC_UhIbgEtuMxq5I5tXg6okokJ4ecjGIE,202
|
|
9
|
-
schenesort/tui/widgets/image_preview.py,sha256=j_KTQBJk0vbO18FzTo-GYkRlgerBe1K3VGQwMPbEuUw,2846
|
|
10
|
-
schenesort/tui/widgets/metadata_panel.py,sha256=8DnsvDdKf3jzqHOo7dDmxJxL0Mjx5mX7HNwwcMUcJn0,4860
|
|
11
|
-
schenesort-2.3.0.dist-info/METADATA,sha256=F8AMExvB_0nU7CgGkW2CM1az2XJizdQafy0EBJkwnPw,12592
|
|
12
|
-
schenesort-2.3.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
13
|
-
schenesort-2.3.0.dist-info/entry_points.txt,sha256=J5lS-N6KgmzjutFi5bG1jv-4Wszbz3MfcOHcbznBVcw,50
|
|
14
|
-
schenesort-2.3.0.dist-info/licenses/LICENSE,sha256=sMw3SMb9ec9dbM2twEMVeunsGwuljza-9kEXg4kSJpg,1070
|
|
15
|
-
schenesort-2.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|