ff_u_itm_seq

Spell sequencer.

On login, and 5 seconds after a level up, gives the spell sequencer (ff_u_itm_seq) to a player character or DM that can cast at least one spell and has no sequencer yet. The level-up check is delayed so that a character that levels down right after leveling up does not keep a useless sequencer.

Using the sequencer toggles the spell bar, which lists the saved macros. Each macro button casts its spells in sequence. The Edit button under the macros closes the bar and opens the macro editor, where macros are created, edited and deleted.

Macros are stored in the sm_sequences table of the character’s database.

Variables

ENABLE__FF_U_ITM_SEQUENCER: (string) (opt-out) Set to "N" on module to disable the unit.

Source code

// @code

#include "ff_i_core"

const string ENABLE__FF_U_ITM_SEQUENCER = "ENABLE__FF_U_ITM_SEQUENCER";

const string SEQUENCER_TAG    = "ff_u_itm_seq";
const string SEQUENCER_RESREF = "ff_u_itm_seq";

// Delay before giving the sequencer after a level up, in seconds.
const float SEQUENCER_LEVELUP_DELAY = 5.0;

// --- Window IDs --------------------------------------------------------------
const string SM_WIN_MENU        = "SM_MENU";
const string SM_WIN_DELETE      = "SM_DELETE";
const string SM_WIN_BAR         = "SM_BAR";
const string SM_WIN_ICON_PICKER = "SM_ICO_PICK";

// --- NUI event types ---------------------------------------------------------
const string SM_EVENT_WATCH     = "watch";
const string SM_EVENT_CLOSE     = "close";
const string SM_EVENT_CLICK     = "click";
const string SM_EVENT_MOUSEDOWN = "mousedown";

// --- PC locals ---------------------------------------------------------------
const string SM_LVAR_SEL_IDX     = "SM_SEL_IDX";     // selected macro index in menu (-1 = none)
const string SM_LVAR_EDIT_IDX    = "SM_EDIT_IDX";    // macro index being edited (-1 = new)
const string SM_LVAR_SPELLS      = "SM_SPELLS";      // JsonArray of spells being built in create view
const string SM_LVAR_SPELL_LIST  = "SM_SPELL_LIST";  // JsonArray of spells listed in create view
const string SM_LVAR_CLS_IDS     = "SM_CLS_IDS";     // JsonArray of class ids of the class combo
const string SM_LVAR_SUPPRESS_TM = "SM_SUPPRESS_TM"; // ignore the first target mode watch
const string SM_LVAR_PENDING     = "SM_PENDING";     // JsonArray of spells waiting for a target
const string SM_LVAR_TARGET_MODE = "SM_TARGET_MODE"; // target mode of the pending cast
const string SM_LVAR_ICO_PARENT  = "SM_ICO_PAR";     // token of the window that opened the icon picker

// --- Module locals -----------------------------------------------------------
const string SM_ICO_CACHE_VAR = "SM_ICO_CACHE";

// --- Groups ------------------------------------------------------------------
const string SM_GRP_SWAP      = "SM_SWAP";
const string SM_GRP_SEL_ICONS = "SM_GRP_SEL";
const string SM_GRP_ICO_GRID  = "SM_ICO_GRID";

// --- Menu binds --------------------------------------------------------------
const string SM_BIND_M_NAMES   = "SM_M_NAMES";
const string SM_BIND_M_ICONS   = "SM_M_ICONS";
const string SM_BIND_M_COUNT   = "SM_M_COUNT";
const string SM_BIND_M_ENC     = "SM_M_ENC";
const string SM_BIND_M_ROW     = "SM_M_ROW";
const string SM_BIND_M_CAST_EN = "SM_M_CAST_EN";
const string SM_BIND_M_EDIT_EN = "SM_M_EDIT_EN";
const string SM_BIND_M_DEL_EN  = "SM_M_DEL_EN";

// --- Create view binds -------------------------------------------------------
const string SM_BIND_C_SPELL_NAMES     = "SM_C_SP_NAMES";
const string SM_BIND_C_SPELL_COUNT     = "SM_C_SP_COUNT";
const string SM_BIND_C_SPELL_ICONS     = "SM_C_SP_ICONS";
const string SM_BIND_C_SPELL_ENC       = "SM_C_SP_ENC";
const string SM_BIND_C_SPELL_ROW       = "SM_C_SP_ROW";
const string SM_BIND_C_SPELL_META_VIS  = "SM_C_SP_MV";
const string SM_BIND_C_SPELL_META_ICON = "SM_C_SP_MI";
const string SM_BIND_C_SPELL_DOM_VIS   = "SM_C_SP_DV";
const string SM_BIND_C_SPELL_DOM_ICON  = "SM_C_SP_DI";
const string SM_BIND_C_SPELL_SEARCH    = "SM_C_SP_SRCH";
const string SM_BIND_C_SEL_COUNT       = "SM_C_SEL_COUNT";
const string SM_BIND_C_SEQ_NAME        = "SM_C_SEQ_NAME";
const string SM_BIND_C_CLS_ENT         = "SM_C_CLS_ENT";
const string SM_BIND_C_CLS_IDX         = "SM_C_CLS_IDX";
const string SM_BIND_C_LVL_ENT         = "SM_C_LVL_ENT";
const string SM_BIND_C_LVL_IDX         = "SM_C_LVL_IDX";
const string SM_BIND_C_META_ENT        = "SM_C_META_ENT";
const string SM_BIND_C_META_IDX        = "SM_C_META_IDX";
const string SM_BIND_C_TARGET_MODE     = "SM_C_TMODE";
const string SM_BIND_C_ADD_EN          = "SM_C_ADD_EN";
const string SM_BIND_C_SAVE_EN         = "SM_C_SAVE_EN";
const string SM_BIND_C_ICON            = "SM_C_ICON";

// --- Icon picker binds -------------------------------------------------------
const string SM_BIND_ICO_SEARCH = "SM_ICO_SRCH";

// --- Button IDs --------------------------------------------------------------
const string SM_BTN_NEW         = "SM_BTN_NEW";
const string SM_BTN_CAST        = "SM_BTN_CAST";
const string SM_BTN_EDIT        = "SM_BTN_EDIT";
const string SM_BTN_DELETE      = "SM_BTN_DELETE";
const string SM_BTN_BACK        = "SM_BTN_BACK";
const string SM_BTN_M_ROW       = "SM_BTN_M_ROW";
const string SM_BTN_SAVE        = "SM_BTN_SAVE";
const string SM_BTN_C_SP_ROW    = "SM_BTN_C_SP_ROW";
const string SM_BTN_C_ADD       = "SM_BTN_C_ADD";
const string SM_BTN_C_ICON_PICK = "SM_BTN_ICOPICK";
const string SM_BTN_DEL_CONFIRM = "SM_BTN_DEL_OK";
const string SM_BTN_DEL_CANCEL  = "SM_BTN_DEL_CANCEL";
const string SM_BTN_BAR_EDIT    = "SM_BTN_BAR_EDIT";

// Prefixed button IDs, followed by an index.
const string SM_BTN_C_DEL_SEL = "SM_BTN_C_DEL_SEL"; // 16 chars
const string SM_ICO_BTN_PFX   = "SM_ICO_B_";        // 9 chars
const string SM_BAR_BTN_PFX   = "SM_BAR_B_";        // 9 chars

// --- Target modes ------------------------------------------------------------
const int SM_TARGET_SELF  = 0;
const int SM_TARGET_ALLY  = 1;
const int SM_TARGET_ENEMY = 2;
const int SM_TARGET_AREA  = 3;

// --- Limits ------------------------------------------------------------------
const int SM_MAX_SEQ    = 10;
const int SM_MAX_SPELLS = 20;

// Spell bar grid: SM_MAX_SEQ macros in rows of SM_BAR_COLUMNS buttons.
const int SM_BAR_COLUMNS = 5;
const int SM_BAR_ROWS    = 2;

// --- Spell JSON field keys ---------------------------------------------------
const string SM_F_ID          = "id";
const string SM_F_CLASS       = "class";
const string SM_F_LEVEL       = "level";
const string SM_F_META        = "meta";
const string SM_F_DOMAIN      = "domain";
const string SM_F_NAME        = "name";
const string SM_F_ICON        = "icon";
const string SM_F_DOMAIN_ICON = "dicon";

const string SM_ICO_DEFAULT = "ir_genspell";


// =============================================================================
// DATABASE
// =============================================================================

// sm_sequences is stored in the PC's .bic database:
//   seq_idx     INTEGER NOT NULL  -- slot 0 to SM_MAX_SEQ - 1
//   name        TEXT DEFAULT ''
//   spells_json TEXT DEFAULT '[]' -- JsonArray of spell objects
//   icon        TEXT DEFAULT ''   -- resref of the macro icon
//   target_mode INTEGER DEFAULT 0 -- SM_TARGET_*
//   PRIMARY KEY (seq_idx)

//! @brief Create the macro table on oPC, adding missing columns.
//! @param oPC The PC.
void SmCreateTable(object oPC)
{
  SqlPlainExec("CREATE TABLE IF NOT EXISTS sm_sequences (" +
    "seq_idx INTEGER NOT NULL, " +
    "name TEXT DEFAULT '', " +
    "spells_json TEXT DEFAULT '[]', " +
    "icon TEXT DEFAULT '', " +
    "target_mode INTEGER DEFAULT 0, " +
    "PRIMARY KEY (seq_idx))", oPC);

  // Migrate tables that lack the target_mode column
  int bHas = FALSE;
  sqlquery qInfo = SqlPrepareQueryObject(oPC, "PRAGMA table_info(sm_sequences)");
  while (SqlStep(qInfo))
  {
    if (SqlGetString(qInfo, 1) == "target_mode")
    {
      bHas = TRUE;
      break;
    }
  }
  if (!bHas)
    SqlPlainExec("ALTER TABLE sm_sequences ADD COLUMN target_mode INTEGER DEFAULT 0", oPC);
}


// -----------------------------------------------------------------------------

//! @brief Save a macro in slot nIdx of oPC.
void SmSave(object oPC, int nIdx, string sName, json jSpells, string sIcon, int nTargetMode)
{
  sqlquery q = SqlPrepareQueryObject(oPC,
    "INSERT OR REPLACE INTO sm_sequences (seq_idx, name, spells_json, icon, target_mode) " +
    "VALUES (@idx, @name, @spells, @icon, @mode)");
  SqlBindInt(q, "@idx", nIdx);
  SqlBindString(q, "@name", sName);
  SqlBindString(q, "@spells", JsonDump(jSpells));
  SqlBindString(q, "@icon", sIcon);
  SqlBindInt(q, "@mode", nTargetMode);
  SqlStep(q);
}


// -----------------------------------------------------------------------------

//! @brief Delete the macro in slot nIdx of oPC.
void SmDelete(object oPC, int nIdx)
{
  sqlquery q = SqlPrepareQueryObject(oPC,
    "DELETE FROM sm_sequences WHERE seq_idx = @idx");
  SqlBindInt(q, "@idx", nIdx);
  SqlStep(q);
}


// -----------------------------------------------------------------------------

//! @brief Return a macro entry {name, spells, icon, target_mode}.
json SmMacroEntry(string sName, string sSpells, string sIcon, int nTargetMode)
{
  json jEntry = JsonObject();
  jEntry = JsonObjectSet(jEntry, "name", JsonString(sName));
  jEntry = JsonObjectSet(jEntry, "spells", JsonParse(sSpells));
  jEntry = JsonObjectSet(jEntry, "icon", JsonString(sIcon));
  return JsonObjectSet(jEntry, "target_mode", JsonInt(nTargetMode));
}


// -----------------------------------------------------------------------------

//! @brief Return all macros of oPC as a JsonArray of
//! {idx, name, spells, icon, target_mode}, ordered by slot.
json SmLoadAll(object oPC)
{
  SmCreateTable(oPC);

  json jResult = JsonArray();
  sqlquery q = SqlPrepareQueryObject(oPC,
    "SELECT seq_idx, name, spells_json, icon, target_mode FROM sm_sequences ORDER BY seq_idx");
  while (SqlStep(q))
  {
    int nIdx = SqlGetInt(q, 0);
    json jEntry = SmMacroEntry(SqlGetString(q, 1), SqlGetString(q, 2),
      SqlGetString(q, 3), SqlGetInt(q, 4));
    jEntry = JsonObjectSet(jEntry, "idx", JsonInt(nIdx));
    jResult = JsonArrayInsert(jResult, jEntry);
  }
  return jResult;
}


// -----------------------------------------------------------------------------

//! @brief Return the first free macro slot of oPC, or -1 if all are used.
int SmNextFreeIdx(object oPC)
{
  int i;
  for (i = 0; i < SM_MAX_SEQ; i++)
  {
    sqlquery q = SqlPrepareQueryObject(oPC,
      "SELECT COUNT(*) FROM sm_sequences WHERE seq_idx = @idx");
    SqlBindInt(q, "@idx", i);
    if (SqlStep(q) && SqlGetInt(q, 0) == 0)
      return i;
  }
  return -1;
}


// -----------------------------------------------------------------------------

//! @brief Return the macro in slot nIdx of oPC as {name, spells, icon,
//! target_mode}, or JsonNull() if the slot is empty.
json SmLoadByIdx(object oPC, int nIdx)
{
  sqlquery q = SqlPrepareQueryObject(oPC,
    "SELECT name, spells_json, icon, target_mode FROM sm_sequences WHERE seq_idx = @idx");
  SqlBindInt(q, "@idx", nIdx);
  if (!SqlStep(q))
    return JsonNull();

  return SmMacroEntry(SqlGetString(q, 0), SqlGetString(q, 1),
    SqlGetString(q, 2), SqlGetInt(q, 3));
}


// =============================================================================
// HELPERS
// =============================================================================

//! @brief Convert a hexadecimal string, with or without "0x", to an int.
int SmHexToInt(string s)
{
  if (GetStringLowerCase(GetStringLeft(s, 2)) == "0x")
    s = GetStringRight(s, GetStringLength(s) - 2);
  s = GetStringLowerCase(s);

  int nResult = 0;
  int nLen = GetStringLength(s);
  int i;
  for (i = 0; i < nLen; i++)
  {
    int d = FindSubString("0123456789abcdef", GetSubString(s, i, 1));
    if (d < 0)
      return nResult;
    nResult = nResult * 16 + d;
  }
  return nResult;
}


// -----------------------------------------------------------------------------

//! @brief Scale fVal down with the GUI scale of oPC, capped at 150%.
float SmScale(object oPC, float fVal)
{
  int nScale = GetPlayerDeviceProperty(oPC, PLAYER_DEVICE_PROPERTY_GUI_SCALE);
  float fScale = nScale / 100.0;
  if (fScale > 1.5)
    fScale = 1.5;
  return fVal / fScale;
}


// -----------------------------------------------------------------------------

//! @brief Open a row with a leading spacer. Close with SmEndCenteredRow().
void SmStartCenteredRow()
{
  NUI_StartRow();
  NUI_AddSpacer();
}


// -----------------------------------------------------------------------------

//! @brief Add a trailing spacer and close the row opened by SmStartCenteredRow().
void SmEndCenteredRow()
{
  NUI_AddSpacer();
  NUI_End();
}


// -----------------------------------------------------------------------------

//! @brief Add a horizontal gap of width fW.
void SmAddGap(object oPC, float fW)
{
  NUI_StartElement(NuiSpacer());
  NUI_SetWidth(SmScale(oPC, fW));
  NUI_End();
}


// -----------------------------------------------------------------------------

//! @brief Add an empty row of height fH.
void SmAddEmptyRow(object oPC, float fH)
{
  NUI_StartRow();
    NUI_StartElement(NuiSpacer());
    NUI_SetHeight(SmScale(oPC, fH));
    NUI_End();
  NUI_End();
}


// -----------------------------------------------------------------------------

//! @brief Open a button labeled sLabel with ID sId. Close with NUI_End().
void SmStartButton(string sLabel, string sId)
{
  NUI_StartElement(NuiButton(JsonString(sLabel)));
  NUI_SetId(sId);
}


// -----------------------------------------------------------------------------

//! @brief Start a non-resizable, closable window titled sTitle.
void SmInitializeWindow(string sTitle, json jRect)
{
  NUI_InitializeWindow(jRect);
  NUI_SetWindowTitle(JsonString(sTitle));
  NUI_SetWindowCollapsed(JsonBool(FALSE));
}


// -----------------------------------------------------------------------------

//! @brief Return the index that follows the nPrefixLen characters of sElement.
int SmGetElementIndex(string sElement, int nPrefixLen)
{
  string sIdx = GetStringRight(sElement, GetStringLength(sElement) - nPrefixLen);
  return StringToInt(sIdx);
}


// -----------------------------------------------------------------------------

//! @brief Remove the highlight of the selected list row.
void SmOffEnc(object oPC, int nToken, string sEncBind, string sRowBind)
{
  json jRow = NuiGetBind(oPC, nToken, sRowBind);
  if (JsonGetType(jRow) == JSON_TYPE_INTEGER)
  {
    json jEnc = NuiGetBind(oPC, nToken, sEncBind);
    jEnc = JsonArraySet(jEnc, JsonGetInt(jRow), JsonBool(FALSE));
    NuiSetBind(oPC, nToken, sEncBind, jEnc);
  }
  NuiSetBind(oPC, nToken, sRowBind, JsonNull());
}


// -----------------------------------------------------------------------------

//! @brief Highlight list row nRow and remember it as selected.
void SmOnEnc(object oPC, int nToken, string sEncBind, string sRowBind, int nRow)
{
  json jEnc = NuiGetBind(oPC, nToken, sEncBind);
  jEnc = JsonArraySet(jEnc, nRow, JsonBool(TRUE));
  NuiSetBind(oPC, nToken, sEncBind, jEnc);
  NuiSetBind(oPC, nToken, sRowBind, JsonInt(nRow));
}


// -----------------------------------------------------------------------------

//! @brief Return the integer field sKey of jObject.
int SmGetInt(json jObject, string sKey)
{
  return JsonGetInt(JsonObjectGet(jObject, sKey));
}


// -----------------------------------------------------------------------------

//! @brief Return the string field sKey of jObject.
string SmGetString(json jObject, string sKey)
{
  return JsonGetString(JsonObjectGet(jObject, sKey));
}


// =============================================================================
// SPELLS
// =============================================================================

//! @brief Return the name of nSpellId, or "" if it has none.
string SmGetSpellName(int nSpellId)
{
  int nStrRef = StringToInt(Get2DAString("spells", "Name", nSpellId));
  string sName = GetStringByStrRef(nStrRef);
  if (GetStringLeft(sName, 3) == "Bad")
    return "";
  return sName;
}


// -----------------------------------------------------------------------------

//! @brief Return the base level of a spell cast in a slot of level nLevel with
//! metamagic nMeta.
int SmGetLevelBeforeMeta(int nLevel, int nMeta)
{
  if (nMeta & METAMAGIC_EXTEND)   nLevel -= 1;
  if (nMeta & METAMAGIC_EMPOWER)  nLevel -= 2;
  if (nMeta & METAMAGIC_QUICKEN)  nLevel -= 4;
  if (nMeta & METAMAGIC_MAXIMIZE) nLevel -= 3;
  if (nMeta & METAMAGIC_SILENT)   nLevel -= 1;
  if (nMeta & METAMAGIC_STILL)    nLevel -= 1;
  return nLevel;
}


// -----------------------------------------------------------------------------

//! @brief Return TRUE if oPC can cast nSpellId right now.
int SmIsAvailable(object oPC, int nSpellId, int nClassId, int nLevel, int nMeta, int bDomain)
{
  if (Get2DAString("classes", "MemorizesSpells", nClassId) == "1")
  {
    int nCount = GetMemorizedSpellCountByLevel(oPC, nClassId, nLevel);
    int i;
    for (i = 0; i < nCount; i++)
    {
      if (GetMemorizedSpellId(oPC, nClassId, nLevel, i) == nSpellId
          && GetMemorizedSpellReady(oPC, nClassId, nLevel, i))
        return TRUE;
    }
    return FALSE;
  }
  int nDomLvl = bDomain ? SmGetLevelBeforeMeta(nLevel, nMeta) : 0;
  return GetSpellUsesLeft(oPC, nClassId, nSpellId, nMeta, nDomLvl) > 0;
}


// -----------------------------------------------------------------------------

//! @brief Return the metamagic overlay icon, drawn in the bottom-left corner
//! of the spell icon, or "" for none.
string SmGetMetaIcon(int nMeta)
{
  if (nMeta == METAMAGIC_EMPOWER)  return "ir_empower_thumb";
  if (nMeta == METAMAGIC_EXTEND)   return "ir_extend_thumb";
  if (nMeta == METAMAGIC_MAXIMIZE) return "ir_maximize_thum";
  if (nMeta == METAMAGIC_QUICKEN)  return "ir_quicken_thumb";
  if (nMeta == METAMAGIC_SILENT)   return "ir_silent_thumb";
  if (nMeta == METAMAGIC_STILL)    return "ir_still_thumb";
  return "";
}


// -----------------------------------------------------------------------------

//! @brief Return TRUE if nClassId uses the whole class spell list (divine
//! prepared casters such as Cleric or Druid).
int SmIsDivinePrepared(int nClassId)
{
  return Get2DAString("classes", "MemorizesSpells", nClassId) == "1"
      && Get2DAString("classes", "SpellBookRestricted", nClassId) != "1";
}


// -----------------------------------------------------------------------------

//! @brief Return the spell IDs available to oPC at base level nBaseLevel for
//! nClassId. Divine prepared casters get the class spell list from spells.2da;
//! spellbook (Wizard) and spontaneous (Sorcerer, Bard) casters get their known
//! spells.
json SmSpellIds(object oPC, int nClassId, int nBaseLevel)
{
  json jIds = JsonArray();
  int i;
  if (SmIsDivinePrepared(nClassId))
  {
    string sCol = Get2DAString("classes", "SpellTableColumn", nClassId);
    int nTotal = Get2DARowCount("spells");
    for (i = 0; i < nTotal; i++)
    {
      string sL = Get2DAString("spells", sCol, i);
      if (sL == "" || sL == "****")
        continue;
      if (StringToInt(sL) == nBaseLevel)
        jIds = JsonArrayInsert(jIds, JsonInt(i));
    }
  }
  else
  {
    int nCount = GetKnownSpellCount(oPC, nClassId, nBaseLevel);
    for (i = 0; i < nCount; i++)
    {
      int nId = GetKnownSpellId(oPC, nClassId, nBaseLevel, i);
      jIds = JsonArrayInsert(jIds, JsonInt(nId));
    }
  }
  return jIds;
}


// -----------------------------------------------------------------------------

//! @brief Return TRUE if nSpellId fits target mode nTargetMode.
int SmMatchesTargetMode(int nSpellId, int nTargetMode)
{
  int nTF = SmHexToInt(Get2DAString("spells", "TargetType", nSpellId));
  int bHostile = Get2DAString("spells", "HostileSetting", nSpellId) == "1";
  if (nTargetMode == SM_TARGET_SELF)  return (nTF & 0x01) != 0;
  if (nTargetMode == SM_TARGET_ALLY)  return (nTF & 0x02) != 0 && !bHostile;
  if (nTargetMode == SM_TARGET_ENEMY) return ((nTF & 0x02) || (nTF & 0x04)) && bHostile;
  if (nTargetMode == SM_TARGET_AREA)  return (nTF & 0x04) != 0;
  return FALSE;
}


// -----------------------------------------------------------------------------

//! @brief Return a spell entry {id, name, icon, meta, domain, dicon}.
json SmSpellEntry(int nId, string sName, int nMeta, int bDomain, string sDomIcon)
{
  json jE = JsonObject();
  jE = JsonObjectSet(jE, SM_F_ID, JsonInt(nId));
  jE = JsonObjectSet(jE, SM_F_NAME, JsonString(sName));
  string sIcon = Get2DAString("spells", "IconResRef", nId);
  jE = JsonObjectSet(jE, SM_F_ICON, JsonString(sIcon));
  jE = JsonObjectSet(jE, SM_F_META, JsonInt(nMeta));
  jE = JsonObjectSet(jE, SM_F_DOMAIN, JsonInt(bDomain));
  jE = JsonObjectSet(jE, SM_F_DOMAIN_ICON, JsonString(sDomIcon));
  return jE;
}


// -----------------------------------------------------------------------------

//! @brief Append the entries of jSrc to jDst and return jDst.
json SmAppend(json jDst, json jSrc)
{
  int i;
  int n = JsonGetLength(jSrc);
  for (i = 0; i < n; i++)
    jDst = JsonArrayInsert(jDst, JsonArrayGet(jSrc, i));
  return jDst;
}


// -----------------------------------------------------------------------------

//! @brief Return the entries of spell nId: its SubRadSpell entries when it has
//! some (the parent is hidden), else the spell itself.
//! @param sSuffix Name suffix, e.g. " - Empower".
//! @param sDomIcon Domain icon resref, or "" for non-domain spells.
json SmGetSpellEntries(int nId, string sName, string sSuffix, int nMeta,
                       int nTargetMode, int bDomain, string sDomIcon)
{
  json jSubs = JsonArray();
  int k;
  for (k = 1; k <= 5; k++)
  {
    string sSub = Get2DAString("spells", "SubRadSpell" + IntToString(k), nId);
    int nSubId = StringToInt(sSub);
    if (nSubId <= 0)
      continue;
    if (!SmMatchesTargetMode(nSubId, nTargetMode))
      continue;
    string sSubName = SmGetSpellName(nSubId);
    if (sSubName == "")
      continue;
    sSubName = sName + " - " + sSubName + sSuffix;
    jSubs = JsonArrayInsert(jSubs, SmSpellEntry(nSubId, sSubName, nMeta, bDomain, sDomIcon));
  }

  if (JsonGetLength(jSubs) > 0)
    return jSubs;

  json jSelf = SmSpellEntry(nId, sName + sSuffix, nMeta, bDomain, sDomIcon);
  return JsonArrayInsert(JsonArray(), jSelf);
}


// -----------------------------------------------------------------------------

//! @brief Return the entries of one metamagic type, or an empty array if
//! nMetaFilter excludes it or oPC lacks the feat.
//! @param nMetaFilter 0 to show all types, else only that type.
json SmGatherMetaSpells(object oPC, int nClassId, int nLevel,
    int nTargetMode, int nMetaFilter,
    int nMetaFlag, int nCost, int nMetaBit, int nFeat, string sSuffix)
{
  json jOut = JsonArray();
  if (nMetaFilter != 0 && nMetaFilter != nMetaFlag)
    return jOut;
  if (!GetHasFeat(nFeat, oPC))
    return jOut;
  int nBase = nLevel - nCost;
  if (nBase < 0)
    return jOut;

  json jIds = SmSpellIds(oPC, nClassId, nBase);
  int i;
  for (i = 0; i < JsonGetLength(jIds); i++)
  {
    int nId = JsonGetInt(JsonArrayGet(jIds, i));
    int nMetaMask = SmHexToInt(Get2DAString("spells", "MetaMagic", nId));
    if (!(nMetaMask & nMetaBit))
      continue;
    if (!SmMatchesTargetMode(nId, nTargetMode))
      continue;
    string sName = SmGetSpellName(nId);
    if (sName == "")
      continue;
    json jEntries = SmGetSpellEntries(nId, sName, sSuffix, nMetaFlag, nTargetMode, 0, "");
    jOut = SmAppend(jOut, jEntries);
  }
  return jOut;
}


// -----------------------------------------------------------------------------

//! @brief Return the spells of the create view as a JsonArray of
//! {id, name, icon, meta, domain, dicon}.
//! @param nLevel Slot level. Base spells appear at their base level, metamagic
//! variants at base + cost.
//! @param nTargetMode SM_TARGET_*; filters on TargetType and HostileSetting.
//! @param nMeta 0 for base spells, domain spells and all metamagic variants,
//! else only the variants of that metamagic.
json SmGatherSpells(object oPC, int nClassId, int nLevel, int nTargetMode, int nMeta)
{
  json jResult = JsonArray();
  int i;

  // Base spells
  if (nMeta == 0)
  {
    json jIds = SmSpellIds(oPC, nClassId, nLevel);
    for (i = 0; i < JsonGetLength(jIds); i++)
    {
      int nId = JsonGetInt(JsonArrayGet(jIds, i));
      if (!SmMatchesTargetMode(nId, nTargetMode))
        continue;
      string sName = SmGetSpellName(nId);
      if (sName == "")
        continue;
      json jEntries = SmGetSpellEntries(nId, sName, "", 0, nTargetMode, 0, "");
      jResult = SmAppend(jResult, jEntries);
    }
  }

  // Domain spells, from domains.2da
  if (nMeta == 0 && GetDomain(oPC, 1, nClassId) != 0)
  {
    int nDomIdx;
    for (nDomIdx = 1; nDomIdx <= 2; nDomIdx++)
    {
      int nDomain = GetDomain(oPC, nDomIdx, nClassId);
      if (nDomain == 0)
        continue;
      string sDomStr = Get2DAString("domains", "Level_" + IntToString(nLevel), nDomain);
      if (sDomStr == "" || sDomStr == "****")
        continue;
      int nDomId = StringToInt(sDomStr);

      // Skip spells already listed
      int bDup = FALSE;
      for (i = 0; i < JsonGetLength(jResult); i++)
      {
        json jE = JsonArrayGet(jResult, i);
        if (SmGetInt(jE, SM_F_ID) == nDomId)
        {
          bDup = TRUE;
          break;
        }
      }
      if (bDup)
        continue;

      if (!SmMatchesTargetMode(nDomId, nTargetMode))
        continue;
      string sDomName = SmGetSpellName(nDomId);
      if (sDomName == "")
        continue;
      string sDomIcon = GetStringLowerCase(Get2DAString("domains", "Icon", nDomain));
      json jEntries = SmGetSpellEntries(nDomId, sDomName, "", 0, nTargetMode, 1, sDomIcon);
      jResult = SmAppend(jResult, jEntries);
    }
  }

  // Metamagic variants
  json jMeta;
  jMeta = SmGatherMetaSpells(oPC, nClassId, nLevel, nTargetMode, nMeta,
    METAMAGIC_EMPOWER, 2, 0x01, FEAT_EMPOWER_SPELL, " - Empower");
  jResult = SmAppend(jResult, jMeta);

  jMeta = SmGatherMetaSpells(oPC, nClassId, nLevel, nTargetMode, nMeta,
    METAMAGIC_EXTEND, 1, 0x02, FEAT_EXTEND_SPELL, " - Extend");
  jResult = SmAppend(jResult, jMeta);

  jMeta = SmGatherMetaSpells(oPC, nClassId, nLevel, nTargetMode, nMeta,
    METAMAGIC_MAXIMIZE, 3, 0x04, FEAT_MAXIMIZE_SPELL, " - Maximize");
  jResult = SmAppend(jResult, jMeta);

  jMeta = SmGatherMetaSpells(oPC, nClassId, nLevel, nTargetMode, nMeta,
    METAMAGIC_QUICKEN, 4, 0x08, FEAT_QUICKEN_SPELL, " - Quicken");
  jResult = SmAppend(jResult, jMeta);

  return jResult;
}


// -----------------------------------------------------------------------------

//! @brief Return the metamagic combo entries of oPC, "None" first, then one
//! per metamagic feat oPC has.
json SmGetPlayerMetas(object oPC)
{
  json jM = JsonArray();
  jM = JsonArrayInsert(jM, NuiComboEntry("None", 0));
  if (GetHasFeat(FEAT_EMPOWER_SPELL, oPC))
    jM = JsonArrayInsert(jM, NuiComboEntry("Empower", METAMAGIC_EMPOWER));
  if (GetHasFeat(FEAT_EXTEND_SPELL, oPC))
    jM = JsonArrayInsert(jM, NuiComboEntry("Extend", METAMAGIC_EXTEND));
  if (GetHasFeat(FEAT_MAXIMIZE_SPELL, oPC))
    jM = JsonArrayInsert(jM, NuiComboEntry("Maximize", METAMAGIC_MAXIMIZE));
  if (GetHasFeat(FEAT_QUICKEN_SPELL, oPC))
    jM = JsonArrayInsert(jM, NuiComboEntry("Quicken", METAMAGIC_QUICKEN));
  return jM;
}


// -----------------------------------------------------------------------------

//! @brief Return the spell level combo entries of nClassId: the slot levels
//! where oPC would see at least one spell.
//! Divine prepared casters: slot levels with memorized slots.
//! Other casters: levels with known spells, plus the slot levels reachable
//! with metamagic, capped at the highest known level.
json SmGetPlayerLevels(object oPC, int nClassId)
{
  int bEmp = GetHasFeat(FEAT_EMPOWER_SPELL, oPC);
  int bExt = GetHasFeat(FEAT_EXTEND_SPELL, oPC);
  int bMax = GetHasFeat(FEAT_MAXIMIZE_SPELL, oPC);
  int bQck = GetHasFeat(FEAT_QUICKEN_SPELL, oPC);
  int bDivine = SmIsDivinePrepared(nClassId);

  // Bitmask of levels that have spells
  int nHasMask = 0;
  int nLvl;
  if (bDivine)
  {
    // Cross-check slots against spells.2da: GetMemorizedSpellCountByLevel can
    // return > 0 for level 0 when the class has no level 0 spells (e.g.
    // Ranger), which would add a "ghost" level 0 entry to the combo.
    string sDivCol = Get2DAString("classes", "SpellTableColumn", nClassId);
    int nSpellRows = Get2DARowCount("spells");
    int nSpellMask = 0;
    int nSpellRow;
    for (nSpellRow = 0; nSpellRow < nSpellRows; nSpellRow++)
    {
      string sL = Get2DAString("spells", sDivCol, nSpellRow);
      if (sL == "" || sL == "****")
        continue;
      int nSL = StringToInt(sL);
      if (nSL >= 0 && nSL <= 9)
        nSpellMask |= (1 << nSL);
    }
    for (nLvl = 0; nLvl <= 9; nLvl++)
    {
      if (GetMemorizedSpellCountByLevel(oPC, nClassId, nLvl) > 0 && ((nSpellMask >> nLvl) & 1))
        nHasMask |= (1 << nLvl);
    }
  }
  else
  {
    for (nLvl = 0; nLvl <= 9; nLvl++)
    {
      if (GetKnownSpellCount(oPC, nClassId, nLvl) > 0)
        nHasMask |= (1 << nLvl);
    }
  }

  // Highest known base level, to avoid unreachable metamagic levels
  int nMaxBase = 0;
  if (!bDivine)
  {
    for (nLvl = 9; nLvl >= 0; nLvl--)
    {
      if ((nHasMask >> nLvl) & 1)
      {
        nMaxBase = nLvl;
        break;
      }
    }
  }

  json jEntries = JsonArray();
  for (nLvl = 0; nLvl <= 9; nLvl++)
  {
    int bHas = (nHasMask >> nLvl) & 1;
    if (!bHas && !bDivine && nLvl <= nMaxBase)
    {
      if (bEmp && nLvl >= 2 && ((nHasMask >> (nLvl - 2)) & 1)) bHas = TRUE;
      if (bExt && nLvl >= 1 && ((nHasMask >> (nLvl - 1)) & 1)) bHas = TRUE;
      if (bMax && nLvl >= 3 && ((nHasMask >> (nLvl - 3)) & 1)) bHas = TRUE;
      if (bQck && nLvl >= 4 && ((nHasMask >> (nLvl - 4)) & 1)) bHas = TRUE;
    }
    if (!bHas)
      continue;
    json jEntry = NuiComboEntry(IntToString(nLvl), nLvl);
    jEntries = JsonArrayInsert(jEntries, jEntry);
  }
  return jEntries;
}


// -----------------------------------------------------------------------------

//! @brief Return the spellcasting classes of oPC as {"ids": [...], "names": [...]}.
json SmGetPlayerClasses(object oPC)
{
  json jIds = JsonArray();
  json jNames = JsonArray();
  int nRows = Get2DARowCount("classes");
  int i;
  for (i = 0; i < nRows; i++)
  {
    if (Get2DAString("classes", "PlayerClass", i) != "1")
      continue;
    if (GetLevelByClass(i, oPC) == 0)
      continue;
    string sCol = Get2DAString("classes", "SpellTableColumn", i);
    if (sCol == "" || sCol == "****")
      continue;

    int nStrRef = StringToInt(Get2DAString("classes", "Name", i));
    string sName = GetStringByStrRef(nStrRef);
    if (sName == "" || GetStringLeft(sName, 3) == "Bad")
      continue;

    jIds = JsonArrayInsert(jIds, JsonInt(i));
    jNames = JsonArrayInsert(jNames, JsonString(sName));
  }
  json jResult = JsonObject();
  jResult = JsonObjectSet(jResult, "ids", jIds);
  jResult = JsonObjectSet(jResult, "names", jNames);
  return jResult;
}


// =============================================================================
// SEQUENCER ITEM
// =============================================================================

//! @brief Return TRUE if oPC can cast at least one spell: it has a class with
//! SpellCaster=1 in classes.2da, and that class gives it at least one spell
//! level (e.g. not a level 1 Paladin or Ranger).
//! @param oPC The PC.
int SmGetIsSpellcaster(object oPC)
{
  int nPos;
  for (nPos = 1; nPos <= 8; nPos++)
  {
    int nClass = GetClassByPosition(nPos, oPC);
    if (nClass == CLASS_TYPE_INVALID)
      return FALSE;
    if (Get2DAString("classes", "SpellCaster", nClass) != "1")
      continue;
    if (JsonGetLength(SmGetPlayerLevels(oPC, nClass)) > 0)
      return TRUE;
  }
  return FALSE;
}


// -----------------------------------------------------------------------------

//! @brief Give the sequencer to oPC if it is a spellcaster without one.
//! @param oPC The PC or DM.
void SmGiveSequencer(object oPC)
{
  if (!GetIsPCorDM(oPC))
    return;
  if (GetIsObjectValid(GetItemPossessedBy(oPC, SEQUENCER_TAG)))
    return;
  if (!SmGetIsSpellcaster(oPC))
    return;

  object oItem = CreateItemOnObject(SEQUENCER_RESREF, oPC);
  if (!GetIsObjectValid(oItem))
  {
    LogError(__FILE__ + ": could not create " + SEQUENCER_RESREF + " on " + GetPCInfo(oPC));
    return;
  }
  SetIdentified(oItem, TRUE);
}


// =============================================================================
// CASTING
// =============================================================================

//! @brief Return the class id of the class combo of the create view.
int SmGetSelectedClass(object oPC, int nToken)
{
  json jClsIds = GetLocalJson(oPC, SM_LVAR_CLS_IDS);
  int nClsIdx = JsonGetInt(NuiGetBind(oPC, nToken, SM_BIND_C_CLS_IDX));
  return JsonGetInt(JsonArrayGet(jClsIds, nClsIdx));
}


// -----------------------------------------------------------------------------

//! @brief Cast spell jS of a macro. Return FALSE if it cannot be cast.
//! @param jS Spell entry.
//! @param oTarget Target creature, or OBJECT_INVALID to cast at lTarget.
int SmCastSpell(object oPC, json jS, object oTarget, location lTarget)
{
  int nSpellId = SmGetInt(jS, SM_F_ID);
  int nClassId = SmGetInt(jS, SM_F_CLASS);
  int nLevel = SmGetInt(jS, SM_F_LEVEL);
  int nMeta = SmGetInt(jS, SM_F_META);
  int bDomain = SmGetInt(jS, SM_F_DOMAIN);

  if (!SmIsAvailable(oPC, nSpellId, nClassId, nLevel, nMeta, bDomain))
  {
    string sReason = ": no uses left today";
    if (Get2DAString("classes", "MemorizesSpells", nClassId) == "1")
      sReason = ": spell not prepared";
    SendMessageToPC(oPC, SmGetString(jS, SM_F_NAME) + sReason);
    return FALSE;
  }

  int nDomLvl = bDomain ? SmGetLevelBeforeMeta(nLevel, nMeta) : 0;
  if (GetIsObjectValid(oTarget))
    AssignCommand(oPC, ActionCastSpellAtObject(nSpellId, oTarget, nMeta, FALSE, nDomLvl, PROJECTILE_PATH_TYPE_DEFAULT, FALSE, nClassId, FALSE));
  else
    AssignCommand(oPC, ActionCastSpellAtLocation(nSpellId, lTarget, nMeta, FALSE, PROJECTILE_PATH_TYPE_DEFAULT, FALSE, nClassId, FALSE, nDomLvl));
  return TRUE;
}


// -----------------------------------------------------------------------------

//! @brief Cast macro jData. Self macros are cast at once; other macros enter
//! targeting mode and are cast in SmOnPlayerTarget().
void SmExecute(object oPC, json jData)
{
  json jSpells = JsonObjectGet(jData, "spells");
  int nTargetMode = SmGetInt(jData, "target_mode");
  int n = JsonGetLength(jSpells);
  if (n == 0)
    return;

  if (nTargetMode == SM_TARGET_SELF)
  {
    int i;
    for (i = 0; i < n; i++)
      SmCastSpell(oPC, JsonArrayGet(jSpells, i), oPC, GetLocation(oPC));
    return;
  }

  SetLocalJson(oPC, SM_LVAR_PENDING, jSpells);
  SetLocalInt(oPC, SM_LVAR_TARGET_MODE, nTargetMode);
  if (nTargetMode == SM_TARGET_AREA)
    EnterTargetingMode(oPC, OBJECT_TYPE_CREATURE | OBJECT_TYPE_TILE);
  else
    EnterTargetingMode(oPC, OBJECT_TYPE_CREATURE);
}


// -----------------------------------------------------------------------------

//! @brief Cast the pending macro at the selected target.
void SmOnPlayerTarget()
{
  object oPC = GetLastPlayerToSelectTarget();
  json jPending = GetLocalJson(oPC, SM_LVAR_PENDING);
  if (JsonGetType(jPending) == JSON_TYPE_NULL)
    return;

  int nTargetMode = GetLocalInt(oPC, SM_LVAR_TARGET_MODE);
  DeleteLocalJson(oPC, SM_LVAR_PENDING);
  DeleteLocalInt(oPC, SM_LVAR_TARGET_MODE);

  object oTarget = GetTargetingModeSelectedObject();
  int bIsCreature = GetObjectType(oTarget) == OBJECT_TYPE_CREATURE;

  // Area spells are cast at the creature position, or at the selected point
  location lTarget;
  if (bIsCreature)
    lTarget = GetLocation(oTarget);
  else
    lTarget = Location(GetArea(oPC), GetTargetingModeSelectedPosition(), 0.0f);

  int n = JsonGetLength(jPending);
  int i;
  for (i = 0; i < n; i++)
  {
    json jS = JsonArrayGet(jPending, i);
    if (nTargetMode == SM_TARGET_AREA)
      SmCastSpell(oPC, jS, OBJECT_INVALID, lTarget);
    else if (bIsCreature)
      SmCastSpell(oPC, jS, oTarget, lTarget);
    else
      SendMessageToPC(oPC, SmGetString(jS, SM_F_NAME) + ": requires a creature target");
  }
}


// =============================================================================
// ICON PICKER
// =============================================================================

//! @brief Return the icon entry {r, n} of icon sRes named sName, or JsonNull()
//! if invalid or already in jSeen.
json SmIconEntry(json jSeen, string sRes, string sName)
{
  if (sRes == "" || sRes == "****")
    return JsonNull();
  if (sName == "" || GetStringLeft(sName, 3) == "Bad")
    return JsonNull();
  if (JsonGetType(JsonObjectGet(jSeen, sRes)) != JSON_TYPE_NULL)
    return JsonNull();

  json jE = JsonObject();
  jE = JsonObjectSet(jE, "r", JsonString(sRes));
  return JsonObjectSet(jE, "n", JsonString(sName));
}


// -----------------------------------------------------------------------------

//! @brief Build the deduplicated icon list from spells.2da, domains.2da and
//! effecticons.2da, and store it on the module.
void SmBuildIconCache()
{
  json jSeen = JsonObject();
  json jList = JsonArray();
  json jE;
  int nRows;
  int i;

  nRows = Get2DARowCount("spells");
  for (i = 0; i < nRows; i++)
  {
    string sRes = Get2DAString("spells", "IconResRef", i);
    jE = SmIconEntry(jSeen, sRes, SmGetSpellName(i));
    if (JsonGetType(jE) == JSON_TYPE_NULL)
      continue;
    JsonObjectSetInplace(jSeen, sRes, JsonInt(1));
    JsonArrayInsertInplace(jList, jE);
  }

  nRows = Get2DARowCount("domains");
  for (i = 0; i < nRows; i++)
  {
    string sRes = GetStringLowerCase(Get2DAString("domains", "Icon", i));
    int nStrRef = StringToInt(Get2DAString("domains", "Name", i));
    jE = SmIconEntry(jSeen, sRes, GetStringByStrRef(nStrRef));
    if (JsonGetType(jE) == JSON_TYPE_NULL)
      continue;
    JsonObjectSetInplace(jSeen, sRes, JsonInt(1));
    JsonArrayInsertInplace(jList, jE);
  }

  nRows = Get2DARowCount("effecticons");
  for (i = 0; i < nRows; i++)
  {
    string sRes = Get2DAString("effecticons", "Icon", i);
    string sName = "";
    string sStrRef = Get2DAString("effecticons", "StrRef", i);
    if (sStrRef != "" && sStrRef != "****")
      sName = GetStringByStrRef(StringToInt(sStrRef));
    if (sName == "" || GetStringLeft(sName, 3) == "Bad")
      sName = Get2DAString("effecticons", "Label", i);
    jE = SmIconEntry(jSeen, sRes, sName);
    if (JsonGetType(jE) == JSON_TYPE_NULL)
      continue;
    JsonObjectSetInplace(jSeen, sRes, JsonInt(1));
    JsonArrayInsertInplace(jList, jE);
  }

  SetLocalJson(GetModule(), SM_ICO_CACHE_VAR, jList);
}


// -----------------------------------------------------------------------------

//! @brief Return the icon list built by SmBuildIconCache().
json SmGetIconCache()
{
  return GetLocalJson(GetModule(), SM_ICO_CACHE_VAR);
}


// -----------------------------------------------------------------------------

//! @brief Rebuild the icon grid with the icons whose name contains sFilter.
void SmRebuildIconGrid(object oPC, int nToken, string sFilter)
{
  json jCache = SmGetIconCache();
  int nTotal = JsonGetLength(jCache);
  float fSize = SmScale(oPC, 40.0);
  sFilter = GetStringLowerCase(sFilter);

  int nInRow = 0;

  NUI_InitializeLayout();
  NUI_StartColumn();

  int i;
  for (i = 0; i < nTotal; i++)
  {
    json jEntry = JsonArrayGet(jCache, i);
    string sRes = SmGetString(jEntry, "r");
    string sName = SmGetString(jEntry, "n");

    if (sFilter != "" && FindSubString(GetStringLowerCase(sName), sFilter) == -1)
      continue;

    if (nInRow == 0)
      NUI_StartRow();

    NUI_StartElement(NuiButtonImage(JsonString(sRes)));
    NUI_SetWidth(fSize);
    NUI_SetHeight(fSize);
    NUI_SetId(SM_ICO_BTN_PFX + IntToString(i));
    NUI_SetTooltip(JsonString(sName));
    NUI_End();
    nInRow++;

    if (nInRow >= 10)
    {
      NUI_End();
      nInRow = 0;
    }
  }
  if (nInRow > 0)
    NUI_End();

  NUI_End();

  NuiSetGroupLayout(oPC, nToken, SM_GRP_ICO_GRID, NUI_FinalizeLayout());
}


// -----------------------------------------------------------------------------

//! @brief Open the icon picker for the window nParentToken.
void SmCreateIconPicker(object oPC, int nParentToken)
{
  int nExisting = NuiFindWindow(oPC, SM_WIN_ICON_PICKER);
  if (nExisting > 0)
    NuiDestroy(oPC, nExisting);

  SetLocalInt(oPC, SM_LVAR_ICO_PARENT, nParentToken);

  SmInitializeWindow("Pick Icon", NuiRect(-1.0, -1.0, SmScale(oPC, 500.0), SmScale(oPC, 480.0)));

  NUI_StartColumn();

    // Search field
    NUI_StartRow();
      NUI_StartElement(NuiTextEdit(JsonString("Search..."), NuiBind(SM_BIND_ICO_SEARCH), 64, FALSE));
      NUI_SetId(SM_BIND_ICO_SEARCH);
      NUI_SetHeight(SmScale(oPC, 28.0));
      NUI_End();
    NUI_End();

    // Icon grid, filled by SmRebuildIconGrid()
    NUI_StartRow();
      NUI_StartGroup(FALSE, NUI_SCROLLBARS_Y);
      NUI_SetId(SM_GRP_ICO_GRID);
      NUI_SetHeight(SmScale(oPC, 380.0));
        NUI_StartRow();
        NUI_End();
      NUI_End();
    NUI_End();

  NUI_End();

  int nToken = NuiCreate(oPC, NUI_FinalizeWindow(), SM_WIN_ICON_PICKER);
  NuiSetBind(oPC, nToken, SM_BIND_ICO_SEARCH, JsonString(""));
  NuiSetBindWatch(oPC, nToken, SM_BIND_ICO_SEARCH, TRUE);
  SmRebuildIconGrid(oPC, nToken, "");
}


// =============================================================================
// EDITOR
// =============================================================================

// Forward declarations
void SmFeedMenu(object oPC, int nToken);
void SmFeedCreate(object oPC, int nToken);
void SmFeedSelected(object oPC, int nToken);


// -----------------------------------------------------------------------------

//! @brief Open the delete confirmation popup for macro sName.
void SmCreateDeletePopup(object oPC, string sName)
{
  SmInitializeWindow("Delete Macro", NuiRect(-1.0, -1.0, SmScale(oPC, 380.0), SmScale(oPC, 160.0)));

  NUI_StartColumn();

    NUI_StartRow();
      NUI_AddElement(NuiLabel(JsonString("Delete macro \"" + sName + "\"?"),
        JsonInt(NUI_HALIGN_CENTER), JsonInt(NUI_VALIGN_MIDDLE)));
    NUI_End();

    SmAddEmptyRow(oPC, 10.0);

    NUI_StartRow();
      NUI_AddSpacer();
      SmStartButton("Yes, delete", SM_BTN_DEL_CONFIRM);
      NUI_SetWidth(SmScale(oPC, 120.0));
      NUI_End();
      SmAddGap(oPC, 10.0);
      SmStartButton("Cancel", SM_BTN_DEL_CANCEL);
      NUI_SetWidth(SmScale(oPC, 100.0));
      NUI_End();
      NUI_AddSpacer();
    NUI_End();

  NUI_End();

  NuiCreate(oPC, NUI_FinalizeWindow(), SM_WIN_DELETE);
}


// -----------------------------------------------------------------------------

//! @brief Add the menu view: macro list with Cast, Edit and Delete buttons.
void SmAddMenuView(object oPC)
{
  float fBtnW = SmScale(oPC, 90.0);

  NUI_StartRow();
    NUI_StartColumn();
    NUI_SetWidth(SmScale(oPC, 558.0));

      // New macro button
      SmStartCenteredRow();
        SmStartButton("New Macro", SM_BTN_NEW);
        NUI_SetWidth(SmScale(oPC, 150.0));
        NUI_End();
      SmEndCenteredRow();

      // Macro list
      NUI_StartList(NuiBind(SM_BIND_M_COUNT), SmScale(oPC, 54.0), TRUE, NUI_SCROLLBARS_Y);
      NUI_SetHeight(SmScale(oPC, 556.0));
        NUI_StartListTemplateCell(0.0, TRUE);
          NUI_StartGroup(TRUE, NUI_SCROLLBARS_NONE);
          NUI_SetId(SM_BTN_M_ROW);
          NUI_SetEncouraged(NuiBind(SM_BIND_M_ENC));
            NUI_StartRow();
              NUI_StartElement(NuiImage(NuiBind(SM_BIND_M_ICONS),
                JsonInt(NUI_ASPECT_FIT), JsonInt(NUI_HALIGN_CENTER), JsonInt(NUI_VALIGN_MIDDLE)));
              NUI_SetWidth(SmScale(oPC, 40.0));
              NUI_SetHeight(SmScale(oPC, 40.0));
              NUI_End();
              SmAddGap(oPC, 4.0);
              NUI_AddElement(NuiLabel(NuiBind(SM_BIND_M_NAMES),
                JsonInt(NUI_HALIGN_LEFT), JsonInt(NUI_VALIGN_MIDDLE)));
            NUI_End();
          NUI_End();
        NUI_End();
      NUI_End();

      // Cast / Edit / Delete buttons
      NUI_StartRow();
        NUI_AddSpacer();
        SmStartButton("Cast", SM_BTN_CAST);
        NUI_SetWidth(fBtnW);
        NUI_SetEnabled(NuiBind(SM_BIND_M_CAST_EN));
        NUI_End();
        NUI_AddSpacer();
        SmStartButton("Edit", SM_BTN_EDIT);
        NUI_SetWidth(fBtnW);
        NUI_SetEnabled(NuiBind(SM_BIND_M_EDIT_EN));
        NUI_End();
        NUI_AddSpacer();
        SmStartButton("Delete", SM_BTN_DELETE);
        NUI_SetWidth(fBtnW);
        NUI_SetEnabled(NuiBind(SM_BIND_M_DEL_EN));
        NUI_End();
        NUI_AddSpacer();
      NUI_End();

    NUI_End();
  NUI_End();
}


// -----------------------------------------------------------------------------

//! @brief Return the menu view, for NuiSetGroupLayout().
json SmBuildMenuView(object oPC)
{
  NUI_InitializeLayout();
  SmAddMenuView(oPC);
  return NUI_FinalizeLayout();
}


// -----------------------------------------------------------------------------

//! @brief Add a label and a combo to the current row.
void SmAddCombo(object oPC, string sLabel, float fLabelW,
                string sEntriesBind, string sIndexBind, float fComboW)
{
  NUI_StartElement(NuiLabel(JsonString(sLabel), JsonInt(NUI_HALIGN_LEFT), JsonInt(NUI_VALIGN_MIDDLE)));
  NUI_SetWidth(SmScale(oPC, fLabelW));
  NUI_End();

  NUI_StartElement(NuiCombo(NuiBind(sEntriesBind), NuiBind(sIndexBind)));
  NUI_SetWidth(SmScale(oPC, fComboW));
  NUI_SetHeight(SmScale(oPC, 26.0));
  NUI_SetId(sIndexBind);
  NUI_End();
}


// -----------------------------------------------------------------------------

//! @brief Add the create view: spell filters, spell list, selected spells,
//! macro name and icon.
void SmAddCreateView(object oPC)
{
  float fIcon = SmScale(oPC, 32.0);
  float fMI = SmScale(oPC, 14.0);
  float fBtnH = SmScale(oPC, 40.0);

  NUI_StartRow();
    NUI_StartColumn();
    NUI_SetWidth(SmScale(oPC, 558.0));

      // Class / Level / Metamagic combos
      NUI_StartRow();
        SmAddCombo(oPC, "Class:", 42.0, SM_BIND_C_CLS_ENT, SM_BIND_C_CLS_IDX, 120.0);
        SmAddGap(oPC, 6.0);
        SmAddCombo(oPC, "Level:", 38.0, SM_BIND_C_LVL_ENT, SM_BIND_C_LVL_IDX, 56.0);
        SmAddGap(oPC, 6.0);
        SmAddCombo(oPC, "Meta:", 38.0, SM_BIND_C_META_ENT, SM_BIND_C_META_IDX, 110.0);
        NUI_AddSpacer();
      NUI_End();

      SmAddEmptyRow(oPC, 4.0);

      // Spell name search
      NUI_StartRow();
        NUI_StartElement(NuiTextEdit(JsonString("Search spell..."), NuiBind(SM_BIND_C_SPELL_SEARCH), 64, FALSE));
        NUI_SetId(SM_BIND_C_SPELL_SEARCH);
        NUI_SetHeight(SmScale(oPC, 26.0));
        NUI_End();
      NUI_End();

      SmAddEmptyRow(oPC, 4.0);

      // Target mode radio buttons
      NUI_StartRow();
        NUI_StartStaticOptions(NUI_DIRECTION_HORIZONTAL, NuiBind(SM_BIND_C_TARGET_MODE));
        NUI_SetId(SM_BIND_C_TARGET_MODE);
          NUI_AddStaticOptionsEntry("Self");
          NUI_AddStaticOptionsEntry("Ally");
          NUI_AddStaticOptionsEntry("Enemy");
          NUI_AddStaticOptionsEntry("Area");
        NUI_End();
      NUI_End();

      SmAddEmptyRow(oPC, 4.0);

      // Spell list; metamagic and domain icons are drawn on the spell icon corners
      NUI_StartList(NuiBind(SM_BIND_C_SPELL_COUNT), SmScale(oPC, 44.0), TRUE, NUI_SCROLLBARS_Y);
      NUI_SetHeight(SmScale(oPC, 200.0));
        NUI_StartListTemplateCell(0.0, TRUE);
          NUI_StartGroup(TRUE, NUI_SCROLLBARS_NONE);
          NUI_SetId(SM_BTN_C_SP_ROW);
          NUI_SetEncouraged(NuiBind(SM_BIND_C_SPELL_ENC));
            NUI_StartRow();
              NUI_StartElement(NuiImage(NuiBind(SM_BIND_C_SPELL_ICONS),
                JsonInt(NUI_ASPECT_FIT), JsonInt(NUI_HALIGN_CENTER), JsonInt(NUI_VALIGN_MIDDLE)));
              NUI_SetWidth(fIcon);
              NUI_SetHeight(fIcon);
                NUI_StartDrawList(JsonBool(FALSE));
                  NUI_AddDrawListItem(NuiDrawListImage(
                    NuiBind(SM_BIND_C_SPELL_META_VIS),
                    NuiBind(SM_BIND_C_SPELL_META_ICON),
                    NuiRect(0.0, fIcon - fMI, fMI, fMI),
                    JsonInt(NUI_ASPECT_FIT),
                    JsonInt(NUI_HALIGN_CENTER),
                    JsonInt(NUI_VALIGN_MIDDLE)));
                  NUI_AddDrawListItem(NuiDrawListImage(
                    NuiBind(SM_BIND_C_SPELL_DOM_VIS),
                    NuiBind(SM_BIND_C_SPELL_DOM_ICON),
                    NuiRect(fIcon - fMI, fIcon - fMI, fMI, fMI),
                    JsonInt(NUI_ASPECT_FIT),
                    JsonInt(NUI_HALIGN_CENTER),
                    JsonInt(NUI_VALIGN_MIDDLE)));
                NUI_End();
              NUI_End();
              NUI_AddElement(NuiLabel(NuiBind(SM_BIND_C_SPELL_NAMES),
                JsonInt(NUI_HALIGN_LEFT), JsonInt(NUI_VALIGN_MIDDLE)));
            NUI_End();
          NUI_End();
        NUI_End();
      NUI_End();

      // Add to sequence button
      SmStartCenteredRow();
        SmStartButton("Add to sequence", SM_BTN_C_ADD);
        NUI_SetEnabled(NuiBind(SM_BIND_C_ADD_EN));
        NUI_SetWidth(SmScale(oPC, 180.0));
        NUI_End();
      SmEndCenteredRow();

      SmAddEmptyRow(oPC, 4.0);

      // Selected spells label and count
      NUI_StartRow();
        NUI_AddElement(NuiLabel(JsonString("Selected spells:"),
          JsonInt(NUI_HALIGN_LEFT), JsonInt(NUI_VALIGN_MIDDLE)));
        NUI_StartElement(NuiLabel(NuiBind(SM_BIND_C_SEL_COUNT),
          JsonInt(NUI_HALIGN_RIGHT), JsonInt(NUI_VALIGN_MIDDLE)));
        NUI_SetWidth(SmScale(oPC, 50.0));
        NUI_End();
      NUI_End();

      // Selected spells: row of icon buttons, filled by SmFeedSelected()
      NUI_StartGroup(TRUE, NUI_SCROLLBARS_X);
      NUI_SetId(SM_GRP_SEL_ICONS);
      NUI_SetHeight(SmScale(oPC, 76.0));
        NUI_StartRow();
        NUI_End();
      NUI_End();

      SmAddEmptyRow(oPC, 6.0);

      // Macro name
      NUI_StartRow();
        NUI_StartElement(NuiLabel(JsonString("Name:"), JsonInt(NUI_HALIGN_LEFT), JsonInt(NUI_VALIGN_MIDDLE)));
        NUI_SetWidth(SmScale(oPC, 46.0));
        NUI_End();
        NUI_StartElement(NuiTextEdit(JsonString("macro name..."), NuiBind(SM_BIND_C_SEQ_NAME), 32, FALSE));
        NUI_SetWidth(SmScale(oPC, 200.0));
        NUI_SetId(SM_BIND_C_SEQ_NAME);
        NUI_End();
        NUI_AddSpacer();
      NUI_End();

      SmAddEmptyRow(oPC, 4.0);

      // Icon selector
      NUI_StartRow();
        NUI_StartElement(NuiLabel(JsonString("Icon:"), JsonInt(NUI_HALIGN_LEFT), JsonInt(NUI_VALIGN_MIDDLE)));
        NUI_SetWidth(SmScale(oPC, 46.0));
        NUI_End();
        NUI_StartElement(NuiImage(NuiBind(SM_BIND_C_ICON),
          JsonInt(NUI_ASPECT_FIT), JsonInt(NUI_HALIGN_CENTER), JsonInt(NUI_VALIGN_MIDDLE)));
        NUI_SetWidth(fBtnH);
        NUI_SetHeight(fBtnH);
        NUI_End();
        SmAddGap(oPC, 6.0);
        SmStartButton("Pick", SM_BTN_C_ICON_PICK);
        NUI_SetWidth(SmScale(oPC, 80.0));
        NUI_SetHeight(fBtnH);
        NUI_End();
        NUI_AddSpacer();
      NUI_End();

      SmAddEmptyRow(oPC, 6.0);

      // Back and Save buttons
      NUI_StartRow();
        NUI_AddSpacer();
        SmStartButton("Back", SM_BTN_BACK);
        NUI_SetWidth(SmScale(oPC, 100.0));
        NUI_End();
        SmAddGap(oPC, 10.0);
        SmStartButton("Save", SM_BTN_SAVE);
        NUI_SetWidth(SmScale(oPC, 100.0));
        NUI_SetEnabled(NuiBind(SM_BIND_C_SAVE_EN));
        NUI_End();
        NUI_AddSpacer();
      NUI_End();

    NUI_End();
  NUI_End();
}


// -----------------------------------------------------------------------------

//! @brief Return the create view, for NuiSetGroupLayout().
json SmBuildCreateView(object oPC)
{
  NUI_InitializeLayout();
  SmAddCreateView(oPC);
  return NUI_FinalizeLayout();
}


// -----------------------------------------------------------------------------

//! @brief Open the macro editor, on the menu view.
void SmCreateWindow(object oPC)
{
  SmInitializeWindow("Spell Macro", NuiRect(-1.0, -1.0, SmScale(oPC, 580.0), SmScale(oPC, 728.0)));

  // The swap group holds either the menu view or the create view
  NUI_StartGroup(FALSE, NUI_SCROLLBARS_NONE);
  NUI_SetId(SM_GRP_SWAP);
  NUI_SetWidth(SmScale(oPC, 562.0));
    SmAddMenuView(oPC);
  NUI_End();

  int nToken = NuiCreate(oPC, NUI_FinalizeWindow(), SM_WIN_MENU);

  SetLocalInt(oPC, SM_LVAR_SEL_IDX, -1);
  SmFeedMenu(oPC, nToken);
}


// -----------------------------------------------------------------------------

//! @brief Close the icon picker and clear the create view state.
void SmClearCreateState(object oPC)
{
  int nPicker = NuiFindWindow(oPC, SM_WIN_ICON_PICKER);
  if (nPicker > 0)
    NuiDestroy(oPC, nPicker);

  DeleteLocalJson(oPC, SM_LVAR_SPELLS);
  DeleteLocalJson(oPC, SM_LVAR_SPELL_LIST);
  DeleteLocalJson(oPC, SM_LVAR_CLS_IDS);
  DeleteLocalInt(oPC, SM_LVAR_SUPPRESS_TM);
  SetLocalInt(oPC, SM_LVAR_SEL_IDX, -1);
}


// -----------------------------------------------------------------------------

//! @brief Swap the editor to the menu view.
void SmSwapToMenuView(object oPC, int nToken)
{
  SmClearCreateState(oPC);
  NuiSetGroupLayout(oPC, nToken, SM_GRP_SWAP, SmBuildMenuView(oPC));
  SmFeedMenu(oPC, nToken);
}


// -----------------------------------------------------------------------------

//! @brief Return the value of the first entry of combo entries jEntries, or 0.
int SmGetFirstComboValue(json jEntries)
{
  if (JsonGetLength(jEntries) == 0)
    return 0;
  json jFirst = JsonArrayGet(jEntries, 0);
  return JsonGetInt(JsonArrayGet(jFirst, 1));
}


// -----------------------------------------------------------------------------

//! @brief Swap the editor to the create view.
//! @param nEditIdx Slot of the macro to edit, or -1 for a new macro.
void SmSwapToCreateView(object oPC, int nToken, int nEditIdx)
{
  SetLocalInt(oPC, SM_LVAR_EDIT_IDX, nEditIdx);

  json jInitSpells = JsonArray();
  string sInitName = "";
  string sInitIcon = SM_ICO_DEFAULT;
  int nInitMode = SM_TARGET_SELF;
  if (nEditIdx >= 0)
  {
    json jData = SmLoadByIdx(oPC, nEditIdx);
    if (JsonGetType(jData) != JSON_TYPE_NULL)
    {
      jInitSpells = JsonObjectGet(jData, "spells");
      sInitName = SmGetString(jData, "name");
      sInitIcon = SmGetString(jData, "icon");
      if (sInitIcon == "")
        sInitIcon = SM_ICO_DEFAULT;
      nInitMode = SmGetInt(jData, "target_mode");
    }
  }
  SetLocalJson(oPC, SM_LVAR_SPELLS, jInitSpells);
  SetLocalInt(oPC, SM_LVAR_SUPPRESS_TM, 1);

  NuiSetGroupLayout(oPC, nToken, SM_GRP_SWAP, SmBuildCreateView(oPC));

  NuiSetBind(oPC, nToken, SM_BIND_C_SEQ_NAME, JsonString(sInitName));
  NuiSetBindWatch(oPC, nToken, SM_BIND_C_SEQ_NAME, TRUE);

  NuiSetBind(oPC, nToken, SM_BIND_C_TARGET_MODE, JsonInt(nInitMode));
  NuiSetBindWatch(oPC, nToken, SM_BIND_C_TARGET_MODE, TRUE);
  NuiSetBindWatch(oPC, nToken, SM_BIND_C_CLS_IDX, TRUE);
  NuiSetBindWatch(oPC, nToken, SM_BIND_C_LVL_IDX, TRUE);
  NuiSetBindWatch(oPC, nToken, SM_BIND_C_META_IDX, TRUE);

  NuiSetBind(oPC, nToken, SM_BIND_C_ADD_EN, JsonBool(FALSE));
  NuiSetBind(oPC, nToken, SM_BIND_C_ICON, JsonString(sInitIcon));
  NuiSetBind(oPC, nToken, SM_BIND_C_SPELL_SEARCH, JsonString(""));
  NuiSetBindWatch(oPC, nToken, SM_BIND_C_SPELL_SEARCH, TRUE);

  json jCls = SmGetPlayerClasses(oPC);
  json jClsIds = JsonObjectGet(jCls, "ids");
  json jClsNames = JsonObjectGet(jCls, "names");
  SetLocalJson(oPC, SM_LVAR_CLS_IDS, jClsIds);

  json jClsEntries = JsonArray();
  int i;
  for (i = 0; i < JsonGetLength(jClsNames); i++)
  {
    string sClsName = JsonGetString(JsonArrayGet(jClsNames, i));
    jClsEntries = JsonArrayInsert(jClsEntries, NuiComboEntry(sClsName, i));
  }
  NuiSetBind(oPC, nToken, SM_BIND_C_CLS_ENT, jClsEntries);
  NuiSetBind(oPC, nToken, SM_BIND_C_CLS_IDX, JsonInt(0));

  int nClassId = JsonGetInt(JsonArrayGet(jClsIds, 0));
  json jLvlEnt = SmGetPlayerLevels(oPC, nClassId);
  int nFirstLvl = SmGetFirstComboValue(jLvlEnt);
  NuiSetBind(oPC, nToken, SM_BIND_C_LVL_ENT, jLvlEnt);
  NuiSetBind(oPC, nToken, SM_BIND_C_LVL_IDX, JsonInt(nFirstLvl));
  NuiSetBind(oPC, nToken, SM_BIND_C_META_ENT, SmGetPlayerMetas(oPC));
  NuiSetBind(oPC, nToken, SM_BIND_C_META_IDX, JsonInt(0));

  SmFeedCreate(oPC, nToken);
}


// -----------------------------------------------------------------------------

//! @brief Refill the level combo after a class change.
void SmRebuildLevels(object oPC, int nToken)
{
  json jLvlEnt = SmGetPlayerLevels(oPC, SmGetSelectedClass(oPC, nToken));
  int nFirstLvl = SmGetFirstComboValue(jLvlEnt);
  NuiSetBind(oPC, nToken, SM_BIND_C_LVL_ENT, jLvlEnt);
  NuiSetBind(oPC, nToken, SM_BIND_C_LVL_IDX, JsonInt(nFirstLvl));
}


// -----------------------------------------------------------------------------

//! @brief Fill the menu view with the macros of oPC.
void SmFeedMenu(object oPC, int nToken)
{
  json jAll = SmLoadAll(oPC);
  int nCount = JsonGetLength(jAll);

  json jNames = JsonArray();
  json jIcons = JsonArray();
  json jEnc = JsonArray();

  int i;
  for (i = 0; i < nCount; i++)
  {
    json jEntry = JsonArrayGet(jAll, i);
    string sIco = SmGetString(jEntry, "icon");
    if (sIco == "")
      sIco = SM_ICO_DEFAULT;
    jNames = JsonArrayInsert(jNames, JsonObjectGet(jEntry, "name"));
    jIcons = JsonArrayInsert(jIcons, JsonString(sIco));
    jEnc = JsonArrayInsert(jEnc, JsonBool(FALSE));
  }

  NuiSetBind(oPC, nToken, SM_BIND_M_NAMES, jNames);
  NuiSetBind(oPC, nToken, SM_BIND_M_ICONS, jIcons);
  NuiSetBind(oPC, nToken, SM_BIND_M_COUNT, JsonInt(nCount));
  NuiSetBind(oPC, nToken, SM_BIND_M_ENC, jEnc);
  NuiSetBind(oPC, nToken, SM_BIND_M_ROW, JsonNull());
  NuiSetBind(oPC, nToken, SM_BIND_M_CAST_EN, JsonBool(FALSE));
  NuiSetBind(oPC, nToken, SM_BIND_M_EDIT_EN, JsonBool(FALSE));
  NuiSetBind(oPC, nToken, SM_BIND_M_DEL_EN, JsonBool(FALSE));
}


// -----------------------------------------------------------------------------

//! @brief Fill the spell list of the create view from its filters.
void SmFeedCreate(object oPC, int nToken)
{
  int nClassId = SmGetSelectedClass(oPC, nToken);

  // NuiCombo binds the selected value, not the array index.
  int nLevel = JsonGetInt(NuiGetBind(oPC, nToken, SM_BIND_C_LVL_IDX));
  int nMeta = JsonGetInt(NuiGetBind(oPC, nToken, SM_BIND_C_META_IDX));
  int nTargetMode = JsonGetInt(NuiGetBind(oPC, nToken, SM_BIND_C_TARGET_MODE));
  string sSearch = JsonGetString(NuiGetBind(oPC, nToken, SM_BIND_C_SPELL_SEARCH));
  sSearch = GetStringLowerCase(sSearch);

  json jAllSpells = SmGatherSpells(oPC, nClassId, nLevel, nTargetMode, nMeta);

  json jSpells = JsonArray();
  json jNames = JsonArray();
  json jIcons = JsonArray();
  json jEnc = JsonArray();
  json jMetaVis = JsonArray();
  json jMetaIcon = JsonArray();
  json jDomVis = JsonArray();
  json jDomIcon = JsonArray();

  int i;
  for (i = 0; i < JsonGetLength(jAllSpells); i++)
  {
    json jS = JsonArrayGet(jAllSpells, i);
    string sName = SmGetString(jS, SM_F_NAME);
    if (sSearch != "" && FindSubString(GetStringLowerCase(sName), sSearch) == -1)
      continue;

    string sMIcon = SmGetMetaIcon(SmGetInt(jS, SM_F_META));
    string sDIcon = SmGetString(jS, SM_F_DOMAIN_ICON);

    jSpells = JsonArrayInsert(jSpells, jS);
    jNames = JsonArrayInsert(jNames, JsonString(sName));
    jIcons = JsonArrayInsert(jIcons, JsonObjectGet(jS, SM_F_ICON));
    jEnc = JsonArrayInsert(jEnc, JsonBool(FALSE));
    jMetaVis = JsonArrayInsert(jMetaVis, JsonBool(sMIcon != ""));
    jMetaIcon = JsonArrayInsert(jMetaIcon, JsonString(sMIcon));
    jDomVis = JsonArrayInsert(jDomVis, JsonBool(sDIcon != ""));
    jDomIcon = JsonArrayInsert(jDomIcon, JsonString(sDIcon));
  }

  NuiSetBind(oPC, nToken, SM_BIND_C_SPELL_NAMES, jNames);
  int nSpellCount = JsonGetLength(jSpells);
  NuiSetBind(oPC, nToken, SM_BIND_C_SPELL_COUNT, JsonInt(nSpellCount));
  NuiSetBind(oPC, nToken, SM_BIND_C_SPELL_ICONS, jIcons);
  NuiSetBind(oPC, nToken, SM_BIND_C_SPELL_ENC, jEnc);
  NuiSetBind(oPC, nToken, SM_BIND_C_SPELL_META_VIS, jMetaVis);
  NuiSetBind(oPC, nToken, SM_BIND_C_SPELL_META_ICON, jMetaIcon);
  NuiSetBind(oPC, nToken, SM_BIND_C_SPELL_DOM_VIS, jDomVis);
  NuiSetBind(oPC, nToken, SM_BIND_C_SPELL_DOM_ICON, jDomIcon);
  NuiSetBind(oPC, nToken, SM_BIND_C_SPELL_ROW, JsonNull());
  NuiSetBind(oPC, nToken, SM_BIND_C_ADD_EN, JsonBool(FALSE));

  SetLocalJson(oPC, SM_LVAR_SPELL_LIST, jSpells);

  SmFeedSelected(oPC, nToken);
}


// -----------------------------------------------------------------------------

//! @brief Fill the selected spells row of the create view. Clicking a spell
//! removes it.
void SmFeedSelected(object oPC, int nToken)
{
  json jSeq = GetLocalJson(oPC, SM_LVAR_SPELLS);
  int n = JsonGetLength(jSeq);

  float fSize = SmScale(oPC, 48.0);
  float fMI = SmScale(oPC, 14.0);
  float fMY = fSize - fMI - SmScale(oPC, 2.0);

  NUI_InitializeLayout();
  NUI_StartRow();

  int i;
  for (i = 0; i < n; i++)
  {
    json jS = JsonArrayGet(jSeq, i);
    NUI_StartElement(NuiButtonImage(JsonObjectGet(jS, SM_F_ICON)));
    NUI_SetId(SM_BTN_C_DEL_SEL + IntToString(i));
    NUI_SetWidth(fSize);
    NUI_SetHeight(fSize);
    NUI_SetTooltip(JsonObjectGet(jS, SM_F_NAME));

      NUI_StartDrawList(JsonBool(FALSE));

        // Position in the sequence, top-left
        NUI_AddDrawListItem(NuiDrawListText(
          JsonBool(TRUE),
          NuiColor(80, 140, 220),
          NuiRect(7.0, 2.0, 44.0, 16.0),
          JsonString(IntToString(i + 1))));

        // Metamagic, bottom-left
        string sMIcon = SmGetMetaIcon(SmGetInt(jS, SM_F_META));
        if (sMIcon != "")
        {
          NUI_AddDrawListItem(NuiDrawListImage(
            JsonBool(TRUE), JsonString(sMIcon),
            NuiRect(7.0, fMY, fMI, fMI),
            JsonInt(NUI_ASPECT_FIT),
            JsonInt(NUI_HALIGN_CENTER),
            JsonInt(NUI_VALIGN_MIDDLE)));
        }

        // Domain, bottom-right
        string sDIcon = SmGetString(jS, SM_F_DOMAIN_ICON);
        if (sDIcon != "")
        {
          NUI_AddDrawListItem(NuiDrawListImage(
            JsonBool(TRUE), JsonString(sDIcon),
            NuiRect(fMY, fMY, fMI, fMI),
            JsonInt(NUI_ASPECT_FIT),
            JsonInt(NUI_HALIGN_CENTER),
            JsonInt(NUI_VALIGN_MIDDLE)));
        }

      NUI_End();
    NUI_End();
  }

  NUI_End();
  NuiSetGroupLayout(oPC, nToken, SM_GRP_SEL_ICONS, NUI_FinalizeLayout());

  string sCount = IntToString(n) + "/" + IntToString(SM_MAX_SPELLS);
  NuiSetBind(oPC, nToken, SM_BIND_C_SEL_COUNT, JsonString(sCount));

  string sName = JsonGetString(NuiGetBind(oPC, nToken, SM_BIND_C_SEQ_NAME));
  NuiSetBind(oPC, nToken, SM_BIND_C_SAVE_EN, JsonBool(n > 0 && sName != ""));
}


// =============================================================================
// SPELL BAR
// =============================================================================

//! @brief Toggle the spell bar: one button per macro, in SM_BAR_ROWS rows of
//! SM_BAR_COLUMNS, and an Edit button that opens the macro editor.
void SmToggleBarWindow(object oPC)
{
  int nExisting = NuiFindWindow(oPC, SM_WIN_BAR);
  if (nExisting > 0)
  {
    NuiDestroy(oPC, nExisting);
    return;
  }

  json jAll = SmLoadAll(oPC);
  int nCount = JsonGetLength(jAll);
  float fBtn = SmScale(oPC, 40.0);

  float fW = SM_BAR_COLUMNS * SmScale(oPC, 48.0) + SmScale(oPC, 50.0);
  float fH = SM_BAR_ROWS * SmScale(oPC, 48.0) + SmScale(oPC, 110.0);
  SmInitializeWindow("Spell Bar", NuiRect(-1.0, -1.0, fW, fH));

  NUI_StartColumn();

    NUI_StartGroup(FALSE, NUI_SCROLLBARS_NONE);
    NUI_SetHeight(SM_BAR_ROWS * SmScale(oPC, 48.0) + SmScale(oPC, 8.0));
      NUI_StartColumn();
        NUI_StartRow();

        int i;
        for (i = 0; i < nCount; i++)
        {
          if (i > 0 && i % SM_BAR_COLUMNS == 0)
          {
            NUI_AddSpacer();
            NUI_End();
            NUI_StartRow();
          }

          json jEntry = JsonArrayGet(jAll, i);
          string sIcon = SmGetString(jEntry, "icon");
          if (sIcon == "")
            sIcon = SM_ICO_DEFAULT;

          int nSeqIdx = SmGetInt(jEntry, "idx");
          NUI_StartElement(NuiButtonImage(JsonString(sIcon)));
          NUI_SetWidth(fBtn);
          NUI_SetHeight(fBtn);
          NUI_SetId(SM_BAR_BTN_PFX + IntToString(nSeqIdx));
          NUI_SetTooltip(JsonObjectGet(jEntry, "name"));
          NUI_End();
        }

          NUI_AddSpacer();
        NUI_End();
      NUI_End();
    NUI_End();

    SmStartCenteredRow();
      SmStartButton("Edit", SM_BTN_BAR_EDIT);
      NUI_SetWidth(SmScale(oPC, 100.0));
      NUI_SetHeight(SmScale(oPC, 30.0));
      NUI_End();
    SmEndCenteredRow();

  NUI_End();

  NuiCreate(oPC, NUI_FinalizeWindow(), SM_WIN_BAR);
}


// =============================================================================
// NUI EVENTS
// =============================================================================

//! @brief Handle the events of the spell bar.
void SmBarEvents(object oPC, int nToken, string sEvent, string sElement)
{
  if (sEvent != SM_EVENT_CLICK)
    return;

  if (sElement == SM_BTN_BAR_EDIT)
  {
    NuiDestroy(oPC, nToken);
    SmCreateWindow(oPC);
    return;
  }

  if (GetStringLeft(sElement, 9) == SM_BAR_BTN_PFX)
  {
    int nSeqIdx = SmGetElementIndex(sElement, 9);
    json jData = SmLoadByIdx(oPC, nSeqIdx);
    if (JsonGetType(jData) != JSON_TYPE_NULL)
      SmExecute(oPC, jData);
  }
}


// -----------------------------------------------------------------------------

//! @brief Add the selected spell of the create view to the sequence.
void SmAddSelectedSpell(object oPC, int nToken)
{
  json jSeq = GetLocalJson(oPC, SM_LVAR_SPELLS);
  if (JsonGetLength(jSeq) >= SM_MAX_SPELLS)
  {
    SendFailureMessage(oPC, "Maximum spells per macro: " + IntToString(SM_MAX_SPELLS) + ".");
    return;
  }

  json jRow = NuiGetBind(oPC, nToken, SM_BIND_C_SPELL_ROW);
  if (JsonGetType(jRow) != JSON_TYPE_INTEGER)
    return;
  int nSpellRow = JsonGetInt(jRow);

  json jSpellList = GetLocalJson(oPC, SM_LVAR_SPELL_LIST);
  if (nSpellRow < 0 || nSpellRow >= JsonGetLength(jSpellList))
    return;

  json jSpell = JsonArrayGet(jSpellList, nSpellRow);
  int nClassId = SmGetSelectedClass(oPC, nToken);
  int nLevel = JsonGetInt(NuiGetBind(oPC, nToken, SM_BIND_C_LVL_IDX));

  json jEntry = JsonObject();
  jEntry = JsonObjectSet(jEntry, SM_F_ID, JsonObjectGet(jSpell, SM_F_ID));
  jEntry = JsonObjectSet(jEntry, SM_F_NAME, JsonObjectGet(jSpell, SM_F_NAME));
  jEntry = JsonObjectSet(jEntry, SM_F_ICON, JsonObjectGet(jSpell, SM_F_ICON));
  jEntry = JsonObjectSet(jEntry, SM_F_CLASS, JsonInt(nClassId));
  jEntry = JsonObjectSet(jEntry, SM_F_LEVEL, JsonInt(nLevel));
  jEntry = JsonObjectSet(jEntry, SM_F_META, JsonObjectGet(jSpell, SM_F_META));
  jEntry = JsonObjectSet(jEntry, SM_F_DOMAIN, JsonObjectGet(jSpell, SM_F_DOMAIN));
  jEntry = JsonObjectSet(jEntry, SM_F_DOMAIN_ICON, JsonObjectGet(jSpell, SM_F_DOMAIN_ICON));

  jSeq = JsonArrayInsert(jSeq, jEntry);
  SetLocalJson(oPC, SM_LVAR_SPELLS, jSeq);
  SmFeedSelected(oPC, nToken);
}


// -----------------------------------------------------------------------------

//! @brief Save the macro of the create view and return to the menu view.
void SmSaveMacro(object oPC, int nToken)
{
  string sName = JsonGetString(NuiGetBind(oPC, nToken, SM_BIND_C_SEQ_NAME));
  json jSeq = GetLocalJson(oPC, SM_LVAR_SPELLS);
  if (sName == "" || JsonGetLength(jSeq) == 0)
    return;

  string sIcon = JsonGetString(NuiGetBind(oPC, nToken, SM_BIND_C_ICON));
  if (sIcon == "")
    sIcon = SM_ICO_DEFAULT;

  int nSaveIdx = GetLocalInt(oPC, SM_LVAR_EDIT_IDX);
  if (nSaveIdx < 0)
    nSaveIdx = SmNextFreeIdx(oPC);
  if (nSaveIdx < 0)
  {
    SendFailureMessage(oPC, "No free macro slot available.");
    return;
  }

  int nTargetMode = JsonGetInt(NuiGetBind(oPC, nToken, SM_BIND_C_TARGET_MODE));
  SmSave(oPC, nSaveIdx, sName, jSeq, sIcon, nTargetMode);
  SmSwapToMenuView(oPC, nToken);
}


// -----------------------------------------------------------------------------

//! @brief Handle the watch events of the create view.
void SmCreateWatchEvents(object oPC, int nToken, string sElement)
{
  if (sElement == SM_BIND_C_CLS_IDX)
  {
    SmRebuildLevels(oPC, nToken);
    SmFeedCreate(oPC, nToken);
  }
  else if (sElement == SM_BIND_C_TARGET_MODE)
  {
    // The first watch event comes from the initial bind and keeps the spells
    if (GetLocalInt(oPC, SM_LVAR_SUPPRESS_TM))
    {
      DeleteLocalInt(oPC, SM_LVAR_SUPPRESS_TM);
      SmFeedCreate(oPC, nToken);
      return;
    }
    SetLocalJson(oPC, SM_LVAR_SPELLS, JsonArray());
    SmFeedCreate(oPC, nToken);
    SendMessageToPC(oPC, "Spell sequence cleared.");
  }
  else if (sElement == SM_BIND_C_LVL_IDX
        || sElement == SM_BIND_C_META_IDX
        || sElement == SM_BIND_C_SPELL_SEARCH)
    SmFeedCreate(oPC, nToken);
  else if (sElement == SM_BIND_C_SEQ_NAME)
    SmFeedSelected(oPC, nToken);
}


// -----------------------------------------------------------------------------

//! @brief Handle the events of the macro editor.
void SmMenuEvents(object oPC, int nToken, string sEvent, string sElement, int nIdx)
{
  if (sEvent == SM_EVENT_CLOSE)
  {
    SmClearCreateState(oPC);
    return;
  }

  if (sEvent == SM_EVENT_WATCH)
  {
    SmCreateWatchEvents(oPC, nToken, sElement);
    return;
  }

  if (sEvent == SM_EVENT_MOUSEDOWN)
  {
    // Menu view: select a macro
    if (sElement == SM_BTN_M_ROW)
    {
      json jAll = SmLoadAll(oPC);
      if (nIdx < 0 || nIdx >= JsonGetLength(jAll))
        return;

      SmOffEnc(oPC, nToken, SM_BIND_M_ENC, SM_BIND_M_ROW);
      SmOnEnc(oPC, nToken, SM_BIND_M_ENC, SM_BIND_M_ROW, nIdx);

      json jEntry = JsonArrayGet(jAll, nIdx);
      SetLocalInt(oPC, SM_LVAR_SEL_IDX, SmGetInt(jEntry, "idx"));
      NuiSetBind(oPC, nToken, SM_BIND_M_CAST_EN, JsonBool(TRUE));
      NuiSetBind(oPC, nToken, SM_BIND_M_EDIT_EN, JsonBool(TRUE));
      NuiSetBind(oPC, nToken, SM_BIND_M_DEL_EN, JsonBool(TRUE));
    }
    // Create view: select a spell
    else if (sElement == SM_BTN_C_SP_ROW)
    {
      SmOffEnc(oPC, nToken, SM_BIND_C_SPELL_ENC, SM_BIND_C_SPELL_ROW);
      SmOnEnc(oPC, nToken, SM_BIND_C_SPELL_ENC, SM_BIND_C_SPELL_ROW, nIdx);
      NuiSetBind(oPC, nToken, SM_BIND_C_ADD_EN, JsonBool(TRUE));
    }
    return;
  }

  if (sEvent != SM_EVENT_CLICK)
    return;

  int nSeqIdx = GetLocalInt(oPC, SM_LVAR_SEL_IDX);

  if (FALSE) {}
  // Menu view buttons
  else if (sElement == SM_BTN_NEW)
  {
    if (JsonGetLength(SmLoadAll(oPC)) >= SM_MAX_SEQ)
    {
      SendFailureMessage(oPC, "Macro limit reached: " + IntToString(SM_MAX_SEQ) + ".");
      return;
    }
    SmSwapToCreateView(oPC, nToken, -1);
  }
  else if (sElement == SM_BTN_CAST)
  {
    if (nSeqIdx < 0)
      return;
    json jData = SmLoadByIdx(oPC, nSeqIdx);
    if (JsonGetType(jData) != JSON_TYPE_NULL)
      SmExecute(oPC, jData);
  }
  else if (sElement == SM_BTN_EDIT)
  {
    if (nSeqIdx >= 0)
      SmSwapToCreateView(oPC, nToken, nSeqIdx);
  }
  else if (sElement == SM_BTN_DELETE)
  {
    if (nSeqIdx < 0)
      return;
    json jData = SmLoadByIdx(oPC, nSeqIdx);
    if (JsonGetType(jData) != JSON_TYPE_NULL)
      SmCreateDeletePopup(oPC, SmGetString(jData, "name"));
  }
  // Create view buttons
  else if (sElement == SM_BTN_C_ADD)
    SmAddSelectedSpell(oPC, nToken);
  else if (GetStringLeft(sElement, 16) == SM_BTN_C_DEL_SEL)
  {
    int nDelIdx = SmGetElementIndex(sElement, 16);
    json jSeq = GetLocalJson(oPC, SM_LVAR_SPELLS);
    if (nDelIdx < 0 || nDelIdx >= JsonGetLength(jSeq))
      return;
    SetLocalJson(oPC, SM_LVAR_SPELLS, JsonArrayDel(jSeq, nDelIdx));
    SmFeedSelected(oPC, nToken);
  }
  else if (sElement == SM_BTN_SAVE)
    SmSaveMacro(oPC, nToken);
  else if (sElement == SM_BTN_C_ICON_PICK)
    SmCreateIconPicker(oPC, nToken);
  else if (sElement == SM_BTN_BACK)
    SmSwapToMenuView(oPC, nToken);
}


// -----------------------------------------------------------------------------

//! @brief Handle the events of the delete confirmation popup.
void SmDeleteEvents(object oPC, int nToken, string sEvent, string sElement)
{
  if (sEvent != SM_EVENT_CLICK)
    return;

  if (sElement == SM_BTN_DEL_CONFIRM)
  {
    SmDelete(oPC, GetLocalInt(oPC, SM_LVAR_SEL_IDX));
    NuiDestroy(oPC, nToken);

    int nMenuToken = NuiFindWindow(oPC, SM_WIN_MENU);
    if (nMenuToken > 0)
      SmSwapToMenuView(oPC, nMenuToken);
  }
  else if (sElement == SM_BTN_DEL_CANCEL)
    NuiDestroy(oPC, nToken);
}


// -----------------------------------------------------------------------------

//! @brief Handle the events of the icon picker.
void SmIconPickerEvents(object oPC, int nToken, string sEvent, string sElement)
{
  if (sEvent == SM_EVENT_WATCH && sElement == SM_BIND_ICO_SEARCH)
  {
    string sFilter = JsonGetString(NuiGetBind(oPC, nToken, SM_BIND_ICO_SEARCH));
    SmRebuildIconGrid(oPC, nToken, sFilter);
    return;
  }

  if (sEvent == SM_EVENT_CLICK && GetStringLeft(sElement, 9) == SM_ICO_BTN_PFX)
  {
    int nCacheIdx = SmGetElementIndex(sElement, 9);
    json jCache = SmGetIconCache();
    if (nCacheIdx < 0 || nCacheIdx >= JsonGetLength(jCache))
      return;

    string sResref = SmGetString(JsonArrayGet(jCache, nCacheIdx), "r");
    int nParentToken = GetLocalInt(oPC, SM_LVAR_ICO_PARENT);
    if (nParentToken > 0)
      NuiSetBind(oPC, nParentToken, SM_BIND_C_ICON, JsonString(sResref));
    NuiDestroy(oPC, nToken);
  }
}


// -----------------------------------------------------------------------------

//! @brief Dispatch the NUI events of the sequencer windows.
void SmOnNuiEvent()
{
  object oPC = NuiGetEventPlayer();
  int nToken = NuiGetEventWindow();
  string sWindow = NuiGetWindowId(oPC, nToken);
  string sEvent = NuiGetEventType();
  string sElement = NuiGetEventElement();

  if (FALSE) {}
  else if (sWindow == SM_WIN_BAR)
    SmBarEvents(oPC, nToken, sEvent, sElement);
  else if (sWindow == SM_WIN_MENU)
    SmMenuEvents(oPC, nToken, sEvent, sElement, NuiGetEventArrayIndex());
  else if (sWindow == SM_WIN_DELETE)
    SmDeleteEvents(oPC, nToken, sEvent, sElement);
  else if (sWindow == SM_WIN_ICON_PICKER)
    SmIconPickerEvents(oPC, nToken, sEvent, sElement);
}


// =============================================================================

void main()
{
  string sEvent = NWNX_Events_GetCurrentEvent();
  if (sEvent == ON_REGISTER)
  {
    if (!GetModuleFlag(ENABLE__FF_U_ITM_SEQUENCER, TRUE))
      return;
    SubscribeToEvent(ON_MODULE_START, __FILE__);
    SubscribeToEvent(ON_DEFAULT_CLIENT_ENTER, __FILE__);
    SubscribeToEvent(ON_DEFAULT_PC_LEVELUP, __FILE__);
    SubscribeToEvent(ON_DEFAULT_PLAYER_TARGET, __FILE__);
    SubscribeToEvent(ON_NUI_EVENT, __FILE__);
  }
  else if (sEvent == ON_MODULE_START)
    SmBuildIconCache();
  else if (sEvent == ON_DEFAULT_CLIENT_ENTER)
  {
    // Wait for the starting package to replace the inventory of new characters
    object oPC = GetEnteringObject();
    DelayCommand(3.0f, SmGiveSequencer(oPC));
  }
  else if (sEvent == ON_DEFAULT_PC_LEVELUP)
  {
    // A level down can quickly follow a level up: check again after a delay
    object oPC = GetPCLevellingUp();
    DelayCommand(SEQUENCER_LEVELUP_DELAY, SmGiveSequencer(oPC));
  }
  else if (sEvent == NWNX_ON_RUN_EVENT_SCRIPT_BEFORE)
  {
    if (GetCurrentlyRunningEvent() == EVENT_SCRIPT_MODULE_ON_ACTIVATE_ITEM)
      SmToggleBarWindow(GetItemActivator());
  }
  else if (sEvent == ON_DEFAULT_PLAYER_TARGET)
    SmOnPlayerTarget();
  else if (sEvent == ON_NUI_EVENT)
    SmOnNuiEvent();
}