TipToe 0.2.0
dotnet add package TipToe --version 0.2.0
NuGet\Install-Package TipToe -Version 0.2.0
<PackageReference Include="TipToe" Version="0.2.0" />
<PackageVersion Include="TipToe" Version="0.2.0" />
<PackageReference Include="TipToe" />
paket add TipToe --version 0.2.0
#r "nuget: TipToe, 0.2.0"
#:package TipToe@0.2.0
#addin nuget:?package=TipToe&version=0.2.0
#tool nuget:?package=TipToe&version=0.2.0
TipToe
A quiet, careful and deliberate WinUI TeachingTip orchestrator.
Declare your teaching tips once. TipToe decides when — and whether — each one is worth showing.
using TipToe;
var guide = Guide.Attach(this, RootGrid);
guide.Add(new Tip
{
Id = "search",
Title = "Find anything",
Subtitle = "Filters the list as you type.",
Target = () => SearchBox,
When = () => TaskCount >= 5,
});
…and, in the feature itself:
guide.Exercise("search");
That is the whole contract. TipToe will not show that tip if the user has already used search, already dismissed it, is mid-drag, is looking at another app, saw a different tip four seconds ago, saw this tip earlier today, or the anchor is not currently on screen.
The principle throughout: a teaching tip at the wrong moment is worse than no teaching tip at all. Almost everything the library does is a reason not to show something.
<sub>The runnable sample — samples/TipToe.Sample — with the inspector docked
beside it. Every verdict on the right is computed by the same code that decides what to show.</sub>
Install
dotnet add package TipToe
net8.0-windows10.0.19041.0. Works in packaged and unpackaged apps. One AnyCPU assembly, no
resource index, no per-architecture assets.
Try it
git clone https://github.com/arcadiogarcia/TipToe
dotnet run --project TipToe/samples/TipToe.Sample -p:Platform=x64
A small task list with five tips and the inspector beside it, on shortened timings so the whole lifecycle is watchable. Every snippet below is that app's real code. What to do with it, and where each pattern lives.
Decide what deserves a tip
Before any of the API: the sample app has nine or so features and ships five tips. It teaches pressing Enter, and it does not teach the button labelled Add. It teaches double-click to rename, and it does not teach the button labelled Star.
Two rules produce that:
- Teach what is invisible. A tip that points at a labelled button to restate its label is the purest form of the noise this library exists to avoid.
- Teach at the moment the user has the question. Every tip is gated on the state that makes it worth reading — the user typing, a task vanishing, a list getting long enough to need filtering.
Everything below is machinery for the second rule. The first one is yours.
When a tip appears
A tip is shown only when every one of these is true, checked in this order:
| Gate | Verdict when it fails | |
|---|---|---|
| 1 | Tips are enabled | TipsDisabled |
| 2 | The user has not exercised the feature | AlreadyExercised |
| 3 | The user has not dismissed the tip | Dismissed |
| 4 | It is under its show budget (MaxShows, default 3) |
MaxShowsReached |
| 5 | Its prerequisites have been exercised | PrerequisiteNotMet |
| 6 | Its When condition is true |
ConditionNotMet |
| 7 | It has not been shown within RepeatDelay (default 1 day) |
RepeatDelay |
| 8 | No other tip is showing, or this one interrupts it | AnotherTipShowing |
| 9 | CooldownBetweenTips has elapsed (default 2 s) |
Cooldown |
| 10 | The session cap is not reached (default: none) | SessionLimit |
| 11 | StartupGrace has elapsed (default 2 s) |
StartupGrace |
| 12 | The window is active | WindowNotActive |
| 13 | No suppressor is active | Suppressed |
| 14 | The user has been idle for UserIdleFor (default 2 s) |
UserNotIdle |
| 15 | The anchor resolves and is on screen | NoAnchor |
The order is load-bearing — durable facts about the user first, so a retired tip reports retired; the expensive anchor walk last — and a test walks the whole ladder to prove the code and this table have not drifted apart.
Gates 8–11 are the only ones a tip can opt out of, with Interrupts — see
Correcting a mistake.
Inspect() reports which of these each tip is currently sitting on, computed through the same code
path the runtime uses — so what it says and what happens cannot disagree.
Nothing shows a tip except a re-evaluation. There is no event handler anywhere that reacts to something by teaching. See docs/triggers.md for what causes an evaluation, what restarts the idle clock, the gate ladder as a diagram, and the state machine of a single tip.
Telling TipToe about your app
Suppressors — "not now", named so a blocked tip can explain itself. Both of the sample's read state the app already had:
guide.AddSuppressor("composing", () => NewTaskBox.Text.Length > 0);
guide.AddSuppressor("dialog", () => _dialogOpen);
While the user is typing a new task, every tip's verdict reads
Suppressed - composing. Open the rename dialog and they all readSuppressed - dialog, and anything already on screen is retired — a tip pointing at a control behind a modal is pointing at something the user cannot reach.
A tip can opt out of one with Ignores, which is how a tip about typing survives the suppressor
that exists to stop tips arriving mid-type:
new Tip
{
Id = "quick-add",
Title = "Press Enter to add",
Subtitle = "Keep typing to add the next one without leaving the keyboard.",
Target = () => NewTaskBox,
When = () => NewTaskBox.Text.Length > 0,
RequireUserIdle = false, // comments on what the user is doing *right now*
Ignores = ["composing"],
}
Both opt-outs are load-bearing here. Without
Ignoresthe tip is suppressed by its own trigger condition; withoutRequireUserIdle = falseit arrives after the user has given up and reached for the mouse, which is the exact thing it was trying to save them.
Correcting a mistake
Some tips are not onboarding — they answer something the user just did wrong. Set Interrupts:
new Tip
{
Id = "roll-by-dragging",
Title = "Drag a die out to roll it",
Subtitle = "Clicking does nothing — flick it out of the window instead.",
Target = () => LastClickedDie,
Priority = -1, // outranks the onboarding tips it may displace
RequireUserIdle = false, // the user has just clicked, so they are not idle
Ignores = ["engaged"],
Interrupts = true,
}
Interrupts says this tip answers something the user just did, and buys exactly two things:
- it may take the screen from a tip of strictly lower priority (a higher
Prioritynumber), which closes withTipCloseKind.Superseded; - it is exempt from the three gates that pace the conversation rather than judge the tip —
Cooldown,SessionLimitandStartupGrace.
Everything else still applies, deliberately: a tip the user has exercised, dismissed or exhausted
stays gone, and When, Prerequisites, RepeatDelay, un-Ignoresd suppressors, the window being
active and the anchor resolving all still gate it. Interrupts overrides the library's manners,
never the user's decisions.
The strictly-lower-priority rule settles ties in favour of the tip the user is already reading, and makes an interrupt loop impossible by construction — two tips can never each outrank the other. Displacing is not instant: the showing tip is asked to close and the replacement is chosen again once it has, so a correction the user has meanwhile made unnecessary simply never appears.
Exercised — call from the feature, never from near the tip, so a user who discovered it on their own is never taught it afterwards. Safe to call every time; only the first has any effect. How narrow the call is matters:
private void OnNewTaskKeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Key != VirtualKey.Enter) return;
AddFromBox();
guide.Exercise("quick-add"); // the Add *button* deliberately does not
}
The tip teaches the shortcut, so only the shortcut retires it. Wiring
ExerciseintoAddFromBoxwould retire the tip for someone who has only ever clicked the button — the one person who still needs it.
Sequencing — Prerequisites says "not until they have done that", declaratively:
new Tip
{
Id = "rename",
Title = "Double-click to rename",
Target = () => TaskList,
Prerequisites = ["search"],
When = () => TaskList.SelectedItem is not null,
MaxShows = 1,
}
Expressing that inside
Whenwould behave identically and diagnose worse: the inspector would sayConditionNotMetinstead of naming the tip that is actually holding this one back.
Activity from elsewhere — a single window is not the whole app:
secondWindow.PointerPressed += (_, _) => guide.ReportActivity();
Without it, a user busy in a second window is indistinguishable from a user who has walked away, and the guide happily decides they are idle enough to teach.
Options
Every value has a defensible default; nothing is required.
var guide = Guide.Attach(window, host, new TipToeOptions
{
UserIdleFor = TimeSpan.FromSeconds(2),
StartupGrace = TimeSpan.FromSeconds(2),
CooldownBetweenTips = TimeSpan.FromSeconds(2),
RepeatDelay = TimeSpan.FromDays(1),
MaxTipsPerSession = 0, // unlimited
DefaultMaxShows = 3,
StateRevision = 1, // bump to discard everything persisted
Log = m => Debug.WriteLine(m),
Presenter = new TeachingTipPresenter { StyleTip = t => t.Background = MyBrush },
});
Validate() runs at attach and throws on a configuration that could never work — a guide that runs
perfectly and shows nothing is a bad way to find that out.
UserIdleFor and StartupGrace both count from Attach, so they overlap: the first tip lands
after max(UserIdleFor, StartupGrace) plus a poll, not their sum — ≈2.0–2.5 s at these
defaults, where the two coincide and StartupGrace therefore adds nothing. See
How long until the first tip before tuning either.
Bring your own control
TipToe owns whether and when; it does not own what it looks like. Implement ITipPresenter to
use a custom coach-mark, callout or inline banner, globally or for a single tip:
public sealed class MyCoachMark : ITipPresenter
{
public void Initialize(Panel host) { ... }
public bool IsShowing => ...;
public void Show(TipPresentation p) { ... }
public void Hide() { ... }
public event EventHandler<TipClosedEventArgs>? Closed;
}
TeachingTipPresenter is the default and carries all the WinUI-specific handling, so an app that
wants none of it pays nothing for it.
Storage
Defaults to app-local settings when the app has package identity, and a JSON file under
%LOCALAPPDATA% when it does not. Supply your own ITipStore to put tip state anywhere else — it
is a three-member string-to-string interface.
Debugging
foreach (var d in guide.Inspect())
Debug.WriteLine(d); // command-palette: UserNotIdle (3.4s left) [shown 1]
or drop in the panel:
DebugPane.Content = new TipsInspector { Guide = guide };
<img src="docs/images/tips-inspector.png" alt="The tips inspector, listing each tip's verdict" width="380" />
It lists every tip, why each is or is not showing, and offers per-tip and global resets. It only polls while it is on screen.
Two habits make it read correctly: check IsShowing before Block (a tip on screen still reports
the gate its own show closed behind it), and treat Detail as optional — it is only populated for
verdicts with a specific cause, like the suppressor's name or the time left on a delay.
Cost
One DispatcherQueueTimer at 500 ms, and only while it can do something:
- stopped whenever the window is not active,
- stopped permanently once every tip has been exercised, dismissed or exhausted — the normal state of a returning user's install, where the library costs nothing at all,
- otherwise it runs, because eligibility is time-based ("idle for two seconds", "not shown today") and nothing raises an event when a duration elapses.
No XAML, no resource index, no background thread, no telemetry, no network.
Why it behaves the way it does
See docs/design-notes.md — presence vs engagement vs idleness, refunding
tips that never rendered, why GetLastInputInfo was rejected, and why the package contains no XAML.
docs/triggers.md is the operational companion: what causes an evaluation, what restarts the idle clock, and the state machine of a single tip.
License
MIT © Arcadio Garcia
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0-windows10.0.19041 is compatible. net9.0-windows was computed. net10.0-windows was computed. |
-
net8.0-windows10.0.19041
- Microsoft.WindowsAppSDK (>= 1.6.240923002)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.