Skip to content

tutorial

CyanideX edited this page Aug 18, 2026 · 2 revisions

Tutorial

Step-by-step guided tutorials that highlight UI elements, dim the background, and walk users through your mod's interface.

The module is exposed as wu.Tutorial.

Quick Start

local wu = GetMod("WindowUtils")
local tutorial = wu.Tutorial

-- 1. Add elementId to your controls
controls.SliderFloat(icon, "brightness", value, 0, 1, { elementId = "my_slider" })
controls.Button("Apply", "active", 0, 0, { elementId = "my_apply_btn" })

-- 2. Register a tutorial
tutorial.register({
    id = "my_intro",
    trigger = { type = "manual" },
    steps = {
        { target = "my_slider", text = "Drag this to adjust brightness.", title = "Brightness" },
        { target = "my_apply_btn", text = "Click Apply to save your changes.", title = "Apply" },
    },
})

-- 3. Start it
tutorial.start("my_intro")

The tutorial engine handles dimming, highlighting, tooltip positioning, navigation buttons, and persistence automatically.

Scoped API

The recommended way for mods to use tutorials. A single forMod call gives you a mod-specific interface with automatic group creation:

local wu = GetMod("WindowUtils")
local tut = wu.Tutorial.forMod("MyMod")

-- Register (group assigned automatically)
tut:register({
    id = "intro",
    steps = {
        { target = "my_btn", text = "Click here to get started." },
        { target = "my_slider", text = "Drag to adjust the value." },
    },
})

-- Start/stop
tut:start("intro")
tut:stop("intro")

-- Check state
tut:isActive("intro")
tut:isCompleted("intro")
tut:resetCompletion("intro")
tut:resetTrigger("intro")

-- Group control (affects all tutorials registered through this instance)
tut:isGroupEnabled()
tut:setGroupEnabled(false)

The scoped instance exposes exactly these methods: register, start, stop, isActive, isCompleted, resetCompletion, resetTrigger, isGroupEnabled, setGroupEnabled. Everything else lives on wu.Tutorial.

How it works:

  • forMod("MyMod") creates a group with ID "mymod" (lowercased)
  • Tutorials registered through the scoped API belong to that group automatically (definition.group is overwritten)
  • The group appears as a per-mod toggle in the WindowUtils settings panel
  • Calling forMod multiple times with the same name returns the same instance
  • An empty or non-string mod name is logged and returns nil

Declaring your mod's windows

forMod takes an optional second argument with the ImGui window names your mod owns:

local tut = wu.Tutorial.forMod("MyMod", { windows = { "My Mod##MyMod" } })

opts.windows must be a table of window name strings. It is used for three things: the focus gate on auto-triggers, offscreen detection, and telling the window hider which windows to leave alone. Passing windows on a later forMod call for the same mod updates the association.

Adding elementId to Controls

Any WindowUtils control that accepts an opts table supports elementId:

-- Buttons
controls.Button("Label", "style", width, height, { elementId = "my_btn" })
controls.HoldButton("id", "Label", { elementId = "my_hold" })

-- Sliders and Drags
controls.SliderFloat(icon, "id", value, min, max, { elementId = "my_slider" })
controls.SliderInt(icon, "id", value, min, max, { elementId = "my_int_slider" })
controls.DragFloat(icon, "id", value, min, max, { elementId = "my_drag" })

-- Inputs and Toggles
controls.Checkbox("Label", value, { elementId = "my_check" })
controls.Combo(icon, "id", index, items, { elementId = "my_combo" })
controls.InputText(icon, "id", text, { elementId = "my_input" })

-- Panels (highlights the entire panel region)
controls.Panel("panel_id", contentFn, { elementId = "my_panel" })

-- Button Rows
controls.ButtonRow(defs, { elementId = "my_row" })
controls.ButtonRow({
    { label = "Save", elementId = "save_btn", onClick = fn },
    { label = "Load", elementId = "load_btn", onClick = fn },
})

The elementId string must be unique within your mod. It's how the tutorial engine locates elements on screen.

Tutorial Definition

{
    id = "unique_string_id",         -- Required: unique identifier
    steps = { ... },                 -- Required: array of step definitions
    title = "Display Name",          -- Optional: human-readable name (used in Continue button)
    group = "my_group",              -- Optional: group membership
    window = "Window Name##ID",      -- Optional: ImGui window containing the step targets
    category = 1,                    -- Optional: default category for all steps
    next = "next_tutorial_id",       -- Optional: suggest next tutorial on last step
    trigger = { type = "manual" },   -- Optional: activation trigger
    keepWindows = { "Other##Mod" },  -- Optional: window names the hider must not hide
    welcomeStep = {                  -- Optional: unanchored step shown before steps[1]
        title = "Welcome Title",     --   Optional: title header
        text = "Welcome text.",      --   Required: non-empty string
        category = 1,               --   Optional: fires category callback like a regular step
    },
    fadeInDuration = 0.3,            -- Optional: background fade-in seconds
    fadeOutDuration = 0.2,           -- Optional: background fade-out seconds
    onStepEnter = function(step, i)  -- Optional: called when entering each step
        -- Custom state preparation
    end,
}

register validates and returns a boolean. It fails (and logs) when id is missing or not a string, when both steps and welcomeStep are absent (either alone is sufficient), when any step has empty or missing text, when welcomeStep is present but has missing or empty text, or when keepWindows is present but is not a table of strings. Duplicate step targets inside one tutorial are logged but still accepted. Registering an existing id replaces the previous definition.

Step Definition

{
    target = "elementId_string", -- Optional: element to spotlight (omit for unanchored steps)
    text = "Explanation text.",   -- Required: shown in the tooltip popup
    title = "Step Title",         -- Optional: bold header in the tooltip
    tab = {                       -- Optional: auto-switch tab before highlighting
        barId = "##tab_bar_id",
        index = 2,               -- 1-based tab index
    },
    category = 1,                -- Optional: auto-switch category (overrides definition default)
}

Steps without a target are unanchored. The tooltip centers on the window (or screen) and no spotlight or dim cutout is drawn. This is useful for context or transition text between anchored steps.

Welcome Step

An optional unanchored step prepended before the regular steps array. Define it via the welcomeStep field on the tutorial definition:

tutorial.register({
    id = "my_tutorial",
    welcomeStep = {
        title = "Getting Started",
        text = "This tutorial will walk you through the main controls. Click Start to begin.",
        category = "controls",
    },
    steps = {
        { target = "main_slider", text = "Drag this slider." },
        { target = "apply_btn", text = "Click Apply." },
    },
})
  • welcomeStep is independent of the steps array. You can add it to an existing tutorial without reordering or renumbering steps.
  • welcomeStep.text is required (non-empty string). welcomeStep.title and welcomeStep.category are optional.
  • The welcome step is treated as logical step 0. getCurrentStep returns 0 while it is active.
  • The tutorial popup centers on screen during the welcome step. There is no spotlight or dim area cutout.
  • Dim and blur behave identically to anchored steps.
  • Navigation buttons show Skip (hold to dismiss) and Start (advances to step 1). When steps is empty, Start is replaced by Close (marks complete + stops).
  • goToStep(tutorialId, 0) navigates back to the welcome step when one is present.

Features

Triggers

Tutorials can activate automatically based on user actions:

-- Manual only (default): must call tutorial.start() explicitly
trigger = { type = "manual" }

-- First time a tab is selected (fires once, persists across sessions)
trigger = { type = "firstTabVisit", target = "##tab_bar_id", tabIndex = 2 }

-- First time a button with this elementId is clicked (fires once, persists)
trigger = { type = "buttonClick", target = "settings_btn" }

-- Whenever a tab is visible and tutorial not completed (re-fires after cancel)
trigger = { type = "onVisible", target = "##tab_bar_id", tabIndex = 2 }

-- First time the group's windows gain focus (re-fires until completed)
trigger = { type = "onFocus" }

firstTabVisit and buttonClick are edge-triggered: they fire once and the "already triggered" state persists across sessions. Use resetTrigger(id) to allow re-triggering.

onVisible is level-triggered: it fires whenever its condition is met and the tutorial hasn't been completed. If the tutorial is cancelled (e.g., overlay closes), it will re-trigger next time the condition is met.

onFocus fires on the transition from unfocused to focused for the tutorial's group windows. It needs definition.group (the scoped API sets this for you) and the group's window names, otherwise it is never indexed and never fires. Like onVisible, it does not consume trigger state, so it keeps firing until the tutorial is completed.

Every auto-trigger also passes through common gates before starting: no other tutorial active, not already triggered, not completed, the group enabled, and the group's windows focused. That focus gate matters even for buttonClick and firstTabVisit. Groups with no declared windows count as focused. On the first frame after the overlay opens, all groups are suppressed so nothing fires from ImGui's initial auto-focus; suppression clears once focus is lost.

Groups

Groups let you enable or disable collections of tutorials as a unit:

tutorial.registerGroup("my_mod", { enabled = true })

tutorial.register({
    id = "my_intro",
    group = "my_mod",
    steps = { ... },
})

tutorial.setGroupEnabled("my_mod", false)  -- disables all "my_mod" tutorials
tutorial.isGroupEnabled("my_mod")          -- false

Group behavior:

  • tutorial.start() returns false for tutorials in a disabled group
  • Auto-triggers skip tutorials in disabled groups
  • Completion state is preserved regardless of group state
  • Unregistered groups default to enabled (permissive)
  • Group state persists in data/tutorials.json
  • registerGroup preserves persisted state on subsequent calls (won't overwrite user preference)

Category Auto-Switching

Steps can declare a category field to auto-switch a sidebar or navigation panel. Register a callback to handle the switch:

-- Global fallback callback
tutorial.setCategoryCallback(function(category)
    ui.selectedSection = category
end)

-- Or scope it to one group
tutorial.setCategoryCallback(function(category)
    myUi.selectedSection = category
end, "mymod")

setCategoryCallback(fn, groupId?) registers a per-group callback when groupId is given, otherwise it sets the global fallback. Dispatch looks up the callback for the tutorial's group first and falls back to the global one.

Category resolves per-step as step.category or definition.category. Nothing fires when the resolved value is nil, and the callback only fires when the resolved value differs from the last value fired. The last-fired value resets when a tutorial starts and when one ends, so re-running a tutorial fires the first category again.

-- Definition-level: all steps default to this category
tutorial.register({
    id = "my_section_tutorial",
    category = 2,
    steps = {
        { target = "ctrl_a", text = "First control." },
        { target = "ctrl_b", text = "Second control." },
    },
})

-- Step-level override: for tutorials that cross categories
tutorial.register({
    id = "my_welcome",
    steps = {
        { target = "nav_btn", text = "Sidebar.", category = 1 },
        { target = "grid_ctrl", text = "Grid section.", category = 2 },
    },
})

Cross-Tab Steps

Steps can target elements on different tabs. The tutorial engine switches tabs automatically:

steps = {
    { target = "slider_on_tab1", text = "This is on tab 1." },
    { target = "button_on_tab2", text = "Switched to tab 2 for you.",
      tab = { barId = "##my_tabs", index = 2 } },
}

onStepEnter Callback

The onStepEnter callback fires when entering any step (including the first step on start). Use it for custom state preparation that isn't covered by category or tab switching:

tutorial.register({
    id = "my_tutorial",
    onStepEnter = function(step, stepIndex)
        -- Custom logic per step
    end,
    steps = { ... },
})

The callback receives the step table and the 1-based step index. Custom fields on steps are passed through untouched.

Auto-Scroll

If the target element is in a scrollable region but not currently visible, the tutorial automatically scrolls it into view.

Tutorial Chaining

Tutorials can suggest a next tutorial to continue with. On the last step, a "Continue to X" button appears below the navigation row if the next tutorial exists and hasn't been completed.

tutorial.register({
    id = "intro",
    title = "Introduction",
    next = "advanced",
    steps = { ... },
})

tutorial.register({
    id = "advanced",
    title = "Advanced",
    steps = { ... },
})

The title field provides the display name for the Continue button ("Continue to Advanced"). If omitted, the raw id is shown. The button only appears when:

  • The current step is the last step
  • The next tutorial is registered
  • The next tutorial hasn't been completed
  • The next tutorial's group is enabled

Clicking Continue completes the current tutorial and immediately starts the next one (no fade between).

Window Hider

When a tutorial is active, external CET windows (discovered via Window Manager) are temporarily hidden to reduce screen clutter. Windows are moved offscreen and restored to their original positions when the tutorial ends.

  • Requires Window Manager (RedCetWM plugin) to be installed
  • Controlled by the tutorialHideWindows setting (default: disabled)
  • The WindowUtils settings window is never hidden, and neither is any window whose name contains ##WindowUtils
  • Your mod's own windows are spared when you declared them via forMod("MyMod", { windows = ... }), as are any names listed in the definition's keepWindows
  • Windows already parked at x >= 9000 are left alone (another mod owns them)
  • Windows are restored on tutorial end, skip, or overlay close
  • Hidden state is written to data/hider_recovery.json before anything moves, so a crash mid-tutorial is recovered on the next overlay open
  • Gracefully degrades to a no-op when Window Manager is unavailable

The hider also nudges the current step's window: if it is collapsed it gets expanded, and if it is mostly offscreen it gets moved back into view. Both are skipped if the user re-collapses or moves the window afterwards.

Window Visibility

When a tutorial has a window field, tutorial.start() checks if that window is visible on screen. If less than 25% of the window area is visible (shoved offscreen by another mod, placed at a screen edge, etc.), it gets centered automatically before the tutorial begins.

If the window hasn't drawn yet this session (no tracked position), it's always centered on first draw.

tutorial.register({
    id = "browser_intro",
    group = "mymod",
    window = "My Browser##MyMod",  -- ensures this window is visible when tutorial starts
    steps = {
        { target = "browser_search", text = "Search here." },
    },
})

Use window when your tutorial's step targets live in a different ImGui window than the group's primary window. If your steps target elements in the same window the group tracks, you don't need it.

Offscreen Detection

The tutorial system detects when a tutorial's group windows are offscreen (at 9000+ coordinates, typically hidden by WindowSwitcher) and prevents tutorials from starting for hidden windows.

Additionally, active tutorials monitor their group's windows each frame. If the windows go offscreen mid-tutorial, the tutorial fades out gracefully without marking as completed. The trigger state is cleared, allowing the tutorial to fire again when the window returns on-screen.

Completion and Persistence

  • Tutorials are marked completed when the user finishes (reaches the last step) or skips
  • Closing the CET overlay cancels the active tutorial and leaves it incomplete
  • Completion state persists in data/tutorials.json
  • Use isCompleted(id) to check and resetCompletion(id) to clear
  • Use completion state to show/hide "Start Tutorial" buttons or change their style

API Reference

Registration

Function Returns Description
register(definition) boolean Register a tutorial definition
unregister(tutorialId) - Remove a registered definition
forMod(modName, opts?) table|nil Get/create scoped API for a mod (opts.windows)

Lifecycle

Function Returns Description
start(tutorialId) boolean Begin a tutorial
stop(tutorialId) - End a tutorial (fade-out, mark completed)
goToStep(tutorialId, index) - Jump to a specific step. Pass 0 for the welcome step when present; otherwise 1-based

start returns false when another tutorial is already active, when the id is not registered, when the tutorial's group is disabled, or when the group's windows are all offscreen. Note that it returns true in every other case, including when the calling mod has no visible window.

stop only acts on the tutorial matching tutorialId, and is a no-op if that tutorial is already fading out. It marks the tutorial completed even when the user skipped it.

goToStep is a no-op unless the given tutorial is the active one. Pass 0 to navigate back to the welcome step when one is present. An out-of-range index is logged and clamped to [0, #steps] when welcomeStep is present, or [1, #steps] otherwise.

Queries

Function Returns Description
isActive(tutorialId) boolean True while the specified tutorial is running
isAnyActive() boolean True while any tutorial is running, fade-out included
isAnyPresenting() boolean True while a tutorial is running and not yet fading out
getCurrentStep(tutorialId) number|nil 1-based step index, or nil if not active
isCompleted(tutorialId) boolean True if completed (persists across sessions)
isWindowFocused(groupId) boolean Focus-gate state for a group (true when the group declares no windows)

Completion

Function Returns Description
resetCompletion(tutorialId) - Clear completion state
resetTrigger(tutorialId) - Clear "already triggered" state
resetGroup(groupId) - Clear completion and trigger state for every tutorial in a group
resetAll() - Clear all completion and trigger state

resetGroup lowercases the id it is given, matching the ids forMod generates.

Groups

Function Returns Description
registerGroup(groupId, opts?) boolean Register a group (opts.enabled defaults to true)
isGroupEnabled(groupId) boolean Check if enabled (true for unregistered groups)
setGroupEnabled(groupId, enabled) - Set enabled state (persists automatically)
getGroups() table All group IDs mapped to enabled state (freshly built table, safe to keep)

Callbacks

Function Description
setCategoryCallback(fn, groupId?) Register callback for category auto-switching (global or per group)
setHider(hiderModule) Register the window hider module (WindowUtils does this at init)
reportWindowFocused(name) Mark a window name as focused this frame (WindowUtils calls this)

Settings

Function Returns Description
getSettings() table Current tutorial visual settings
setSetting(key, value) - Update a visual setting

getSettings() returns a shared table. It hands back the same internal table on every call, refilled from the live settings each time. Read from it immediately; do not cache it, do not hold it across frames, and do not write to it. Writes are ignored by the settings system and get overwritten on the next call. Use setSetting(key, value) to change a value, which writes to master settings and marks them dirty. An unknown key is ignored silently.

Visual settings:

Key Type Default Description
spotlightRounding number -1 Corner rounding (-1 = match UI frame rounding)
dimOpacity number 0.55 Background dim overlay opacity
dimColor {r,g,b} {0.122, 0.122, 0.122} Background dim color
blurEnabled boolean true Activate blur during tutorials
strokeMin number 1 Minimum spotlight outline thickness
strokeMax number 3 Maximum spotlight outline thickness
pulseSpeed number 4.0 Pulse animation speed (radians/sec)
pulseEasing string "easeInOut" Pulse curve: linear, easeIn, easeOut, easeInOut, bounce

Those eight keys are the whole map. The other tutorial settings (tutorialHideWindows, tutorialFocusElements, tutorialMaxWidthPct) are user-facing options in the WindowUtils panel and are not reachable through getSettings/setSetting.

Settings persist to data/settings.json automatically.

-- Example: building a settings UI for tutorial visuals
local ts = tutorial.getSettings()

local newOpacity = controls.SliderFloat(icon, "tut_opacity", ts.dimOpacity, 0, 1, {
    tooltip = "Dim background opacity",
    default = 0.55,
})
if newOpacity ~= ts.dimOpacity then
    tutorial.setSetting("dimOpacity", newOpacity)
end

Complete Example

local wu = GetMod("WindowUtils")
local tut = wu.Tutorial.forMod("MyMod")
local controls = wu.Controls

-- Register tutorials (group "mymod" is created automatically)
tut:register({
    id = "my_mod_intro",
    trigger = { type = "manual" },
    steps = {
        { target = "preset_combo", text = "Choose a preset to get started.", title = "Presets" },
        { target = "intensity_slider", text = "Fine-tune the effect intensity." },
        { target = "apply_btn", text = "Apply saves your changes to disk.", title = "Save" },
    },
})

-- In your draw function
controls.Combo(icon, "preset", idx, presets, { elementId = "preset_combo" })
controls.SliderFloat(icon, "intensity", val, 0, 1, { elementId = "intensity_slider" })
controls.Button("Apply", "active", -1, 0, { elementId = "apply_btn" })

-- Show Tutorials toggle (controls the implicit "mymod" group)
local tutEnabled = tut:isGroupEnabled()
local newEnabled, changed = controls.Checkbox("Show Tutorials", tutEnabled)
if changed then
    tut:setGroupEnabled(newEnabled)
end

-- Start button (only visible when not completed and group enabled)
if not tut:isCompleted("my_mod_intro") and tut:isGroupEnabled() then
    if controls.Button("Tutorial", "inactive") then
        tut:start("my_mod_intro")
    end
end

Clone this wiki locally