--[[--------------------------------------------------------------------------
ManiaxScroll — mouse wheel everywhere, the way retail does it.
On 3.3.5a the wheel works in a handful of places and nowhere else. Grepping
the stock FrameXML for OnMouseWheel returns eleven files: the quest log,
reputation, skills, gossip, friends, channels, the binding UI, the hybrid
scroll template (achievements, LFD), the chat frames, and two templates.
Everything else in the game is dead to the wheel — the auction house, mail,
the trade skill window, the trainer, macros, the guild bank, the calendar,
the world map, the merchant, books, the dressing room, the talent panel.
APPROACH: SWEEP, DO NOT LIST
The obvious implementation is a table of every panel's scroll frame. It is
also wrong: it silently misses anything not in the table - other add-ons'
windows, load-on-demand panels nobody remembered, and anything added later.
A list that is 90% complete reads exactly like a system that works.
So this sweeps EnumerateFrames() instead and wires every ScrollFrame it can
find. EnumerateFrames exists on 3.3.5a - four add-ons in this pack already
use it. Cost is one walk of the frame list, run when panels appear rather
than continuously.
WHERE THE SWEEP DOES NOT REACH, AND WHY THOSE ARE LISTS
Four systems are not scroll frames at all and cannot be found by type:
- Paged panels (merchant, spellbook, mail, books, companions, the auction
browse list) are two buttons and a page number.
- Models (character sheet, dressing room, pet, stable, tabard, inspect)
zoom by moving the model, not by scrolling anything.
- The world map zooms by changing which map is displayed.
- The minimap zooms through its own two buttons.
These are named lists on purpose. For models the sweep is not merely useless
but actively harmful: WorldMapPing and ItemAnimTemplate are Model frames
too, and enabling the wheel on WorldMapPing would eat the wheel over the
world map - the exact frame we want it on. A type sweep cannot tell those
apart. A name list can, and every entry is looked up with _G and skipped
when absent, so a panel that does not exist on this client costs nothing.
WHAT IT WILL NOT TOUCH
Any frame that already has an OnMouseWheel script is left alone. Auctionator,
ElvUI and several others handle their own wheel, and replacing a working
handler with a generic one is how you break a window that was fine. The
handler is only installed where there is nothing.
It also does not patch a widget metatable. Adding a method to
getmetatable(CreateFrame("ScrollFrame")).__index would reach every frame type
in the game - frame types share one metatable on 3.3.5a - and that mistake
has already broken this client's keyboard twice. Everything here is per
frame, at the call site.
FAUX AND REAL SCROLL FRAMES ARE BOTH DRIVEN THROUGH THE SCROLLBAR
A FauxScrollFrame does not scroll - it redraws a fixed set of rows from an
offset, and the offset comes from the scrollbar's value. A real ScrollFrame
moves its child. Setting the scrollbar's value drives both, because in the
faux case OnValueChanged fires the frame's OnVerticalScroll, which is what
every panel's update function already hangs off. So there is one code path,
not two.
Finding that scrollbar takes three tries, not one. FauxScrollFrame and
UIPanelScrollFrameTemplate name it "ScrollBar", which is what a
_G lookup finds. HybridScrollFrame stores it as frame.scrollBar with no
global name at all - see hybridscrollframe.lua, which reads self.scrollBar
throughout - and third-party frames use frame.ScrollBar. Looking in only the
first place is how a window ends up wired to a handler that does nothing.
----------------------------------------------------------------------------]]
local ADDON = "ManiaxScroll"
ManiaxScrollDB = ManiaxScrollDB or {}
local DEFAULTS = {
lines = 3, -- wheel notches to scroll per click, where a step is known
minimap = true, -- wheel to zoom the minimap
paged = true, -- wheel to turn pages on merchant / mail / books / ...
pageAtEnd = true, -- wheeling past the end of a paged list turns the page
models = true, -- wheel to zoom character / dressing room / pet models
worldmap = true, -- wheel to zoom the world map out and back in
message = true, -- wheel over ScrollingMessageFrames (guild bank log, ...)
modifiers = true, -- shift = one page, ctrl = jump to top / bottom
}
-- Public handle, so ManiaxScroll_Options.lua can read and write settings
-- without duplicating the defaults or the re-wiring rules.
ManiaxScroll = ManiaxScroll or {}
local NS = ManiaxScroll
NS.DEFAULTS = DEFAULTS
-- SavedVariables are restored before an add-on's Lua runs, so this is already
-- the player's table here; VARIABLES_LOADED only fills in missing keys.
local db = ManiaxScrollDB
-- Weak keys throughout: a frame that is destroyed drops out on its own rather
-- than being held alive by our bookkeeping.
--
-- Two sets, not one, and the difference matters. `examined` is the sweep's own
-- bookkeeping - every frame it has looked at and ruled out, so it does not pay
-- for a GetObjectType call on the same Button ten thousand times. `wired` is
-- only the frames this add-on installed a handler on.
--
-- Collapsing them breaks the four named lists: MerchantFrame, Minimap and
-- WorldMapButton are all ordinary frames, so the sweep rules them out and
-- would mark them - and wirePaged, wireMinimap and wireMap all skip a frame
-- that is already marked. The whole non-scroll half of the add-on would go
-- quiet, with nothing thrown and nothing to see.
local WEAK = { __mode = "k" }
local examined = setmetatable({}, WEAK) -- frame -> true, the sweep is done with it
local wired = setmetatable({}, WEAK) -- frame -> true, we installed a handler
local barCache = setmetatable({}, WEAK) -- frame -> its scrollbar
local pageOwner = setmetatable({}, WEAK) -- scroll frame -> {next=, prev=}
local modelZoom = setmetatable({}, WEAK) -- model frame -> current offset
local counts = { scroll = 0, message = 0, paged = 0, model = 0, other = 0 }
local function opt(key)
local v = db and db[key]
if v == nil then return DEFAULTS[key] end
return v
end
NS.Get = opt
local function say(msg)
DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00ManiaxScroll|r " .. msg)
end
NS.Say = say
-- IsEnabled() returns 1 or 0 on 3.3.5a, not a boolean - Blizzard's own code
-- compares against both (see inboxframe.lua and channelframe.lua). Third-party
-- buttons occasionally return nil instead, so test for both falsey forms.
local function usable(btn)
if not btn or not btn.IsShown or not btn:IsShown() then return false end
local e = btn.IsEnabled and btn:IsEnabled()
return e and e ~= 0 and true or false
end
---------------------------------------------------------------------------
-- Finding the scrollbar.
---------------------------------------------------------------------------
local function findBar(frame)
local name = frame.GetName and frame:GetName()
if name then
local b = _G[name .. "ScrollBar"]
if b and b.GetValue then return b end
end
local b = frame.scrollBar or frame.ScrollBar
if b and b.GetValue then return b end
-- Last resort: an unnamed slider parented straight to the scroll frame.
if frame.GetChildren then
local kids = { frame:GetChildren() }
for i = 1, #kids do
local c = kids[i]
if c and c.GetObjectType then
local ok, kind = pcall(c.GetObjectType, c)
if ok and kind == "Slider" then return c end
end
end
end
return nil
end
---------------------------------------------------------------------------
-- The scroll frame handler.
--
-- delta is +1 for up and -1 for down. Scrollbar values run the other way -
-- larger value means further down the list - so the sign is inverted.
---------------------------------------------------------------------------
local function turnPage(frame, delta)
local page = pageOwner[frame]
if not page or not opt("paged") or not opt("pageAtEnd") then return false end
local btn = (delta > 0) and page.prev or page.next
if usable(btn) then btn:Click(); return true end
return false
end
local function onWheel(self, delta)
local mods = opt("modifiers")
local shift = mods and IsShiftKeyDown and IsShiftKeyDown()
local ctrl = mods and IsControlKeyDown and IsControlKeyDown()
local bar = barCache[self]
if bar and bar.GetValue then
-- GetValueStep is the row height for a FauxScrollFrame and the pixel step
-- for a real one. Where it is zero or missing, fall back to something that
-- moves a visible amount rather than doing nothing.
local step = 0
if bar.GetValueStep then step = bar:GetValueStep() or 0 end
if step <= 0 then step = 16 end
local lo, hi = 0, 0
if bar.GetMinMaxValues then lo, hi = bar:GetMinMaxValues() end
lo, hi = lo or 0, hi or 0
local value = bar:GetValue() or 0
-- Already against the stop, and this list is one page of a paged panel:
-- turn the page instead of doing nothing. This is what makes the auction
-- house and a book read continuously rather than in 8-row chunks.
if (delta < 0 and value >= hi - 0.5) or (delta > 0 and value <= lo + 0.5) then
if turnPage(self, delta) then return end
end
local target
if ctrl then
target = (delta > 0) and lo or hi
else
local amount = step * opt("lines")
if shift then
-- One screenful. The frame's own height over the row height is the
-- row count for a faux frame and a sane pixel page for a real one.
local h = (self.GetHeight and self:GetHeight()) or 0
local pageSize = (h > step) and (math.floor(h / step) * step) or (step * 10)
amount = pageSize
end
target = value - (delta * amount)
end
if target < lo then target = lo end
if hi > lo and target > hi then target = hi end
bar:SetValue(target)
return
end
-- No scrollbar: a plain ScrollFrame moved directly.
if self.GetVerticalScroll and self.SetVerticalScroll then
local cur = self:GetVerticalScroll() or 0
local max = (self.GetVerticalScrollRange and self:GetVerticalScrollRange()) or 0
if (delta < 0 and cur >= max - 0.5) or (delta > 0 and cur <= 0.5) then
if turnPage(self, delta) then return end
end
local target
if ctrl then
target = (delta > 0) and 0 or max
else
local amount = 16 * opt("lines")
if shift then
local h = (self.GetHeight and self:GetHeight()) or 0
if h > 0 then amount = h end
end
target = cur - (delta * amount)
end
if target < 0 then target = 0 end
if max > 0 and target > max then target = max end
self:SetVerticalScroll(target)
end
end
---------------------------------------------------------------------------
-- ScrollingMessageFrame handler.
--
-- These are not ScrollFrames - GetObjectType returns "ScrollingMessageFrame"
-- and they have no scrollbar and no scroll range. They scroll by line, with
-- their own methods. The chat frames already carry Blizzard's handler
-- (FloatingChatFrame_OnMouseScroll) and are therefore skipped; what this
-- reaches is everything else, the guild bank log first among them.
--
-- Shift jumping to the ends matches what stock chat does not do here but
-- retail does, and is the only sensible use of a modifier on a line buffer.
---------------------------------------------------------------------------
local function onWheelMessage(self, delta)
if not opt("message") then return end
local shift = opt("modifiers") and IsShiftKeyDown and IsShiftKeyDown()
if delta > 0 then
if shift and self.ScrollToTop then self:ScrollToTop()
elseif self.ScrollUp then
for _ = 1, opt("lines") do self:ScrollUp() end
end
else
if shift and self.ScrollToBottom then self:ScrollToBottom()
elseif self.ScrollDown then
for _ = 1, opt("lines") do self:ScrollDown() end
end
end
end
---------------------------------------------------------------------------
-- Wiring one frame found by the sweep.
---------------------------------------------------------------------------
local function alreadyHandled(frame)
if not frame.GetScript then return false end
local ok, handler = pcall(frame.GetScript, frame, "OnMouseWheel")
return ok and handler ~= nil
end
local function wire(frame)
if examined[frame] or wired[frame] then return false end
local ok, kind = pcall(frame.GetObjectType, frame)
if not ok then return false end
if kind ~= "ScrollFrame" and kind ~= "ScrollingMessageFrame" then
examined[frame] = true -- wrong type, and that will never change
return false
end
-- Leave anything that already handles the wheel completely alone.
if alreadyHandled(frame) then
examined[frame] = true -- remember, so it is not re-examined every sweep
return false
end
if kind == "ScrollingMessageFrame" then
-- Not marked when the option is off, so switching it on later still picks
-- the frame up on the next sweep.
if not opt("message") then return false end
if not frame.ScrollUp then examined[frame] = true; return false end
pcall(frame.EnableMouseWheel, frame, true)
pcall(frame.SetScript, frame, "OnMouseWheel", onWheelMessage)
examined[frame], wired[frame] = true, true
counts.message = counts.message + 1
return true
end
-- A scroll frame with neither a scrollbar nor a scroll range has nothing to
-- drive, and enabling the wheel on it would only swallow the event from
-- whatever is underneath.
local bar = findBar(frame)
if not bar and not frame.SetVerticalScroll then
examined[frame] = true
return false
end
if bar then barCache[frame] = bar end
pcall(frame.EnableMouseWheel, frame, true)
pcall(frame.SetScript, frame, "OnMouseWheel", onWheel)
examined[frame], wired[frame] = true, true
counts.scroll = counts.scroll + 1
return true
end
local function sweep()
local added, frame = 0, EnumerateFrames()
while frame do
if wire(frame) then added = added + 1 end
frame = EnumerateFrames(frame)
end
return added
end
---------------------------------------------------------------------------
-- Paged panels. Not scroll frames at all - two buttons and a page number -
-- so the wheel has to be mapped onto the buttons instead.
--
-- `list`, where present, is the scroll frame that shows one page of the same
-- data. Wiring it as the page owner is what makes wheeling past the last row
-- of an auction search fetch the next fifty results instead of stopping, and
-- what turns a book's page when its text runs out.
--
-- Every target is looked up by name and skipped when absent, so a panel that
-- does not exist on this client, or is renamed, costs nothing. Six of these
-- exist in the stock UI; five of them had no wheel at all.
---------------------------------------------------------------------------
local PAGED = {
{ frame = "MerchantFrame", next = "MerchantNextPageButton", prev = "MerchantPrevPageButton" },
{ frame = "SpellBookFrame", next = "SpellBookNextPageButton", prev = "SpellBookPrevPageButton" },
{ frame = "InboxFrame", next = "InboxNextPageButton", prev = "InboxPrevPageButton" },
{ frame = "ItemTextFrame", next = "ItemTextNextPageButton", prev = "ItemTextPrevPageButton",
list = "ItemTextScrollFrame" },
{ frame = "AuctionFrameBrowse",next = "BrowseNextPageButton", prev = "BrowsePrevPageButton",
list = "BrowseScrollFrame" },
-- The companion (mount / critter) pages live inside the pet paperdoll tab.
{ frame = "PetPaperDollFrameCompanionFrame",
next = "CompanionNextPageButton", prev = "CompanionPrevPageButton" },
}
local function onWheelPaged(self, delta)
if not opt("paged") then return end
local page = pageOwner[self]
if not page then return end
-- Clicking the button rather than calling the page function keeps the click
-- sound, the disabled state at the ends of the list, and whatever else the
-- button's own handler does.
local btn = (delta > 0) and page.prev or page.next
if usable(btn) then btn:Click() end
end
local function wirePaged()
if not opt("paged") then return end
for _, spec in ipairs(PAGED) do
local frame = _G[spec.frame]
if frame then
local nextBtn, prevBtn = _G[spec.next], _G[spec.prev]
if nextBtn and prevBtn then
local page = { next = nextBtn, prev = prevBtn }
if not wired[frame] then
pageOwner[frame] = page
pcall(frame.EnableMouseWheel, frame, true)
pcall(frame.SetScript, frame, "OnMouseWheel", onWheelPaged)
wired[frame] = true
counts.paged = counts.paged + 1
end
-- The list inside the panel keeps its own scrolling handler; it only
-- learns which buttons to press once it runs out of rows.
if spec.list then
local list = _G[spec.list]
if list then
pageOwner[list] = page
wire(list)
end
end
end
end
end
end
---------------------------------------------------------------------------
-- Model zoom: character sheet, dressing room, pet, stable, tabard, inspect.
--
-- A Model is moved, not scrolled - SetPosition's first argument is depth, so
-- a larger value brings the model toward the camera. There is no GetPosition
-- on 3.3.5a, so the current offset is tracked here rather than read back, and
-- it is dropped when the frame hides so the next open starts at default.
--
-- Listed rather than swept: see the header. WorldMapPing is a Model too.
---------------------------------------------------------------------------
local MODELS = {
"CharacterModelFrame", -- paperdoll
"DressUpModel", -- dressing room
"PetModelFrame", -- pet paperdoll
"PetStableModel", -- stable master
"CompanionModelFrame", -- mounts and critters
"TabardModel", -- guild tabard designer
"InspectModelFrame", -- Blizzard_InspectUI, load on demand
}
local MODEL_STEP, MODEL_MIN, MODEL_MAX = 0.35, -1, 4
local function onWheelModel(self, delta)
if not opt("models") then return end
local z = (modelZoom[self] or 0) + (delta * MODEL_STEP)
if z < MODEL_MIN then z = MODEL_MIN end
if z > MODEL_MAX then z = MODEL_MAX end
modelZoom[self] = z
pcall(self.SetPosition, self, z, 0, 0)
end
local function wireModels()
if not opt("models") then return end
for _, name in ipairs(MODELS) do
local model = _G[name]
if model and not wired[model] and type(model.SetPosition) == "function"
and not alreadyHandled(model) then
pcall(model.EnableMouseWheel, model, true)
pcall(model.SetScript, model, "OnMouseWheel", onWheelModel)
-- Hooked, not set: the frame's own OnHide does real work on several of
-- these and must keep running.
if model.HookScript then
pcall(model.HookScript, model, "OnHide", function(self)
if modelZoom[self] then
modelZoom[self] = nil
pcall(self.SetPosition, self, 0, 0, 0)
end
end)
end
wired[model] = true
counts.model = counts.model + 1
end
end
end
---------------------------------------------------------------------------
-- World map zoom.
--
-- 3.3.5a has no continuous map zoom; it has a hierarchy - cosmic, world,
-- continent, zone - walked by SetMapZoom and by the zoom-out button, and
-- there is no wheel anywhere in worldmapframe.xml. Wheeling down walks out.
--
-- Walking back in has no button, because the client has no idea which zone
-- you want. So the way out is remembered and the wheel retraces it, which
-- makes zoom-out/zoom-in symmetrical the way it is on retail. Zooming by any
-- other means invalidates nothing - the stack is only consulted, and a stale
-- entry is discarded when the map it names is already displayed.
--
-- The handler goes on WorldMapButton, the map surface, not on WorldMapFrame:
-- the frame also contains the quest list, and that keeps its own scrolling.
---------------------------------------------------------------------------
local mapStack = {}
local function onWheelMap(self, delta)
if not opt("worldmap") then return end
if delta < 0 then
local btn = _G["WorldMapZoomOutButton"]
if usable(btn) then
local c, z = GetCurrentMapContinent(), GetCurrentMapZone()
btn:Click()
-- Only record it if the click actually moved us; otherwise wheeling up
-- again would "restore" the map we are already looking at.
if GetCurrentMapContinent() ~= c or GetCurrentMapZone() ~= z then
mapStack[#mapStack + 1] = { c = c, z = z }
end
end
return
end
local top = mapStack[#mapStack]
while top do
mapStack[#mapStack] = nil
if top.c ~= GetCurrentMapContinent() or top.z ~= GetCurrentMapZone() then
SetMapZoom(top.c, top.z)
return
end
top = mapStack[#mapStack]
end
end
local function wireMap()
if not opt("worldmap") then return end
local btn = _G["WorldMapButton"]
if not btn or wired[btn] or alreadyHandled(btn) then return end
if not _G["WorldMapZoomOutButton"] then return end
pcall(btn.EnableMouseWheel, btn, true)
pcall(btn.SetScript, btn, "OnMouseWheel", onWheelMap)
wired[btn] = true
counts.other = counts.other + 1
end
---------------------------------------------------------------------------
-- Minimap zoom.
---------------------------------------------------------------------------
local function wireMinimap()
if not opt("minimap") or not Minimap or wired[Minimap] then return end
if alreadyHandled(Minimap) then wired[Minimap] = true; return end
local zin, zout = _G["MinimapZoomIn"], _G["MinimapZoomOut"]
if not zin or not zout then return end
pcall(Minimap.EnableMouseWheel, Minimap, true)
pcall(Minimap.SetScript, Minimap, "OnMouseWheel", function(self, delta)
if not opt("minimap") then return end
-- Same reasoning as the paged buttons: click, so the zoom clamps itself at
-- both ends and the buttons grey out correctly.
local btn = (delta > 0) and zin or zout
if usable(btn) then btn:Click() end
end)
wired[Minimap] = true
counts.other = counts.other + 1
end
---------------------------------------------------------------------------
-- When to sweep.
--
-- Frames are created lazily, and load-on-demand panels (auction house, trade
-- skills, talents, guild bank, macros...) do not exist until their add-on
-- loads. Three triggers, in order of how well they aim:
--
-- ADDON_LOADED - exact, and covers every load-on-demand panel.
-- ShowUIPanel hook - exact, and covers a panel whose frames are built the
-- first time a player opens it, which no event reports.
-- a backing-off timer - the safety net for anything built by neither.
--
-- The timer starts at five seconds and doubles up to thirty when a pass finds
-- nothing new, then resets the moment either exact trigger fires. Sweeping
-- every frame in the game every five seconds forever is a cost with no payer.
---------------------------------------------------------------------------
local function fullPass()
local added = sweep()
wirePaged()
wireModels()
wireMap()
wireMinimap()
return added
end
NS.FullPass = fullPass
NS.Counts = function() return counts end
local MIN_INTERVAL, MAX_INTERVAL = 5, 30
local interval, elapsed = MIN_INTERVAL, 0
local function urgent()
interval, elapsed = MIN_INTERVAL, MIN_INTERVAL
end
NS.Rescan = function()
local added = fullPass()
urgent()
return added
end
local f = CreateFrame("Frame", "ManiaxScrollFrame")
f:RegisterEvent("VARIABLES_LOADED")
f:RegisterEvent("PLAYER_ENTERING_WORLD")
f:RegisterEvent("ADDON_LOADED")
f:SetScript("OnEvent", function(self, event)
if event == "VARIABLES_LOADED" then
db = ManiaxScrollDB
-- An install that ran /mscroll off carries enabled=false forever, and
-- nothing reads it any more. Drop it, so the saved file cannot be read as
-- an explanation for behaviour it no longer controls.
db.enabled = nil
for k, v in pairs(DEFAULTS) do
if db[k] == nil then db[k] = v end
end
NS.db = db
return
end
fullPass()
urgent()
end)
f:SetScript("OnUpdate", function(self, delta)
elapsed = elapsed + delta
if elapsed < interval then return end
elapsed = 0
if fullPass() > 0 then
interval = MIN_INTERVAL
else
interval = math.min(interval * 2, MAX_INTERVAL)
end
end)
-- Panels that build their frames on first open report nothing. This does.
if type(hooksecurefunc) == "function" and type(ShowUIPanel) == "function" then
hooksecurefunc("ShowUIPanel", function() urgent() end)
end
---------------------------------------------------------------------------
-- /mscroll
---------------------------------------------------------------------------
local TOGGLES = {
minimap = "minimap zoom",
paged = "merchant / mail / book paging",
pageend = "page turn at the end of a list",
models = "model zoom",
worldmap = "world map zoom",
message = "message frame scrolling",
mods = "shift and ctrl modifiers",
}
local KEY = { pageend = "pageAtEnd", mods = "modifiers" }
SLASH_MANIAXSCROLL1 = "/mscroll"
SlashCmdList["MANIAXSCROLL"] = function(input)
local cmd, value = string.match(input or "", "^(%a*)%s*([%d%.]*)$")
cmd = string.lower(cmd or "")
if TOGGLES[cmd] then
local key = KEY[cmd] or cmd
db[key] = not opt(key)
if db[key] then NS.Rescan() end
say(TOGGLES[cmd] .. " " .. (db[key] and "on" or "off") .. ".")
return
elseif cmd == "lines" and value ~= "" then
local n = tonumber(value)
if n and n > 0 then
db.lines = n
say("scrolling " .. n .. " step(s) per notch.")
return
end
elseif cmd == "scan" then
local added = NS.Rescan()
say("swept: " .. added .. " newly wired; " .. counts.scroll .. " scroll, "
.. counts.message .. " message, " .. counts.paged .. " paged, "
.. counts.model .. " model, " .. counts.other .. " other.")
return
elseif cmd == "config" then
if ManiaxScrollOptionsPanel and InterfaceOptionsFrame_OpenToCategory then
-- Called twice on purpose: on 3.3.5a the first call only opens the
-- window, and the category is not selected until the second.
InterfaceOptionsFrame_OpenToCategory(ManiaxScrollOptionsPanel)
InterfaceOptionsFrame_OpenToCategory(ManiaxScrollOptionsPanel)
else
say("options panel not loaded.")
end
return
elseif cmd == "reset" then
for k, v in pairs(DEFAULTS) do db[k] = v end
say("settings reset to defaults; /reload to unwire what is already wired.")
return
end
say("usage:")
say(" |cffaaaaaa/mscroll config|r open the options panel")
say(" |cffaaaaaa/mscroll lines |r steps per wheel notch (now " .. opt("lines") .. ")")
say(" |cffaaaaaa/mscroll minimap|r minimap zoom (" .. tostring(opt("minimap")) .. ")")
say(" |cffaaaaaa/mscroll paged|r merchant / mail / book paging (" .. tostring(opt("paged")) .. ")")
say(" |cffaaaaaa/mscroll pageend|r turn the page at the end of a list (" .. tostring(opt("pageAtEnd")) .. ")")
say(" |cffaaaaaa/mscroll models|r model zoom (" .. tostring(opt("models")) .. ")")
say(" |cffaaaaaa/mscroll worldmap|r world map zoom (" .. tostring(opt("worldmap")) .. ")")
say(" |cffaaaaaa/mscroll message|r message frames (" .. tostring(opt("message")) .. ")")
say(" |cffaaaaaa/mscroll mods|r shift = page, ctrl = jump (" .. tostring(opt("modifiers")) .. ")")
say(" |cffaaaaaa/mscroll scan|r re-sweep now, report what got wired")
say(" |cffaaaaaa/mscroll reset|r back to defaults")
-- No master on/off on purpose. The pack is opt-in: not ticking the add-on is
-- the off switch, and a second one that persists silently into
-- SavedVariables, with no login notice and nothing on screen, reads exactly
-- like the add-on being broken. That is not a hypothetical - it cost an
-- evening. Untick it in the add-on list to turn it off.
end