Last active
January 2, 2026 10:05
-
-
Save sharl/7af787ad95ea8852575d6153dcdd96f6 to your computer and use it in GitHub Desktop.
ローカルの画像が更新されたら自動で再表示
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # -*- coding: utf-8 -*- | |
| import tkinter as tk | |
| from PIL import Image, ImageTk | |
| from watchdog.observers import Observer | |
| from watchdog.events import FileSystemEventHandler | |
| import pystray | |
| from pystray import MenuItem as item | |
| import threading | |
| import os | |
| import time | |
| class ImageChangeHandler(FileSystemEventHandler): | |
| def __init__(self, callback, target_path): | |
| self.callback = callback | |
| self.target_path = os.path.abspath(target_path) | |
| def on_modified(self, event): | |
| if os.path.abspath(event.src_path) == self.target_path: | |
| time.sleep(0.1) | |
| self.callback() | |
| class ImageViewer(tk.Tk): | |
| def __init__(self, image_path): | |
| super().__init__() | |
| self.image_path = image_path | |
| self.overrideredirect(True) | |
| self.attributes('-topmost', True) | |
| self.configure(bg='black') | |
| self.withdraw() | |
| self.zoom_level = 1.0 | |
| self.is_fit_mode = False | |
| self.original_img = None | |
| self.topmost_var = tk.BooleanVar(value=True) | |
| self.label = tk.Label(self, bg='black') | |
| self.label.pack(expand=True, fill='both') | |
| self.context_menu = tk.Menu(self, tearoff=0) | |
| self.context_menu.add_checkbutton(label='常に最前面', variable=self.topmost_var, command=self.sync_topmost) | |
| self.context_menu.add_command(label='フィット表示切替 (F)', command=self.toggle_fit) | |
| self.context_menu.add_separator() | |
| self.context_menu.add_command(label='非表示にする', command=self.withdraw) | |
| self.context_menu.add_command(label='終了 (Esc)', command=self.quit_app) | |
| self.bind('<Escape>', lambda e: self.quit_app()) | |
| self.bind('f', lambda e: self.toggle_fit()) | |
| self.label.bind('<Button-3>', self.show_context_menu) | |
| self.label.bind('<Button-1>', self.start_move) | |
| self.label.bind('<B1-Motion>', self.do_move) | |
| self.bind('<MouseWheel>', self.handle_zoom) | |
| self.load_image() | |
| self.start_watching() | |
| self.setup_tray() | |
| self.deiconify() | |
| def load_image(self): | |
| if not os.path.exists(self.image_path): | |
| return | |
| try: | |
| with Image.open(self.image_path) as img: | |
| self.original_img = img.copy() | |
| self.refresh_display() | |
| except Exception as e: | |
| print(f'Load Error: {e}') | |
| def refresh_display(self): | |
| if self.original_img is None: | |
| return | |
| orig_w, orig_h = self.original_img.size | |
| sw, sh = self.winfo_screenwidth(), self.winfo_screenheight() | |
| if self.is_fit_mode: | |
| ratio = min(sw / orig_w, sh / orig_h) | |
| nw, nh = int(orig_w * ratio), int(orig_h * ratio) | |
| self.geometry(f'{nw}x{nh}+{(sw-nw)//2}+{(sh-nh)//2}') | |
| else: | |
| nw, nh = int(orig_w * self.zoom_level), int(orig_h * self.zoom_level) | |
| self.geometry(f'{max(nw, 1)}x{max(nh, 1)}') | |
| resized = self.original_img.resize((max(nw, 1), max(nh, 1)), Image.Resampling.LANCZOS) | |
| self.photo = ImageTk.PhotoImage(resized) | |
| self.label.config(image=self.photo) | |
| def handle_zoom(self, event): | |
| self.zoom_level *= 1.1 if event.delta > 0 else 0.9 | |
| self.is_fit_mode = False | |
| self.refresh_display() | |
| def toggle_fit(self): | |
| self.is_fit_mode = not self.is_fit_mode | |
| if not self.is_fit_mode: | |
| self.zoom_level = 1.0 | |
| self.refresh_display() | |
| def sync_topmost(self): | |
| self.attributes('-topmost', self.topmost_var.get()) | |
| def start_move(self, event): | |
| self._drag_data = {'x': event.x, 'y': event.y} | |
| def do_move(self, event): | |
| dx, dy = event.x - self._drag_data['x'], event.y - self._drag_data['y'] | |
| self.geometry(f'+{self.winfo_x() + dx}+{self.winfo_y() + dy}') | |
| def show_context_menu(self, event): | |
| self.context_menu.post(event.x_root, event.y_root) | |
| def setup_tray(self): | |
| icon_img = self.original_img.resize((64, 64)) if self.original_img else Image.new('RGB', (64, 64), (255, 255, 255)) | |
| menu = pystray.Menu( | |
| item('Show', self.show_window, default=True), | |
| item('Hide', self.withdraw), | |
| item('Exit', self.quit_app) | |
| ) | |
| self.tray_icon = pystray.Icon('image_watcher', icon_img, 'Image Watcher', menu) | |
| threading.Thread(target=self.tray_icon.run, daemon=True).start() | |
| def show_window(self): | |
| self.after(0, self.deiconify) | |
| self.after(0, lambda: self.attributes('-topmost', True)) | |
| def start_watching(self): | |
| event_handler = ImageChangeHandler(lambda: self.after(0, self.load_image), self.image_path) | |
| self.observer = Observer() | |
| self.observer.schedule(event_handler, os.path.dirname(os.path.abspath(self.image_path)), recursive=False) | |
| self.observer.start() | |
| def quit_app(self): | |
| self.observer.stop() | |
| self.tray_icon.stop() | |
| self.destroy() | |
| if __name__ == '__main__': | |
| import sys | |
| path = str() | |
| if len(sys.argv) > 1: | |
| path = sys.argv[1] | |
| if os.path.exists(path): | |
| app = ImageViewer(path) | |
| app.mainloop() | |
| else: | |
| print(f'File not found: {path}') |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment