schenesort 2.1.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.
@@ -0,0 +1,97 @@
1
+ """Image preview widget using textual-image."""
2
+
3
+ from pathlib import Path
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Container
7
+ from textual.widgets import Static
8
+ from textual_image.widget import Image
9
+
10
+
11
+ class ImagePreview(Container):
12
+ """Widget for displaying image preview with zoom support."""
13
+
14
+ DEFAULT_CSS = """
15
+ ImagePreview {
16
+ width: 100%;
17
+ height: 100%;
18
+ background: $background;
19
+ align: center middle;
20
+ }
21
+
22
+ ImagePreview Image {
23
+ width: 100%;
24
+ height: 100%;
25
+ }
26
+
27
+ ImagePreview .no-image {
28
+ color: $text-disabled;
29
+ text-style: italic;
30
+ text-align: center;
31
+ }
32
+ """
33
+
34
+ def __init__(self, **kwargs) -> None:
35
+ super().__init__(**kwargs)
36
+ self._image_path: Path | None = None
37
+ self._zoom_level: float = 1.0
38
+ self._image_widget: Image | None = None
39
+
40
+ def compose(self) -> ComposeResult:
41
+ yield Static("No image selected", classes="no-image", id="placeholder")
42
+
43
+ def load_image(self, path: Path | None) -> None:
44
+ """Load and display an image."""
45
+ self._image_path = path
46
+ self._zoom_level = 1.0
47
+
48
+ # Remove existing image widget if present
49
+ if self._image_widget is not None:
50
+ self._image_widget.remove()
51
+ self._image_widget = None
52
+
53
+ placeholder = self.query_one("#placeholder", Static)
54
+
55
+ if path is None or not path.exists():
56
+ placeholder.update("No image selected")
57
+ placeholder.display = True
58
+ return
59
+
60
+ try:
61
+ placeholder.display = False
62
+ self._image_widget = Image(path)
63
+ self.mount(self._image_widget)
64
+ except Exception as e:
65
+ placeholder.update(f"Error loading image: {e}")
66
+ placeholder.display = True
67
+
68
+ def zoom_in(self) -> None:
69
+ """Zoom in on the image."""
70
+ self._zoom_level = min(4.0, self._zoom_level * 1.25)
71
+ self._apply_zoom()
72
+
73
+ def zoom_out(self) -> None:
74
+ """Zoom out of the image."""
75
+ self._zoom_level = max(0.25, self._zoom_level / 1.25)
76
+ self._apply_zoom()
77
+
78
+ def reset_zoom(self) -> None:
79
+ """Reset zoom to 100%."""
80
+ self._zoom_level = 1.0
81
+ self._apply_zoom()
82
+
83
+ def _apply_zoom(self) -> None:
84
+ """Apply current zoom level to the image widget."""
85
+ # textual-image handles scaling internally based on container size
86
+ # For now, zoom is a placeholder for future enhancement
87
+ pass
88
+
89
+ @property
90
+ def current_path(self) -> Path | None:
91
+ """Get the currently displayed image path."""
92
+ return self._image_path
93
+
94
+ @property
95
+ def zoom_level(self) -> float:
96
+ """Get the current zoom level."""
97
+ return self._zoom_level
@@ -0,0 +1,161 @@
1
+ """Metadata panel widget for displaying image metadata."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import VerticalScroll
5
+ from textual.widgets import Static
6
+
7
+ from schenesort.xmp import ImageMetadata
8
+
9
+
10
+ class MetadataPanel(VerticalScroll):
11
+ """Panel displaying image metadata from XMP sidecar."""
12
+
13
+ DEFAULT_CSS = """
14
+ MetadataPanel {
15
+ width: 100%;
16
+ height: 100%;
17
+ background: $surface;
18
+ padding: 1 2;
19
+ }
20
+
21
+ MetadataPanel .metadata-title {
22
+ text-style: bold;
23
+ color: $text;
24
+ margin-bottom: 1;
25
+ }
26
+
27
+ MetadataPanel .metadata-label {
28
+ color: $text-muted;
29
+ margin-top: 1;
30
+ }
31
+
32
+ MetadataPanel .metadata-value {
33
+ color: $text;
34
+ margin-left: 2;
35
+ }
36
+
37
+ MetadataPanel .metadata-empty {
38
+ color: $text-disabled;
39
+ text-style: italic;
40
+ }
41
+
42
+ MetadataPanel .metadata-tags {
43
+ color: $primary;
44
+ }
45
+
46
+ MetadataPanel .metadata-colors {
47
+ color: $secondary;
48
+ }
49
+
50
+ MetadataPanel .metadata-scene {
51
+ color: $text;
52
+ margin-left: 2;
53
+ text-style: italic;
54
+ }
55
+ """
56
+
57
+ def __init__(self, **kwargs) -> None:
58
+ super().__init__(**kwargs)
59
+ self._metadata: ImageMetadata | None = None
60
+ self._filename: str = ""
61
+
62
+ def compose(self) -> ComposeResult:
63
+ yield Static("No image selected", classes="metadata-empty", id="content")
64
+
65
+ def update_metadata(self, metadata: ImageMetadata | None, filename: str = "") -> None:
66
+ """Update the displayed metadata."""
67
+ self._metadata = metadata
68
+ self._filename = filename
69
+ self._refresh_display()
70
+
71
+ def _refresh_display(self) -> None:
72
+ """Refresh the metadata display."""
73
+ content = self.query_one("#content", Static)
74
+
75
+ if self._metadata is None or self._metadata.is_empty():
76
+ if self._filename:
77
+ content.update(
78
+ f"[bold]{self._filename}[/bold]\n\n[dim italic]No metadata[/dim italic]"
79
+ )
80
+ else:
81
+ content.update("[dim italic]No image selected[/dim italic]")
82
+ return
83
+
84
+ lines = []
85
+
86
+ # Filename as title
87
+ if self._filename:
88
+ lines.append(f"[bold]{self._filename}[/bold]")
89
+ lines.append("")
90
+
91
+ # Description
92
+ if self._metadata.description:
93
+ lines.append("[dim]Description[/dim]")
94
+ lines.append(f" {self._metadata.description}")
95
+
96
+ # Scene (detailed description)
97
+ if self._metadata.scene:
98
+ lines.append("")
99
+ lines.append("[dim]Scene[/dim]")
100
+ lines.append(f" [italic]{self._metadata.scene}[/italic]")
101
+
102
+ # Tags
103
+ if self._metadata.tags:
104
+ lines.append("")
105
+ lines.append("[dim]Tags[/dim]")
106
+ tags_str = ", ".join(f"[cyan]{tag}[/cyan]" for tag in self._metadata.tags)
107
+ lines.append(f" {tags_str}")
108
+
109
+ # Mood
110
+ if self._metadata.mood:
111
+ lines.append("")
112
+ lines.append("[dim]Mood[/dim]")
113
+ mood_str = ", ".join(f"[magenta]{m}[/magenta]" for m in self._metadata.mood)
114
+ lines.append(f" {mood_str}")
115
+
116
+ # Style
117
+ if self._metadata.style:
118
+ lines.append("")
119
+ lines.append("[dim]Style[/dim]")
120
+ lines.append(f" [yellow]{self._metadata.style}[/yellow]")
121
+
122
+ # Colors
123
+ if self._metadata.colors:
124
+ lines.append("")
125
+ lines.append("[dim]Colors[/dim]")
126
+ colors_str = ", ".join(f"[green]{c}[/green]" for c in self._metadata.colors)
127
+ lines.append(f" {colors_str}")
128
+
129
+ # Time of day
130
+ if self._metadata.time_of_day:
131
+ lines.append("")
132
+ lines.append("[dim]Time[/dim]")
133
+ lines.append(f" {self._metadata.time_of_day}")
134
+
135
+ # Subject
136
+ if self._metadata.subject:
137
+ lines.append("")
138
+ lines.append("[dim]Subject[/dim]")
139
+ lines.append(f" {self._metadata.subject}")
140
+
141
+ # Dimensions
142
+ if self._metadata.width and self._metadata.height:
143
+ lines.append("")
144
+ lines.append("[dim]Dimensions[/dim]")
145
+ lines.append(f" {self._metadata.width} x {self._metadata.height}")
146
+ if self._metadata.recommended_screen:
147
+ lines.append(f" [green]Best for: {self._metadata.recommended_screen}[/green]")
148
+
149
+ # Source
150
+ if self._metadata.source:
151
+ lines.append("")
152
+ lines.append("[dim]Source[/dim]")
153
+ lines.append(f" [blue]{self._metadata.source}[/blue]")
154
+
155
+ # AI Model
156
+ if self._metadata.ai_model:
157
+ lines.append("")
158
+ lines.append("[dim]AI Model[/dim]")
159
+ lines.append(f" [dim]{self._metadata.ai_model}[/dim]")
160
+
161
+ content.update("\n".join(lines))
schenesort/xmp.py ADDED
@@ -0,0 +1,294 @@
1
+ """XMP sidecar file handling for image metadata."""
2
+
3
+ import xml.etree.ElementTree as ET
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ import defusedxml.ElementTree as DefusedET
8
+
9
+ # XML namespaces
10
+ NAMESPACES = {
11
+ "x": "adobe:ns:meta/",
12
+ "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
13
+ "dc": "http://purl.org/dc/elements/1.1/",
14
+ "schenesort": "http://github.com/sthysel/schenesort/",
15
+ }
16
+
17
+ # Register namespaces for writing
18
+ for prefix, uri in NAMESPACES.items():
19
+ ET.register_namespace(prefix, uri)
20
+
21
+
22
+ # Common screen resolutions for recommendations
23
+ SCREEN_RESOLUTIONS = [
24
+ ("8K", 7680, 4320),
25
+ ("5K", 5120, 2880),
26
+ ("4K", 3840, 2160),
27
+ ("Ultrawide 4K", 5120, 2160),
28
+ ("Ultrawide 1440p", 3440, 1440),
29
+ ("1440p", 2560, 1440),
30
+ ("Ultrawide 1080p", 2560, 1080),
31
+ ("1080p", 1920, 1080),
32
+ ("720p", 1280, 720),
33
+ ]
34
+
35
+
36
+ def get_recommended_screen(width: int, height: int) -> str:
37
+ """Determine the recommended screen size based on image dimensions."""
38
+ if width <= 0 or height <= 0:
39
+ return ""
40
+
41
+ # Find the largest screen this image can cover without upscaling
42
+ recommendations = []
43
+ for name, sw, sh in SCREEN_RESOLUTIONS:
44
+ if width >= sw and height >= sh:
45
+ recommendations.append(name)
46
+
47
+ if not recommendations:
48
+ return "below 720p"
49
+
50
+ # Return the best (first) match
51
+ return recommendations[0]
52
+
53
+
54
+ @dataclass
55
+ class ImageMetadata:
56
+ """Metadata for a wallpaper image."""
57
+
58
+ description: str = ""
59
+ scene: str = "" # Fuller scene description
60
+ tags: list[str] = field(default_factory=list)
61
+ mood: list[str] = field(default_factory=list)
62
+ style: str = ""
63
+ colors: list[str] = field(default_factory=list)
64
+ time_of_day: str = ""
65
+ subject: str = ""
66
+ source: str = ""
67
+ ai_model: str = ""
68
+ width: int = 0
69
+ height: int = 0
70
+ recommended_screen: str = ""
71
+
72
+ def is_empty(self) -> bool:
73
+ return not any(
74
+ [
75
+ self.description,
76
+ self.scene,
77
+ self.tags,
78
+ self.mood,
79
+ self.style,
80
+ self.colors,
81
+ self.time_of_day,
82
+ self.subject,
83
+ self.source,
84
+ self.ai_model,
85
+ self.width,
86
+ self.height,
87
+ ]
88
+ )
89
+
90
+
91
+ def get_xmp_path(image_path: Path) -> Path:
92
+ """Get the XMP sidecar path for an image."""
93
+ return image_path.parent / f"{image_path.name}.xmp"
94
+
95
+
96
+ def read_xmp(image_path: Path) -> ImageMetadata:
97
+ """Read metadata from XMP sidecar file."""
98
+ xmp_path = get_xmp_path(image_path)
99
+
100
+ if not xmp_path.exists():
101
+ return ImageMetadata()
102
+
103
+ try:
104
+ tree = DefusedET.parse(xmp_path)
105
+ root = tree.getroot()
106
+
107
+ metadata = ImageMetadata()
108
+
109
+ # Find the Description element
110
+ desc_elem = root.find(".//rdf:Description", NAMESPACES)
111
+ if desc_elem is None:
112
+ return metadata
113
+
114
+ # Read description
115
+ dc_desc = desc_elem.find("dc:description", NAMESPACES)
116
+ if dc_desc is not None:
117
+ # Handle both simple text and Alt/li structure
118
+ alt = dc_desc.find("rdf:Alt/rdf:li", NAMESPACES)
119
+ if alt is not None and alt.text:
120
+ metadata.description = alt.text
121
+ elif dc_desc.text:
122
+ metadata.description = dc_desc.text
123
+
124
+ # Read tags
125
+ dc_subject = desc_elem.find("dc:subject/rdf:Bag", NAMESPACES)
126
+ if dc_subject is not None:
127
+ for li in dc_subject.findall("rdf:li", NAMESPACES):
128
+ if li.text:
129
+ metadata.tags.append(li.text)
130
+
131
+ # Read source
132
+ dc_source = desc_elem.find("dc:source", NAMESPACES)
133
+ if dc_source is not None and dc_source.text:
134
+ metadata.source = dc_source.text
135
+
136
+ # Read AI model
137
+ ai_model = desc_elem.find("schenesort:ai_model", NAMESPACES)
138
+ if ai_model is not None and ai_model.text:
139
+ metadata.ai_model = ai_model.text
140
+
141
+ # Read mood
142
+ mood_elem = desc_elem.find("schenesort:mood/rdf:Bag", NAMESPACES)
143
+ if mood_elem is not None:
144
+ for li in mood_elem.findall("rdf:li", NAMESPACES):
145
+ if li.text:
146
+ metadata.mood.append(li.text)
147
+
148
+ # Read style
149
+ style_elem = desc_elem.find("schenesort:style", NAMESPACES)
150
+ if style_elem is not None and style_elem.text:
151
+ metadata.style = style_elem.text
152
+
153
+ # Read colors
154
+ colors_elem = desc_elem.find("schenesort:colors/rdf:Bag", NAMESPACES)
155
+ if colors_elem is not None:
156
+ for li in colors_elem.findall("rdf:li", NAMESPACES):
157
+ if li.text:
158
+ metadata.colors.append(li.text)
159
+
160
+ # Read time of day
161
+ time_elem = desc_elem.find("schenesort:time_of_day", NAMESPACES)
162
+ if time_elem is not None and time_elem.text:
163
+ metadata.time_of_day = time_elem.text
164
+
165
+ # Read subject
166
+ subject_elem = desc_elem.find("schenesort:subject", NAMESPACES)
167
+ if subject_elem is not None and subject_elem.text:
168
+ metadata.subject = subject_elem.text
169
+
170
+ # Read scene (full description)
171
+ scene_elem = desc_elem.find("schenesort:scene", NAMESPACES)
172
+ if scene_elem is not None and scene_elem.text:
173
+ metadata.scene = scene_elem.text
174
+
175
+ # Read dimensions
176
+ width_elem = desc_elem.find("schenesort:width", NAMESPACES)
177
+ if width_elem is not None and width_elem.text:
178
+ try:
179
+ metadata.width = int(width_elem.text)
180
+ except ValueError:
181
+ pass
182
+
183
+ height_elem = desc_elem.find("schenesort:height", NAMESPACES)
184
+ if height_elem is not None and height_elem.text:
185
+ try:
186
+ metadata.height = int(height_elem.text)
187
+ except ValueError:
188
+ pass
189
+
190
+ # Read recommended screen
191
+ screen_elem = desc_elem.find("schenesort:recommended_screen", NAMESPACES)
192
+ if screen_elem is not None and screen_elem.text:
193
+ metadata.recommended_screen = screen_elem.text
194
+
195
+ return metadata
196
+
197
+ except Exception:
198
+ return ImageMetadata()
199
+
200
+
201
+ def write_xmp(image_path: Path, metadata: ImageMetadata) -> None:
202
+ """Write metadata to XMP sidecar file."""
203
+ xmp_path = get_xmp_path(image_path)
204
+
205
+ # Build XMP structure
206
+ root = ET.Element(f"{{{NAMESPACES['x']}}}xmpmeta")
207
+
208
+ rdf = ET.SubElement(root, f"{{{NAMESPACES['rdf']}}}RDF")
209
+ desc = ET.SubElement(rdf, f"{{{NAMESPACES['rdf']}}}Description")
210
+
211
+ # Add description
212
+ if metadata.description:
213
+ dc_desc = ET.SubElement(desc, f"{{{NAMESPACES['dc']}}}description")
214
+ alt = ET.SubElement(dc_desc, f"{{{NAMESPACES['rdf']}}}Alt")
215
+ li = ET.SubElement(alt, f"{{{NAMESPACES['rdf']}}}li")
216
+ li.set(f"{{{NAMESPACES['rdf']}}}parseType", "Literal")
217
+ li.text = metadata.description
218
+
219
+ # Add tags
220
+ if metadata.tags:
221
+ dc_subject = ET.SubElement(desc, f"{{{NAMESPACES['dc']}}}subject")
222
+ bag = ET.SubElement(dc_subject, f"{{{NAMESPACES['rdf']}}}Bag")
223
+ for tag in metadata.tags:
224
+ li = ET.SubElement(bag, f"{{{NAMESPACES['rdf']}}}li")
225
+ li.text = tag
226
+
227
+ # Add source
228
+ if metadata.source:
229
+ dc_source = ET.SubElement(desc, f"{{{NAMESPACES['dc']}}}source")
230
+ dc_source.text = metadata.source
231
+
232
+ # Add AI model
233
+ if metadata.ai_model:
234
+ ai_model = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}ai_model")
235
+ ai_model.text = metadata.ai_model
236
+
237
+ # Add mood
238
+ if metadata.mood:
239
+ mood_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}mood")
240
+ bag = ET.SubElement(mood_elem, f"{{{NAMESPACES['rdf']}}}Bag")
241
+ for mood in metadata.mood:
242
+ li = ET.SubElement(bag, f"{{{NAMESPACES['rdf']}}}li")
243
+ li.text = mood
244
+
245
+ # Add style
246
+ if metadata.style:
247
+ style_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}style")
248
+ style_elem.text = metadata.style
249
+
250
+ # Add colors
251
+ if metadata.colors:
252
+ colors_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}colors")
253
+ bag = ET.SubElement(colors_elem, f"{{{NAMESPACES['rdf']}}}Bag")
254
+ for color in metadata.colors:
255
+ li = ET.SubElement(bag, f"{{{NAMESPACES['rdf']}}}li")
256
+ li.text = color
257
+
258
+ # Add time of day
259
+ if metadata.time_of_day:
260
+ time_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}time_of_day")
261
+ time_elem.text = metadata.time_of_day
262
+
263
+ # Add subject
264
+ if metadata.subject:
265
+ subject_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}subject")
266
+ subject_elem.text = metadata.subject
267
+
268
+ # Add scene (full description)
269
+ if metadata.scene:
270
+ scene_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}scene")
271
+ scene_elem.text = metadata.scene
272
+
273
+ # Add dimensions
274
+ if metadata.width > 0:
275
+ width_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}width")
276
+ width_elem.text = str(metadata.width)
277
+
278
+ if metadata.height > 0:
279
+ height_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}height")
280
+ height_elem.text = str(metadata.height)
281
+
282
+ # Add recommended screen
283
+ if metadata.recommended_screen:
284
+ screen_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}recommended_screen")
285
+ screen_elem.text = metadata.recommended_screen
286
+
287
+ # Write to file
288
+ tree = ET.ElementTree(root)
289
+ ET.indent(tree, space=" ")
290
+
291
+ with open(xmp_path, "w", encoding="utf-8") as f:
292
+ f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
293
+ tree.write(f, encoding="unicode")
294
+ f.write("\n")