[구글 다운로드 링크] - D2R_PathFinder_v0.5.exe (26. 6. 6 20:50 최신)







모딩하다 경로 오가기 답답해서 AI로 만들어 쓰고 있는 패스파인더 앱입니다.
(물론 저만 고전적이게 폴더 이리저리 돌아다녔을 거 같긴 한데...)

간단하게 버튼 클릭하면 원하는 exe파일이나 폴더를 켜는 앱입니다.
                                         // (v0.46 추가) 인터넷 사이트 추가 가능

[모더용]이라고 말머리를 달려다가 다른 용도로도 쓸 수 있다고 판단해서 그냥 올립니다.




최초 실행 시 창입니다. 상단의 설정(톱니바퀴) 버튼으로 버튼명과 경로를 편집하면 됩니다.




               * 버튼 이름과 경로를 선택할 수 있고 항목을 추가하거나 X로 버튼을 지울 수 있습니다.
                 (항목이 추가/제거되면 자동으로 버튼 개수와 창 크기가 조정됩니다.)
                 (버튼 앞 이모지 단축키 : Win + . (마침표))
                 (v.0.48 추가) 이모지는 GUI로 선택 가능합니다.

               * exe 파일의 경우 바로가기 생성 후 우클릭 - 속성 - 대상(T)의 경로를 붙여넣으시면 됩니다.
                 (따옴표(" ")는 제거하세요.)

               * (v.0.4 추가) 각 개체는 왼쪽 부분 드래그로 버튼순서 변경이 가능합니다.

               * (v.0.43 추가) 설정파일(Config.json)의 저장위치 변경이 가능합니다.

               * (v.0.44 추가) 창 분실 시 강제로 중앙으로 불러오는 단축키 설정이 가능합니다.


상단바 기능



                              ① 설정 : 버튼 / 경로설정

                              ② 투명도 조절바 : 좌 - 불투명, 우 - 투명

                              ③ 앱 종료버튼

                              ④ 항상 위에 고정 : 고정 및 창 이동, 종료버튼 비활성화

                              * 상단바 드래그 : 창 이동


                               * 앱 이름 더블클릭 : 접기 / 펼치기(v.0.3에서 추가)
                               * 화살표 버튼 : 접기 / 펼치기(v.0.42에서 추가)


p.s 고정핀이 종료버튼의 왼쪽에 있는 게 자연스러운데, 자꾸 코드가 꼬여서 에라 모르겠다 했습니다 ㅜㅜ


-----------------------------------------------------------------------------------------------------------------------------------------------

※ 설정 - 경로 수정시 설정 파일(config.json)이 자동 생성 됩니다.

    ▶ 설정파일 저장 위치 : %APPDATA%D2RPathFinder

※ 단, 설정창 하단의 Config 파일 위치를 따로 지정하셨다면 해당 경로에 생성 됩니다(v.0.43 추가).
-----------------------------------------------------------------------------------------------------------------------------------------------
[패치 내역]

* D2R_PathFinder_v0.2 ~ 0.3 업데이트
  - 간헐적으로 앱이 작업표시줄에 표시되지 않는 문제 수정
  - 앱 이름 클릭 시 접기/펼치기 기능 추가

* D2R_PathFinder_v0.4 업데이트

  - 설정에서 버튼 위치변경 기능 추가 : 드래그
  - 폴더와 연계된 응용프로그램을 실행할 수 없던 문제 수정
  - 사용 가능한 이모지가 더 많은 폰트로 변경 (※ 안 되는 이모지가 여전히 많지만, 최선인 듯 합니다.)
     // 사용 가능한 이모지 목록 (인벤 게시글에 쓸 수 없는 문자라고 떠서 이미지 첨부)


* D2R_PathFinder_v0.41 ~ 0.43 업데이트
  - 접기/펼치기를 더블클릭으로 변경
  - 접기/펼치기를 별개의 버튼(▲, ▼)으로 변경
  설정(⚙️)에서 설정파일(Config.json)의 저장위치를 변경하는 기능 추가

* D2R_PathFinder_v0.44 ~ 0.47 업데이트
  - 핀 고정 상태에서도 종료버튼 작동하도록 변경
  - 앱 종료 위치를 기억, 다음 실행시 같은 위치에 팝업되도록 변경
  - 창 분실(상단바가 화면 밖으로 이탈) 시 찾을 수 있도록 설정(⚙️)창 중앙이동 단축키 설정 추가
  - 앱 종료 시 위치 뿐만 아니라 투명도와 핀 고정도 기억하도록 변경
  - 앱 종료위치가 화면 밖일 경우 다음 로드 시 화면 중앙에서 팝업하도록 변경
  - 버튼에 URL을 할당하면 인터넷 브라우저도 열 수 있게 수정(https://로 시작하도록 입력할 것)
  - 설정(⚙️)의 항목 제거 버튼이 사라진 문제 수정

* D2R_PathFinder_v0.48 업데이트(26. 6. 4 20:10)
  
  - 설정(⚙️)의 버튼 항목을 이모지와 제목으로 분할, 이모지를 GUI로 선택할 수 있도록 개선

* D2R_PathFinder_v0.49h 추가

  - 가로 버튼 모드(기능 동일)

* D2R_PathFinder_v0.5 업데이트
  - 듀얼 모니터 환경에서 위치 저장 기능 오작동 수정
     // 서브 모니터 사망 후 안 쓰고 있어 직접 테스트가 불가함 : 문제시 알려주시면 감사하겠습니다.
  - 설정(⚙️)에 가로모드/세로모드 선택 기능 추가
 
▲ 하단 '버튼 배치' 부분에서 선택
 
▲ 세로 모드 화면(2열 N행)
▲ 가로 모드 화면(N열 1행, 1행 최대 10개 버튼)

-----------------------------------------------------------------------------------------------------------------------------------------------

보안 및 안전 안내
  • 100% 안전한 툴: 모더분들의 작업 동선을 줄이기 위해 제작된 단순 런처 프로그램입니다.
  • 소스코드 투명 공개: 혹시 모를 불안감이 있으신 분들을 위해 사용된 파이썬(Python) 소스코드를 하단에 그대로 첨부합니다. 의심스러우시다면 코드를 직접 검증하시거나 파이썬으로 직접 실행하셔도 좋습니다!
  • 미인증 개인 제작 파일 특성상 백신 프로그램이 오진할 수 있으나, 어떠한 악성 기능도 없는 청정 프로그램임을 보증합니다

▼ 클릭 시 펼쳐집니다.

Python Code 보기
"""
========================================================
  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()