Add account manager, character reordering, and glue Lua console

Three features layered onto the Turtle WoW baseline:

- Account manager (AccountLogin.lua / .xml): saved-account list backed
  by Windows Credential Manager via ClassicAPI. Pass-through to
  LoginWithSavedAccount; per-account 'last used' timestamp; password
  field hides itself when the typed name matches a saved entry and a
  'Change password' button reveals it on demand.
- Character reordering (CharacterSelect.lua / .xml): drag-to-reorder
  the character list, persisted via ClassicAPI's
  GetSavedCharacterOrder / SetSavedCharacterOrder.
- Glue Lua console (GlueConsole.lua / .xml, new files): in-glue RunScript
  console reachable from the title and char-select screens.

Currently no key opens the console — the backtick binding was removed
pending a ClassicAPI EditBox arrow/key script handler (see TODO.md section 96).
This commit is contained in:
2026-06-22 23:32:54 -05:00
parent 13d77d77c9
commit e504f88167
6 changed files with 1940 additions and 266 deletions
+322 -43
View File
@@ -6,6 +6,23 @@ CHARACTER_ROTATION_CONSTANT = 0.6;
MAX_CHARACTERS_DISPLAYED = 10;
MAX_CHARACTERS_PER_REALM = 10;
AUTO_DRAG_TIME = 0.5;
-- Per-account character autoselect was removed alongside the plaintext
-- credential store; the AutologinSaveCharacterButton checkbox in
-- CharacterSelect.xml is now permanently hidden, and the two functions
-- below are no-op wrappers around `EnterWorld` to keep the existing
-- call sites working.
function Autologin_OnCharactersLoad()
if ( AutologinSaveCharacterButton ) then
AutologinSaveCharacterButton:Hide();
end
end
function Autologin_EnterWorld()
EnterWorld();
end
function CharacterSelect_OnLoad()
this:SetSequence(0);
this:SetCamera(0);
@@ -14,6 +31,12 @@ function CharacterSelect_OnLoad()
this.selectedIndex = 0;
this.selectLast = 0;
this.currentModel = "";
this.translationTable = {};
this.orderChanged = nil;
this.pressDownButton = nil;
this.pressDownTime = 0;
this.draggedIndex = nil;
this.suppressNextClick = nil;
this:RegisterEvent("ADDON_LIST_UPDATE");
this:RegisterEvent("CHARACTER_LIST_UPDATE");
this:RegisterEvent("UPDATE_SELECTED_CHARACTER");
@@ -36,7 +59,7 @@ function CharacterSelect_OnLoad()
local backdropColor = DEFAULT_TOOLTIP_COLOR;
CharacterSelectCharacterFrame:SetBackdropBorderColor(backdropColor[1], backdropColor[2], backdropColor[3]);
CharacterSelectCharacterFrame:SetBackdropColor(backdropColor[4], backdropColor[5], backdropColor[6], 0.85);
end
function CharacterSelect_OnShow()
@@ -49,7 +72,7 @@ function CharacterSelect_OnShow()
local serverType = "";
if ( serverName ) then
if( not connected ) then
serverName = serverName.."\n("..SERVER_DOWN..")";
serverName = serverName.."\n("..TEXT(SERVER_DOWN)..")";
end
if ( isPVP ) then
if ( isRP ) then
@@ -81,7 +104,7 @@ function CharacterSelect_OnShow()
else
local billingTimeLeft = GetBillingTimeRemaining();
-- Set default text for the payment plan
local billingText = _G["BILLING_TEXT"..paymentPlan];
local billingText = getglobal("BILLING_TEXT"..paymentPlan);
if ( paymentPlan == 1 ) then
-- Recurring account
billingTimeLeft = ceil(billingTimeLeft/(60 * 24));
@@ -92,7 +115,7 @@ function CharacterSelect_OnShow()
-- Free account
if ( billingTimeLeft < (24 * 60) ) then
billingText = format(BILLING_FREE_TIME_EXPIRE, billingTimeLeft.." "..GetText("MINUTES_ABBR", nil, billingTimeLeft));
end
end
elseif ( paymentPlan == 3 ) then
-- Fixed but not recurring
if ( isGameRoom == 1 ) then
@@ -107,7 +130,7 @@ function CharacterSelect_OnShow()
billingText = BILLING_FIXED_LASTDAY;
else
billingText = format(billingText, MinutesToTime(billingTimeLeft));
end
end
end
elseif ( paymentPlan == 4 ) then
-- Usage plan
@@ -178,14 +201,19 @@ function CharacterSelect_OnEvent()
if ( event == "ADDON_LIST_UPDATE" ) then
UpdateAddonButton();
elseif ( event == "CHARACTER_LIST_UPDATE" ) then
CharacterSelect_RebuildTranslationTable();
CharacterOrder_Apply();
UpdateCharacterList();
CharSelectCharacterName:SetText(GetCharacterInfo(this.selectedIndex));
CharSelectCharacterName:SetText(GetCharacterInfo(GetCharIDFromIndex(this.selectedIndex)));
Autologin_OnCharactersLoad();
elseif ( event == "UPDATE_SELECTED_CHARACTER" ) then
-- arg1 is a server-assigned charID; translate to a display index
-- so `selectedIndex` always means "position in the displayed list".
if ( arg1 == 0 ) then
CharSelectCharacterName:SetText("");
else
CharSelectCharacterName:SetText(GetCharacterInfo(arg1));
this.selectedIndex = arg1;
this.selectedIndex = GetIndexFromCharID(arg1);
end
UpdateCharacterSelection();
elseif ( event == "SELECT_LAST_CHARACTER" ) then
@@ -200,9 +228,7 @@ function CharacterSelect_OnEvent()
GlueDialog_Show("SUGGEST_REALM");
elseif ( event == "FORCE_RENAME_CHARACTER" ) then
CharacterRenameDialog:Show();
CharacterRenameBackground:SetHeight(16 + CharacterRenameText1:GetHeight() + CharacterRenameText2:GetHeight() + 23 + CharacterRenameEditBox:GetHeight() + 8 + CharacterRenameButton1:GetHeight() + 16);
CharacterRenameText1:SetText(_G[arg1]);
CharacterRenameText1:SetText(getglobal(arg1));
end
end
@@ -212,44 +238,56 @@ function CharacterSelect_UpdateModel()
end
function UpdateCharacterSelection()
-- Drag-aware: during a drag, dim every slot except the one currently
-- holding the dragged character, and override the highlight to track
-- the drag rather than the engine-side "selected" character.
local draggedIndex = CharacterSelect.draggedIndex;
local highlightIndex = draggedIndex or CharacterSelect.selectedIndex;
for i=1, MAX_CHARACTERS_DISPLAYED, 1 do
_G["CharSelectCharacterButton"..i]:UnlockHighlight();
local btn = getglobal("CharSelectCharacterButton"..i);
btn:UnlockHighlight();
if ( draggedIndex and i ~= draggedIndex ) then
btn:SetAlpha(0.6);
else
btn:SetAlpha(1);
end
end
local index = this.selectedIndex;
if ( (index > 0) and (index <= MAX_CHARACTERS_DISPLAYED) )then
_G["CharSelectCharacterButton"..index]:LockHighlight();
if ( highlightIndex and (highlightIndex > 0) and (highlightIndex <= MAX_CHARACTERS_DISPLAYED) ) then
getglobal("CharSelectCharacterButton"..highlightIndex):LockHighlight();
end
end
function UpdateCharacterList()
local numChars = GetNumCharacters();
local index = 1;
local coords;
for i=1, numChars, 1 do
local name, race, class, level, zone, fileString, gender, ghost = GetCharacterInfo(i);
local button = _G["CharSelectCharacterButton"..index];
local name, race, class, level, zone, fileString, gender, ghost = GetCharacterInfo(GetCharIDFromIndex(i));
if ( gender == 0 ) then
gender = "MALE";
else
gender = "FEMALE";
end
local button = getglobal("CharSelectCharacterButton"..index);
if ( not name ) then
button:SetText("ERROR - Tell Jeremy");
else
if ( not zone ) then
zone = "";
end
local classColor
local classToken = TW_CLASS_TOKEN and TW_CLASS_TOKEN[class]
if classToken and CLASS_COLORS[classToken] then
classColor = CLASS_COLORS[classToken]
class = classColor .. class .. "|r"
local classToken = TW_CLASS_TOKEN and TW_CLASS_TOKEN[class];
if ( classToken and CLASS_COLORS[classToken] ) then
class = CLASS_COLORS[classToken] .. class .. "|r";
end
_G["CharSelectCharacterButton"..index.."ButtonTextName"]:SetText(name);
if ( ghost ) then
_G["CharSelectCharacterButton"..index.."ButtonTextInfo"]:SetText(format(CHARACTER_SELECT_INFO_GHOST, level, class));
getglobal("CharSelectCharacterButton"..index.."ButtonTextName"):SetText(name);
if( ghost ) then
getglobal("CharSelectCharacterButton"..index.."ButtonTextInfo"):SetText(format(TEXT(CHARACTER_SELECT_INFO_GHOST), level, class));
else
_G["CharSelectCharacterButton"..index.."ButtonTextInfo"]:SetText(format(CHARACTER_SELECT_INFO, level, class));
getglobal("CharSelectCharacterButton"..index.."ButtonTextInfo"):SetText(format(TEXT(CHARACTER_SELECT_INFO), level, class));
end
_G["CharSelectCharacterButton"..index.."ButtonTextLocation"]:SetText(zone);
getglobal("CharSelectCharacterButton"..index.."ButtonTextLocation"):SetText(zone);
end
button:Show();
@@ -268,18 +306,18 @@ function UpdateCharacterList()
end
CharacterSelect.createIndex = 0;
CharSelectCreateCharacterButton:Hide();
CharSelectCreateCharacterButton:Hide();
local connected = IsConnectedToServer();
for i=index, MAX_CHARACTERS_DISPLAYED, 1 do
local button = _G["CharSelectCharacterButton"..index];
local button = getglobal("CharSelectCharacterButton"..index);
if ( (CharacterSelect.createIndex == 0) and (numChars < MAX_CHARACTERS_PER_REALM) ) then
CharacterSelect.createIndex = index;
if ( connected ) then
--If can create characters position and show the create button
CharSelectCreateCharacterButton:SetID(index);
--CharSelectCreateCharacterButton:SetPoint("TOP", button, "TOP", 0, -5);
CharSelectCreateCharacterButton:Show();
CharSelectCreateCharacterButton:Show();
end
end
button:Hide();
@@ -307,6 +345,12 @@ function CharacterSelect_OnChar()
end
function CharacterSelectButton_OnClick()
-- A drag just ended on top of this button — eat the click so the
-- drop doesn't also re-select the character at the new slot.
if ( CharacterSelect.suppressNextClick ) then
CharacterSelect.suppressNextClick = nil;
return;
end
local id = this:GetID();
if ( id ~= CharacterSelect.selectedIndex ) then
CharacterSelect_SelectCharacter(id);
@@ -314,6 +358,10 @@ function CharacterSelectButton_OnClick()
end
function CharacterSelectButton_OnDoubleClick()
if ( CharacterSelect.suppressNextClick ) then
CharacterSelect.suppressNextClick = nil;
return;
end
local id = this:GetID();
if ( id ~= CharacterSelect.selectedIndex ) then
CharacterSelect_SelectCharacter(id);
@@ -322,10 +370,10 @@ function CharacterSelectButton_OnDoubleClick()
end
function CharacterSelect_TabResize()
local buttonMiddle = _G[this:GetName().."Middle"];
local buttonMiddleDisabled = _G[this:GetName().."MiddleDisabled"];
local buttonMiddle = getglobal(this:GetName().."Middle");
local buttonMiddleDisabled = getglobal(this:GetName().."MiddleDisabled");
local width = this:GetTextWidth() - 8;
local leftWidth = _G[this:GetName().."Left"]:GetWidth();
local leftWidth = getglobal(this:GetName().."Left"):GetWidth();
buttonMiddle:SetWidth(width);
buttonMiddleDisabled:SetWidth(width);
this:SetWidth(width + (2 * leftWidth));
@@ -340,26 +388,27 @@ function CharacterSelect_SelectCharacter(id, noCreate)
SetGlueScreen("charcreate");
end
else
local name, race, class, level, zone, fileString = GetCharacterInfo(id);
local charID = GetCharIDFromIndex(id);
local name, race, class, level, zone, fileString = GetCharacterInfo(charID);
if ( fileString ~= CharacterSelect.currentModel ) then
CharacterSelect.currentModel = fileString;
SetBackgroundModel(CharacterSelect, fileString);
end
SelectCharacter(id);
SelectCharacter(charID);
end
end
function CharacterDeleteDialog_OnShow()
local name, race, class, level = GetCharacterInfo(CharacterSelect.selectedIndex);
CharacterDeleteText1:SetText(format(CONFIRM_CHAR_DELETE, name, level, class));
local name, race, class, level = GetCharacterInfo(GetCharIDFromIndex(CharacterSelect.selectedIndex));
CharacterDeleteText1:SetText(format(TEXT(CONFIRM_CHAR_DELETE), name, level, class));
CharacterDeleteBackground:SetHeight(16 + CharacterDeleteText1:GetHeight() + CharacterDeleteText2:GetHeight() + 23 + CharacterDeleteEditBox:GetHeight() + 8 + CharacterDeleteButton1:GetHeight() + 16);
CharacterDeleteButton1:Disable();
end
function CharacterSelect_EnterWorld()
PlaySound("gsCharacterSelectionEnterWorld");
EnterWorld();
Autologin_EnterWorld();
end
function CharacterSelect_Exit()
@@ -374,7 +423,7 @@ end
function CharacterSelect_TechSupport()
PlaySound("gsCharacterSelectionAcctOptions");
LaunchURL(TECH_SUPPORT_URL);
LaunchURL(TEXT(TECH_SUPPORT_URL));
end
function CharacterSelect_Delete()
@@ -409,6 +458,16 @@ function CharacterSelectFrame_OnUpdate()
CHARACTER_SELECT_ROTATION_START_X = GetCursorPosition();
SetCharacterSelectFacing(GetCharacterSelectFacing() + diff);
end
-- Press-and-hold detector: a character button held past AUTO_DRAG_TIME
-- promotes into drag mode. Released sooner, OnMouseUp clears the
-- button and the click flows normally.
if ( CharacterSelect.pressDownButton ) then
CharacterSelect.pressDownTime = CharacterSelect.pressDownTime + arg1;
if ( CharacterSelect.pressDownTime >= AUTO_DRAG_TIME ) then
CharacterSelectButton_OnDragStart(CharacterSelect.pressDownButton);
end
end
end
function CharacterSelectRotateRight_OnUpdate()
@@ -425,5 +484,225 @@ end
function CharacterSelect_ManageAccount()
PlaySound("gsCharacterSelectionAcctOptions");
LaunchURL(AUTH_NO_TIME_URL);
LaunchURL(TEXT(AUTH_NO_TIME_URL));
end
-- ---------------------------------------------------------------------------
-- Character reordering
-- ---------------------------------------------------------------------------
--
-- `CharacterSelect.translationTable[displayIndex] = charID` maps the slot
-- a button occupies on screen to the server-assigned character ID expected
-- by `GetCharacterInfo` / `SelectCharacter` / `DeleteCharacter` / etc.
-- Default identity table (1->1, 2->2, ...) gets reshuffled by MoveCharacter
-- and restored on every CHARACTER_LIST_UPDATE.
function GetCharIDFromIndex(index)
return CharacterSelect.translationTable[index] or index;
end
function GetIndexFromCharID(charID)
-- Fast path while the table is still identity.
if ( not CharacterSelect.orderChanged ) then
return charID;
end
for index = 1, table.getn(CharacterSelect.translationTable) do
if ( CharacterSelect.translationTable[index] == charID ) then
return index;
end
end
return 0;
end
function CharacterSelect_RebuildTranslationTable()
CharacterSelect.translationTable = {};
CharacterSelect.orderChanged = nil;
local numChars = GetNumCharacters();
for i = 1, numChars do
table.insert(CharacterSelect.translationTable, i);
end
end
function MoveCharacter(originIndex, targetIndex, fromDrag)
CharacterSelect.orderChanged = 1;
local n = table.getn(CharacterSelect.translationTable);
if ( n < 2 ) then return; end
if ( targetIndex < 1 ) then
targetIndex = n;
elseif ( targetIndex > n ) then
targetIndex = 1;
end
if ( originIndex == CharacterSelect.selectedIndex ) then
CharacterSelect.selectedIndex = targetIndex;
elseif ( targetIndex == CharacterSelect.selectedIndex ) then
CharacterSelect.selectedIndex = originIndex;
end
local t = CharacterSelect.translationTable;
t[originIndex], t[targetIndex] = t[targetIndex], t[originIndex];
if ( fromDrag ) then
CharacterSelect.draggedIndex = targetIndex;
end
UpdateCharacterSelection();
UpdateCharacterList();
CharacterOrder_Save();
end
-- ---------------------------------------------------------------------------
-- Drag handlers
-- ---------------------------------------------------------------------------
--
-- 1.12 glue widgets don't expose `RegisterForDrag`, so we synthesize drag
-- mode by watching how long a character button is held: any press past
-- AUTO_DRAG_TIME enters drag mode. The dragged button's OnUpdate then
-- watches the cursor's Y position and calls MoveCharacter when it crosses
-- into another slot. Mouse-up tears everything down.
function CharacterSelectButton_OnMouseDown()
CharacterSelect.pressDownButton = this;
CharacterSelect.pressDownTime = 0;
end
function CharacterSelectButton_OnMouseUp()
if ( CharacterSelect.draggedIndex ) then
CharacterSelectButton_OnDragStop(this);
-- The drag concluded on a button; the engine still dispatches
-- OnClick (and OnDoubleClick) afterwards. Suppress one.
CharacterSelect.suppressNextClick = 1;
end
CharacterSelect.pressDownButton = nil;
end
function CharacterSelectButton_OnDragStart(button)
if ( GetNumCharacters() < 2 ) then return; end
CharacterSelect.pressDownButton = nil;
CharacterSelect.draggedIndex = button:GetID();
-- Cache slot geometry once per drag from the live buttons. Stride
-- accounts for the 13px visual overlap defined in CharacterSelect.xml.
CharacterSelect.dragListTop = CharSelectCharacterButton1:GetTop();
CharacterSelect.dragRowStride = CharSelectCharacterButton1:GetTop() - CharSelectCharacterButton2:GetTop();
if ( not CharacterSelect.dragRowStride or CharacterSelect.dragRowStride <= 0 ) then
-- Fallback if button2 wasn't ready (single character, etc.) —
-- shouldn't reach here because of the >=2 guard above, but be safe.
CharacterSelect.dragRowStride = 57;
end
button:SetScript("OnUpdate", CharacterSelectButton_OnDragUpdate);
UpdateCharacterSelection();
end
function CharacterSelectButton_OnDragUpdate()
if ( not CharacterSelect.draggedIndex ) then
CharacterSelectButton_OnDragStop(this);
return;
end
local _, cursorY = GetCursorPosition();
local top = CharacterSelect.dragListTop;
local stride = CharacterSelect.dragRowStride;
if ( cursorY <= top ) then
local hoverIndex = math.floor((top - cursorY) / stride) + 1;
local hover = getglobal("CharSelectCharacterButton"..hoverIndex);
if ( hover and hover:IsShown() and hoverIndex ~= CharacterSelect.draggedIndex ) then
if ( hoverIndex > CharacterSelect.draggedIndex ) then
MoveCharacter(CharacterSelect.draggedIndex, CharacterSelect.draggedIndex + 1, 1);
else
MoveCharacter(CharacterSelect.draggedIndex, CharacterSelect.draggedIndex - 1, 1);
end
end
end
end
function CharacterSelectButton_OnDragStop(button)
CharacterSelect.pressDownButton = nil;
CharacterSelect.draggedIndex = nil;
if ( button ) then
button:SetScript("OnUpdate", nil);
end
-- draggedIndex is now nil, so UpdateCharacterSelection restores
-- full alpha on every slot and highlights only the selected one.
UpdateCharacterSelection();
end
-- ---------------------------------------------------------------------------
-- Delete / Rename wrappers (selectedIndex is now a display index, so
-- engine calls expecting a charID need translation)
-- ---------------------------------------------------------------------------
function CharacterSelect_DeleteCharacter()
DeleteCharacter(GetCharIDFromIndex(CharacterSelect.selectedIndex));
CharacterDeleteDialog:Hide();
end
function CharacterSelect_RenameCharacter()
if ( RenameCharacter(GetCharIDFromIndex(CharacterSelect.selectedIndex), CharacterRenameEditBox:GetText()) ) then
CharacterRenameDialog:Hide();
end
end
-- ---------------------------------------------------------------------------
-- Persistence — relies on ClassicAPI's glue bindings
-- (GetSavedCharacterOrder / SetSavedCharacterOrder). Both calls are
-- guarded so the drag feature is functional in-session even before the
-- DLL binding ships; persistence kicks in automatically once it does.
-- ---------------------------------------------------------------------------
function CharacterOrder_Load()
if ( type(GetSavedCharacterOrder) ~= "function" ) then return ""; end
local realm = GetServerName() or "";
if ( realm == "" ) then return ""; end
return GetSavedCharacterOrder(realm) or "";
end
function CharacterOrder_Apply()
local saved = CharacterOrder_Load();
if ( not saved or saved == "" ) then return; end
local numChars = GetNumCharacters();
if ( numChars < 2 ) then return; end
-- Build name -> charID lookup against the server's natural order.
-- The translation table is identity at this point (set by
-- RebuildTranslationTable), so charID == display index here.
local nameToID = {};
for charID = 1, numChars do
local name = GetCharacterInfo(charID);
if ( name ) then nameToID[name] = charID; end
end
local newOrder = {};
local claimed = {};
for name in string.gfind(saved, "([^|]+)") do
local charID = nameToID[name];
if ( charID and not claimed[charID] ) then
table.insert(newOrder, charID);
claimed[charID] = 1;
end
end
-- Append any characters that exist on the server but weren't in the
-- saved order (newly created since last save).
for charID = 1, numChars do
if ( not claimed[charID] ) then
table.insert(newOrder, charID);
end
end
-- Only adopt the rebuilt order if it actually differs from identity.
local differs = false;
for i = 1, table.getn(newOrder) do
if ( newOrder[i] ~= i ) then differs = true; break; end
end
if ( differs ) then
CharacterSelect.translationTable = newOrder;
CharacterSelect.orderChanged = 1;
end
end
function CharacterOrder_Save()
if ( type(SetSavedCharacterOrder) ~= "function" ) then return; end
local realm = GetServerName() or "";
if ( realm == "" ) then return; end
local numChars = GetNumCharacters();
local names = {};
for i = 1, numChars do
local name = GetCharacterInfo(GetCharIDFromIndex(i));
if ( name ) then table.insert(names, name); end
end
SetSavedCharacterOrder(realm, table.concat(names, "|"));
end