"""
ScriptManager.py
Script Manager - Maya Script Browser for MEL & Python

Usage (Maya Script Editor - Python tab):
    exec(open(r'D:/path/to/ScriptManager.py').read())
  or:
    import ScriptManager; ScriptManager.show()

Tabs:
  - Each tab is a script folder.  The "+" tab on the right adds a new folder.
  - The small "+" button (top right) makes a new group in the current tab.
Reorder / group:
  - Drag the colored PY / MEL badge on the left of a card to reorder a script.
  - Drag the grip (::) on the left of a group bar to reorder the whole group.
  - Drop a badge onto a group bar to move that script into the group.
Show/hide run command:  click the arrow to the right of the Src button.
Collapse a group:        click the arrow on the group bar.
Rename a group:          click the group name text.
Recolor a group:         click the small colored swatch on the right of the bar.
Delete a group:          a Delete button appears only while the group is empty.
"""

import maya.cmds as mc
import maya.mel  as mel
import os, re, functools, hashlib, subprocess, __main__

# -- constants -----------------------------------------------------------------
WIN_MAIN    = "smWin"
WIN_PREFS   = "smPrefsWin"
WIN_COLOR   = "smColorWin"
OV_DIRS     = "smScriptDirs"
OV_EDITOR   = "smEditorPath"
OV_CMD      = "smCmd_"
OV_ORDER    = "smOrder_"
OV_ALIAS    = "smAlias_"
OV_CMDCOL   = "smCmdCol_"   # 1=collapsed(hidden)  0=expanded(visible)
OV_GRPCOL   = "smGrpCol_"   # 1=collapsed           0=expanded
OV_TABCOL   = "smTabCol_"   # per-folder tab color  "r,g,b"

ICON_CLOSED = "arrowRight.png"   # Maya built-in arrow (collapsed)
ICON_OPEN   = "arrowDown.png"    # Maya built-in arrow (expanded)

_filter     = ""
_active_tab = 0

# Pending callback for the color-picker window
_pending_color_cb = [None]

# Preset colors for group bars
_GRP_SWATCHES = [
    ("Red",    (0.56, 0.28, 0.28)),
    ("Green",  (0.30, 0.50, 0.30)),
    ("Blue",   (0.30, 0.42, 0.66)),
    ("Amber",  (0.58, 0.46, 0.20)),
    ("Purple", (0.48, 0.32, 0.62)),
    ("Teal",   (0.22, 0.50, 0.54)),
    ("Pink",   (0.62, 0.34, 0.52)),
    ("Gray",   (0.42, 0.42, 0.42)),
]


# -- option vars ---------------------------------------------------------------

def _ov_get(key, default=None):
    return mc.optionVar(query=key) if mc.optionVar(exists=key) else default

def _ov_clear(key):
    if mc.optionVar(exists=key): mc.optionVar(remove=key)

def _ov_key(prefix, s, nocase=True):
    if nocase:
        s = os.path.normcase(s)
    return prefix + hashlib.md5(s.encode()).hexdigest()[:16]


def _init_opts():
    if not mc.optionVar(exists=OV_DIRS):
        mc.optionVar(stringValue=(OV_DIRS, ""))
    if not mc.optionVar(exists=OV_EDITOR):
        default = ""
        for cand in (os.path.join(os.environ.get("WINDIR", r"C:\Windows"),
                                  "system32", "notepad.exe"),
                     r"C:\Windows\notepad.exe"):
            if os.path.exists(cand):
                default = cand; break
        mc.optionVar(stringValue=(OV_EDITOR, default))


def _get_dirs():
    """The configured script folders, with empty slots dropped."""
    v = mc.optionVar(query=OV_DIRS)
    v = v if isinstance(v, list) else ([v] if v else [])
    return [d for d in v if d]


def _get_editor():           return _ov_get(OV_EDITOR, "")
def _set_editor(path):       mc.optionVar(stringValue=(OV_EDITOR, path or ""))


# -- per-script saved commands -------------------------------------------------

def _cmd_ov_key(path):       return _ov_key(OV_CMD, path)
def _save_cmd(path, cmd):    mc.optionVar(stringValue=(_cmd_ov_key(path), cmd))
def _load_cmd(path):         return _ov_get(_cmd_ov_key(path))
def _clear_saved_cmd(path):  _ov_clear(_cmd_ov_key(path))


# -- order management ----------------------------------------------------------

def _order_ov_key(directory): return _ov_key(OV_ORDER, directory)

def _save_order(directory, items):
    key = _order_ov_key(directory)
    mc.optionVar(clearArray=key)
    for item in items: mc.optionVar(stringValueAppend=(key, item))

def _load_order(directory):
    key = _order_ov_key(directory)
    if not mc.optionVar(exists=key): return None
    v = mc.optionVar(query=key)
    return v if isinstance(v, list) else ([v] if v else [])


# -- alias management ----------------------------------------------------------

def _alias_ov_key(path):     return _ov_key(OV_ALIAS, path)
def _save_alias(path, name): mc.optionVar(stringValue=(_alias_ov_key(path), name))
def _load_alias(path):       return _ov_get(_alias_ov_key(path))
def _clear_alias(path):      _ov_clear(_alias_ov_key(path))


# -- group management ----------------------------------------------------------

def _parse_grp(item):
    """'::GRP::name||r,g,b||level'  ->  (name, (r,g,b), level).
    Older markers without a level part default to level 0."""
    parts = item[7:].split("||")
    name = parts[0]
    cstr = parts[1] if len(parts) >= 2 else "0.48,0.32,0.62"
    try:
        level = int(parts[2]) if len(parts) >= 3 else 0
    except Exception:
        level = 0
    try:
        r, g, b = [float(x) for x in cstr.split(",")]
    except Exception:
        r, g, b = 0.48, 0.32, 0.62
    return name, (r, g, b), max(0, level)

def _make_grp_str(name, color, level=0):
    r, g, b = color
    name = name.replace("||", "")
    return "::GRP::{}||{:.3f},{:.3f},{:.3f}||{}".format(name, r, g, b, max(0, int(level)))

def _grp_level(item):
    return _parse_grp(item)[2]

def _shift_level(item, delta):
    name, color, level = _parse_grp(item)
    return _make_grp_str(name, color, max(0, level + delta))

def _depth_at(items, idx):
    """Nesting level of the innermost group whose scope contains position idx,
    or -1 if that position is top-level / ungrouped."""
    stack = []
    for i in range(max(0, min(idx, len(items)))):
        it = items[i]
        if it.startswith("::GRP::"):
            L = _grp_level(it)
            while stack and stack[-1] >= L:
                stack.pop()
            stack.append(L)
    return stack[-1] if stack else -1

def _grp_block_end(items, g_idx):
    """Index just past the group at g_idx and all of its descendants."""
    level = _grp_level(items[g_idx])
    end = g_idx + 1
    n = len(items)
    while end < n and not (items[end].startswith("::GRP::")
                           and _grp_level(items[end]) <= level):
        end += 1
    return end

def _grp_is_empty(items, g_idx):
    """A group is empty if it has no direct children (scripts or sub-groups)."""
    if g_idx + 1 >= len(items):
        return True
    nxt = items[g_idx + 1]
    return nxt.startswith("::GRP::") and _grp_level(nxt) <= _grp_level(items[g_idx])

def _grp_col_key(grp_item):       return _ov_key(OV_GRPCOL, grp_item, nocase=False)
def _get_grp_collapsed(grp_item): return bool(_ov_get(_grp_col_key(grp_item), 0))
def _set_grp_collapsed(grp_item, state):
    mc.optionVar(intValue=(_grp_col_key(grp_item), 1 if state else 0))


# -- cmd-row collapse management -----------------------------------------------

def _cmd_col_key(path):       return _ov_key(OV_CMDCOL, path)
def _get_cmd_collapsed(path): return bool(_ov_get(_cmd_col_key(path), 1))  # collapsed by default
def _set_cmd_collapsed(path, state):
    mc.optionVar(intValue=(_cmd_col_key(path), 1 if state else 0))


# -- color-picker window (no mc.colorEditor - keeps the tool window alive) -----

def _color_win(callback, default_color=None):
    """Open a small preset color-swatch window. Calls callback((r,g,b)) on pick."""
    global _pending_color_cb
    _pending_color_cb[0] = callback

    if mc.window(WIN_COLOR, exists=True):
        mc.deleteUI(WIN_COLOR)

    mc.window(WIN_COLOR, title="Group Color", width=224, height=58,
              sizeable=False, toolbox=True)
    mc.rowLayout(numberOfColumns=8, columnWidth=[(i + 1, 26) for i in range(8)])
    for label, color in _GRP_SWATCHES:
        mc.button(label="", width=24, height=48, backgroundColor=color,
                  annotation=label,
                  command=lambda *a, c=color: _color_picked(c))
    mc.setParent("..")
    mc.showWindow(WIN_COLOR)


def _color_picked(color):
    global _pending_color_cb
    if mc.window(WIN_COLOR, exists=True):
        mc.deleteUI(WIN_COLOR)
    cb = _pending_color_cb[0]
    _pending_color_cb[0] = None
    if cb:
        cb(color)


# -- file utilities ------------------------------------------------------------

def _get_files(directory, filt=""):
    if not directory or not os.path.isdir(directory):
        return []
    mel_f = mc.getFileList(folder=directory + "\\", filespec="*{}*.mel".format(filt)) or []
    py_f  = mc.getFileList(folder=directory + "\\", filespec="*{}*.py".format(filt)) or []
    return sorted(mel_f + py_f)


def _get_ordered_items(directory, filt=""):
    real_files = _get_files(directory, filt)
    real_set   = set(real_files)
    saved      = _load_order(directory)
    if saved is None:
        return list(real_files)
    result, seen = [], set()
    for item in saved:
        if item.startswith("::GRP::"):
            if not filt: result.append(item)
        elif item in real_set and item not in seen:
            seen.add(item); result.append(item)
    for f in real_files:
        if f not in seen: result.append(f)
    return result


def _get_scripts(directory, filt=""):
    return [x for x in _get_ordered_items(directory, filt) if not x.startswith("::GRP::")]


def _ext(fn):
    _, e = os.path.splitext(fn); return e.lstrip(".").lower() or "mel"
def _full_path(d, fn):    return os.path.join(d, fn)
def _mel_src_path(d, fn): return _full_path(d, fn).replace("\\", "\\\\")

def _file_mdate(path):
    try:
        import datetime
        return datetime.datetime.fromtimestamp(os.path.getmtime(path)).strftime("%Y-%m-%d")
    except Exception: return ""

def _tab_label(directory):
    name = directory.rstrip("\\/").replace("\\", "/").split("/")[-1]
    return " " + (name[:24] + ("..." if len(name) > 24 else "")) + " "


# -- python entry-point detection ----------------------------------------------

def _detect_py_entry(path):
    base = os.path.splitext(os.path.basename(path))[0]
    try:
        with open(path, "r", errors="replace") as f: content = f.read()
    except OSError: return "{}()".format(base)
    lines = content.splitlines()
    call_re  = re.compile(r'^([\w.]+\([^)]*\))\s*(?:#.*)?$')
    break_re = re.compile(
        r'^(?:def |class |import |from |@|#|"""|\'\'\''
        r'|if |elif |else:|for |while |try:|except|with |return )')
    for line in reversed(lines):
        if not line.strip(): continue
        if line[:1] in (' ', '\t'): continue
        if break_re.match(line): break
        m = call_re.match(line)
        if m: return m.group(1)
        break
    m = re.search(
        r'if\s+__name__\s*==\s*["\']__main__["\']\s*:\s*\n((?:[ \t]+[^\n]+\n?)+)',
        content)
    if m:
        for bl in m.group(1).splitlines():
            bl = bl.strip()
            if bl and not bl.startswith('#') and '(' in bl: return bl
    if re.search(r'^def\s+main\s*\(', content, re.MULTILINE): return "main()"
    if re.search(r'^def\s+{}\s*\('.format(re.escape(base)), content, re.MULTILINE):
        return "{}()".format(base)
    m = re.search(r'^def\s+(\w+)\s*\(', content, re.MULTILINE)
    if m: return "{}()".format(m.group(1))
    return "{}()".format(base)


# -- core actions --------------------------------------------------------------

def _source_py(path):
    """Load a .py file into Maya's __main__ namespace for Run, but execute it
    with __name__ set to the file stem (not "__main__").  Sourcing into
    vars(__main__) alone leaves __name__ as "__main__", so scripts that guard
    UI with `if __name__ == "__main__": show()` launch twice when Run also
    execs show() — e.g. skin tool flash (delete + reopen) or renamer races."""
    with open(path, "r", errors="replace") as f:
        code = f.read()
    main = vars(__main__)
    globs = dict(main)
    globs["__name__"] = os.path.splitext(os.path.basename(path))[0]
    globs["__file__"] = path
    exec(compile(code, path, "exec"), globs)
    for key, val in globs.items():
        if key.startswith("__"):
            continue
        main[key] = val
    print("# Sourced: {}".format(path))

def _source_mel(directory, fn):
    mel.eval('source "{}"'.format(_mel_src_path(directory, fn)))
    print("// Sourced: {}".format(_full_path(directory, fn)))


def _get_run_cmd(di, si):
    """Read cmd from the scrollField if it exists, else fall back to saved/detected."""
    dirs  = _get_dirs()
    files = _get_scripts(dirs[di], _filter)
    fn    = files[si]
    ext   = _ext(fn)
    path  = _full_path(dirs[di], fn)
    tf    = "smCmd_{}_{}".format(di, si)
    if mc.scrollField(tf, exists=True):
        return mc.scrollField(tf, q=True, text=True)
    if ext == "py":
        saved = _load_cmd(path)
        return saved if saved is not None else _detect_py_entry(path)
    return "{}()".format(os.path.splitext(fn)[0])


def execute(di, si):
    dirs  = _get_dirs()
    files = _get_scripts(dirs[di], _filter)
    fn    = files[si];  ext = _ext(fn)
    path  = _full_path(dirs[di], fn)
    cmd   = _get_run_cmd(di, si)

    if ext == "py":
        try: _source_py(path)
        except Exception as e:
            mc.warning("[Script Manager] Source error '{}': {}".format(fn, e)); return
        if cmd and cmd.strip():
            try:
                print("# Run: {}".format(cmd.split("\n")[0]))
                exec(compile(cmd, "<run>", "exec"), vars(__main__))
            except Exception as e:
                mc.warning("[Script Manager] Run error: {}".format(e))
    else:
        proc = re.sub(r'\(.*', '', cmd).strip()
        if proc:
            try: known = mel.eval('whatIs "{}"'.format(proc))
            except Exception: known = "Unknown"
            if known == "Unknown":
                try: _source_mel(dirs[di], fn)
                except Exception as e:
                    mc.warning("[Script Manager] Source error: {}".format(e)); return
        if cmd:
            try:
                print("// Run: {}".format(cmd.split("\n")[0]))
                mel.eval(cmd)
            except Exception as e:
                mc.warning("[Script Manager] Run error: {}".format(e))


def source_script(di, si):
    dirs  = _get_dirs()
    files = _get_scripts(dirs[di], _filter)
    fn    = files[si];  path = _full_path(dirs[di], fn)
    try:
        if _ext(fn) == "py": _source_py(path)
        else:                 _source_mel(dirs[di], fn)
    except Exception as e:
        mc.warning("[Script Manager] Source error: {}".format(e))


def edit_script(di, si):
    editor = _get_editor()
    if not editor or not os.path.exists(editor):
        mc.warning("[Script Manager] No text editor set - choose one in Preferences."); return
    dirs  = _get_dirs()
    if di >= len(dirs): return
    files = _get_scripts(dirs[di], _filter)
    if si >= len(files): return
    path  = _full_path(dirs[di], files[si])
    try:
        subprocess.Popen([editor, path])
    except Exception as e:
        mc.warning("[Script Manager] Could not open editor: {}".format(e))


# -- tabs (folders) ------------------------------------------------------------

def _save_dirs(raw):
    """Persist the folder list (filtered of empties)."""
    raw = [d for d in raw if d]
    mc.optionVar(remove=OV_DIRS)
    if raw:
        mc.optionVar(stringValue=(OV_DIRS, raw[0]))
        for d in raw[1:]: mc.optionVar(stringValueAppend=(OV_DIRS, d))
    else:
        mc.optionVar(stringValue=(OV_DIRS, ""))


def _tab_col_key(directory): return _ov_key(OV_TABCOL, directory)

def _get_tab_color(directory):
    v = _ov_get(_tab_col_key(directory))
    if not v: return None
    try:
        r, g, b = [float(x) for x in v.split(",")]
        return (r, g, b)
    except Exception:
        return None

def _set_tab_color(directory, color):
    mc.optionVar(stringValue=(_tab_col_key(directory),
                              "{:.3f},{:.3f},{:.3f}".format(*color)))

def _tab_bg(directory, active):
    """Tab button color: a custom per-folder color (brighter when active),
    else the default neutral gray."""
    tc = _get_tab_color(directory)
    if tc:
        return tuple(min(1.0, c * 1.25) for c in tc) if active \
            else tuple(c * 0.70 for c in tc)
    return (0.34, 0.34, 0.40) if active else (0.22, 0.22, 0.25)


def _recolor_tab(di, *a):
    dirs = _get_dirs()
    if di < 0 or di >= len(dirs): return
    directory = dirs[di]
    def on_color(color):
        _set_tab_color(directory, color)
        _reload()
    _color_win(on_color, _get_tab_color(directory))


def _switch_tab(idx, *a):
    """Show the chosen tab's panel; hide the rest; recolor the tab buttons."""
    global _active_tab, _filter
    _active_tab = idx
    if _filter:                      # leave the combined search view, open this tab
        _filter = ""
        _reload()
        return
    dirs = _get_dirs()
    i = 0
    while True:
        panel = "smPanel_{}".format(i)
        btn   = "smTab_{}".format(i)
        if not mc.formLayout(panel, exists=True): break
        mc.formLayout(panel, edit=True, visible=(i == idx))
        if mc.iconTextButton(btn, exists=True) and i < len(dirs):
            mc.iconTextButton(btn, edit=True, backgroundColor=_tab_bg(dirs[i], i == idx))
        i += 1


def _tab_drag(di, *args):
    return ["T:%d" % di]


def _tab_drop(di, *args):
    messages = args[2] if len(args) >= 3 else None
    if not messages:
        return
    parts = messages[0].split(":")
    if len(parts) != 2 or parts[0] != "T":
        return
    try:
        src = int(parts[1])
    except Exception:
        return
    _reorder_tabs(src, di)


def _reorder_tabs(src, dst):
    """Drop tab `src` onto tab `dst`.  A plain pop+insert at the target index
    moves the tab correctly in BOTH directions."""
    global _active_tab
    raw = _get_dirs()
    n = len(raw)
    if src < 0 or src >= n or dst < 0 or dst >= n or src == dst:
        return
    folder = raw.pop(src)
    raw.insert(dst, folder)
    _save_dirs(raw)
    _active_tab = dst
    _reload()


def _open_in_explorer(di, *a):
    raw = _get_dirs()
    if di < 0 or di >= len(raw):
        return
    path = os.path.normpath(raw[di])
    if not os.path.isdir(path):
        mc.warning("[Script Manager] Folder not found: {}".format(path)); return
    try:
        os.startfile(path)                       # Windows: opens the folder
    except Exception:
        try:
            subprocess.Popen(["explorer", path])
        except Exception as e:
            mc.warning("[Script Manager] Could not open folder: {}".format(e))


def _delete_tab(di, *a):
    raw = _get_dirs()
    if di < 0 or di >= len(raw):
        return
    res = mc.confirmDialog(
        title="Delete Tab",
        message="Remove this folder from Script Manager?\n(The folder and its files are NOT deleted.)\n\n{}".format(raw[di]),
        button=["Delete", "Cancel"], defaultButton="Cancel",
        cancelButton="Cancel", dismissString="Cancel")
    if res != "Delete":
        return
    raw.pop(di)
    _save_dirs(raw)
    global _active_tab
    if _active_tab >= len(raw):
        _active_tab = max(0, len(raw) - 1)
    _reload()


def _add_directory(*a):
    try:
        result = mc.fileDialog2(fileMode=3, caption="Select Script Folder", okCaption="Add")
    except Exception:
        result = None
    if not result:
        return
    path = result[0]
    if not path or not os.path.isdir(path):
        mc.warning("[Script Manager] Not a folder: {}".format(path)); return
    raw = _get_dirs()
    if os.path.normcase(path) in [os.path.normcase(d) for d in raw]:
        mc.warning("[Script Manager] Folder already added."); return
    raw.append(path)
    _save_dirs(raw)
    global _active_tab
    _active_tab = len(raw) - 1
    _reload()


# -- cmd field handlers --------------------------------------------------------

def _cmd_changed(di, si, *a):
    dirs  = _get_dirs()
    files = _get_scripts(dirs[di], _filter)
    if si >= len(files): return
    fn = files[si]; ext = _ext(fn); base = os.path.splitext(fn)[0]
    path = _full_path(dirs[di], fn)
    tf   = "smCmd_{}_{}".format(di, si)
    if not mc.scrollField(tf, exists=True): return
    cmd = mc.scrollField(tf, q=True, text=True)
    if ext == "py":
        _save_cmd(path, cmd)
        is_custom = cmd.strip() != _detect_py_entry(path).strip()
    else:
        is_custom = re.sub(r'\(.*', '', cmd).strip() != base
    mc.scrollField(tf, edit=True, font="boldLabelFont" if is_custom else "plainLabelFont")


def _cmd_reset(di, si, *a):
    dirs  = _get_dirs()
    files = _get_scripts(dirs[di], _filter)
    if si >= len(files): return
    fn = files[si]; ext = _ext(fn); path = _full_path(dirs[di], fn)
    tf = "smCmd_{}_{}".format(di, si)
    _clear_saved_cmd(path)
    default = _detect_py_entry(path) if ext == "py" else "{}()".format(os.path.splitext(fn)[0])
    if mc.scrollField(tf, exists=True):
        mc.scrollField(tf, edit=True, text=default, font="plainLabelFont")


# -- in-place collapse toggles (no reload) -------------------------------------

def _toggle_cmd_col(di, si, path, *a):
    row = "smCmdRow_{}_{}".format(di, si)
    btn = "smCmdTgl_{}_{}".format(di, si)
    if not mc.rowLayout(row, exists=True): return
    showing = mc.rowLayout(row, q=True, manage=True)
    mc.rowLayout(row, edit=True, manage=not showing)
    if mc.iconTextButton(btn, exists=True):
        mc.iconTextButton(btn, edit=True, image1=ICON_CLOSED if showing else ICON_OPEN)
    _set_cmd_collapsed(path, showing)


def _toggle_grp(di, grp_idx, grp_item, *a):
    col = "smGrpContent_{}_{}".format(di, grp_idx)
    btn = "smGrpTgl_{}_{}".format(di, grp_idx)
    if not mc.columnLayout(col, exists=True): return
    showing = mc.columnLayout(col, q=True, manage=True)
    mc.columnLayout(col, edit=True, manage=not showing)
    if mc.iconTextButton(btn, exists=True):
        mc.iconTextButton(btn, edit=True, image1=ICON_CLOSED if showing else ICON_OPEN)
    _set_grp_collapsed(grp_item, showing)


# -- drag & drop reorder / grouping --------------------------------------------

def _script_drag(di, item_idx, *args):
    # Maya calls (dragControl, x, y, modifiers); must return a string list.
    return ["S:%d:%d" % (di, item_idx)]


def _grp_drag(di, grp_idx, *args):
    return ["G:%d:%d" % (di, grp_idx)]


def _on_drop(di, dst_idx, mode, *args):
    # Maya calls (dragControl, dropControl, messages, x, y, dragType)
    messages = args[2] if len(args) >= 3 else None
    if not messages:
        return
    parts = messages[0].split(":")
    if len(parts) != 3:
        return
    kind = parts[0]
    try:
        s_di, s_idx = int(parts[1]), int(parts[2])
    except Exception:
        return
    if s_di != di:          # only reorder within the same tab
        return
    if kind == "S":
        _move_item_to(di, s_idx, dst_idx, mode)
    elif kind == "G":
        _move_group_block(di, s_idx, dst_idx, mode)


def _move_item_to(di, src_idx, dst_idx, mode):
    dirs = _get_dirs()
    if di >= len(dirs):
        return
    items = _get_ordered_items(dirs[di])
    n = len(items)
    if src_idx < 0 or src_idx >= n or dst_idx < 0 or dst_idx >= n:
        return
    if mode == "before" and src_idx == dst_idx:
        return
    src = items.pop(src_idx)
    if dst_idx > src_idx:
        dst_idx -= 1
    insert_at = dst_idx + 1 if mode == "into" else dst_idx   # "into" = right after the group marker
    insert_at = max(0, min(insert_at, len(items)))
    items.insert(insert_at, src)
    _save_order(dirs[di], items)
    _reload()


def _move_group_block(di, g_idx, dst_idx, mode):
    """Move a whole group (its marker + every descendant).  The group's nesting
    level is re-based on where it is dropped:
      - onto another group bar ("into")  -> becomes that group's last sub-group
      - before a group  -> sibling of that group
      - before a card   -> a sub-group sitting beside that card
      - on empty area    -> top level (handled by _on_drop_ungroup)"""
    dirs = _get_dirs()
    if di >= len(dirs):
        return
    items = _get_ordered_items(dirs[di])
    n = len(items)
    if g_idx < 0 or g_idx >= n or not items[g_idx].startswith("::GRP::"):
        return
    level = _grp_level(items[g_idx])
    end   = _grp_block_end(items, g_idx)
    if g_idx <= dst_idx < end:        # dropped on itself / its own descendants
        return

    block = items[g_idx:end]
    rest  = items[:g_idx] + items[end:]
    dst_rest = dst_idx - len(block) if dst_idx >= end else dst_idx
    dst_rest = max(0, min(dst_rest, len(rest)))

    if mode == "into" and dst_rest < len(rest) and rest[dst_rest].startswith("::GRP::"):
        t_level   = _grp_level(rest[dst_rest])
        new_base  = t_level + 1
        insert_at = _grp_block_end(rest, dst_rest)      # append as last sub-group
    elif dst_rest < len(rest) and rest[dst_rest].startswith("::GRP::"):
        new_base  = _grp_level(rest[dst_rest])          # sibling of that group
        insert_at = dst_rest
    else:
        new_base  = _depth_at(rest, dst_rest) + 1
        insert_at = dst_rest

    delta = new_base - level
    if delta:
        block = [_shift_level(it, delta) if it.startswith("::GRP::") else it
                 for it in block]
    insert_at = max(0, min(insert_at, len(rest)))
    rest[insert_at:insert_at] = block
    _save_order(dirs[di], rest)
    _reload()


def _ungroup_item(di, src_idx):
    """Move a script out of any group - placed at the end of the top-level
    (ungrouped) list, just before the first group."""
    dirs = _get_dirs()
    if di >= len(dirs):
        return
    items = _get_ordered_items(dirs[di])
    n = len(items)
    if src_idx < 0 or src_idx >= n or items[src_idx].startswith("::GRP::"):
        return
    first_grp = next((i for i, x in enumerate(items) if x.startswith("::GRP::")), n)
    src = items.pop(src_idx)
    if src_idx < first_grp:
        first_grp -= 1
    items.insert(first_grp, src)
    _save_order(dirs[di], items)
    _reload()


def _ungroup_group(di, g_idx):
    """Move a group (and its descendants) out to the top level, at the end."""
    dirs = _get_dirs()
    if di >= len(dirs):
        return
    items = _get_ordered_items(dirs[di])
    n = len(items)
    if g_idx < 0 or g_idx >= n or not items[g_idx].startswith("::GRP::"):
        return
    level = _grp_level(items[g_idx])
    end   = _grp_block_end(items, g_idx)
    block = items[g_idx:end]
    rest  = items[:g_idx] + items[end:]
    if level:
        block = [_shift_level(it, -level) if it.startswith("::GRP::") else it
                 for it in block]
    rest.extend(block)
    _save_order(dirs[di], rest)
    _reload()


def _on_drop_ungroup(di, *args):
    messages = args[2] if len(args) >= 3 else None
    if not messages:
        return
    parts = messages[0].split(":")
    if len(parts) != 3:
        return
    kind = parts[0]
    try:
        s_di, s_idx = int(parts[1]), int(parts[2])
    except Exception:
        return
    if s_di != di:
        return
    if kind == "S":
        _ungroup_item(di, s_idx)        # script -> ungrouped
    elif kind == "G":
        _ungroup_group(di, s_idx)       # sub-group -> top-level group


# -- group CRUD ----------------------------------------------------------------

def _add_group(di, *a):
    """Create a new group - prompt for a name, then pick a color."""
    dirs = _get_dirs()
    if not dirs:
        mc.warning("[Script Manager] Add a script folder first (the + tab)."); return
    if di >= len(dirs):
        di = 0
    items  = _get_ordered_items(dirs[di])
    n_grps = sum(1 for x in items if x.startswith("::GRP::"))
    default_name = "Group {}".format(n_grps + 1)

    result = mc.promptDialog(
        title="New Group", message="Group name:", text=default_name,
        button=["Create", "Cancel"], defaultButton="Create",
        cancelButton="Cancel", dismissString="Cancel")
    if result != "Create": return
    name = mc.promptDialog(query=True, text=True).strip() or default_name

    default_color = _GRP_SWATCHES[n_grps % len(_GRP_SWATCHES)][1]

    def on_color(color):
        dirs2 = _get_dirs()
        if di >= len(dirs2): return
        items2 = _get_ordered_items(dirs2[di])
        items2.append(_make_grp_str(name, color, 0))
        _save_order(dirs2[di], items2)
        _reload()

    _color_win(on_color, default_color)


def _add_subgroup(di, grp_idx, grp_item, *a):
    """Create an empty sub-group as the last child of the given group."""
    dirs = _get_dirs()
    if di >= len(dirs): return
    items = _get_ordered_items(dirs[di])
    if grp_idx >= len(items) or not items[grp_idx].startswith("::GRP::"): return

    default_name = "Sub-group"
    result = mc.promptDialog(
        title="New Sub-group", message="Sub-group name:", text=default_name,
        button=["Create", "Cancel"], defaultButton="Create",
        cancelButton="Cancel", dismissString="Cancel")
    if result != "Create": return
    name = mc.promptDialog(query=True, text=True).strip() or default_name
    default_color = _parse_grp(grp_item)[1]

    def on_color(color):
        dirs2 = _get_dirs()
        if di >= len(dirs2): return
        items2 = _get_ordered_items(dirs2[di])
        if grp_idx >= len(items2) or not items2[grp_idx].startswith("::GRP::"): return
        level = _grp_level(items2[grp_idx])
        end   = _grp_block_end(items2, grp_idx)
        items2.insert(end, _make_grp_str(name, color, level + 1))
        _save_order(dirs2[di], items2)
        _reload()

    _color_win(on_color, default_color)


def _rename_grp(di, grp_idx, grp_item, *a):
    grp_name, grp_color, grp_level = _parse_grp(grp_item)
    result = mc.promptDialog(
        title="Rename Group", message="Group name:", text=grp_name,
        button=["OK", "Cancel"], defaultButton="OK",
        cancelButton="Cancel", dismissString="Cancel")
    if result != "OK": return
    new_name = mc.promptDialog(query=True, text=True).strip() or grp_name
    dirs = _get_dirs()
    if di >= len(dirs): return
    items = _get_ordered_items(dirs[di])
    if grp_idx < len(items):
        new_str = _make_grp_str(new_name, grp_color, grp_level)
        _set_grp_collapsed(new_str, _get_grp_collapsed(grp_item))
        items[grp_idx] = new_str
        _save_order(dirs[di], items)
    _reload()


def _recolor_grp(di, grp_idx, grp_item, *a):
    grp_name, grp_color, grp_level = _parse_grp(grp_item)

    def on_color(color):
        dirs2 = _get_dirs()
        if di >= len(dirs2): return
        items2 = _get_ordered_items(dirs2[di])
        if grp_idx < len(items2):
            new_str = _make_grp_str(grp_name, color, grp_level)
            _set_grp_collapsed(new_str, _get_grp_collapsed(grp_item))
            items2[grp_idx] = new_str
            _save_order(dirs2[di], items2)
        _reload()

    _color_win(on_color, grp_color)


def _remove_item(di, item_idx, *a):
    dirs = _get_dirs()
    if di >= len(dirs): return
    items = _get_ordered_items(dirs[di])
    if item_idx < len(items):
        items.pop(item_idx)
        _save_order(dirs[di], items)
    _reload()


# -- display alias -------------------------------------------------------------

def _rename_display(di, si, *a):
    dirs = _get_dirs()
    if di >= len(dirs): return
    files = _get_scripts(dirs[di], _filter)
    if si >= len(files): return
    path    = _full_path(dirs[di], files[si])
    current = _load_alias(path) or files[si]
    result  = mc.promptDialog(
        title="Rename Display", message="Display name (file not renamed):",
        text=current, button=["OK", "Reset", "Cancel"],
        defaultButton="OK", cancelButton="Cancel", dismissString="Cancel")
    if result == "Reset":
        _clear_alias(path); _reload()
    elif result == "OK":
        name = mc.promptDialog(query=True, text=True).strip()
        if name: _save_alias(path, name); _reload()


# -- preferences window --------------------------------------------------------

def _prefs_editor_changed(*a):
    _set_editor(mc.textField("smPEdit", q=True, text=True).strip())

def _prefs_editor_browse(*a):
    try:
        result = mc.fileDialog2(fileMode=1, caption="Select Text Editor",
                                okCaption="Select")
    except Exception:
        result = None
    if not result: return
    _set_editor(result[0])
    if mc.textField("smPEdit", exists=True):
        mc.textField("smPEdit", edit=True, text=result[0])


def _prefs_path(idx, *a):
    p = mc.textField("smPF{}".format(idx), q=True, text=True)
    if os.path.isdir(p):
        count = len(_get_dirs())
        mc.optionVar(clearArray=OV_DIRS)
        for i in range(count):
            mc.optionVar(stringValueAppend=(OV_DIRS,
                mc.textField("smPF{}".format(i), q=True, text=True)))
        mc.text("smPL{}".format(idx), edit=True,
                label=" {}".format(idx + 1), font="plainLabelFont")
        _reload()
    else:
        mc.text("smPL{}".format(idx), edit=True, label=" !", font="boldLabelFont")
        mc.warning("Directory not found: " + p)


def _prefs_del(idx, *a):
    dirs = _get_dirs()
    _save_dirs([d for i, d in enumerate(dirs) if i != idx])
    if mc.window(WIN_PREFS, exists=True): mc.deleteUI(WIN_PREFS)
    preferences(); _reload()


def preferences():
    dirs = _get_dirs(); w = 370
    if mc.window(WIN_PREFS, exists=True): mc.deleteUI(WIN_PREFS)
    mc.window(WIN_PREFS, width=w + 24, height=470,
              title="Script Manager  -  Preferences", sizeable=True)
    mc.scrollLayout(width=w + 24)
    mc.columnLayout(width=w, rowSpacing=4)

    # -- Text editor --
    mc.frameLayout(width=w, label="  Text editor", collapsable=True,
                   marginWidth=6, marginHeight=4, backgroundColor=(0.22, 0.22, 0.22))
    mc.columnLayout(width=w - 12, rowSpacing=3)
    mc.separator(width=w - 12, height=4, style="none")
    mc.rowLayout(numberOfColumns=2, adjustableColumn=1, columnWidth2=(w - 84, 70))
    mc.textField("smPEdit", width=w - 86, text=_get_editor(),
                 changeCommand=_prefs_editor_changed,
                 annotation="Full path to your text editor (.exe) - used by the Edit button")
    mc.button(label="Browse", width=66, height=22, backgroundColor=(0.34, 0.40, 0.52),
              command=_prefs_editor_browse)
    mc.setParent("..")
    mc.text(label="  Opened by the Edit button on each script card.",
            align="left", font="smallPlainLabelFont")
    mc.separator(width=w - 12, height=4, style="none")
    mc.setParent(".."); mc.setParent("..")

    mc.frameLayout(width=w, label="  Script folders  (.mel and .py)",
                   collapsable=True, marginWidth=6, marginHeight=4,
                   backgroundColor=(0.22, 0.22, 0.22))
    mc.columnLayout(width=w - 12, rowSpacing=3)
    mc.separator(width=w - 12, height=4, style="none")
    if not dirs:
        mc.text(label="  No folders yet - use the + tab in the main window.",
                align="left", height=22)
    for i, d in enumerate(dirs):
        mc.rowLayout(numberOfColumns=3, columnWidth3=(22, w - 70, 40))
        mc.text("smPL{}".format(i), label=" {}".format(i + 1), width=22)
        mc.textField("smPF{}".format(i), width=w - 72, text=d,
                     changeCommand=functools.partial(_prefs_path, i))
        mc.button(width=40, height=20, label="Del",
                  backgroundColor=(0.62, 0.30, 0.30),
                  command=functools.partial(_prefs_del, i))
        mc.setParent("..")
    mc.separator(width=w - 12, height=6)
    mc.button(label="+ Add folder", width=w - 12, height=26,
              backgroundColor=(0.34, 0.52, 0.34), command=lambda *a: _add_directory())
    mc.separator(width=w - 12, height=4, style="none")
    mc.setParent(".."); mc.setParent("..")
    mc.showWindow(WIN_PREFS)


# -- helpers -------------------------------------------------------------------

def _reload(*a):
    # Rebuild only the content (not the dock) so the docked position is kept.
    # Deferred so we never deleteUI() from inside a control's own callback.
    mc.evalDeferred(_build_ui)

def _filter_changed(*a):
    global _filter
    _filter = mc.textField("smFilter", q=True, text=True)
    _reload()

def _clear_filter(*a):
    global _filter; _filter = ""; _reload()


# -- one script card -----------------------------------------------------------

def _build_card(di, si, directory, fn, item_idx):
    """Create a single script card under the current parent layout."""
    ext  = _ext(fn);  base = os.path.splitext(fn)[0]
    path = _full_path(directory, fn);  mdate = _file_mdate(path)
    py   = (ext == "py")

    if py:
        saved     = _load_cmd(path)
        run_cmd   = saved if saved is not None else _detect_py_entry(path)
        is_custom = saved is not None
    else:
        run_cmd   = "{}()".format(base);  is_custom = False

    display_name  = _load_alias(path) or fn
    cmd_collapsed = _get_cmd_collapsed(path)
    card_bg       = (0.23, 0.23, 0.23)

    mc.frameLayout("smCard_{}_{}".format(di, si),
                   labelVisible=False, collapsable=False,
                   marginWidth=0, marginHeight=2, backgroundColor=card_bg,
                   dropCallback=functools.partial(_on_drop, di, item_idx, "before"))
    mc.columnLayout(adjustableColumn=True)

    # Row 1:  [PY/MEL badge] | [arrow toggle] | name | Run | Src | Edit
    mc.rowLayout("smL1_{}_{}".format(di, si),
                 numberOfColumns=6, adjustableColumn=3, height=26,
                 columnWidth=[(1, 34), (2, 22), (3, 100), (4, 46), (5, 38), (6, 42)],
                 columnAlign=[(1, "center"), (2, "center"), (3, "left"),
                              (4, "center"), (5, "center"), (6, "center")])

    badge_label = " PY" if py else "MEL"
    badge_bg    = (0.22, 0.50, 0.26) if py else (0.22, 0.38, 0.60)
    # A plain text (not a button) so a left-click doesn't flash/highlight;
    # it still works as a middle-mouse drag handle and a drop target.
    mc.text("smBadge_{}_{}".format(di, si),
            label=badge_label, font="smallBoldLabelFont", align="center",
            width=32, height=22, backgroundColor=badge_bg,
            annotation="Middle mouse drag to reorder, or drop onto a group bar",
            dragCallback=functools.partial(_script_drag, di, item_idx),
            dropCallback=functools.partial(_on_drop, di, item_idx, "before"))

    mc.iconTextButton("smCmdTgl_{}_{}".format(di, si),
                      style="iconOnly", image1=ICON_CLOSED if cmd_collapsed else ICON_OPEN,
                      width=22, height=22,
                      annotation="Show / hide run command",
                      command=functools.partial(_toggle_cmd_col, di, si, path))

    fn_ctrl = mc.text("smFN_{}_{}".format(di, si),
                      label=" " + display_name, align="left", backgroundColor=card_bg,
                      annotation="{}\n{}\nRight-click to rename display".format(path, mdate))
    mc.popupMenu(parent=fn_ctrl, button=3)
    mc.menuItem(label="Rename Display", command=functools.partial(_rename_display, di, si))

    mc.button("smRun_{}_{}".format(di, si),
              label="Run", width=44, height=22, backgroundColor=(0.26, 0.58, 0.42),
              annotation="Source + run",
              command=lambda *a, d=di, s=si: execute(d, s))

    mc.button("smSrc_{}_{}".format(di, si),
              label="Src", width=38, height=22, backgroundColor=(0.48, 0.34, 0.64),
              annotation="Source / load file",
              command=lambda *a, d=di, s=si: source_script(d, s))

    mc.button("smEdit_{}_{}".format(di, si),
              label="Edit", width=40, height=22, backgroundColor=(0.58, 0.44, 0.22),
              annotation="Open in the text editor set in Preferences",
              command=lambda *a, d=di, s=si: edit_script(d, s))

    mc.setParent("..")   # row1 -> card column

    # Run-command row (hidden via manage when collapsed)
    if py:
        mc.rowLayout("smCmdRow_{}_{}".format(di, si),
                     numberOfColumns=2, adjustableColumn=1,
                     columnWidth=[(1, 100), (2, 26)], height=50)
        mc.scrollField("smCmd_{}_{}".format(di, si),
                       text=run_cmd, height=46, wordWrap=False,
                       font="boldLabelFont" if is_custom else "plainLabelFont",
                       backgroundColor=(0.14, 0.14, 0.14),
                       changeCommand=lambda *a, d=di, s=si: _cmd_changed(d, s))
        mc.button("smRst_{}_{}".format(di, si),
                  label="x", width=24, height=20, backgroundColor=(0.52, 0.28, 0.28),
                  annotation="Reset command",
                  command=lambda *a, d=di, s=si: _cmd_reset(d, s))
        mc.setParent("..")
    else:
        mc.rowLayout("smCmdRow_{}_{}".format(di, si),
                     numberOfColumns=1, adjustableColumn=1,
                     columnWidth=[(1, 100)], height=50)
        mc.scrollField("smCmd_{}_{}".format(di, si),
                       text=run_cmd, height=46, wordWrap=False,
                       font="boldLabelFont" if is_custom else "plainLabelFont",
                       backgroundColor=(0.18, 0.18, 0.18),
                       changeCommand=lambda *a, d=di, s=si: _cmd_changed(d, s))
        mc.setParent("..")

    if cmd_collapsed:
        mc.rowLayout("smCmdRow_{}_{}".format(di, si), edit=True, manage=False)

    mc.setParent("..")   # card column -> card frame
    mc.setParent("..")   # card frame  -> parent layout


# -- one group -----------------------------------------------------------------

def _build_group(di, grp_idx, grp_item, empty, depth=0):
    """Create a group bar + (collapsible) content column.  Leaves the content
    column as the current parent so the caller can append the group's children
    (cards and/or nested sub-groups).  `depth` is the nesting level, used to
    indent sub-groups to the right of their parent."""
    grp_name, grp_color, grp_level = _parse_grp(grp_item)
    grp_collapsed = _get_grp_collapsed(grp_item)

    box_bg  = tuple(max(0.18, c * 0.55) for c in grp_color)   # container behind cards
    sw_bg   = tuple(min(1.0,  c * 1.35) for c in grp_color)   # color swatch
    sub_bg  = tuple(max(0.14, c * 0.45) for c in grp_color)   # + sub button
    indent  = max(1, min(depth, 6) * 18)                      # left indent per level

    mc.separator(height=3, style="none")

    # Indent wrapper: a left spacer column pushes the whole group box to the right.
    mc.rowLayout("smGrpWrap_{}_{}".format(di, grp_idx),
                 numberOfColumns=2, adjustableColumn=2, columnWidth=[(1, indent)])
    mc.text(label="")   # left indent spacer
    mc.frameLayout("smGrpBox_{}_{}".format(di, grp_idx),
                   labelVisible=False, collapsable=False,
                   marginWidth=0, marginHeight=2, backgroundColor=box_bg)
    mc.columnLayout(adjustableColumn=True, rowSpacing=0)

    # -- Group bar --
    # The whole bar is a middle-mouse drag handle (move / reorder the group);
    # only the arrow, the name, the + and the swatch capture their own clicks.
    ncol = 5 if empty else 4
    cw   = [(1, 22), (2, 100), (3, 20), (4, 18)]
    if empty: cw.append((5, 24))
    mc.rowLayout("smGrpHdr_{}_{}".format(di, grp_idx),
                 numberOfColumns=ncol, adjustableColumn=2, height=24,
                 columnWidth=cw, backgroundColor=grp_color,
                 dragCallback=functools.partial(_grp_drag, di, grp_idx),
                 dropCallback=functools.partial(_on_drop, di, grp_idx, "into"))

    # collapse arrow  (open / close)
    mc.iconTextButton("smGrpTgl_{}_{}".format(di, grp_idx),
                      style="iconOnly", image1=ICON_CLOSED if grp_collapsed else ICON_OPEN,
                      width=22, height=22, backgroundColor=grp_color,
                      annotation="Collapse / expand group",
                      command=functools.partial(_toggle_grp, di, grp_idx, grp_item))

    # name  (right-click to rename; middle mouse drag anywhere to move)
    nm = mc.text("smGrpName_{}_{}".format(di, grp_idx),
                 label=grp_name, font="boldLabelFont", align="center",
                 height=22, backgroundColor=grp_color,
                 annotation="Middle mouse drag to move group layer.  Right-click to rename",
                 dragCallback=functools.partial(_grp_drag, di, grp_idx),
                 dropCallback=functools.partial(_on_drop, di, grp_idx, "into"))
    mc.popupMenu(parent=nm, button=3)
    mc.menuItem(label="Rename Group",
                command=functools.partial(_rename_grp, di, grp_idx, grp_item))

    # + sub-group
    mc.button("smGrpSub_{}_{}".format(di, grp_idx),
              label="+", width=18, height=18, backgroundColor=sub_bg,
              annotation="Add a sub-group inside this group",
              command=functools.partial(_add_subgroup, di, grp_idx, grp_item))

    # color swatch  (click to recolor)
    mc.button("smGrpClr_{}_{}".format(di, grp_idx),
              label="", width=16, height=16, backgroundColor=sw_bg,
              annotation="Change group color",
              command=functools.partial(_recolor_grp, di, grp_idx, grp_item))

    # delete (only while empty)
    if empty:
        mc.button("smGrpDel_{}_{}".format(di, grp_idx),
                  label="X", width=22, height=20, backgroundColor=(0.55, 0.28, 0.28),
                  annotation="Delete empty group",
                  command=functools.partial(_remove_item, di, grp_idx))

    mc.setParent("..")   # group bar -> group column

    # -- Group content (collapsible) --
    mc.columnLayout("smGrpContent_{}_{}".format(di, grp_idx),
                    adjustableColumn=True, rowSpacing=0)
    mc.separator(height=2, style="none")
    # children are appended by the caller; the column is closed by _close_group.


def _close_group(di, entry):
    """Close the layouts opened by _build_group and apply the collapse state."""
    mc.setParent("..")   # content column  -> group column
    mc.setParent("..")   # group column    -> group box frame
    mc.setParent("..")   # group box frame -> indent wrapper (rowLayout)
    mc.setParent("..")   # indent wrapper  -> parent (panel col or parent content)
    if _get_grp_collapsed(entry["grp_item"]):
        cc = "smGrpContent_{}_{}".format(di, entry["grp_idx"])
        if mc.columnLayout(cc, exists=True):
            mc.columnLayout(cc, edit=True, manage=False)


# -- content builders ----------------------------------------------------------

def _build_tab_panel(di, directory, content):
    """Build one folder's panel (scroll + cards/groups), attached to `content`."""
    panel  = mc.formLayout("smPanel_{}".format(di), visible=(di == _active_tab))
    # The scroll's empty background area is the ungroup drop target (no label,
    # no color) - dropping a script on it removes it from its group.
    scroll = mc.scrollLayout("smScroll_{}".format(di), childResizable=True,
                             dropCallback=functools.partial(_on_drop_ungroup, di))
    mc.columnLayout("smPanelCol_{}".format(di), adjustableColumn=True, rowSpacing=0)
    mc.separator(height=4, style="none")

    items_all  = _get_ordered_items(directory)
    items_disp = _get_ordered_items(directory, _filter)
    # Script filenames are unique within a folder, so a name->index map is safe
    # for them.  Group markers can repeat, so groups use their positional index.
    file_idx_map = {it: i for i, it in enumerate(items_all)
                    if not it.startswith("::GRP::")}

    if not items_disp:
        mc.text(label="  No scripts found.  (filter active)" if _filter
                else "  Folder is empty or not found.",
                height=60, align="left", backgroundColor=(0.20, 0.20, 0.20))
    else:
        # Linear walk with a stack of open groups -> supports nested sub-groups.
        si = 0
        stack = []   # innermost group last: {level, grp_idx, grp_item}
        for disp_i, item in enumerate(items_disp):
            if item.startswith("::GRP::"):
                level = _grp_level(item)
                while stack and stack[-1]["level"] >= level:
                    _close_group(di, stack.pop())
                _build_group(di, disp_i, item,
                             _grp_is_empty(items_all, disp_i), len(stack))
                stack.append({"level": level, "grp_idx": disp_i, "grp_item": item})
            else:
                _build_card(di, si, directory, item,
                            file_idx_map.get(item, disp_i)); si += 1
        while stack:
            _close_group(di, stack.pop())

    mc.separator(height=6, style="none")
    mc.setParent("..")   # panel column -> scroll
    mc.setParent("..")   # scroll -> panel form

    # "+ Group" pinned to the bottom-right corner, floating over the scroll
    addbtn = mc.button("smAddGrp_{}".format(di), label="+ Group",
                       width=70, height=22, backgroundColor=(0.28, 0.36, 0.52),
                       annotation="Add an empty group layer",
                       command=functools.partial(_add_group, di))
    mc.formLayout(panel, edit=True, attachForm=[
        (scroll, 'top', 0), (scroll, 'left', 0), (scroll, 'right', 0),
        (scroll, 'bottom', 0), (addbtn, 'bottom', 6), (addbtn, 'right', 6)])
    mc.setParent("..")   # panel form -> content
    mc.formLayout(content, edit=True, attachForm=[
        (panel, 'top', 0), (panel, 'left', 0), (panel, 'right', 0), (panel, 'bottom', 0)])


def _build_search_results(dirs, content):
    """Combined view shown while searching: every matching script across ALL
    folders, under a folder header (tab color) and, when grouped, its
    group-path header (group color)."""
    panel = mc.scrollLayout("smSearchPanel", childResizable=True)
    mc.columnLayout("smSearchCol", adjustableColumn=True, rowSpacing=0)
    mc.separator(height=4, style="none")
    found = False
    for di, directory in enumerate(dirs):
        matching = _get_scripts(directory, _filter)
        if not matching:
            continue
        found = True
        si_map    = {fn: i for i, fn in enumerate(matching)}
        items_all = _get_ordered_items(directory)
        file_idx_map = {it: i for i, it in enumerate(items_all)
                        if not it.startswith("::GRP::")}

        # folder header (tab color)
        mc.separator(height=4, style="none")
        mc.text(label=" " + _tab_label(directory).strip(),
                align="left", height=22, font="boldLabelFont",
                backgroundColor=_tab_bg(directory, di == _active_tab))

        # Walk the folder's items tracking the innermost group, emitting a
        # group-path header whenever a matched script's group changes.
        stack = []        # group marker strings, innermost last
        last_key = 0      # sentinel (a real group key is None or a "::GRP::" string)
        for item in items_all:
            if item.startswith("::GRP::"):
                lvl = _grp_level(item)
                while stack and _grp_level(stack[-1]) >= lvl:
                    stack.pop()
                stack.append(item)
                continue
            if item not in si_map:
                continue
            grp_key = stack[-1] if stack else None
            if grp_key != last_key:
                last_key = grp_key
                if stack:
                    gpath  = " / ".join(_parse_grp(g)[0] for g in stack)
                    gcolor = _parse_grp(stack[-1])[1]
                    indent = max(1, min(len(stack) - 1, 6) * 18)   # like sub-groups
                    mc.rowLayout(numberOfColumns=2, adjustableColumn=2,
                                 columnWidth=[(1, indent)])
                    mc.text(label="")          # left indent spacer
                    mc.text(label=" " + gpath, align="left", height=18,
                            font="boldLabelFont", backgroundColor=gcolor)
                    mc.setParent("..")
            _build_card(di, si_map[item], directory, item,
                        file_idx_map.get(item, si_map[item]))
    if not found:
        mc.text(label='  No scripts match "{}".'.format(_filter),
                height=60, align="left", backgroundColor=(0.20, 0.20, 0.20))
    mc.separator(height=6, style="none")
    mc.setParent("..")   # search column -> panel
    mc.setParent("..")   # panel -> content
    mc.formLayout(content, edit=True, attachForm=[
        (panel, 'top', 0), (panel, 'left', 0), (panel, 'right', 0), (panel, 'bottom', 0)])


# -- main window (dockable workspaceControl) -----------------------------------

def show():
    """Create the dockable panel once, then (re)build and raise its content.
    It starts floating - drag it to an edge of the Maya window to dock it."""
    _init_opts()
    if mc.window(WIN_MAIN, exists=True):        # remove any old non-dockable window
        mc.deleteUI(WIN_MAIN)
    if not mc.workspaceControl(WIN_MAIN, exists=True):
        mc.workspaceControl(WIN_MAIN, label="Script Manager", retain=False,
                            floating=True, initialWidth=420, initialHeight=560)
    _build_ui()
    mc.workspaceControl(WIN_MAIN, edit=True, restore=True)


def _build_ui():
    """(Re)build the UI inside the workspaceControl.  Reloads rebuild only this
    content (not the dock), so the docked/floating position is preserved."""
    global _active_tab
    _init_opts()

    dirs        = _get_dirs()
    all_scripts = [_get_scripts(d, _filter) for d in dirs]
    total       = sum(len(s) for s in all_scripts)
    n_filled    = sum(1 for s in all_scripts if s)
    n_dirs      = len(dirs)

    if _active_tab >= n_dirs: _active_tab = max(0, n_dirs - 1)

    if not mc.workspaceControl(WIN_MAIN, exists=True):
        return
    if mc.formLayout("smRoot", exists=True):
        mc.deleteUI("smRoot")
    mc.setParent(WIN_MAIN)
    root = mc.formLayout("smRoot")

    # -- Search bar (Preferences sits to the right of the clear button) --------
    srch = mc.rowLayout("smSrch", numberOfColumns=4, adjustableColumn=2, height=30,
                        columnWidth=[(1, 58), (3, 30), (4, 96)],
                        backgroundColor=(0.20, 0.20, 0.20))
    mc.text(label="  Search:", align="left", width=58, backgroundColor=(0.20, 0.20, 0.20))
    mc.textField("smFilter", text=_filter, changeCommand=_filter_changed,
                 backgroundColor=(0.15, 0.15, 0.15))
    mc.button(label="x", width=28, height=22, backgroundColor=(0.55, 0.28, 0.28),
              annotation="Clear search", command=_clear_filter)
    mc.button(label="Preferences", width=92, height=22, backgroundColor=(0.42, 0.42, 0.42),
              command=lambda *a: preferences())
    mc.setParent("..")

    # -- Tab bar (custom buttons; pure cmds, crash-safe) -----------------------
    # left-click = open, middle-drag = reorder (both directions),
    # right-click = Delete; trailing "+" adds a folder.
    tabbar = mc.formLayout("smTabBar", height=28, backgroundColor=(0.16, 0.16, 0.16))
    labels = [_tab_label(d) for d in dirs]
    widths = [max(54, len(lbl) * 8 + 12) for lbl in labels]
    ncol   = n_dirs + 1
    cw     = [(i + 1, widths[i]) for i in range(n_dirs)] + [(ncol, 30)]
    trow = mc.rowLayout("smTabRow", numberOfColumns=ncol, columnWidth=cw, height=26)
    for di, directory in enumerate(dirs):
        b = mc.iconTextButton("smTab_{}".format(di), label=labels[di],
                              style="textOnly", font="boldLabelFont",
                              width=widths[di], height=24,
                              backgroundColor=_tab_bg(directory, di == _active_tab),
                              annotation="Left-click: open    Middle-drag: reorder"
                                         "    Right-click: menu",
                              command=functools.partial(_switch_tab, di),
                              dragCallback=functools.partial(_tab_drag, di),
                              dropCallback=functools.partial(_tab_drop, di))
        mc.popupMenu(parent=b, button=3)
        mc.menuItem(label="Change Color",
                    command=functools.partial(_recolor_tab, di))
        mc.menuItem(label="Open in Explorer",
                    command=functools.partial(_open_in_explorer, di))
        mc.menuItem(divider=True)
        mc.menuItem(label="Delete Tab", command=functools.partial(_delete_tab, di))
    mc.iconTextButton("smTabAdd", label="+", style="textOnly", font="boldLabelFont",
                      width=30, height=24, backgroundColor=(0.30, 0.42, 0.30),
                      annotation="Add a script folder",
                      command=lambda *a: _add_directory())
    mc.setParent("..")   # trow -> tabbar
    mc.formLayout(tabbar, edit=True,
                  attachForm=[(trow, 'top', 1), (trow, 'left', 2), (trow, 'bottom', 1)])
    mc.setParent("..")   # tabbar -> root

    # -- Content (all panels stacked; only the active one is visible) ----------
    content = mc.formLayout("smContent")

    if n_dirs == 0:
        empty = mc.columnLayout("smEmpty", adjustableColumn=True)
        mc.separator(height=24, style="none")
        mc.button(label="+   Add script folder", height=36,
                  backgroundColor=(0.30, 0.42, 0.30), command=lambda *a: _add_directory())
        mc.text(label="  Add a folder of .mel / .py scripts to begin.", align="left")
        mc.setParent("..")
        mc.formLayout(content, edit=True, attachForm=[
            (empty, 'top', 0), (empty, 'left', 0), (empty, 'right', 0)])
    elif _filter:
        # Searching -> one combined view of matches across every folder/group.
        _build_search_results(dirs, content)
    else:
        for di, directory in enumerate(dirs):
            _build_tab_panel(di, directory, content)

    mc.setParent("..")   # content -> root

    # -- Footer ----------------------------------------------------------------
    ftr = mc.rowLayout("smFtr", numberOfColumns=2, adjustableColumn=1, height=28,
                       columnWidth2=(240, 92), backgroundColor=(0.18, 0.18, 0.18))
    mc.text(label="   {} script{}  |  {} folder{}".format(
                total, "s" if total != 1 else "", n_filled, "s" if n_filled != 1 else ""),
            align="left", font="smallPlainLabelFont", backgroundColor=(0.18, 0.18, 0.18))
    mc.button(label="Refresh", width=90, height=22,
              backgroundColor=(0.42, 0.42, 0.42), command=lambda *a: _reload())
    mc.setParent("..")

    # -- Root form attachments -------------------------------------------------
    mc.formLayout(root, edit=True,
        attachForm=[
            (srch,    'top',    0), (srch,    'left', 0), (srch,    'right', 0),
            (tabbar,  'left',   0), (tabbar,  'right', 0),
            (content, 'left',   0), (content, 'right', 0),
            (ftr,     'bottom', 0), (ftr,     'left', 0), (ftr,     'right', 0),
        ],
        attachControl=[
            (tabbar,  'top',    0, srch),
            (content, 'top',    0, tabbar),
            (content, 'bottom', 0, ftr),
        ])


# Run when sourced
show()
