"""
========================================================
D2 Folder Launcher — Diablo II Modding Quick Access
========================================================
폴더/앱 설정은 앱 내 ⚙ 설정 버튼에서 관리하세요.
설정은 %%APPDATA%%D2Launcherconfig.json 에 저장됩니다.
"""
import tkinter as tk
from tkinter import messagebox, filedialog
import subprocess
import os
import json
# ──────────────────────────────────────────────────────
# 앱 설정
# ──────────────────────────────────────────────────────
APP_TITLE = "D2R PathFinder v.0.1"
COLUMNS = 2
BTN_WIDTH = 17
BTN_HEIGHT = 1
CONFIG_DIR = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "D2RPathFinder")
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.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", 11, "bold")
FONT_UI = ("Segoe UI", 10)
FONT_SMALL = ("Segoe UI", 9)
# ──────────────────────────────────────────────────────
# config.json 로드 / 저장
# ──────────────────────────────────────────────────────
def load_config():
if not os.path.exists(CONFIG_FILE):
return [dict(f) for f in DEFAULT_FOLDERS]
try:
with open(CONFIG_FILE, "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):
os.makedirs(CONFIG_DIR, exist_ok=True)
with open(CONFIG_FILE, "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])
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}
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)
# 순서 번호
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=(2, 4))
# 이름 입력
lv = tk.StringVar(value=label_val)
name_entry = tk.Entry(row_frame, textvariable=lv, font=FONT_UI,
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}
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)
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()
]
save_config(new_folders)
on_save(new_folders)
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)
# ── 드래그 ───────────────────────────────────────────
_drag = {"x": 0, "y": 0}
def drag_start(e):
if pin_state["on"]: return
_drag["x"] = e.x; _drag["y"] = e.y
def drag_move(e):
if pin_state["on"]: return
root.geometry(f"+{root.winfo_x()+e.x-_drag['x']}+{root.winfo_y()+e.y-_drag['y']}")
# ── 타이틀바 ─────────────────────────────────────────
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"])
if pin_state["on"]:
btn_close.config(fg=COLORS["border"], cursor="arrow")
else:
btn_close.config(fg=COLORS["text_secondary"], cursor="hand2")
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"]) if not pin_state["on"] else None)
btn_close.bind("<Leave>", lambda e: btn_close.config(fg=COLORS["text_secondary"]) if not pin_state["on"] else None)
btn_close.bind("<Button-1>", lambda e: root.destroy() if not pin_state["on"] else None)
# 투명도 슬라이더 라벨
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"]))
# 금빛 구분선
tk.Frame(root, bg=COLORS["accent"], height=2).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)
def on_config_save(new_folders):
nonlocal folders
folders = new_folders
rebuild_buttons(folders)
btn_cfg.bind("<Button-1>", lambda e: open_config_window(root, folders, on_config_save))
# 하단 장식선
tk.Frame(root, bg=COLORS["accent"], height=2).pack(fill="x")
# 화면 중앙
root.update_idletasks()
sw = root.winfo_screenwidth()
sh = root.winfo_screenheight()
root.geometry(f"+{(sw-root.winfo_width())//2}+{(sh-root.winfo_height())//2}")
root.mainloop()
if __name__ == "__main__":
build_gui()