diff --git a/CHANGELOG.md b/CHANGELOG.md index b5b7972..997c826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,11 @@ ### New - **Accurate without the DLL.** OctoBus now ships with each route's restart offset (how many seconds into its trip it docks, counted from the last server restart), measured on 12 October. At every login it asks the server how long it has been up and sets every route it has not seen since that restart from the restart time plus the offset. A brand new copy with no DLL and no sightings gets every timer right from the first login, and stays right after later restarts. +- **Settings window**: `/ob options`, or right-click the minimap icon. Background opacity, window opacity, text size, window size, lock, which corner stays put as the list grows or shrinks, auto-show, which routes to show, and bell alerts. Changes apply at once. + ### Changed - The "no DLL: estimates only" note is gone from the panel: without the DLL the timers are now accurate. `/ob debug` still shows whether the DLL is loaded. +- The footer no longer names the route with the oldest data (the restart correction makes it unnecessary). It only appears after a server restart, while a route in the window has not been seen since. ## 1.3.4 (12 October 2026) diff --git a/OctoBus.lua b/OctoBus.lua index d8595a4..60bd7a0 100644 --- a/OctoBus.lua +++ b/OctoBus.lua @@ -22,6 +22,7 @@ SLASH COMMANDS (/ob, also /transit and /zep) /ob show / hide the window (works anywhere) + /ob options settings window (also: right-click the minimap icon) /ob list print all timers in chat /ob alert bell alerts fire this many seconds before arrival (default 45) /ob sound|chat|flash toggle that alert type @@ -362,29 +363,6 @@ local OB_OK, OB_ERR = pcall(function () return string.format("%.1f days", sec / 86400) end - -- footer text, and whether it should be highlighted: names the transport whose latest - -- sighting is the oldest, and how long ago that was. Orange after 5 days. - local function CalAgeText() - local oldR, oldT = nil, nil - for i = 1, table.getn(ROUTES) do - local r = ROUTES[i] - if Visible(r) then - local t = math.max(DB.lastObs[r.entry] or 0, DB.anchor[r.entry] or r.anchor or 0) - if not oldT or t < oldT then oldR, oldT = r, t end - end - end - if not oldR then return "", false end - if oldT <= 0 then return "oldest data: " .. RouteName(oldR) .. ", never seen", true end - local gone = time() - oldT - local ago - if gone < 120 then ago = "just now" - elseif gone < 3600 then ago = math.floor(gone / 60 + 0.5) .. " min ago" - elseif gone < 86400 then ago = string.format("%.0f", gone / 3600) .. " h ago" - else ago = math.floor(gone / 86400 + 0.5) .. " days ago" end - local stale = (gone >= 5 * 86400) - return "oldest data: " .. RouteName(oldR) .. ", seen " .. ago .. (stale and ", go see it" or ""), stale - end - local function CalFinish(complete) if not cal then return end Say(complete and "calibration complete." or "calibration stopped.") @@ -398,9 +376,6 @@ local OB_OK, OB_ERR = pcall(function () (done and "" or " - not seen, still using the old value")) end end - if complete then - Say("done. The window footer tracks how fresh this is; it nags after 5 days.") - end cal = nil end @@ -916,7 +891,7 @@ local OB_OK, OB_ERR = pcall(function () btn:SetFrameLevel(Minimap:GetFrameLevel() + 8) btn:SetWidth(31) btn:SetHeight(31) - btn:RegisterForClicks("LeftButtonUp") + btn:RegisterForClicks("LeftButtonUp", "RightButtonUp") btn:RegisterForDrag("LeftButton") btn.icon = btn:CreateTexture(nil, "ARTWORK") btn.icon:SetWidth(20) @@ -961,9 +936,11 @@ local OB_OK, OB_ERR = pcall(function () end) -- the timer window - local WIDTH = 280 - local ROW_H = 18 - local TOP = 22 + -- sizes at text size 100%; ApplyLook scales them with the text size setting + local textScale = CFG.textScale or 1 + local WIDTH = math.floor(280 * textScale + 0.5) + local ROW_H = math.floor(18 * textScale + 0.5) + local TOP = math.floor(22 * textScale + 0.5) local panel = CreateFrame("Frame", "OctoBusPanel", UIParent) panel:SetFrameStrata("MEDIUM") @@ -990,9 +967,17 @@ local OB_OK, OB_ERR = pcall(function () panel.foot:SetPoint("BOTTOMLEFT", panel, "BOTTOMLEFT", 8, 4) panel.foot:SetJustifyH("LEFT") + -- The window is pinned by one corner (CFG.anchor, set in the settings window; default + -- bottom-left), so when the list gets longer or shorter the other edges move. local function SavePos() - local x, y = panel:GetLeft(), panel:GetBottom() - if x and y then CFG.pos = { x = x, y = y } end + local a = CFG.anchor or "BOTTOMLEFT" + local x = string.find(a, "RIGHT") and panel:GetRight() or panel:GetLeft() + local y = string.find(a, "TOP") and panel:GetTop() or panel:GetBottom() + if x and y then + CFG.pos = { x = x, y = y, a = a } + panel:ClearAllPoints() + panel:SetPoint(a, UIParent, "BOTTOMLEFT", x, y) + end end local function StartDrag() @@ -1091,11 +1076,20 @@ local OB_OK, OB_ERR = pcall(function () local rows = {} - local function CreateRow(i) - local row = CreateFrame("Button", nil, panel) + local function PlaceRow(row, i) row:SetHeight(ROW_H) + row:ClearAllPoints() row:SetPoint("TOPLEFT", panel, "TOPLEFT", 4, -(TOP + (i - 1) * ROW_H)) row:SetPoint("RIGHT", panel, "RIGHT", -4, 0) + local fs = math.floor(10 * textScale + 0.5) + if row.name then + row.name:SetFont(font, fs, "OUTLINE") + row.time:SetFont(font, fs, "OUTLINE") + end + end + + local function CreateRow(i) + local row = CreateFrame("Button", nil, panel) row:RegisterForClicks("LeftButtonUp", "RightButtonUp") row:RegisterForDrag("LeftButton") row:SetScript("OnDragStart", StartDrag) @@ -1199,10 +1193,30 @@ local OB_OK, OB_ERR = pcall(function () GameTooltip:Show() end) row:SetScript("OnLeave", function() GameTooltip:Hide() end) + PlaceRow(row, i) rows[i] = row return row end + -- Appearance (set from the settings window, /ob options): background opacity, window + -- opacity and text size. Text size scales the fonts and the row height and width with them. + local function ApplyLook() + local bg = CFG.bgAlpha or 0.75 + panel:SetBackdropColor(0.02, 0.02, 0.02, bg) + panel:SetBackdropBorderColor(0.22, 0.22, 0.22, math.min(1, bg + 0.25)) + panel:SetAlpha(CFG.alpha or 1) + textScale = CFG.textScale or 1 + WIDTH = math.floor(280 * textScale + 0.5) + ROW_H = math.floor(18 * textScale + 0.5) + TOP = math.floor(22 * textScale + 0.5) + panel:SetWidth(WIDTH) + panel.title:SetFont(font, math.floor(10 * textScale + 0.5), "OUTLINE") + panel.status:SetFont(font, math.floor(9 * textScale + 0.5), "OUTLINE") + panel.foot:SetFont(font, math.floor(9 * textScale + 0.5), "OUTLINE") + for i = 1, table.getn(rows) do PlaceRow(rows[i], i) end + end + ApplyLook() + -- returns state ("docked" / "wait" / nil), seconds (may be nil if unknown) local function Status(r) if Flipped(r) then return Predict(r, true) end @@ -1329,7 +1343,7 @@ local OB_OK, OB_ERR = pcall(function () end end for i = n + 1, table.getn(rows) do rows[i]:Hide() end - panel:SetHeight(TOP + n * ROW_H + 18) + panel:SetHeight(TOP + n * ROW_H + ((cal or (panel.foot:GetText() or "") ~= "") and math.floor(18 * textScale + 0.5) or 10)) panel.title:SetText("|cff33ffccTransports|r" .. (CFG.full and " |cff777777- all|r" or "")) if recOn then panel.status:SetText("|cffff4444REC|r " .. (type(DB.rec) == "table" and table.getn(DB.rec) or 0)) @@ -1353,22 +1367,22 @@ local OB_OK, OB_ERR = pcall(function () panel.foot:SetText("|cffffaa33calibrating " .. d .. "/" .. t .. ": waiting for arrivals|r") end else - local txt, stale = CalAgeText() + -- the footer only appears after a server restart, while a shown route is unseen since + local txt = "" for i = 1, table.getn(ROUTES) do if Visible(ROUTES[i]) and Stale(ROUTES[i]) then - txt = "server restarted " .. FormatSpan(time() - DB.restartAt) .. " ago: ? = not seen since" - stale = true + txt = "|cffffaa33server restarted " .. FormatSpan(time() - DB.restartAt) .. " ago: ? = not seen since|r" break end end - panel.foot:SetText((stale and "|cffffaa33" or "|cff777777") .. txt .. "" .. "|r") + panel.foot:SetText(txt) end end local function PlacePanel() panel:ClearAllPoints() if CFG.pos then - panel:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", CFG.pos.x, CFG.pos.y) + panel:SetPoint(CFG.pos.a or "BOTTOMLEFT", UIParent, "BOTTOMLEFT", CFG.pos.x, CFG.pos.y) else panel:SetPoint("TOPRIGHT", anchorTo, "BOTTOMRIGHT", 0, -8) end @@ -1424,11 +1438,14 @@ local OB_OK, OB_ERR = pcall(function () ApplyVisibility() end - btn:SetScript("OnClick", function() ToggleShown() end) + btn:SetScript("OnClick", function() + if arg1 == "RightButton" then OctoBus_Options() else ToggleShown() end + end) btn:SetScript("OnEnter", function() GameTooltip:SetOwner(this, "ANCHOR_LEFT") GameTooltip:AddLine("Transports", 0.2, 1, 0.8) GameTooltip:AddLine("Click: " .. (panel:IsShown() and "hide" or "show") .. " the window (works anywhere)", 1, 1, 1) + GameTooltip:AddLine("Right-click: settings", 1, 1, 1) GameTooltip:AddLine("Opens by itself at either end of any route.", 0.7, 0.7, 0.7) GameTooltip:AddLine("Drag: move this icon around the minimap", 0.7, 0.7, 0.7) GameTooltip:Show() @@ -1611,6 +1628,9 @@ local OB_OK, OB_ERR = pcall(function () elseif cmd == "boat" then CFG.hideBoat = not CFG.hideBoat Say("Sparkwater Port boat " .. (CFG.hideBoat and "hidden" or "shown") .. ".") + elseif cmd == "options" or cmd == "config" or cmd == "settings" then + OctoBus_Options() + elseif cmd == "lock" then Toggle("window locked", "locked") if CFG.locked then grip:Hide() else grip:Show() end @@ -1735,10 +1755,29 @@ local OB_OK, OB_ERR = pcall(function () if recOn then Say("recording: on, " .. table.getn(DB.rec) .. " lines so far.") end else - Say("/ob (window on/off) | list | alert | all|sound|chat|flash|zone|boat|lock | scale | where | shape | allow|disallow | resetpos | name | period | travel | calibrate | reset | debug [on|off]") + Say("/ob (window on/off) | options | list | alert | all|sound|chat|flash|zone|boat|lock | scale | where | shape | allow|disallow | resetpos | name | period | travel | calibrate | reset | debug [on|off]") end end + -- for the settings window (OctoBusOptions.lua) + OctoBus = { + cfg = CFG, + ApplyLook = function() ApplyLook() Refresh() end, + Refresh = function() Refresh() end, + Show = function() override = "show" ApplyVisibility() end, + SetAnchor = function(a) + CFG.anchor = a + if panel:GetLeft() then SavePos() end -- keeps the window where it is, pinned by the new corner + end, + SetScale = function(v) ApplyScale(v) end, + scaleMin = SCALE_MIN, + scaleMax = SCALE_MAX, + SetLocked = function(v) + CFG.locked = v and true or false + if CFG.locked then grip:Hide() else grip:Show() end + end, + } + end) if not OB_OK then OB_STAGE = "start-up error: " .. tostring(OB_ERR) diff --git a/OctoBus.toc b/OctoBus.toc index 158f397..c399cf0 100644 --- a/OctoBus.toc +++ b/OctoBus.toc @@ -6,3 +6,4 @@ ## SavedVariables: OctoBusDB OctoBus.lua +OctoBusOptions.lua diff --git a/OctoBusOptions.lua b/OctoBusOptions.lua new file mode 100644 index 0000000..25f4a2e --- /dev/null +++ b/OctoBusOptions.lua @@ -0,0 +1,170 @@ +--[[ + OctoBus settings window: /ob options, or right-click the minimap icon. + Changes apply at once and are saved with the rest of OctoBus's settings. +]] + +local font = STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF" +local W = 300 +local frame, controls = nil, {} +local loading = false + +local function Backdrop(f) + f:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + f:SetBackdropColor(0.02, 0.02, 0.02, 0.92) + f:SetBackdropBorderColor(0.3, 0.3, 0.3, 1) +end + +local function Heading(text, y) + local fs = frame:CreateFontString(nil, "OVERLAY") + fs:SetFont(font, 11, "OUTLINE") + fs:SetPoint("TOPLEFT", frame, "TOPLEFT", 12, y) + fs:SetText("|cff33ffcc" .. text .. "|r") +end + +local sliderCount = 0 +-- get() returns the setting's current value; set(v) applies it; fmt(v) is the label text +local function Slider(label, y, lo, hi, step, get, set, fmt) + sliderCount = sliderCount + 1 + local name = "OctoBusOptionsSlider" .. sliderCount + local s = CreateFrame("Slider", name, frame, "OptionsSliderTemplate") + s:SetPoint("TOPLEFT", frame, "TOPLEFT", 18, y) + s:SetWidth(W - 36) + s:SetMinMaxValues(lo, hi) + s:SetValueStep(step) + getglobal(name .. "Low"):SetText("") + getglobal(name .. "High"):SetText("") + local text = getglobal(name .. "Text") + text:SetFont(font, 10) + s.Update = function() + loading = true + s:SetValue(get()) + loading = false + text:SetText(label .. ": |cffffffff" .. fmt(get()) .. "|r") + end + s:SetScript("OnValueChanged", function() + if loading then return end + local v = math.floor(this:GetValue() / step + 0.5) * step + set(v) + text:SetText(label .. ": |cffffffff" .. fmt(v) .. "|r") + end) + table.insert(controls, s) + return s +end + +local checkCount = 0 +local function Check(label, x, y, get, set) + checkCount = checkCount + 1 + local name = "OctoBusOptionsCheck" .. checkCount + local c = CreateFrame("CheckButton", name, frame, "OptionsCheckButtonTemplate") + c:SetWidth(24) + c:SetHeight(24) + c:SetPoint("TOPLEFT", frame, "TOPLEFT", x, y) + local text = getglobal(name .. "Text") + text:SetFont(font, 10) + text:SetText(label) + c.Update = function() c:SetChecked(get() and 1 or nil) end + c:SetScript("OnClick", function() set(this:GetChecked() and true or false) end) + table.insert(controls, c) + return c +end + +local function pct(v) return math.floor(v * 100 + 0.5) .. "%" end + +local function Build() + local OB = OctoBus + local C = OB.cfg + frame = CreateFrame("Frame", "OctoBusOptions", UIParent) + frame:SetWidth(W) + frame:SetHeight(538) + frame:SetPoint("CENTER", UIParent, "CENTER", 0, 60) + frame:SetFrameStrata("DIALOG") + frame:SetClampedToScreen(true) + frame:SetMovable(true) + frame:EnableMouse(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", function() this:StartMoving() end) + frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + Backdrop(frame) + table.insert(UISpecialFrames, "OctoBusOptions") -- Escape closes it + + local title = frame:CreateFontString(nil, "OVERLAY") + title:SetFont(font, 12, "OUTLINE") + title:SetPoint("TOP", frame, "TOP", 0, -10) + title:SetText("|cff33ffccOcto|rBus settings") + + local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton") + close:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 2, 2) + + local function look(key) return function(v) C[key] = v OB.ApplyLook() end end + + Heading("Window", -34) + Slider("Background opacity", -64, 0, 1, 0.05, function() return C.bgAlpha or 0.75 end, look("bgAlpha"), pct) + Slider("Window opacity", -102, 0.2, 1, 0.05, function() return C.alpha or 1 end, look("alpha"), pct) + Slider("Text size", -140, 0.7, 1.5, 0.05, function() return C.textScale or 1 end, look("textScale"), pct) + Slider("Window size", -178, OB.scaleMin, OB.scaleMax, 0.05, function() return C.scale or 1 end, + function(v) OB.SetScale(v) end, pct) + Check("Lock position and size", 14, -204, function() return C.locked end, function(v) OB.SetLocked(v) end) + + -- which corner stays in place when the list gets longer or shorter + local corner = frame:CreateFontString(nil, "OVERLAY") + corner:SetFont(font, 10) + corner:SetPoint("TOPLEFT", frame, "TOPLEFT", 18, -234) + corner:SetText("Corner that stays put as the list grows or shrinks:") + corner:SetTextColor(1, 0.82, 0) + local CORNERS = { + { "TOPLEFT", "Top left", 14, -248 }, { "TOPRIGHT", "Top right", 150, -248 }, + { "BOTTOMLEFT", "Bottom left", 14, -272 }, { "BOTTOMRIGHT", "Bottom right", 150, -272 }, + } + for i = 1, table.getn(CORNERS) do + local a = CORNERS[i][1] + Check(CORNERS[i][2], CORNERS[i][3], CORNERS[i][4], function() return (C.anchor or "BOTTOMLEFT") == a end, + function() + OB.SetAnchor(a) + for j = 1, table.getn(controls) do controls[j].Update() end -- one corner at a time + end) + end + + Heading("Routes", -302) + Check("Open by itself only at route ends", 14, -320, function() return C.zoneOnly end, + function(v) C.zoneOnly = v end) + Check("Show all routes (not just for where you are)", 14, -344, function() return C.full end, + function(v) C.full = v OB.Refresh() end) + Check("Show the Sparkwater Port boat", 14, -368, function() return not C.hideBoat end, + function(v) C.hideBoat = not v OB.Refresh() end) + + Heading("Alerts (click a row to put a bell on it)", -400) + Slider("Warn before arrival", -430, 10, 120, 5, function() return C.alert or 45 end, + function(v) C.alert = v end, function(v) return math.floor(v + 0.5) .. " s" end) + Check("Chat message", 14, -452, function() return C.chat end, function(v) C.chat = v end) + Check("Sound", 120, -452, function() return C.sound end, function(v) C.sound = v end) + Check("Flash the minimap icon", 14, -476, function() return C.flash end, function(v) C.flash = v end) + + local reset = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate") + reset:SetWidth(150) + reset:SetHeight(20) + reset:SetPoint("BOTTOM", frame, "BOTTOM", 0, 12) + reset:SetText("Reset window look") + reset:SetScript("OnClick", function() + C.bgAlpha, C.alpha, C.textScale = nil, nil, nil + OB.SetScale(1) + OB.ApplyLook() + for i = 1, table.getn(controls) do controls[i].Update() end + end) +end + +function OctoBus_Options() + if not OctoBus then + DEFAULT_CHAT_FRAME:AddMessage("|cff33ffccOctoBus:|r not running, so its settings are unavailable.") + return + end + if not frame then Build() end + if frame:IsShown() then frame:Hide() return end + for i = 1, table.getn(controls) do controls[i].Update() end + OctoBus.Show() -- show the timer window so changes can be seen + frame:Show() +end diff --git a/README.md b/README.md index 13a0341..ae9386c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ OctoBus needs one extra file, `ZepSense.dll`, which you install by hand in the g | Row order | Soonest first: whatever docks, leaves or needs you to fly next is at the top. Rows move as the timers change. | | Bell on a row | Alerts are on for that route (click a row to toggle). | | Title | Click it to switch between the routes for where you are and all routes (`Transports - all`). | -| Footer | The route whose data is oldest, e.g. `oldest data: Orgrimmar -> Undercity, seen 3 h ago`. It turns orange after 5 days, as a hint to go and see it again. | +| Footer | Only shown after a server restart, while a route in the window has not been seen since: `server restarted 20 min ago: ? = not seen since`. | | Dots, bottom-right corner | Drag to make the window bigger or smaller. | | Hover over a row | Cycle, stay at the dock, how the far end was measured, and what left- and right-click do. | @@ -81,6 +81,7 @@ Saved data lives in `WTF\...\SavedVariables\OctoBus.lua`. - **Auto-show**: the window opens by itself at either end of any route (the zones in the table above), and while a tracked transport is in view, such as on a crossing. It hides elsewhere. The icon toggle always works manually. Add more auto-show places with `/ob allow ` (capitals do not matter; `/ob where` shows the names), remove one with `/ob disallow `, or turn the behaviour off with `/ob zone`. - **Bells**: click a row to toggle a bell. Belled routes print a chat message `/ob alert` seconds (default 45) before the transport docks at the row's start, e.g. `Orgrimmar -> Undercity: docks at Orgrimmar in 0:45`. Nothing is printed to chat for routes without a bell. - **Reverse a row**: right-click it. +- **Settings window**: `/ob options`, or right-click the minimap icon. Background opacity, window opacity, text size, window size, lock, and which corner stays put as the list grows or shrinks, plus auto-show, which routes to show, and bell alerts. Changes apply at once. - **Move and size the window**: drag it to move it; drag the dots in the bottom-right corner (or use `/ob scale`) to resize it. `/ob lock` locks position and size; `/ob resetpos` puts the window and icon back at their default place and size. ## How the timing works @@ -108,6 +109,7 @@ The Ratchet boat cannot be seen from Orgrimmar, so its row is always an estimate | Command | What it does | |---|---| | `/ob` | Show / hide the window (works anywhere) | +| `/ob options` | Settings window (also: right-click the minimap icon) | | `/ob list` | Print all timers in chat | | `/ob alert ` | Bell alerts fire this many seconds before arrival (default 45, `0` = off) | | `/ob chat`, `/ob sound`, `/ob flash` | Toggle that alert type (chat on by default; sound and screen flash off) |