Tier 3 · Shared Foundation
Start Here

Framework

The shared foundation every application is built on. If you're building a new application, read Starting a new app first and use tools/new_app_template.py as your literal starting point — it already wires up everything described below.

Design goals
1
Problem

Every new application in a growing suite tends to reinvent its own window management, its own menu registration, its own settings storage, and its own visual style - producing a collection of tools that don't feel like they belong to the same product, and duplicating the same bugs across each one independently.

2
Architectural decision

Pull the parts every application needs into one shared layer, built once and reused everywhere: a dockable-window base class, filesystem-driven menu discovery with zero registration boilerplate, one settings registry, one config store, one visual identity. A new application starts from a working scaffold, not a blank file.

3
Production benefit

A fix or improvement to the shared layer (a docking bug, a config edge case, a theme change) benefits every application at once instead of needing to be re-applied per tool - and a reviewer can understand any application's window lifecycle, settings, or styling by reading this one page, not by re-deriving it from each application's own code.

Any Application Dockable Windows show_dockable() Menu Discovery filesystem → menu Settings + Config registry & UserConfig Theme shared visual identity

Four shared pieces every application builds on, documented in the sections below.

Dockable windows

Two window shapes exist in CoreTools, and the choice is deliberate per window:

Every CoreTools window, dockable or not, must parent itself to Maya's main window (dcc.maya.ui.maya_main_window(), wrapping omui.MQtUtil.mainWindow()). An unparented top-level Qt widget behaves like an independent app window — its own taskbar entry, doesn't raise/lower with Maya.

DockableWindow

class MyApp(dcc_ui.DockableWindow):
    WINDOW_OBJECT_NAME = "CF_MyAppWindow"   # must be unique across all applications

    def __init__(self, parent=None):
        super().__init__(parent)
        ...

WINDOW_OBJECT_NAME drives the name of the Maya workspaceControl backing the window (workspace_control_name() returns f"{WINDOW_OBJECT_NAME}WorkspaceControl"), so it must be unique. A dockable widget's "is one already open" state lives in that workspaceControl, not in Qt's own topLevelWidgets() — which is why launching and relaunching go through dedicated helpers rather than plain construct-and-.show().

Launching: show_dockable(window_class, **show_kwargs)

The normal launch/relaunch entry point, always called from a tool's own main():

def main():
    return dcc_ui.show_dockable(MyApp, area="right")
Confirmed-live crash avoided: an earlier version of this function always deleted then immediately recreated the control on every call — that crashed Maya outright (not a catchable exception), because deleteUI() on a workspaceControl needs the Qt event loop to process the deletion before a same-named control can be safely reconstructed; doing both synchronously races that. Don't hand-roll a "delete then recreate" pattern — see rebuild_dockable() below for the one place that actually needs to do this, and how it avoids the race.

show_dockable() returns None on reuse — two helpers for that case

Because show_dockable() returns None when it reuses an existing panel, a caller that needs the actual instance (most commonly: to rebind a per-launch context hook, like AnimHub's "Ask AI" binding a fresh ClipsAPI on every click) can't rely on its return value:

window = ai_panel.show()
if window is None:
    window = dcc_ui.find_dockable_instance(ai_panel.AIPanel)
if window is not None:
    window.set_context(...)

rebuild_dockable() — dev-reload only

Tears the workspaceControl down and reconstructs it against freshly-reloaded code — the one legitimate "delete then recreate" path, and it avoids the crash above by splitting the two steps across Maya idle cycles (cmds.evalDeferred) instead of doing both synchronously. This exists specifically for RefreshCoreTools; normal relaunches should always go through show_dockable().

A .py file under dcc/maya/menu_tools/ automatically becomes a Maya menu item — no registration call, no manifest to edit. The mechanism:

The only convention a launcher file needs to follow: define a module-level main(). No special docstring, attribute, or decorator. menu_query.run_launcher(dotted_module) imports (or reloads, if already imported) the launcher module and calls getattr(module, "main", None), raising if it's missing.

Launcher files are thin shims — the real implementation lives under tools/. The actual worked example, menu_tools/Utilities/New_App_Template.py, is the entire convention in miniature:

"""Menu launcher for the new-app scaffold/reference - thin shim per the
menu_tools convention (see menu_query.py's own docstring: launchers here,
real implementation in tools/new_app_template.py)."""


def main():
    from CoreTools.tools import new_app_template
    return new_app_template.main()

A few more discovery details worth knowing:

menu.build() destroys any existing CoreTools menu first, then recursively renders the tree via cmds.menuItem. Each leaf's click handler wraps menu_query.run_launcher(...) in a try/except that reports failures via cmds.warning(...) instead of raising into Maya's own UI code — one broken tool never breaks the whole menu.

Settings dialog + registry

Four "core" pages (Perforce, AI, Logging, About) are hardcoded directly into dcc/maya/dialogs/settings.py, since they're framework-level concerns, not any one app's — Project used to be a fifth core page here but was relocated entirely into AnimHub's own Export tab (see AnimHub), since AH is the only thing that actually needs it to resolve a per-clip export path. Its Maya-menu/QuickLaunch icon is Settings.png, next to the Settings.py launcher (see Menu discovery above) — the dialog itself doesn't set its own setWindowIcon(). Everything else is an app-registered page, via settings_registry.py:

from CoreTools.dcc.maya.dialogs import settings_registry

def _build_my_page(parent):
    return MySettingsPage(parent)

settings_registry.register_page("My App", _build_my_page)

CoreSettingsDialog(parent=None, initial_page=None) lets a caller land directly on a specific page by label (e.g. a tool's own "Tools > Settings..." landing on its own page) via initial_page. Core pages always occupy the fixed order ("Project", "Perforce", "AI", "Logging", "About"); app-registered pages are appended after all five regardless of registration order. If a page factory throws, it's caught, logged, and skipped — one broken settings page never breaks the whole dialog.

Module-level settings.show(initial_page=None) is the normal external entry point — resolves the Maya main window as parent, constructs the dialog, calls .exec_() (modal), and returns the dialog instance.

Core page field reference

What's actually on each of the 5 hardcoded pages:

PageFields
Project Three radio buttons for ProjectMode (Maya / Perforce / Custom), a path field + browse button shown only in Custom mode, a read-only label showing the live-resolved root (or an amber warning if resolution fails), and a manual refresh button.
Perforce An enable checkbox (rest of the page hidden when off), server/user/client fields, an "auto checkout on edit" checkbox, a Test Connection button (tests the currently-typed fields, not necessarily the saved ones) with a pass/fail result label, and an install/update-p4python button with a live installed-or-not status label.
AI Provider combo (populated from PROVIDERS), an install/update button for that provider's SDK, an API key field (password-masked, with a show/hide toggle, disabled entirely if keyring isn't installed) with a label explaining where it's stored, and an editable model combo with a "fetch real model list from the provider's API" refresh button (requires a key to already be set).
Logging A level combo (DEBUG/INFO/WARNING/ERROR, calling core_log.reconfigure() on change), a "log to file" checkbox, a folder field + browse button (shown only when file logging is on), and an "open log folder" button.
About Informational only, no controls: a short blurb on what CoreTools is, a pointer to the AI page for the AI Companion's own setup, and a note that Project/Perforce config here is shared foundation any current or future CoreTools app can read.

Every field on every page persists immediately on its own signal (editingFinished, toggled, currentIndexChanged, ...) straight to UserConfig — there's a single primary "close" button at the bottom of the dialog and no Cancel, consistent with the live-persist convention described above.

App-registered pages

Pages an application adds itself via register_page(), appended after the five core pages above in the order shown here. AnimHub is currently the only application with app-specific fields worth calling out individually:

Page · tabFields
AnimHub · Interface Default row style (Optimized / Compact / Preview — see Clip row anatomy), an "enable clip preview" toggle for the hover popup, preview delay (ms, default 1200 — see Hover preview popup), and preview playback speed (fps, default 52). The playback-speed field is read fresh from UserConfig on every hover, never cached, so a change here takes effect on the very next hover with no relaunch — see Clip preview thumbnails for how it's applied.
AnimHub · Export Project root mode (Maya Project / P4 Workspace / Custom — moved here from the generic Project page, since AnimHub's own export path resolution was always its only real consumer), a global export folder (the fallback used when no project root resolves), and File Type (ASCII / Binary, default ASCII). See Export pipeline for how these feed the actual FBX write.

Config (UserConfig)

core/config.py's UserConfig is the shared, registry-backed (QSettings, a single per-user Windows registry key namespaced to this project) settings store every application reads and writes through — one store, section-addressed, not one file per app.

from CoreTools.core import config as core_config
cfg = core_config.UserConfig()

cfg.get(section, key, default)         # single value, type-coerced to match `default`
cfg.set(section, key, value)           # single value, persists immediately
cfg.section(section) -> dict           # every key in a section, defaults merged in
cfg.update_section(section, mapping)   # bulk write
cfg.reset()                            # clears everything back to DEFAULTS

DEFAULTS (a module-level dict in config.py) only needs an entry for a section if something needs to enumerate that section's keys generically (section(), a Settings page's load()). A tool reading one key with its own explicit default (cfg.get("my_app", "some_key", 0)) doesn't need a DEFAULTS entry at all — new_app_template.py deliberately skips adding one for its own demo key, to keep the bar for "just persist one setting" low.

UserConfig is a singleton (__new__-based) — constructing it anywhere gets you the same underlying QSettings connection.

Logging

Built on Python's stdlib logging, not a custom system — one shared logger tree rooted at "CoreTools".

from CoreTools.core import log as core_log
log = core_log.get_logger(__name__)

log.info("...")
log.warning("...")
log.exception("...")   # inside an except block - includes the traceback

get_logger() calls configure() first (idempotent), so nothing needs explicit init-order handling. Passing __name__ from anywhere inside CoreTools lands correctly in the "CoreTools.*" subtree without double-prefixing.

Project root + Perforce

core/project.pyget_project_root()

The one function every application should call instead of inventing its own root-finding logic. Reads the mode from Settings > Project (ProjectMode.MAYA / .P4 / .CUSTOM) and resolves accordingly:

Returns "" when unresolvable rather than raising — callers decide how to handle a missing root (AnimHub's export path resolution falls through to its own next-tier fallback, for example).

core/p4/client.pyP4Client

A direct P4Python integration (clean-room, not a port). Pure logic — no maya.cmds, no dialogs — so it's importable from plain mayapy too. Access through the module-level singleton accessor, not by constructing directly:

from CoreTools.core import p4 as core_p4
core_p4.p4().checkout(path)

Off by default (Settings > Perforce > Enable). With server/user/client left blank, it falls back to P4Python's own environment resolution (P4CONFIG, P4PORT/P4USER/P4CLIENT) — a user with a normal system P4 setup gets a working connection with zero CoreTools-side config. Explicit values in Settings always override.

Status is one of STATUS_OK / STATUS_OFFLINE / STATUS_DISABLED (.status / .is_available). Common operations: checkout, add_or_checkout, sync_file / sync_path, revert, opened_files(), file_status(), scene_status() (one of "In Sync" / "Off Sync" / "Checked Out" / "Not In Depot"), project_root(), client_workspace(). Every operation wraps its own connect() and swallows/logs failures rather than propagating — callers get a safe falsy/empty value back, not an exception to catch everywhere.

dcc/maya/p4_status_bar.pyP4StatusBar

A reusable status strip (timestamp | project pill | status dot+text | workspace | user@machine) any P4-aware app can drop into its layout:

self.p4_bar = P4StatusBar()
layout.addWidget(self.p4_bar)
...
self.p4_bar.refresh()   # call after anything that might change P4 state

There's no registration mechanism — it's a plain widget, refresh-on-demand (only its clock label ticks on its own timer). It hides itself entirely when P4 is disabled, rather than showing inert "P4 Disabled" chrome — its whole premise is "this app is using P4 right now." dcc/maya/p4_ui.py layers interactive cmds.confirmDialog-driven flows on top (an offline warning, an interactive scene-status check-and-act flow) for apps that want a blocking prompt rather than just the status strip.

RefreshCoreTools (dev reload)

menu_tools/RefreshCoreTools.py is the dev-loop tool: rebuild the CoreTools menu, reinstall the QuickLaunch toolbar widget, and refresh any currently-open dockable tools — all without restarting Maya. Has its own menu icon (RefreshCoreTools.png, from the CF Clean 256 set) rather than falling back to the shared default.

Why a plain importlib.reload() isn't enough: it only re-executes one module's own top-level code. A from X import Y statement inside it just re-binds to the already-cached X, it does not transitively reload X. Separately — and this is the "why doesn't my edit show up" gotcha every app in this suite has hit at least once — reloading a module's code does not change the class of an already-constructed instance. reload() replaces the class object the module name points to, but a live window's __class__ still points at the old class object, so its methods keep running old code until the instance itself is torn down and reconstructed against the freshly reloaded class.

What RefreshCoreTools actually does:

  1. Walks sys.modules for everything under "CoreTools", sorts by dot-count descending (deepest/leaf modules first), and calls importlib.reload() on each — a mitigation for the from-import staleness problem above, not a complete fix. Each reload is individually try/excepted; one failure is logged and skipped, not fatal to the rest.
  2. Rebuilds the menu (menu.build()) and reinstalls QuickLaunch.
  3. Refreshes open dockable tools via a hardcoded registry, DOCKABLE_TOOLS — a tuple of (dotted_module, window_class_attr) pairs. Any new dockable application needs a manual entry added to this tuple to be refresh-aware — it is not auto-discovered. For each entry already open (checked via is_dockable_window_open()), it calls dcc_ui.rebuild_dockable(window_class, area="right") — the one legitimate caller of that function, since it's the one case that genuinely needs the code to change under an existing window.

Only tools that are already open get rebuilt — nothing is force-launched by a refresh.

Visual design system

dcc/maya/theme.py is CoreTools' own visual identity — not a copy of Maya's or of any other tool's. Cool neutral grays for nearly everything, no role-based button coloring (Add/Remove/Save all read the same neutral gray), and a single warm amber accent reserved for interactive feedback, distinct from the one blue reserved for the primary action.

TokenHexUse
Color.BG#2b2e35Window base
Color.BG_ELEV#23262cPanels, inputs, trees
Color.BORDER#3a3d44Neutral card/input borders
Color.TEXT / TEXT_DIM#dcdcdc / #8b8e96Primary / secondary text
Color.ACCENT#e8a33dAmber — focus rings, pressed state, selected rows. Not the primary-button color.
Color.HEADER#29abe2Bold blue — section titles
Color.SUBTITLE#5b8fa8Muted blue, one step down from HEADER — small secondary captions
Color.PRIMARY#4fc3f7Lighter blue — reserved for the single primary action button, and the "signature outline"
Color.STATUS_OK / STATUS_OFFLINE#6fa870 / #e25c5cConnection-status semantics (P4, AI provider key, etc.) — a separate signal class from ACCENT/PRIMARY

MAIN_STYLE is the full shared QSS every application applies via self.setStyleSheet(theme.MAIN_STYLE). tools/ui_scheme.py ("CF UI Scheme") is the living reference for it — a page rendering one of every styled widget type, meant to be launched after any theme.py edit so a palette/spacing change can be eyeballed in one place instead of chased across every dialog.

Small captions: subtitleLabel

A reusable small-caption convention for labeling a sub-section that doesn't need a full heading (Character Setup Tool's "SOCKETS" label, CF QuickLaunch's "PRESETS" label): muted blue, 8pt, semi-bold.

label = QtWidgets.QLabel("MY SECTION")
label.setObjectName("subtitleLabel")

AnimHub's own "FILTER VIEW"/"ACTIONS" toolbar captions (see AnimHub) use a visually similar but deliberately separate, dimmer light-blue tint instead of this shared rule — styled locally (theme.rgba(theme.Color.PRIMARY, 100)) rather than through subtitleLabel, specifically so tuning it doesn't shift every other caption using the shared convention along with it.

The signature outline

A faint, translucent Color.PRIMARY-blue 2px border around a application's own outer edge — the one deliberate piece of "branding" shared across every application, tried first on AnimHub and promoted here once confirmed live ("if we like it we'll add them to all CF tools"). Widened from an initial 1px once the whole scheme was in live use.

self.setStyleSheet(theme.MAIN_STYLE)
theme.apply_signature_outline(self)   # must come after objectName() is already set

Must be called after the window's objectName() is already set (a DockableWindow sets it from WINDOW_OBJECT_NAME automatically in its own __init__, before your subclass body runs, so this is almost always safe to call right after setStyleSheet). It scopes the border via that existing object name — giving a dockable window a new setObjectName() just for this purpose breaks Maya's own workspaceControl naming ("...WorkspaceControl is not unique").

A window with its own QMenuBar needs the matching helper too, so the outline doesn't visibly stop short of the menu bar's own top edge:

self.menu_bar = QtWidgets.QMenuBar()
theme.apply_signature_outline_to_menu_bar(self.menu_bar)

Every top-level application has this applied (AnimHub, Character Setup Tool, Clip Previewer, the AI Companion) and it's baked directly into tools/new_app_template.py, so a new app gets it for free without needing to remember to add it. It isn't limited to full dockable windows, either — QuickLaunch (QuickLaunch), a small widget embedded directly into Maya's own ToolBox rather than a standalone app, carries just this outline with none of the rest of MAIN_STYLE applied, so it stays visually native to the ToolBox while still reading as unmistakably a CF surface.

Starting a new app

tools/new_app_template.py is the copyable scaffold — a real, working, deliberately over-complete reference rather than a stub. Three steps, straight from its own docstring:

  1. Copy the file to a new name; rename _TemplateApp to your app's class name (keep the CF prefix convention on WINDOW_OBJECT_NAME).
  2. Copy menu_tools/Utilities/New_App_Template.py alongside it (or into a category subfolder — subfolders become submenus, see Menu discovery) — this becomes your launcher.
  3. Delete whatever you don't need — the demo settings page, the config example, the demo counter all exist purely as a complete worked example, not a required shape.

What the template demonstrates, all of which every real application has converged on: