ikkVisualKit 0.0.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.
@@ -0,0 +1,167 @@
1
+ # TkUiBase.py
2
+ import tkinter as tk
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional, Callable, Any
6
+
7
+
8
+ class TkUiBase:
9
+ """
10
+ Tkは必ずメインスレッドで動かす。
11
+ サブクラスは _build(self, root) をオーバーライドする。
12
+ """
13
+ # ==========================================
14
+ def __init__(self
15
+ ,title: str = "Title"
16
+ ,size: tuple[int, int] = (600, 300)
17
+ ,icon_path: Optional[str] = None
18
+ ):
19
+ '''コンストラクタ
20
+ title: ウィンドウタイトル
21
+ size: ウィンドウサイズ (幅, 高さ)
22
+ icon_path: アイコン画像のパス(必要に応じて指定、Noneの場合はデフォルトアイコン)
23
+ '''
24
+ self._title = title
25
+ self._size = size
26
+ self._icon_path = icon_path
27
+ self._tkRoot: Optional[tk.Tk] = tk.Tk()
28
+
29
+ self._tkRoot.title(self._title)
30
+ self._tkRoot.geometry(f"{self._size[0]}x{self._size[1]}")
31
+ self._tkRoot.protocol("WM_DELETE_WINDOW", self._handle_close)
32
+ #画像をキーで管理する辞書
33
+ self._images: dict[str, tk.PhotoImage] = {}
34
+ base_dir = Path(__file__).resolve().parent
35
+ self._image_dir = base_dir / "image"
36
+
37
+ # 汎用画像を起動時に自動登録
38
+ self._register_common_images()
39
+
40
+ # UI構築
41
+ self._build(self._tkRoot)
42
+ # ==========================================
43
+ # サブクラス用
44
+ # ==========================================
45
+ def _build(self, root: tk.Tk):
46
+ """UI構築(サブクラスでオーバーライド)"""
47
+ # ウィンドウアイコン(.ico)
48
+ iconPath = self._icon_path
49
+ if iconPath and os.path.exists(iconPath):
50
+ root.iconbitmap(iconPath)
51
+ # ==========================================
52
+ # 他スレッドから安全にUI処理を投げる
53
+ # ==========================================
54
+ def invoke_ui(self, func: Callable, *args, **kwargs):
55
+ if not self._tkRoot:
56
+ return
57
+ self._tkRoot.after(0, lambda: func(*args, **kwargs))
58
+ # ==========================================
59
+ # 表示制御
60
+ # ==========================================
61
+ def show_window(self):
62
+ if not self._tkRoot:
63
+ return
64
+ self._tkRoot.deiconify()
65
+ self._tkRoot.lift()
66
+
67
+ def hide_window(self):
68
+ if not self._tkRoot:
69
+ return
70
+ self._tkRoot.withdraw()
71
+ # ==========================================
72
+ # 終了制御
73
+ # ==========================================
74
+ def close(self):
75
+ if self._tkRoot:
76
+ self._tkRoot.destroy()
77
+ self._tkRoot = None
78
+ #--------------------------------------------
79
+ def _handle_close(self):
80
+ self._on_close()
81
+ #--------------------------------------------
82
+ def _on_close(self):
83
+ """×ボタン動作(必要なら継承先で上書き)"""
84
+ self.close()
85
+ # ==========================================
86
+ # mainloop開始(必ずメインスレッドで呼ぶ)
87
+ # ==========================================
88
+ def run(self):
89
+ if self._tkRoot:
90
+ self._tkRoot.mainloop()
91
+
92
+ #==========================================
93
+ # ボタン作成
94
+ def create_button(self,
95
+ text: str, #表示する文字
96
+ x: int,y: int, #位置
97
+ command: Optional[Callable[[], Any]] = None, #割り当てるメソッド
98
+ image_key: Optional[str] = None,
99
+ compound: str = "left",
100
+ ) -> Optional[tk.Button]:
101
+ """共通ボタン生成"""
102
+ if not self._tkRoot:
103
+ return None
104
+ #
105
+ button_kwargs: dict[str, Any] = {
106
+ "text": text,
107
+ "width": 32,
108
+ "height": 24,
109
+ "state": tk.NORMAL if command is not None else tk.DISABLED,
110
+ }
111
+
112
+ if command is not None:
113
+ button_kwargs["command"] = command
114
+
115
+ image = self.get_image(image_key) if image_key else None
116
+ if image is not None:
117
+ button_kwargs["image"] = image
118
+ button_kwargs["compound"] = compound
119
+
120
+ btn = tk.Button(self._tkRoot, **button_kwargs)
121
+ btn.place(x=x, y=y)
122
+ return btn
123
+ #UIに使う画像の登録
124
+ def register_image(self, key: str, filename: str) -> bool:
125
+ """imageフォルダ配下の画像をキーで登録する(主用途: png)"""
126
+ path = self._image_dir / filename
127
+ if not path.exists():
128
+ return False
129
+ try:
130
+ self._images[key] = tk.PhotoImage(file=str(path))
131
+ return True
132
+ except tk.TclError:
133
+ return False
134
+ #登録した画像を取得
135
+ def get_image(self, key: str) -> Optional[tk.PhotoImage]:
136
+ return self._images.get(key)
137
+ #
138
+ def _register_common_images(self):
139
+ """baseクラスで汎用利用するpngを事前登録する。"""
140
+ if not self._image_dir.exists():
141
+ return
142
+
143
+ for image_path in self._image_dir.glob("*.png"):
144
+ # 拡張子なしファイル名をキーとして登録(例: setting.dio.png -> setting.dio)
145
+ self.register_image(image_path.stem, image_path.name)
146
+ # アイコンリスト(icoファイルのアイコンはTkinterで直接扱えないため、ここではpngを想定)
147
+ @property
148
+ def icon_list(self) -> list[str]:
149
+ """imageフォルダ内のpngファイル名のリストを返す"""
150
+ if not self._image_dir.exists():
151
+ return []
152
+ return [p.stem for p in self._image_dir.glob("*.png")]
153
+ #####################################################################
154
+ #
155
+ #####################################################################
156
+ if __name__ == "__main__":
157
+ window = TkUiBase(title="テスト", size=(400, 200,), icon_path="../../icon.ico")
158
+ window.show_window()
159
+
160
+ # 2秒後に閉じる
161
+ def _close_later():
162
+ if window._tkRoot:
163
+ window._tkRoot.after(2000, window.close)
164
+ window.invoke_ui(_close_later)
165
+
166
+ window.run()
167
+ print("ウィンドウを閉じました")
@@ -0,0 +1,264 @@
1
+ import sys
2
+ import os
3
+ import re
4
+ import tempfile
5
+ from pathlib import Path
6
+ import logging
7
+ import threading
8
+ from typing import Protocol, Callable, Any, Optional, List, Tuple
9
+
10
+
11
+ class SingleInstanceGuard:
12
+ """Keeps a single-instance lock alive for the process lifetime."""
13
+
14
+ def __init__(self, lock_handle: Any, releaser: Optional[Callable[[Any], None]] = None):
15
+ self._lock_handle = lock_handle
16
+ self._releaser = releaser
17
+
18
+ def release(self) -> None:
19
+ if self._lock_handle is None:
20
+ return
21
+
22
+ try:
23
+ if self._releaser:
24
+ self._releaser(self._lock_handle)
25
+ finally:
26
+ self._lock_handle = None
27
+ self._releaser = None
28
+
29
+
30
+ def _build_lock_name(app_name: str) -> str:
31
+ normalized_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", app_name).strip("._")
32
+ return normalized_name or "resident_app"
33
+
34
+
35
+ def _release_windows_mutex(mutex_handle: Any) -> None:
36
+ import ctypes
37
+
38
+ ctypes.windll.kernel32.CloseHandle(mutex_handle)
39
+
40
+
41
+ def _acquire_windows_guard(app_name: str) -> Optional[SingleInstanceGuard]:
42
+ import ctypes
43
+
44
+ mutex_name = f"Global\\{app_name}_Mutex"
45
+ mutex_handle = ctypes.windll.kernel32.CreateMutexW(None, False, mutex_name)
46
+ if not mutex_handle:
47
+ raise OSError("CreateMutexW failed")
48
+
49
+ last_error = ctypes.windll.kernel32.GetLastError()
50
+ ERROR_ALREADY_EXISTS = 183
51
+ if last_error == ERROR_ALREADY_EXISTS:
52
+ ctypes.windll.kernel32.CloseHandle(mutex_handle)
53
+ return None
54
+
55
+ return SingleInstanceGuard(mutex_handle, _release_windows_mutex)
56
+
57
+
58
+ def _release_posix_lock(lock_data: tuple[Any, str]) -> None:
59
+ import fcntl
60
+
61
+ lock_file, lock_path = lock_data
62
+ try:
63
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
64
+ finally:
65
+ lock_file.close()
66
+ try:
67
+ os.unlink(lock_path)
68
+ except FileNotFoundError:
69
+ pass
70
+
71
+
72
+ def _acquire_posix_guard(app_name: str) -> Optional[SingleInstanceGuard]:
73
+ import fcntl
74
+
75
+ lock_name = _build_lock_name(app_name)
76
+ lock_path = os.path.join(tempfile.gettempdir(), f"{lock_name}.lock")
77
+ lock_file = open(lock_path, "a+")
78
+
79
+ try:
80
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
81
+ except BlockingIOError:
82
+ lock_file.close()
83
+ return None
84
+
85
+ lock_file.seek(0)
86
+ lock_file.truncate()
87
+ lock_file.write(str(os.getpid()))
88
+ lock_file.flush()
89
+
90
+ return SingleInstanceGuard((lock_file, lock_path), _release_posix_lock)
91
+
92
+
93
+ def acquire_single_instance_guard(app_name: str) -> Optional[SingleInstanceGuard]:
94
+ """Create a process-wide single-instance guard.
95
+
96
+ Returns a guard object when this is the first instance, otherwise None.
97
+ """
98
+ if sys.platform == "win32":
99
+ return _acquire_windows_guard(app_name)
100
+ return _acquire_posix_guard(app_name)
101
+ # ============================================================
102
+ # アプリケーションのインターフェース定義
103
+ # ============================================================
104
+ class IAppLogic(Protocol):
105
+ def start(self) -> None: ... #アプリのロジック開始
106
+ # def show(self, icon=None, item=None) -> None: ... #表示
107
+ # def hide(self, icon=None, item=None) -> None: ... #非表示
108
+ # def settings(self, icon=None, item=None) -> None: ... #設定の表示
109
+ def shutdown(self) -> None: ... #アプリのシャットダウン処理
110
+ # ============================================================
111
+ # タスクトレイの インターフェース定義
112
+ # ============================================================
113
+ class ITray(Protocol):
114
+ def run(self) -> None: ... #タスクトレイを開始(ブロッキング)
115
+ def run_detached(self) -> None: ... #タスクトレイを開始(非ブロッキング)
116
+ def stop(self) -> None: ... #タスクトレイを停止
117
+ # ============================================================
118
+ # 常駐
119
+ # ============================================================
120
+ class ResidentApp:
121
+ """
122
+ 常駐アプリのフレームワーク
123
+ """
124
+ # --------------------------------------------------------
125
+ def __init__(
126
+ self,
127
+ app_logic: IAppLogic,
128
+ tray_factory: Callable[..., ITray],
129
+ app_name: str = "常駐アプリ",
130
+ single_instance: bool = True,
131
+ icon_path: Optional[str] = None,
132
+ menu_items: Optional[List[Tuple[str, Callable]]] = None,
133
+ ):
134
+ '''コンストラクタ
135
+ :param app_logic: アプリのロジックを定義したクラスのインスタンス(IAppLogicを実装している必要がある)
136
+ :param tray_factory: タスクトレイ管理クラスのファクトリー関数(例:TrayApp)
137
+ :param app_name: アプリ名(トレイアイコンの右クリックメニューなどで使用)
138
+ :param single_instance: 多重起動を防止するかどうか
139
+ :param menu_items: 「表示名」と「コールバック関数」のタプルのリスト
140
+ '''
141
+ self._app_logic = app_logic
142
+ self._tray_factory = tray_factory
143
+ self._app_name = app_name
144
+ self._single_instance = single_instance
145
+ self._icon_path = icon_path
146
+ self._tray: Optional[ITray] = None
147
+ self._single_instance_guard: Optional[SingleInstanceGuard] = None
148
+ self._menu_items: Optional[List[Tuple[str, Callable]]] = menu_items
149
+ self._setup_logging()
150
+ # --------------------------------------------------------
151
+ def _setup_logging(self) -> None:
152
+ logging.basicConfig(
153
+ level=logging.INFO,
154
+ format="%(asctime)s [%(levelname)s] %(message)s",
155
+ )
156
+ self._logger = logging.getLogger(self._app_name)
157
+ # --------------------------------------------------------
158
+ def run(self) -> None:
159
+ '''アプリを起動'''
160
+ try:
161
+ self._logger.info("Application starting...")
162
+ # 多重起動防止
163
+ if self._single_instance:
164
+ self._single_instance_guard = acquire_single_instance_guard(self._app_name)
165
+ if self._single_instance_guard is None:
166
+ self._logger.warning("Another instance is already running.")
167
+ return
168
+ # アプリのロジックを開始
169
+ self._app_logic.start()
170
+ # 呼び出し元から渡されたメニュー + 終了メニューを構成
171
+ menu_items: List[Tuple[str, Callable]] = []
172
+ if self._menu_items:
173
+ menu_items.extend(self._menu_items)
174
+ menu_items.append(("終了", self._exit)) # 終了は固定でメニューに追加
175
+ # タスクトレイを開始
176
+ self._tray = self._tray_factory(
177
+ icon_name=self._app_name,
178
+ on_exit=self._exit,
179
+ menu_items=menu_items,
180
+ icon_path=self._icon_path, # アイコン画像のパス(必要に応じて指定)
181
+ )
182
+ self._tray.run()
183
+ except Exception as e:
184
+ self._logger.exception("Fatal error occurred: %s", e)
185
+ self._safe_shutdown()
186
+ # --------------------------------------------------------
187
+ def _exit(self, icon=None, item=None) -> None:
188
+ '''アプリを終了
189
+ :param icon: タスクトレイアイコン(pystray.Icon)※トレイの終了アクションから呼び出された場合に渡される
190
+ :param item: タスクトレイメニューアイテム(pystray.MenuItem)※トレイの終了アクションから呼び出された場合に渡される
191
+ '''
192
+ self._logger.info("Exit requested.")
193
+ self._safe_shutdown()
194
+ if icon:
195
+ icon.stop()
196
+
197
+ # --------------------------------------------------------
198
+ def _safe_shutdown(self) -> None:
199
+ '''安全にシャットダウン'''
200
+ try:
201
+ self._app_logic.shutdown()
202
+ self._logger.info("Application shutdown complete.")
203
+ except Exception as e:
204
+ self._logger.exception("Error during shutdown: %s", e)
205
+ finally:
206
+ self._release_mutex()
207
+ # --------------------------------------------------------
208
+ # 多重起動防止
209
+ # --------------------------------------------------------
210
+ def _acquire_mutex(self) -> bool:
211
+ '''多重起動防止のためのミューテックスを取得'''
212
+ self._single_instance_guard = acquire_single_instance_guard(self._app_name)
213
+ return self._single_instance_guard is not None
214
+ # --------------------------------------------------------
215
+ def _release_mutex(self) -> None:
216
+ '''多重起動防止のためのミューテックスを解放'''
217
+ if self._single_instance_guard:
218
+ self._single_instance_guard.release()
219
+ self._single_instance_guard = None
220
+ # ============================================================
221
+ # 動作例
222
+ # ============================================================
223
+ try:
224
+ from .TrayMenu import TrayMenu # パッケージとして import される通常ケース
225
+ except ImportError:
226
+ from TrayMenu import TrayMenu # ResidentApp.py を直接実行した場 合のフォールバック
227
+ import time
228
+ # アプリロジッククラスのダミー:インタフェースで基底されている関数は必須実装
229
+ class DummyLogic(IAppLogic):
230
+ def start(self) -> None:
231
+ print("🚀アプリのロジックを開始します")
232
+ def show(self, icon=None, item=None):
233
+ print("🦊表示します")
234
+ def hide(self, icon=None, item=None):
235
+ print("🦉非表示にします")
236
+ def settings(self, icon=None, item=None):
237
+ print("設定ウインドウを表示します")
238
+ def shutdown(self):
239
+ print("🚦停止します")
240
+
241
+ if __name__ == "__main__":
242
+ # 表示名とメソッドのペアをここで定義
243
+ tray_menu_items = [
244
+ ("表示", lambda: print("表示")), # 例: 任意のメソッド名
245
+ ("一時停止", lambda: print("一時停止")), # 例
246
+ ]
247
+
248
+ #常駐アプリクラスのインスタンスを作成
249
+ app = ResidentApp(
250
+ app_logic=DummyLogic(),
251
+ tray_factory=TrayMenu, #タスクトレイモジュールを渡す(スレッドの中でインスタンスして起動される)
252
+ app_name="常駐アプリテスト",
253
+ single_instance=False, #多重起動を許可
254
+ menu_items=tray_menu_items,
255
+ icon_path="../../icon.ico", #アイコン画像のパス(必要に応じて指定、Noneの場合はデフォルトアイコン)
256
+ )
257
+ #スレッドで常駐アプリを起動
258
+ t = threading.Thread(target=app.run, daemon=True)
259
+ t.start()
260
+
261
+ print("トレイ表示確認してください(15秒)")
262
+ time.sleep(15)
263
+ print("終了します")
264
+ app._exit()
@@ -0,0 +1,140 @@
1
+ import threading
2
+ from typing import Callable, List, Tuple, Optional
3
+
4
+ import pystray #タスクトレイアイコン用ライブラリ
5
+ from pystray import MenuItem as Item #トレイアイコンの右クリックメニューのアイテム用クラス
6
+ from PIL import Image, ImageDraw
7
+ from pathlib import Path
8
+ from typing import Callable, Optional
9
+
10
+ class TrayMenu:
11
+ """
12
+ 汎用システムトレイ管理クラス
13
+ 他アプリにそのまま流用可能
14
+ """
15
+ # ----------------------------------------------------------
16
+ def __init__(self,
17
+ icon_name: str,
18
+ on_exit: Callable,
19
+ menu_items: List[Tuple[str, Callable]],
20
+ icon_path: Optional[str] = None,
21
+ ):
22
+ '''コンストラクタ
23
+ :param icon_name: アイコンの名前(トレイアイコンの右クリックメニューなどで使用)
24
+ :param on_exit: 終了アクション関数(例:アプリの終了処理を呼び出す関数)
25
+ :param menu_items: 右クリックメニューのアイテム(表示名とアクション関数のタプルのリスト)
26
+ :param icon_path: アイコン画像のパス(省略可、見つからない場合はデフォルトアイコンを使用)
27
+ '''
28
+ self._icon_name = icon_name
29
+ self._on_exit: Callable = on_exit
30
+ self._menu_items: List[Tuple[str, Callable]] = menu_items
31
+ self._icon_path: Optional[str] = icon_path
32
+ self._tray_icon: Optional[pystray.Icon] = None
33
+ self._thread: Optional[threading.Thread] = None
34
+ # ----------------------------------------------------------
35
+ def run(self) -> None:
36
+ """タスクトレイを開始(ブロッキング)"""
37
+ self._create_icon()
38
+ self._tray_icon.run()
39
+ # ----------------------------------------------------------
40
+ def run_detached(self) -> None:
41
+ """タスクトレイ開始(別スレッド)"""
42
+ self._create_icon()
43
+ self._thread = threading.Thread(target=self._tray_icon.run, daemon=True)
44
+ self._thread.start() #タスクスタート
45
+ # ----------------------------------------------------------
46
+ def stop(self) -> None:
47
+ """トレイを停止"""
48
+ if self._tray_icon:
49
+ self._tray_icon.stop()
50
+ # ----------------------------------------------------------
51
+ def _create_icon(self) -> None:
52
+ #タスクトレイ用アイコン作成
53
+ image = self._load_icon_image()
54
+ #右クリックメニュー作成
55
+ menu = pystray.Menu(
56
+ *[ Item(name, action)
57
+ for name, action in self._menu_items
58
+ ]
59
+ )
60
+ #タスクトレイアイコン作成
61
+ self._tray_icon = pystray.Icon(
62
+ self._icon_name, # アイコンの名前
63
+ image, # アイコン画像
64
+ self._icon_name, # ツールチップ
65
+ menu # 右クリックメニュー
66
+ )
67
+ # ----------------------------------------------------------
68
+ def _load_icon_image(self) -> Image.Image:
69
+ default_size = (64, 64)
70
+
71
+ # 指定パスがあれば候補を順に探す(絶対/カレント/このモジュールと同じフォルダ)
72
+ if self._icon_path:
73
+ p = Path(self._icon_path)
74
+ candidates = [p] if p.is_absolute() else [
75
+ Path.cwd() / p,
76
+ Path(__file__).parent / p,
77
+ p, # try raw as last resort (Pillow が扱える場合)
78
+ ]
79
+ for c in candidates:
80
+ try:
81
+ if c.exists():
82
+ img = Image.open(c)
83
+ return self._prepare_image(img, default_size)
84
+ except Exception:
85
+ continue
86
+ # 見つからなければデフォルトアイコンへフォールバック
87
+
88
+ # デフォルトアイコン(RGBA で返す)
89
+ image = Image.new("RGBA", default_size, (0, 0, 255, 255))
90
+ draw = ImageDraw.Draw(image)
91
+ draw.rectangle((16, 16, 48, 48), fill="white")
92
+ #draw.chord((32, 32, 48), 0, 180, fill="red")
93
+ return image
94
+
95
+ def _prepare_image(self, img: Image.Image, size: tuple) -> Image.Image:
96
+ """
97
+ Image を pystray 用サイズに変換して返す(透過パディングで中央配置)
98
+ """
99
+ img = img.convert("RGBA")
100
+ # サムネイル(アスペクト比維持)
101
+ img.thumbnail(size, Image.LANCZOS)
102
+ background = Image.new("RGBA", size, (0, 0, 0, 0))
103
+ x = (size[0] - img.width) // 2
104
+ y = (size[1] - img.height) // 2
105
+ background.paste(img, (x, y), img)
106
+ return background
107
+ #####################################################################
108
+ #テスト
109
+ #####################################################################
110
+ if __name__ == "__main__":
111
+ import time
112
+ loop = True
113
+ # 終処理の例
114
+ def on_exit_sample():
115
+ print("Exiting...")
116
+ global loop
117
+ loop = False
118
+ tray.stop()
119
+ # メニューアクションの例
120
+ def on_action_sample():
121
+ print("Hello from tray!")
122
+ # 右クリックメニューに登録する例(表示文字と関数のタプルのリスト)
123
+ menu_items = [
124
+ ("Say Hello", on_action_sample),
125
+ ("Exit", on_exit_sample)
126
+ ]
127
+ # トレイメニューの作成と起動
128
+ tray = TrayMenu(
129
+ icon_name="MyTrayApp",
130
+ on_exit=on_exit_sample,
131
+ menu_items=menu_items,
132
+ icon_path=None # アイコンパスを指定(例: "icon.png")
133
+ )
134
+ tray.run_detached()
135
+ #止めるまでループ
136
+ try:
137
+ while loop:
138
+ time.sleep(1)
139
+ except KeyboardInterrupt:
140
+ tray.stop()
@@ -0,0 +1,4 @@
1
+ from .TrayMenu import TrayMenu
2
+ from .ResidentApp import acquire_single_instance_guard, SingleInstanceGuard
3
+
4
+ __all__ = ["TrayMenu", "acquire_single_instance_guard", "SingleInstanceGuard"]