"""
========================================================
D2 Folder Launcher — Diablo II Modding Quick Access
========================================================
폴더/앱 설정은 앱 내 ⚙ 설정 버튼에서 관리하세요.
설정은 %%APPDATA%%D2RPathFinderconfig.json 에 저장됩니다.
"""
import tkinter as tk
from tkinter import messagebox, filedialog
import subprocess
import os
import json
import threading
# ──────────────────────────────────────────────────────
# 앱 설정
# ──────────────────────────────────────────────────────
APP_TITLE = "D2R PathFinder v.0.45"
COLUMNS = 2
BTN_WIDTH = 17
BTN_HEIGHT = 1
CONFIG_DIR = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "D2RPathFinder")
META_FILE = os.path.join(CONFIG_DIR, "meta.json") # 항상 고정 — config 위치 저장용
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") # 기본값 (meta.json에서 덮어씌워짐)
DEFAULT_FOLDERS = [
{"label": "🗡 Diablo II Root", "path": r"C:Program Files (x86)Diablo II"},
{"label": "📦 Mod 폴더", "path": r"C:Program Files (x86)Diablo IImods"},
{"label": "🖼 MPQ / Assets", "path": r"C:Program Files (x86)Diablo IIMPQs"},
{"label": "📝 Excel / Data Tables", "path": r"C:D2ModsDataGlobalExcel"},
{"label": "🎨 Sprites / DC6", "path": r"C:D2ModsDataGlobalUI"},
{"label": "🔊 Sound Files", "path": r"C:D2ModsDataGlobalSound"},
{"label": "⚙️ Patch_D2 Workspace", "path": r"C:D2ModsPatch_D2"},
{"label": "🛠 Tools", "path": r"C:D2ModsTools"},
{"label": "💾 Backups", "path": r"C:D2ModsBackups"},
{"label": "📁 My Documents", "path": os.path.expanduser("~Documents")},
]
# ──────────────────────────────────────────────────────
# 다크 테마 색상
# ──────────────────────────────────────────────────────
COLORS = {
"bg": "#0e0e10",
"surface": "#1a1a1f",
"surface2": "#25252d",
"border": "#2e2e3a",
"accent": "#c8973a",
"accent_hover": "#e6b04a",
"accent_press": "#a07828",
"text_primary": "#e8e0d0",
"text_secondary":"#7a7a8a",
"titlebar_bg": "#131316",
"danger": "#e05050",
}
FONT_TITLE = ("Segoe UI", 10, "bold")
FONT_BTN = ("Segoe UI Emoji", 11, "bold")
FONT_UI = ("Segoe UI", 10)
FONT_SMALL = ("Segoe UI", 9)
# ──────────────────────────────────────────────────────
# meta.json — config.json 위치 관리
# ──────────────────────────────────────────────────────
def load_meta():
"""config.json의 실제 경로를 meta.json에서 읽어옴"""
os.makedirs(CONFIG_DIR, exist_ok=True)
if not os.path.exists(META_FILE):
return {"config_path": CONFIG_FILE}
try:
with open(META_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {"config_path": CONFIG_FILE}
def save_meta(meta):
os.makedirs(CONFIG_DIR, exist_ok=True)
with open(META_FILE, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
def get_config_path():
return load_meta().get("config_path", CONFIG_FILE)
# ──────────────────────────────────────────────────────
# config.json 로드 / 저장
# ──────────────────────────────────────────────────────
def load_config():
path = get_config_path()
if not os.path.exists(path):
return [dict(f) for f in DEFAULT_FOLDERS]
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("folders", [dict(f) for f in DEFAULT_FOLDERS])
except Exception:
return [dict(f) for f in DEFAULT_FOLDERS]
def save_config(folders, config_path=None):
path = config_path or get_config_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump({"folders": folders}, f, ensure_ascii=False, indent=2)
# ──────────────────────────────────────────────────────
# 폴더/앱 열기
# ──────────────────────────────────────────────────────
def open_path(path: str):
if not os.path.exists(path):
messagebox.showwarning(
"경로를 찾을 수 없음",
f"아래 경로가 존재하지 않습니다:n{path}nn"
"⚙ 설정에서 경로를 확인하세요."
)
return
try:
if os.path.isfile(path):
subprocess.Popen([path], cwd=os.path.dirname(path))
else:
subprocess.Popen(["explorer", path])
except Exception as e:
messagebox.showerror("오류", str(e))
# ──────────────────────────────────────────────────────
# 호버 효과
# ──────────────────────────────────────────────────────
def on_enter(btn):
btn.config(bg=COLORS["surface"], fg=COLORS["accent_hover"], relief="flat")
def on_leave(btn):
btn.config(bg=COLORS["surface2"], fg=COLORS["text_primary"], relief="flat")
def on_press(btn):
btn.config(bg=COLORS["accent_press"], fg=COLORS["bg"])
def on_release(btn, path):
btn.config(bg=COLORS["surface2"], fg=COLORS["text_primary"])
open_path(path)
# ──────────────────────────────────────────────────────
# 툴팁
# ──────────────────────────────────────────────────────
class _Tooltip:
def __init__(self, widget, text):
self.widget = widget
self.text = text
self.tipwin = None
widget.bind("<Enter>", self.show, add="+")
widget.bind("<Leave>", self.hide, add="+")
def show(self, _=None):
if self.tipwin:
return
x = self.widget.winfo_rootx() + 20
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
self.tipwin = tw = tk.Toplevel(self.widget)
tw.wm_overrideredirect(True)
tw.wm_geometry(f"+{x}+{y}")
tk.Label(tw, text=self.text, font=("Segoe UI", 8),
fg=COLORS["text_secondary"], bg=COLORS["surface"],
relief="flat", bd=1, padx=8, pady=4).pack()
def hide(self, _=None):
if self.tipwin:
self.tipwin.destroy()
self.tipwin = None
# ──────────────────────────────────────────────────────
# 설정 창
# ──────────────────────────────────────────────────────
def open_config_window(parent, folders, on_save):
win = tk.Toplevel(parent)
win.overrideredirect(True)
win.configure(bg=COLORS["bg"])
win.resizable(False, False)
win.grab_set()
# ── 설정 창 타이틀바 ─────────────────────────────────
_drag = {"x": 0, "y": 0}
def drag_start(e): _drag["x"] = e.x; _drag["y"] = e.y
def drag_move(e):
win.geometry(f"+{win.winfo_x()+e.x-_drag['x']}+{win.winfo_y()+e.y-_drag['y']}")
tb = tk.Frame(win, bg=COLORS["titlebar_bg"])
tb.pack(fill="x")
tb.bind("<ButtonPress-1>", drag_start)
tb.bind("<B1-Motion>", drag_move)
lbl = tk.Label(tb, text="⚙ 설정", font=FONT_TITLE,
fg=COLORS["accent"], bg=COLORS["titlebar_bg"], padx=10, pady=6)
lbl.pack(side="left")
lbl.bind("<ButtonPress-1>", drag_start)
lbl.bind("<B1-Motion>", drag_move)
btn_x = tk.Label(tb, text="✕", font=("Consolas", 10, "bold"),
fg=COLORS["text_secondary"], bg=COLORS["titlebar_bg"],
padx=10, pady=6, cursor="hand2")
btn_x.pack(side="right")
btn_x.bind("<Enter>", lambda e: btn_x.config(fg=COLORS["danger"]))
btn_x.bind("<Leave>", lambda e: btn_x.config(fg=COLORS["text_secondary"]))
btn_x.bind("<Button-1>", lambda e: win.destroy())
tk.Frame(win, bg=COLORS["accent"], height=2).pack(fill="x")
# ── 스크롤 영역 ──────────────────────────────────────
outer = tk.Frame(win, bg=COLORS["bg"])
outer.pack(fill="both", expand=True, padx=10, pady=8)
canvas = tk.Canvas(outer, bg=COLORS["bg"], highlightthickness=0, width=500, height=360)
scrollbar = tk.Scrollbar(outer, orient="vertical", command=canvas.yview,
bg=COLORS["surface2"], troughcolor=COLORS["bg"],
activebackground=COLORS["accent"])
canvas.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side="right", fill="y")
canvas.pack(side="left", fill="both", expand=True)
inner = tk.Frame(canvas, bg=COLORS["bg"])
inner_id = canvas.create_window((0, 0), window=inner, anchor="nw")
def on_inner_configure(e):
canvas.configure(scrollregion=canvas.bbox("all"))
inner.bind("<Configure>", on_inner_configure)
canvas.bind("<Configure>", lambda e: canvas.itemconfig(inner_id, width=e.width))
canvas.bind("<MouseWheel>", lambda e: canvas.yview_scroll(-1*(1 if e.delta>0 else -1), "units"))
# ── 항목 행 목록 ─────────────────────────────────────
rows = [] # {"label": StringVar, "path": StringVar, "frame": Frame}
drag_state = {"dragging": None, "start_y": 0, "ghost": None}
def get_row_index(rf):
return next((i for i, r in enumerate(rows) if r["frame"] is rf), -1)
def reorder_rows():
for r in rows:
r["frame"].pack_forget()
for r in rows:
r["frame"].pack(fill="x", pady=3)
refresh_indices()
def on_handle_press(e, row_data):
drag_state["dragging"] = row_data
drag_state["start_y"] = e.y_root
row_data["frame"].config(bg=COLORS["border"])
for child in row_data["frame"].winfo_children():
try: child.config(bg=COLORS["border"])
except Exception: pass
def on_handle_motion(e, row_data):
if drag_state["dragging"] is not row_data:
return
y = e.y_root
idx = get_row_index(row_data["frame"])
# 위아래 이웃 행의 중간 y좌표와 비교해 순서 교환
if idx > 0:
above = rows[idx - 1]["frame"]
mid_above = above.winfo_rooty() + above.winfo_height() // 2
if y < mid_above:
rows[idx], rows[idx - 1] = rows[idx - 1], rows[idx]
reorder_rows()
if idx < len(rows) - 1:
below = rows[idx + 1]["frame"]
mid_below = below.winfo_rooty() + below.winfo_height() // 2
if y > mid_below:
rows[idx], rows[idx + 1] = rows[idx + 1], rows[idx]
reorder_rows()
def on_handle_release(e, row_data):
drag_state["dragging"] = None
row_data["frame"].config(bg=COLORS["surface2"])
for child in row_data["frame"].winfo_children():
try: child.config(bg=COLORS["surface2"])
except Exception: pass
def make_row(label_val="", path_val="", index=None):
row_frame = tk.Frame(inner, bg=COLORS["surface2"], pady=4, padx=6)
row_frame.pack(fill="x", pady=3)
# 드래그 핸들
handle = tk.Label(row_frame, text="⠿", font=("Segoe UI", 13),
fg=COLORS["text_secondary"], bg=COLORS["surface2"],
cursor="fleur", padx=2)
handle.pack(side="left", padx=(0, 4))
# 순서 번호
idx_lbl = tk.Label(row_frame, text="", font=FONT_SMALL,
fg=COLORS["text_secondary"], bg=COLORS["surface2"], width=2)
idx_lbl.pack(side="left", padx=(0, 4))
# 이름 입력
lv = tk.StringVar(value=label_val)
name_entry = tk.Entry(row_frame, textvariable=lv, font=("Segoe UI Emoji", 10),
bg=COLORS["surface"], fg=COLORS["text_primary"],
insertbackground=COLORS["accent"],
relief="flat", bd=0, width=18)
name_entry.pack(side="left", padx=(0, 6), ipady=4)
# 경로 입력
pv = tk.StringVar(value=path_val)
path_entry = tk.Entry(row_frame, textvariable=pv, font=FONT_UI,
bg=COLORS["surface"], fg=COLORS["text_primary"],
insertbackground=COLORS["accent"],
relief="flat", bd=0, width=28)
path_entry.pack(side="left", padx=(0, 4), ipady=4)
# 탐색 버튼
def browse(pvar=pv):
chosen = filedialog.askdirectory(title="폴더 선택")
if not chosen:
chosen = filedialog.askopenfilename(title="파일 선택")
if chosen:
pvar.set(chosen.replace("/", ""))
tk.Button(row_frame, text="📁", font=FONT_SMALL,
bg=COLORS["surface"], fg=COLORS["accent"],
activebackground=COLORS["border"], activeforeground=COLORS["accent_hover"],
relief="flat", bd=0, cursor="hand2", padx=4,
command=browse).pack(side="left", padx=(0, 4))
# 삭제 버튼
def delete_row(rf=row_frame):
rows[:] = [r for r in rows if r["frame"] is not rf]
rf.destroy()
refresh_indices()
tk.Button(row_frame, text="✕", font=FONT_SMALL,
bg=COLORS["surface"], fg=COLORS["danger"],
activebackground=COLORS["border"], activeforeground=COLORS["danger"],
relief="flat", bd=0, cursor="hand2", padx=4,
command=delete_row).pack(side="left")
row_data = {"label": lv, "path": pv, "frame": row_frame, "idx_lbl": idx_lbl}
# 핸들 이벤트 바인딩
handle.bind("<ButtonPress-1>", lambda e, rd=row_data: on_handle_press(e, rd))
handle.bind("<B1-Motion>", lambda e, rd=row_data: on_handle_motion(e, rd))
handle.bind("<ButtonRelease-1>",lambda e, rd=row_data: on_handle_release(e, rd))
if index is None:
rows.append(row_data)
else:
rows.insert(index, row_data)
refresh_indices()
def refresh_indices():
for i, r in enumerate(rows):
r["idx_lbl"].config(text=str(i + 1))
# 기존 항목 로드
for item in folders:
make_row(item["label"], item["path"])
# ── 하단 버튼 ────────────────────────────────────────
tk.Frame(win, bg=COLORS["border"], height=1).pack(fill="x", padx=10)
# config.json 경로 표시 및 변경
cfg_frame = tk.Frame(win, bg=COLORS["bg"], pady=6)
cfg_frame.pack(fill="x", padx=10)
tk.Label(cfg_frame, text="📄 config 위치:", font=FONT_SMALL,
fg=COLORS["text_secondary"], bg=COLORS["bg"]).pack(side="left")
cfg_path_var = tk.StringVar(value=get_config_path())
cfg_entry = tk.Entry(cfg_frame, textvariable=cfg_path_var, font=("Segoe UI", 8),
bg=COLORS["surface"], fg=COLORS["text_secondary"],
insertbackground=COLORS["accent"],
relief="flat", bd=0, width=38)
cfg_entry.pack(side="left", padx=(6, 4), ipady=3)
def browse_config_path():
folder = filedialog.askdirectory(title="config.json 저장 폴더 선택")
if folder:
new_path = os.path.join(folder.replace("/", ""), "config.json")
cfg_path_var.set(new_path)
tk.Button(cfg_frame, text="📁", font=FONT_SMALL,
bg=COLORS["surface"], fg=COLORS["accent"],
activebackground=COLORS["border"], activeforeground=COLORS["accent_hover"],
relief="flat", bd=0, cursor="hand2", padx=4,
command=browse_config_path).pack(side="left")
# 단축키 설정
tk.Frame(win, bg=COLORS["border"], height=1).pack(fill="x", padx=10)
hk_frame = tk.Frame(win, bg=COLORS["bg"], pady=6)
hk_frame.pack(fill="x", padx=10)
tk.Label(hk_frame, text="⌨ 창 중앙 단축키:", font=FONT_SMALL,
fg=COLORS["text_secondary"], bg=COLORS["bg"]).pack(side="left")
current_hk = load_meta().get("hotkey", "")
hk_var = tk.StringVar(value=current_hk)
hk_entry = tk.Entry(hk_frame, textvariable=hk_var, font=("Segoe UI", 9),
bg=COLORS["surface"], fg=COLORS["accent"],
insertbackground=COLORS["accent"],
relief="flat", bd=0, width=18)
hk_entry.pack(side="left", padx=(6, 4), ipady=3)
tk.Label(hk_frame, text="예) <ctrl>+<shift>+h", font=("Segoe UI", 8),
fg=COLORS["text_secondary"], bg=COLORS["bg"]).pack(side="left", padx=(4, 0))
tk.Frame(win, bg=COLORS["border"], height=1).pack(fill="x", padx=10)
bottom = tk.Frame(win, bg=COLORS["bg"], pady=8)
bottom.pack(fill="x", padx=10)
def add_row():
make_row("🆕 새 버튼", "")
canvas.update_idletasks()
canvas.yview_moveto(1.0)
tk.Button(bottom, text="+ 항목 추가", font=FONT_SMALL,
bg=COLORS["surface2"], fg=COLORS["accent"],
activebackground=COLORS["surface"], activeforeground=COLORS["accent_hover"],
relief="flat", bd=0, cursor="hand2", padx=10, pady=6,
command=add_row).pack(side="left")
def save_and_close():
new_folders = [
{"label": r["label"].get().strip(), "path": r["path"].get().strip()}
for r in rows
if r["label"].get().strip() or r["path"].get().strip()
]
# config 경로가 바뀐 경우 처리
new_cfg_path = cfg_path_var.get().strip()
old_cfg_path = get_config_path()
if new_cfg_path != old_cfg_path:
try:
if os.path.exists(old_cfg_path):
import shutil
os.makedirs(os.path.dirname(new_cfg_path), exist_ok=True)
shutil.move(old_cfg_path, new_cfg_path)
except Exception as e:
messagebox.showerror("오류", f"config 이동 실패:n{e}")
return
meta = load_meta()
meta["config_path"] = new_cfg_path
save_meta(meta)
# 단축키 저장
new_hk = hk_var.get().strip()
meta = load_meta()
meta["hotkey"] = new_hk
save_meta(meta)
save_config(new_folders, new_cfg_path)
on_save(new_folders, new_hk)
win.destroy()
tk.Button(bottom, text="💾 저장", font=FONT_SMALL,
bg=COLORS["accent"], fg=COLORS["bg"],
activebackground=COLORS["accent_hover"], activeforeground=COLORS["bg"],
relief="flat", bd=0, cursor="hand2", padx=14, pady=6,
command=save_and_close).pack(side="right")
tk.Frame(win, bg=COLORS["accent"], height=2).pack(fill="x")
# 화면 중앙
win.update_idletasks()
sw = win.winfo_screenwidth()
sh = win.winfo_screenheight()
w = win.winfo_width()
h = win.winfo_height()
win.geometry(f"+{(sw-w)//2}+{(sh-h)//2}")
# ──────────────────────────────────────────────────────
# 메인 GUI
# ──────────────────────────────────────────────────────
def build_gui():
folders = load_config()
root = tk.Tk()
root.overrideredirect(True)
root.configure(bg=COLORS["bg"])
root.resizable(False, False)
root.attributes("-topmost", False)
root.attributes("-alpha", 1.0)
root.title(APP_TITLE)
# ── 작업표시줄 / Alt+Tab 등록 (Windows) ─────────────
def register_taskbar():
try:
import ctypes
GWL_EXSTYLE = -20
WS_EX_APPWINDOW = 0x00040000
WS_EX_TOOLWINDOW = 0x00000080
GA_ROOT = 2
user32 = ctypes.windll.user32
hwnd = user32.GetAncestor(root.winfo_id(), GA_ROOT)
style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
style = (style & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW
user32.SetWindowLongW(hwnd, GWL_EXSTYLE, style)
# 현재 위치 저장 후 숨겼다가 복원
x, y = root.winfo_x(), root.winfo_y()
root.withdraw()
def restore():
root.deiconify()
root.after(50, restore)
except Exception:
pass
root.after(100, register_taskbar)
# ── 드래그 ───────────────────────────────────────────
_drag = {"x": 0, "y": 0, "moved_x": 0, "moved_y": 0}
def drag_start(e):
if pin_state["on"]: return
_drag["x"] = e.x; _drag["y"] = e.y
_drag["moved_x"] = 0; _drag["moved_y"] = 0
def drag_move(e):
if pin_state["on"]: return
dx = e.x - _drag["x"]
dy = e.y - _drag["y"]
_drag["moved_x"] = dx; _drag["moved_y"] = dy
root.geometry(f"+{root.winfo_x()+dx}+{root.winfo_y()+dy}")
# ── 타이틀바 ─────────────────────────────────────────
titlebar = tk.Frame(root, bg=COLORS["titlebar_bg"], height=28)
titlebar.pack(fill="x")
titlebar.bind("<ButtonPress-1>", drag_start)
titlebar.bind("<B1-Motion>", drag_move)
lbl_title = tk.Label(titlebar, text=APP_TITLE, font=FONT_TITLE,
fg=COLORS["accent"], bg=COLORS["titlebar_bg"], padx=10, pady=4)
lbl_title.pack(side="left")
lbl_title.bind("<ButtonPress-1>", drag_start)
lbl_title.bind("<B1-Motion>", drag_move)
# 핀 버튼
PIN_SIZE = 20
pin_canvas = tk.Canvas(titlebar, width=PIN_SIZE, height=PIN_SIZE,
bg=COLORS["titlebar_bg"], highlightthickness=0, cursor="hand2")
pin_canvas.pack(side="right", padx=(0, 6), pady=4)
pin_state = {"on": False}
def draw_pin(active):
pin_canvas.delete("all")
if active:
pin_canvas.create_oval(1, 1, PIN_SIZE-2, PIN_SIZE-2,
fill=COLORS["accent"], outline=COLORS["accent_hover"], width=1)
pin_canvas.create_text(PIN_SIZE//2, PIN_SIZE//2+1, text="📌",
font=("Segoe UI Emoji", 9))
else:
pin_canvas.create_oval(1, 1, PIN_SIZE-2, PIN_SIZE-2,
fill="", outline=COLORS["border"], width=1)
pin_canvas.create_text(PIN_SIZE//2, PIN_SIZE//2+1, text="📌",
font=("Segoe UI Emoji", 9), fill=COLORS["text_secondary"])
draw_pin(False)
def toggle_pin(_=None):
pin_state["on"] = not pin_state["on"]
root.attributes("-topmost", pin_state["on"])
draw_pin(pin_state["on"])
pin_canvas.bind("<Button-1>", toggle_pin)
# 종료 버튼
btn_close = tk.Label(titlebar, text="✕", font=("Consolas", 10, "bold"),
fg=COLORS["text_secondary"], bg=COLORS["titlebar_bg"],
padx=10, pady=4, cursor="hand2")
btn_close.pack(side="right")
btn_close.bind("<Enter>", lambda e: btn_close.config(fg=COLORS["danger"]))
btn_close.bind("<Leave>", lambda e: btn_close.config(fg=COLORS["text_secondary"]))
# 종료 시 창 위치 저장 후 종료 (on_close는 아래에서 정의 후 재바인딩)
btn_close.bind("<Button-1>", lambda e: None) # on_close 에서 재바인딩
# 투명도 슬라이더 라벨
tk.Label(titlebar, text="◑", font=("Segoe UI", 9),
fg=COLORS["text_secondary"], bg=COLORS["titlebar_bg"], padx=2).pack(side="right")
# 투명도 슬라이더
alpha_var = tk.DoubleVar(value=1.0)
scale = tk.Scale(titlebar, from_=1.0, to=0.3, resolution=0.05,
orient="horizontal", variable=alpha_var,
command=lambda v: root.attributes("-alpha", float(v)),
length=90, showvalue=False,
bg=COLORS["titlebar_bg"], fg=COLORS["accent"],
troughcolor="#3a3a4a", activebackground=COLORS["accent_hover"],
highlightthickness=1, highlightcolor=COLORS["border"],
highlightbackground="#3a3a4a", bd=0,
sliderlength=12, sliderrelief="flat", cursor="hand2")
scale.pack(side="right", padx=(0, 4), pady=3)
# 설정 버튼
btn_cfg = tk.Label(titlebar, text="⚙", font=("Segoe UI", 11),
fg=COLORS["text_secondary"], bg=COLORS["titlebar_bg"],
padx=8, pady=4, cursor="hand2")
btn_cfg.pack(side="right")
btn_cfg.bind("<Enter>", lambda e: btn_cfg.config(fg=COLORS["accent_hover"]))
btn_cfg.bind("<Leave>", lambda e: btn_cfg.config(fg=COLORS["text_secondary"]))
# 금빛 구분선
accent_top = tk.Frame(root, bg=COLORS["accent"], height=2)
accent_top.pack(fill="x")
# ── 버튼 그리드 ──────────────────────────────────────
btn_frame = tk.Frame(root, bg=COLORS["bg"], padx=12, pady=8)
btn_frame.pack()
def rebuild_buttons(folder_list):
for w in btn_frame.winfo_children():
w.destroy()
for i, item in enumerate(folder_list):
row = i // COLUMNS
col = i % COLUMNS
container = tk.Frame(btn_frame, bg=COLORS["border"], padx=1, pady=1)
container.grid(row=row, column=col, padx=5, pady=4, sticky="nsew")
btn = tk.Button(container, text=item["label"], font=FONT_BTN,
fg=COLORS["text_primary"], bg=COLORS["surface2"],
activeforeground=COLORS["accent"],
activebackground=COLORS["surface"],
relief="flat", bd=0, width=BTN_WIDTH, height=BTN_HEIGHT,
cursor="hand2", anchor="w", padx=12)
btn.pack()
btn.bind("<Enter>", lambda e, b=btn: on_enter(b))
btn.bind("<Leave>", lambda e, b=btn: on_leave(b))
btn.bind("<ButtonPress-1>", lambda e, b=btn: on_press(b))
btn.bind("<ButtonRelease-1>",lambda e, b=btn, p=item["path"]: on_release(b, p))
_Tooltip(btn, item["path"])
for c in range(COLUMNS):
btn_frame.columnconfigure(c, weight=1)
root.update_idletasks()
rebuild_buttons(folders)
# ── 전역 단축키 (창 중앙 이동) ───────────────────────
_hotkey_listener = {"listener": None}
def center_window():
sw = root.winfo_screenwidth()
sh = root.winfo_screenheight()
root.update_idletasks()
x = (sw - root.winfo_width()) // 2
y = (sh - root.winfo_height()) // 2
root.geometry(f"+{x}+{y}")
def register_hotkey(hk_str):
from pynput import keyboard as kb
# 기존 리스너 종료
if _hotkey_listener["listener"]:
_hotkey_listener["listener"].stop()
_hotkey_listener["listener"] = None
if not hk_str.strip():
return
try:
parts = [p.strip().lower() for p in hk_str.split("+")]
canonical_map = {
"<ctrl>": kb.Key.ctrl,
"<shift>": kb.Key.shift,
"<alt>": kb.Key.alt,
"<cmd>": kb.Key.cmd,
}
required = set()
trigger = None
for p in parts:
if p in canonical_map:
required.add(canonical_map[p])
else:
clean = p.replace("<", "").replace(">", "")
try:
trigger = getattr(kb.Key, clean)
except AttributeError:
trigger = kb.KeyCode.from_char(clean)
if trigger is None:
return
pressed = set()
listener_ref = [None]
def on_press(key):
# canonical로 정규화 (ctrl_l → ctrl 등)
try:
canonical = listener_ref[0].canonical(key)
except Exception:
canonical = key
pressed.add(canonical)
mods_ok = required - {trigger} <= pressed
if isinstance(trigger, kb.KeyCode):
trigger_ok = any(
isinstance(k, kb.KeyCode) and k.char == trigger.char
for k in pressed
)
else:
trigger_ok = trigger in pressed
if mods_ok and trigger_ok:
root.after(0, center_window)
def on_release(key):
try:
canonical = listener_ref[0].canonical(key)
except Exception:
canonical = key
pressed.discard(canonical)
listener = kb.Listener(on_press=on_press, on_release=on_release)
listener.daemon = True
listener_ref[0] = listener
listener.start()
_hotkey_listener["listener"] = listener
except Exception:
pass
# 저장된 단축키 즉시 등록
register_hotkey(load_meta().get("hotkey", ""))
def on_config_save(new_folders, new_hk=""):
nonlocal folders
folders = new_folders
rebuild_buttons(folders)
register_hotkey(new_hk)
btn_cfg.bind("<Button-1>", lambda e: open_config_window(root, folders, on_config_save))
# 하단 장식선
accent_bottom = tk.Frame(root, bg=COLORS["accent"], height=2)
accent_bottom.pack(fill="x")
# ── 접기 / 펼치기 ────────────────────────────────────
collapsed = {"on": False}
body_widgets = [accent_top, btn_frame, accent_bottom]
def toggle_collapse(_=None):
collapsed["on"] = not collapsed["on"]
if collapsed["on"]:
for w in body_widgets:
w.pack_forget()
lbl_title.config(fg=COLORS["text_secondary"])
btn_collapse.config(text="▼")
else:
accent_top.pack(fill="x", after=titlebar)
btn_frame.pack(after=accent_top)
accent_bottom.pack(fill="x", after=btn_frame)
lbl_title.config(fg=COLORS["accent"])
btn_collapse.config(text="▲")
root.update_idletasks()
lbl_title.config(cursor="arrow")
# 접기/펼치기 버튼
btn_collapse = tk.Label(titlebar, text="▲", font=("Segoe UI", 9),
fg=COLORS["text_secondary"], bg=COLORS["titlebar_bg"],
padx=6, pady=4, cursor="hand2")
btn_collapse.pack(side="left")
btn_collapse.bind("<Enter>", lambda e: btn_collapse.config(fg=COLORS["accent_hover"]))
btn_collapse.bind("<Leave>", lambda e: btn_collapse.config(fg=COLORS["text_secondary"]))
btn_collapse.bind("<Button-1>", toggle_collapse)
# ── 창 위치 / 투명도 / 핀 상태 복원 ─────────────────
def restore_position():
meta = load_meta()
sw = root.winfo_screenwidth()
sh = root.winfo_screenheight()
root.update_idletasks()
saved_x = meta.get("win_x")
saved_y = meta.get("win_y")
use_center = not (
saved_x is not None and saved_y is not None
and 0 <= saved_x <= sw - 50 and 0 <= saved_y <= sh - 50
)
# 투명도 먼저
saved_alpha = meta.get("alpha", 1.0)
alpha_var.set(saved_alpha)
root.attributes("-alpha", saved_alpha)
# 핀 상태 (topmost 설정이 위치에 영향을 줄 수 있으므로 위치보다 먼저)
if meta.get("pinned", False):
pin_state["on"] = True
root.attributes("-topmost", True)
draw_pin(True)
# 위치를 마지막에 설정 (topmost 적용 이후)
if use_center:
root.geometry(f"+{(sw-root.winfo_width())//2}+{(sh-root.winfo_height())//2}")
else:
root.geometry(f"+{saved_x}+{saved_y}")
# topmost 환경에서 위치가 밀릴 수 있어 한 번 더 보정
root.after(80, lambda: root.geometry(f"+{saved_x}+{saved_y}") if not use_center else None)
# register_taskbar 완료(150ms) 이후에 위치 복원
root.after(150, restore_position)
# 창 위치를 지속적으로 캐싱 (topmost/overrideredirect 환경에서도 정확하게)
_pos_cache = {"x": None, "y": None}
def update_pos_cache():
try:
geo = root.geometry()
parts = geo.split("+")
_pos_cache["x"] = int(parts[1])
_pos_cache["y"] = int(parts[2])
except Exception:
pass
root.after(500, update_pos_cache)
root.after(500, update_pos_cache)
# 종료 시 창 위치 / 투명도 / 핀 상태 저장
def on_close():
meta = load_meta()
meta["win_x"] = _pos_cache["x"] if _pos_cache["x"] is not None else root.winfo_x()
meta["win_y"] = _pos_cache["y"] if _pos_cache["y"] is not None else root.winfo_y()
meta["alpha"] = alpha_var.get()
meta["pinned"] = pin_state["on"]
save_meta(meta)
if _hotkey_listener["listener"]:
_hotkey_listener["listener"].stop()
root.destroy()
# 종료 버튼 on_close 로 연결
btn_close.bind("<Button-1>", lambda e: on_close())
root.mainloop()
if __name__ == "__main__":
build_gui()