schenesort 2.3.0__py3-none-any.whl → 2.4.0__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 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[
@@ -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
+ }
@@ -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__ = ["WallpaperBrowser"]
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.0
3
+ Version: 2.4.0
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.3.0
15
+ # Schenesort v2.4.0
16
16
 
17
- A CLI tool for managing wallpaper collections with model generated metadata, terminal UI browsing, and SQLite-based
18
- querying.
17
+ ![Collage example](docs/collage.png)
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
  ![Browse example - Stallman](docs/browse-stallman.png)
101
111
 
102
112
  **Keyboard shortcuts:**
113
+
103
114
  | Key | Action |
104
115
  |--------------|----------------|
105
116
  | `j` / `Down` | Next image |
@@ -111,6 +122,63 @@ 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
+ Browse your indexed collection with a thumbnail grid and filter panel:
128
+
129
+ ```bash
130
+ # Open gallery browser
131
+ schenesort gallery
132
+
133
+ # Pre-filter results
134
+ schenesort gallery --mood peaceful
135
+ schenesort gallery --tag nature --style photography
136
+ ```
137
+
138
+ The gallery requires an indexed collection (`schenesort index`) and cached thumbnails for fast loading.
139
+
140
+ ### Generate Thumbnails
141
+
142
+ ```bash
143
+ # Generate thumbnails for a directory
144
+ schenesort thumbnail ~/wallpapers
145
+
146
+ # Recursive with progress bar
147
+ schenesort thumbnail ~/wallpapers -r
148
+
149
+ # Force regenerate all thumbnails
150
+ schenesort thumbnail ~/wallpapers --force
151
+
152
+ # Clear thumbnail cache
153
+ schenesort thumbnail ~/wallpapers --clear
154
+ ```
155
+
156
+ Thumbnails are cached at `~/.cache/schenesort/thumbnails/` (320x200 JPEG).
157
+
158
+ **Gallery keyboard shortcuts:**
159
+
160
+ | Key | Action |
161
+ |------------------|-----------------------|
162
+ | `j` / `Down` | Move down |
163
+ | `k` / `Up` | Move up |
164
+ | `h` / `Left` | Move left |
165
+ | `l` / `Right` | Move right |
166
+ | `g` / `Home` | First image |
167
+ | `G` / `End` | Last image |
168
+ | `Enter` | Open detail view |
169
+ | `Tab` | Switch panel |
170
+ | `Escape` | Clear filters |
171
+ | `r` | Refresh |
172
+ | `q` / `Ctrl+C` | Quit |
173
+
174
+ **Detail view shortcuts:**
175
+
176
+ | Key | Action |
177
+ |------------------|-----------------------|
178
+ | `j` / `Down` | Next image |
179
+ | `k` / `Up` | Previous image |
180
+ | `Escape` / `q` | Back to grid |
181
+
114
182
  ## Collection Indexing and Querying
115
183
 
116
184
  Build a SQLite index for fast querying across your entire collection:
@@ -167,7 +235,9 @@ schenesort collage collage.png --tile-width 640 --tile-height 360
167
235
  schenesort collage night_cities.png --time night --subject urban --cols 3 --rows 2
168
236
  ```
169
237
 
170
- ![Collage example](docs/collage.png)
238
+ `schenesort collage landscape.png --cols 4 --rows 4 --tile-width 640 --tile-height 360 --tag landscape`
239
+
240
+ ![Collage example](docs/landscape.png)
171
241
 
172
242
  | Option | Description | Default |
173
243
  |-----------------|--------------------------------------|---------|
@@ -277,6 +347,7 @@ schenesort cleanup ~/wallpapers -r
277
347
  ## Configuration
278
348
 
279
349
  Schenesort follows XDG Base Directory spec:
350
+
280
351
  - Config: `$XDG_CONFIG_HOME/schenesort/config.toml` (default: `~/.config/schenesort/config.toml`)
281
352
  - Data: `$XDG_DATA_HOME/schenesort/index.db` (default: `~/.local/share/schenesort/index.db`)
282
353
 
@@ -354,7 +425,7 @@ See [schenesort.yazi/README.md](schenesort.yazi/README.md) for details.
354
425
 
355
426
  ## XMP Sidecar Format
356
427
 
357
- ```
428
+ ```text
358
429
  ~/wallpapers/
359
430
  ├── mountain_sunset.jpg
360
431
  ├── mountain_sunset.jpg.xmp ← metadata stored here
@@ -0,0 +1,19 @@
1
+ schenesort/__init__.py,sha256=Z7bmXIWiFUCKj1j4JRlPZvmfzc4RC10Lh1WCKNsJn20,61
2
+ schenesort/cli.py,sha256=TFq_AjH-PSJYAr7b14rXdXl7xEKovqNnt7SQAZ7psZE,55702
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.0.dist-info/METADATA,sha256=rIqVFt9JgHlVGSbsG1irhOllNWNzD0zEGMUjduxXhWc,14698
16
+ schenesort-2.4.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
17
+ schenesort-2.4.0.dist-info/entry_points.txt,sha256=J5lS-N6KgmzjutFi5bG1jv-4Wszbz3MfcOHcbznBVcw,50
18
+ schenesort-2.4.0.dist-info/licenses/LICENSE,sha256=sMw3SMb9ec9dbM2twEMVeunsGwuljza-9kEXg4kSJpg,1070
19
+ schenesort-2.4.0.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,,