Photo-Composition-Designer 0.0.7__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.
- Photo_Composition_Designer/__init__.py +0 -0
- Photo_Composition_Designer/__main__.py +8 -0
- Photo_Composition_Designer/_version.py +34 -0
- Photo_Composition_Designer/cli/__init__.py +0 -0
- Photo_Composition_Designer/cli/__main__.py +8 -0
- Photo_Composition_Designer/cli/cli.py +106 -0
- Photo_Composition_Designer/common/Anniversaries.py +93 -0
- Photo_Composition_Designer/common/Locations.py +87 -0
- Photo_Composition_Designer/common/MoonPhase.py +85 -0
- Photo_Composition_Designer/common/Photo.py +113 -0
- Photo_Composition_Designer/common/__init__.py +0 -0
- Photo_Composition_Designer/common/logging.py +216 -0
- Photo_Composition_Designer/config/__init__.py +0 -0
- Photo_Composition_Designer/config/config.py +321 -0
- Photo_Composition_Designer/core/__init__.py +0 -0
- Photo_Composition_Designer/core/base.py +383 -0
- Photo_Composition_Designer/gui/GuiLogWriter.py +79 -0
- Photo_Composition_Designer/gui/__init__.py +0 -0
- Photo_Composition_Designer/gui/__main__.py +8 -0
- Photo_Composition_Designer/gui/gui.py +565 -0
- Photo_Composition_Designer/image/CalendarRenderer.py +319 -0
- Photo_Composition_Designer/image/CollageRenderer.py +433 -0
- Photo_Composition_Designer/image/DescriptionRenderer.py +74 -0
- Photo_Composition_Designer/image/MapRenderer.py +101 -0
- Photo_Composition_Designer/image/__init__.py +0 -0
- Photo_Composition_Designer/tools/DescriptionsFileGenerator.py +44 -0
- Photo_Composition_Designer/tools/GeoPlotter.py +211 -0
- Photo_Composition_Designer/tools/Helpers.py +18 -0
- Photo_Composition_Designer/tools/ImageDistributor.py +153 -0
- Photo_Composition_Designer/tools/__init__.py +0 -0
- __init__.py +0 -0
- firewall_handler.py +198 -0
- main.py +146 -0
- path_handler.py +10 -0
- photo_composition_designer-0.0.7.dist-info/METADATA +205 -0
- photo_composition_designer-0.0.7.dist-info/RECORD +40 -0
- photo_composition_designer-0.0.7.dist-info/WHEEL +5 -0
- photo_composition_designer-0.0.7.dist-info/entry_points.txt +3 -0
- photo_composition_designer-0.0.7.dist-info/licenses/LICENSE +24 -0
- photo_composition_designer-0.0.7.dist-info/top_level.txt +5 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# Photo_Composition_Designer/core/base.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from PIL import Image, ImageDraw
|
|
10
|
+
|
|
11
|
+
from Photo_Composition_Designer.common.Locations import Locations
|
|
12
|
+
from Photo_Composition_Designer.common.Photo import Photo, get_photos_from_dir
|
|
13
|
+
from Photo_Composition_Designer.config.config import ConfigParameterManager
|
|
14
|
+
from Photo_Composition_Designer.image.CalendarRenderer import (
|
|
15
|
+
CalendarRenderer,
|
|
16
|
+
from_config,
|
|
17
|
+
)
|
|
18
|
+
from Photo_Composition_Designer.image.CollageRenderer import CollageRenderer
|
|
19
|
+
from Photo_Composition_Designer.image.DescriptionRenderer import DescriptionRenderer
|
|
20
|
+
from Photo_Composition_Designer.image.MapRenderer import MapRenderer
|
|
21
|
+
from Photo_Composition_Designer.tools.Helpers import load_font
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CompositionDesigner:
|
|
25
|
+
"""
|
|
26
|
+
CompositionDesigner adapted to the new ConfigParameterManager.
|
|
27
|
+
|
|
28
|
+
- Converts mm-based sizes in the config to pixels using config.size.dpi.value
|
|
29
|
+
- Uses create_calendar_generator_from_config to create a CalendarGenerator
|
|
30
|
+
- Accesses parameters through config.<category>.<param>.value
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, config: ConfigParameterManager | None = None):
|
|
34
|
+
self.config = config or ConfigParameterManager()
|
|
35
|
+
self.dpi: int = int(self.config.size.dpi.value)
|
|
36
|
+
# load locations config path and create Locations instance
|
|
37
|
+
locations_cfg_path = Path(self.config.general.locationsConfig.value)
|
|
38
|
+
self.locations = Locations(locations_cfg_path).locations_dict
|
|
39
|
+
|
|
40
|
+
# mm-based -> pixel helper bound to this instance
|
|
41
|
+
self._mm_to_px = lambda mm: int(round(float(mm) * self.dpi / 25.4))
|
|
42
|
+
self.fontSizeSmall = int(
|
|
43
|
+
config.layout.fontSizeSmall.value
|
|
44
|
+
* config.size.calendarHeight.value
|
|
45
|
+
* config.size.dpi.value
|
|
46
|
+
/ 25.4
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# basic properties
|
|
50
|
+
self.compositionTitle: str | None = self.config.general.compositionTitle.value or ""
|
|
51
|
+
self.photoDir: Path = Path(self.config.general.photoDirectory.value).expanduser().resolve()
|
|
52
|
+
self.outputDir: Path = (self.photoDir.parent / "collages").resolve()
|
|
53
|
+
os.makedirs(self.outputDir, exist_ok=True)
|
|
54
|
+
self.descriptions = self._get_description(self.photoDir)
|
|
55
|
+
|
|
56
|
+
# size in pixels
|
|
57
|
+
self.width_px = self._mm_to_px(self.config.size.width.value)
|
|
58
|
+
self.height_px = self._mm_to_px(self.config.size.height.value)
|
|
59
|
+
|
|
60
|
+
# margins / spacing in pixels
|
|
61
|
+
self.margin_top_px = self._mm_to_px(self.config.layout.marginTop.value)
|
|
62
|
+
self.margin_bottom_px = self._mm_to_px(self.config.layout.marginBottom.value)
|
|
63
|
+
self.margin_sides_px = self._mm_to_px(self.config.layout.marginSides.value)
|
|
64
|
+
self.spacing_px = self._mm_to_px(self.config.layout.spacing.value)
|
|
65
|
+
|
|
66
|
+
# calendar sizes
|
|
67
|
+
self.calendar_height_px = self._mm_to_px(self.config.size.calendarHeight.value)
|
|
68
|
+
self.map_width_px = self._mm_to_px(self.config.size.mapWidth.value)
|
|
69
|
+
self.map_height_px = self._mm_to_px(self.config.size.mapHeight.value)
|
|
70
|
+
|
|
71
|
+
# colors (Color objects have .to_pil() in your calendar factory)
|
|
72
|
+
# Use the calendar factory which expects the full config object
|
|
73
|
+
self.calendarObj: CalendarRenderer = from_config(self.config)
|
|
74
|
+
|
|
75
|
+
# colors
|
|
76
|
+
background_color = self.config.colors.backgroundColor.value.to_pil()
|
|
77
|
+
text_color1 = self.config.colors.textColor1.value.to_pil()
|
|
78
|
+
text_color2 = self.config.colors.textColor2.value.to_pil()
|
|
79
|
+
|
|
80
|
+
# Create other helpers/generators — pass config object for them to pull values from.
|
|
81
|
+
# If their constructors changed, update these lines accordingly.
|
|
82
|
+
self.mapGenerator: MapRenderer = MapRenderer(
|
|
83
|
+
self.map_height_px,
|
|
84
|
+
self.map_width_px,
|
|
85
|
+
self.config.geo.minimalExtension.value,
|
|
86
|
+
background_color,
|
|
87
|
+
text_color1,
|
|
88
|
+
)
|
|
89
|
+
self.descGenerator: DescriptionRenderer = DescriptionRenderer(
|
|
90
|
+
self.width_px,
|
|
91
|
+
self.fontSizeSmall,
|
|
92
|
+
self.spacing_px,
|
|
93
|
+
self.margin_sides_px,
|
|
94
|
+
background_color,
|
|
95
|
+
text_color2,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# startDate: if title present we keep the previous behavior (shift -7 days)
|
|
99
|
+
|
|
100
|
+
# Photo layout manager expects pixel dims: width, collage_height, spacing, backgroundColor
|
|
101
|
+
collage_height_px = self.get_available_collage_height_px()
|
|
102
|
+
self.layoutManager: CollageRenderer = CollageRenderer(
|
|
103
|
+
self.width_px, collage_height_px, self.spacing_px, background_color
|
|
104
|
+
)
|
|
105
|
+
start_date_cfg = self.config.calendar.startDate.value
|
|
106
|
+
if self.compositionTitle:
|
|
107
|
+
self.startDate = start_date_cfg - timedelta(days=7)
|
|
108
|
+
else:
|
|
109
|
+
self.startDate = start_date_cfg
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------
|
|
112
|
+
# Helpers: unit conversions & derived sizes
|
|
113
|
+
# ---------------------------------------------------------------------
|
|
114
|
+
def get_available_collage_height_px(self) -> int:
|
|
115
|
+
"""
|
|
116
|
+
Compute available vertical space for the collage area in pixels.
|
|
117
|
+
This subtracts calendar and description heights when configured.
|
|
118
|
+
"""
|
|
119
|
+
available_height = self.height_px
|
|
120
|
+
|
|
121
|
+
# calendar or title reduces space
|
|
122
|
+
if self.config.calendar.useCalendar.value or bool(self.compositionTitle):
|
|
123
|
+
available_height -= self.calendar_height_px + self.margin_bottom_px + self.margin_top_px
|
|
124
|
+
|
|
125
|
+
# description area
|
|
126
|
+
if self.config.layout.usePhotoDescription.value:
|
|
127
|
+
# descGenerator should expose .height in pixels like before; if not, compute it
|
|
128
|
+
desc_height = getattr(self.descGenerator, "height", None)
|
|
129
|
+
if desc_height is None:
|
|
130
|
+
# fallback: estimate description height using layout font sizes & calendarHeight
|
|
131
|
+
desc_height = self._mm_to_px(self.config.size.calendarHeight.value // 4)
|
|
132
|
+
available_height -= desc_height
|
|
133
|
+
|
|
134
|
+
# ensure positive height
|
|
135
|
+
return max(0, int(available_height))
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------
|
|
138
|
+
# Composition rendering
|
|
139
|
+
# ---------------------------------------------------------------------
|
|
140
|
+
def _generate_composition(
|
|
141
|
+
self,
|
|
142
|
+
photos: list[Photo],
|
|
143
|
+
date,
|
|
144
|
+
photo_description: str = "",
|
|
145
|
+
is_title=False,
|
|
146
|
+
) -> Image.Image:
|
|
147
|
+
"""
|
|
148
|
+
Creates a composition with pictures, a calendar and a map of Europe with photo locations.
|
|
149
|
+
"""
|
|
150
|
+
background_color = self.config.colors.backgroundColor.value.to_pil()
|
|
151
|
+
text_color2 = self.config.colors.textColor2.value.to_pil()
|
|
152
|
+
|
|
153
|
+
composition = Image.new("RGB", (self.width_px, self.height_px), background_color)
|
|
154
|
+
available_cal_width = self.width_px
|
|
155
|
+
|
|
156
|
+
# add title or calendar
|
|
157
|
+
if is_title and self.compositionTitle:
|
|
158
|
+
title_img = self.calendarObj.generateTitle(
|
|
159
|
+
self.compositionTitle, available_cal_width, self.calendar_height_px
|
|
160
|
+
)
|
|
161
|
+
composition.paste(
|
|
162
|
+
title_img, (self.margin_sides_px, self.height_px - self.calendar_height_px)
|
|
163
|
+
)
|
|
164
|
+
elif self.config.calendar.useCalendar.value:
|
|
165
|
+
if self.config.geo.usePhotoLocationMaps.value:
|
|
166
|
+
available_cal_width -= self.map_width_px + self.margin_sides_px
|
|
167
|
+
calendar_img = self.calendarObj.generate(
|
|
168
|
+
date, available_cal_width, self.calendar_height_px
|
|
169
|
+
)
|
|
170
|
+
composition.paste(
|
|
171
|
+
calendar_img,
|
|
172
|
+
(
|
|
173
|
+
self.margin_sides_px,
|
|
174
|
+
self.height_px - self.calendar_height_px - self.margin_bottom_px,
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# description area
|
|
179
|
+
if self.config.layout.usePhotoDescription.value:
|
|
180
|
+
description_img = self.descGenerator.generate(photo_description)
|
|
181
|
+
desc_h = getattr(self.descGenerator, "height", description_img.size[1])
|
|
182
|
+
composition.paste(
|
|
183
|
+
description_img,
|
|
184
|
+
(
|
|
185
|
+
0,
|
|
186
|
+
self.height_px - self.calendar_height_px - desc_h - self.margin_bottom_px,
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if len(photos) == 0:
|
|
191
|
+
print("No pictures found.")
|
|
192
|
+
return composition
|
|
193
|
+
|
|
194
|
+
# Arrange image composition
|
|
195
|
+
collage = self.layoutManager.generate([photo.get_image() for photo in photos])
|
|
196
|
+
composition.paste(collage, (0, self.margin_top_px))
|
|
197
|
+
|
|
198
|
+
# add location map (if configured and not the title page)
|
|
199
|
+
if self.config.geo.usePhotoLocationMaps.value and not is_title:
|
|
200
|
+
coordinates = [loc for photo in photos if (loc := photo.get_location()) is not None]
|
|
201
|
+
imgMap = self.mapGenerator.generate(coordinates)
|
|
202
|
+
composition.paste(
|
|
203
|
+
imgMap,
|
|
204
|
+
(
|
|
205
|
+
self.width_px - self.map_width_px - self.margin_sides_px,
|
|
206
|
+
self.height_px - self.map_height_px - self.margin_bottom_px,
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# draw dates of images into lower-right corner (max 3 unique dates)
|
|
211
|
+
image_dates = [d for photo in photos if (d := photo.get_date()) is not None]
|
|
212
|
+
unique_dates = set()
|
|
213
|
+
date_str = ""
|
|
214
|
+
for d in image_dates:
|
|
215
|
+
formatted = d.strftime("%d. %b ")
|
|
216
|
+
if formatted not in unique_dates:
|
|
217
|
+
unique_dates.add(formatted)
|
|
218
|
+
date_str += formatted
|
|
219
|
+
if len(unique_dates) >= 3:
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
draw = ImageDraw.Draw(composition)
|
|
223
|
+
|
|
224
|
+
# Build small font size in px:
|
|
225
|
+
# In CalendarGenerator factory you used:
|
|
226
|
+
# font_px = layout.fontSizeSmall.value * size.calendarHeight.value * dpi / 25.4
|
|
227
|
+
font_small_px = int(
|
|
228
|
+
float(self.config.layout.fontSizeSmall.value)
|
|
229
|
+
* float(self.config.size.calendarHeight.value)
|
|
230
|
+
* self.dpi
|
|
231
|
+
/ 25.4
|
|
232
|
+
* 0.8
|
|
233
|
+
)
|
|
234
|
+
# fallback
|
|
235
|
+
if font_small_px <= 0:
|
|
236
|
+
font_small_px = max(10, int(self.dpi * 0.04))
|
|
237
|
+
|
|
238
|
+
font = load_font("DejaVuSans.ttf", font_small_px)
|
|
239
|
+
|
|
240
|
+
# Anchor rd expects coordinates relative to lower-right;
|
|
241
|
+
# to put text inside margins we shift left/up
|
|
242
|
+
x = self.width_px - self.margin_sides_px
|
|
243
|
+
y = self.height_px - self.margin_bottom_px
|
|
244
|
+
draw.text((x, y), date_str, font=font, fill=text_color2, anchor="rd")
|
|
245
|
+
|
|
246
|
+
return composition
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _get_description(folder_path: Path) -> list[str]:
|
|
250
|
+
"""
|
|
251
|
+
Search for a .txt file in the folder and return list(lines) without leading 'Label: ' parts.
|
|
252
|
+
Returns empty string or list when none found.
|
|
253
|
+
"""
|
|
254
|
+
photo_description: list[str] = [""]
|
|
255
|
+
if not folder_path.exists():
|
|
256
|
+
return photo_description
|
|
257
|
+
text_files = [
|
|
258
|
+
folder_path / file
|
|
259
|
+
for file in sorted(os.listdir(folder_path))
|
|
260
|
+
if file.lower().endswith(".txt")
|
|
261
|
+
]
|
|
262
|
+
if text_files:
|
|
263
|
+
text_file = text_files[0]
|
|
264
|
+
with open(text_file, "r", encoding="utf-8") as f:
|
|
265
|
+
lines = [line.strip() for line in f.readlines() if line.strip()]
|
|
266
|
+
photo_description = [re.sub(r"^[^:]*:\s*", "", line) for line in lines]
|
|
267
|
+
if not photo_description:
|
|
268
|
+
photo_description = [text_file.stem]
|
|
269
|
+
return photo_description
|
|
270
|
+
|
|
271
|
+
def generate_compositions_from_folder(self, folder_name: str) -> Image.Image | None:
|
|
272
|
+
"""
|
|
273
|
+
Generates a single collage for the given folder name.
|
|
274
|
+
Returns True if a composition was generated, False if skipped.
|
|
275
|
+
"""
|
|
276
|
+
folder_path = self.photoDir / folder_name
|
|
277
|
+
|
|
278
|
+
if not folder_path.is_dir():
|
|
279
|
+
print(f"{folder_path} is not a valid directory. Skipping...")
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
# Extract photos
|
|
283
|
+
photos = get_photos_from_dir(folder_path, self.locations)
|
|
284
|
+
if not photos:
|
|
285
|
+
print(f"No images found in {folder_path}, skipping...")
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
# Determine description (folder-level overrides global)
|
|
289
|
+
# Week index must be inferred from folder ordering
|
|
290
|
+
sorted_folders = sorted(
|
|
291
|
+
[f for f in os.listdir(self.photoDir) if (self.photoDir / f).is_dir()]
|
|
292
|
+
)
|
|
293
|
+
try:
|
|
294
|
+
week_index = sorted_folders.index(folder_name)
|
|
295
|
+
except ValueError:
|
|
296
|
+
print(f"Folder '{folder_name}' not found in photoDirectory (unexpected).")
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
global_description = (
|
|
300
|
+
self.descriptions[week_index] if week_index < len(self.descriptions) else ""
|
|
301
|
+
)
|
|
302
|
+
collage_description: str = self._get_description(folder_path)[0] or global_description
|
|
303
|
+
|
|
304
|
+
start_date = self.startDate + timedelta(weeks=week_index)
|
|
305
|
+
|
|
306
|
+
composition = self._generate_composition(
|
|
307
|
+
photos, start_date, collage_description, is_title=week_index == 0
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
return composition
|
|
311
|
+
|
|
312
|
+
def generate_compositions_from_folders(self):
|
|
313
|
+
"""
|
|
314
|
+
Generates collages for all weeks from the specified folder.
|
|
315
|
+
"""
|
|
316
|
+
# Precompute folder order for consistent week indexing
|
|
317
|
+
sorted_folders = sorted(
|
|
318
|
+
[f for f in os.listdir(self.photoDir) if (self.photoDir / f).is_dir()]
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
for folder_name in sorted_folders:
|
|
322
|
+
# We call the new method, which handles everything
|
|
323
|
+
composition = self.generate_compositions_from_folder(folder_name)
|
|
324
|
+
if composition:
|
|
325
|
+
self.save(composition, folder_name)
|
|
326
|
+
|
|
327
|
+
if self.config.layout.generatePdf.value:
|
|
328
|
+
self.generate_pdf(self.outputDir)
|
|
329
|
+
|
|
330
|
+
def save(self, composition: Image.Image, element: str):
|
|
331
|
+
# save with configured quality/dpi
|
|
332
|
+
output_prefix = f"collage_{element}"
|
|
333
|
+
output_file_name = f"{output_prefix}.jpg"
|
|
334
|
+
output_path = self.outputDir / output_file_name
|
|
335
|
+
jpg_quality = int(self.config.size.jpgQuality.value)
|
|
336
|
+
dpi_tuple = (self.dpi, self.dpi)
|
|
337
|
+
composition.save(output_path, quality=jpg_quality, dpi=dpi_tuple)
|
|
338
|
+
print(f"Composition saved: {output_path}")
|
|
339
|
+
|
|
340
|
+
def generate_pdf(self, collages_dir: Path | str, output_pdf: str = "output.pdf"):
|
|
341
|
+
"""
|
|
342
|
+
Creates a PDF file from all images in a directory.
|
|
343
|
+
"""
|
|
344
|
+
collages_dir = Path(collages_dir)
|
|
345
|
+
image_extensions = {".jpg", ".jpeg", ".png", ".bmp", ".gif"}
|
|
346
|
+
|
|
347
|
+
image_files = sorted(
|
|
348
|
+
[
|
|
349
|
+
f
|
|
350
|
+
for f in os.listdir(collages_dir)
|
|
351
|
+
if os.path.splitext(f)[1].lower() in image_extensions
|
|
352
|
+
]
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if not image_files:
|
|
356
|
+
print("No images found in the directory.")
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
image_list: list[Image.Image] = []
|
|
360
|
+
for image_file in image_files:
|
|
361
|
+
img_path = collages_dir / image_file
|
|
362
|
+
img = Image.open(img_path).convert("RGB")
|
|
363
|
+
image_list.append(img)
|
|
364
|
+
|
|
365
|
+
first_image, *remaining_images = image_list
|
|
366
|
+
output_path = collages_dir / output_pdf
|
|
367
|
+
first_image.save(
|
|
368
|
+
str(output_path),
|
|
369
|
+
save_all=True,
|
|
370
|
+
append_images=remaining_images,
|
|
371
|
+
quality=int(self.config.size.jpgQuality.value),
|
|
372
|
+
dpi=(self.dpi, self.dpi),
|
|
373
|
+
)
|
|
374
|
+
print(f"PDF successfully created: {output_path}")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
if __name__ == "__main__":
|
|
378
|
+
# Example usage: read default config (or pass path to config file)
|
|
379
|
+
cfg_file = None
|
|
380
|
+
# If you want to use a specific config file, you can set cfg_file = "config/config.yaml"
|
|
381
|
+
cfg = ConfigParameterManager(cfg_file)
|
|
382
|
+
cd = CompositionDesigner(cfg)
|
|
383
|
+
cd.generate_compositions_from_folders()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import tkinter as tk
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from Photo_Composition_Designer.common.logging import get_logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GuiLogWriter:
|
|
11
|
+
"""Log writer that handles GUI text widget updates in a thread-safe way."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, text_widget):
|
|
14
|
+
self.text_widget = text_widget
|
|
15
|
+
self.root = text_widget.winfo_toplevel()
|
|
16
|
+
self.hyperlink_tags = {} # To store clickable links
|
|
17
|
+
|
|
18
|
+
def write(self, text):
|
|
19
|
+
"""Write text to the widget in a thread-safe manner."""
|
|
20
|
+
# Schedule the GUI update in the main thread
|
|
21
|
+
self.root.after(0, self._update_text, text)
|
|
22
|
+
|
|
23
|
+
def _update_text(self, text):
|
|
24
|
+
"""Update the text widget (must be called from main thread)."""
|
|
25
|
+
try:
|
|
26
|
+
current_end = self.text_widget.index(tk.END)
|
|
27
|
+
self.text_widget.insert(tk.END, text)
|
|
28
|
+
|
|
29
|
+
# Check for a directory path (simplified regex for common path formats)
|
|
30
|
+
# This regex looks for paths that start with a drive letter (C:\), a forward slash (/)
|
|
31
|
+
# or a backslash (\) followed by word characters, and ends with a word character.
|
|
32
|
+
# This is a basic approach; more robust path detection might be needed for edge cases.
|
|
33
|
+
import re
|
|
34
|
+
|
|
35
|
+
path_match = re.search(
|
|
36
|
+
r"([A-Za-z]:[\\/][\S ]*|[\\][\\/][\S ]*|[\w/.-]+[/][\S ]*)\b", text
|
|
37
|
+
)
|
|
38
|
+
if path_match:
|
|
39
|
+
path = path_match.group(0).strip()
|
|
40
|
+
# Ensure the path exists and is a directory to make it clickable
|
|
41
|
+
if Path(path).is_dir():
|
|
42
|
+
start_index = self.text_widget.search(path, current_end, tk.END)
|
|
43
|
+
if start_index:
|
|
44
|
+
end_index = f"{start_index}+{len(path)}c"
|
|
45
|
+
tag_name = f"link_{len(self.hyperlink_tags)}"
|
|
46
|
+
self.text_widget.tag_config(tag_name, foreground="blue", underline=True)
|
|
47
|
+
self.text_widget.tag_bind(
|
|
48
|
+
tag_name, "<Button-1>", lambda e, p=path: self._open_path_in_explorer(p)
|
|
49
|
+
)
|
|
50
|
+
self.text_widget.tag_bind(
|
|
51
|
+
tag_name, "<Enter>", lambda e: self.text_widget.config(cursor="hand2")
|
|
52
|
+
)
|
|
53
|
+
self.text_widget.tag_bind(
|
|
54
|
+
tag_name, "<Leave>", lambda e: self.text_widget.config(cursor="")
|
|
55
|
+
)
|
|
56
|
+
self.text_widget.tag_add(tag_name, start_index, end_index)
|
|
57
|
+
self.hyperlink_tags[tag_name] = path
|
|
58
|
+
|
|
59
|
+
self.text_widget.see(tk.END)
|
|
60
|
+
self.text_widget.update_idletasks()
|
|
61
|
+
except tk.TclError:
|
|
62
|
+
# Widget might be destroyed
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def _open_path_in_explorer(self, path):
|
|
66
|
+
"""Opens the given path in the file explorer."""
|
|
67
|
+
try:
|
|
68
|
+
if sys.platform == "win32":
|
|
69
|
+
os.startfile(path)
|
|
70
|
+
elif sys.platform == "darwin":
|
|
71
|
+
subprocess.Popen(["open", path])
|
|
72
|
+
else:
|
|
73
|
+
subprocess.Popen(["xdg-open", path])
|
|
74
|
+
except Exception as e:
|
|
75
|
+
get_logger("gui.main").error(f"Failed to open path {path}: {e}")
|
|
76
|
+
|
|
77
|
+
def flush(self):
|
|
78
|
+
"""Flush method for compatibility."""
|
|
79
|
+
pass
|
|
File without changes
|