Class CalloutManager
Callouts are registered with add(Component, int, String) (or its grouped overload,
add(Component, int, String, String)) while a GUI is being built; use the
add(Component, int, String, String, int) overload if registration order does not match the desired
chain sequence. Nothing is displayed at that point. Once
the host window is fully realized, showAll(Object) or showPending(Object) displays every callout
registered for a given scope, as a single sequential, "Got it!"-dismissible chain, in registration order.
A callout's scope ("group") defaults to its owner's top-level window, so callouts registered without an explicit
group are automatically chained together with every other such callout in the same window; unrelated callers
sharing a window get independent chains only if each supplies its own, distinct group key. Dismissal is
persisted per callout (keyed off the owner's identity in the component tree), so showPending(Object)
does not repeat a chain the user already stepped through in an earlier session; showAll(Object) always
(re)displays it regardless, e.g., from a "replay tour" menu command.
Example:
// while building the GUI, e.g., in a dialog's constructor -- AFTER adding each button to its parent
// container, so the callout's persisted identity is derived from a stable position in the component tree:
toolbar.add(saveButton);
CalloutManager.add(saveButton, SwingConstants.BOTTOM, "Click here to save your work");
toolbar.add(exportButton);
CalloutManager.add(exportButton, CalloutManager.AUTO, "Export results to a spreadsheet or image stack");
// once the dialog is fully built. Safe to call even before it is showing: display of each callout is
// deferred automatically until its owner component is actually visible on screen
SwingUtilities.invokeLater(() -> CalloutManager.showPending(this));
// e.g., wired to a "Replay Tour" menu item, to show the same walkthrough again on demand, regardless
// of whether the user already stepped through (and dismissed) it in an earlier session
replayTourItem.addActionListener(e -> {
CalloutManager.hideAll();
CalloutManager.showAll(this);
});
Tips are unrelated to callouts: a tip is a single, standalone "tooltip"-like balloon shown on demand (e.g., a
rotating pool of tips behind a "hints" button), not part of an onboarding chain. It has no group, no persisted
dismissal, and no arrow -- showTip(Component, String, int) and showTip(Component, String, int, int) are entirely independent of add(Component, int, String)/showAll(Object) and friends
above.
Example:
// load (and shuffle) a plain-text, one-tip-per-line resource once, e.g. as a field or in a constructor;
// lineProcessor is called on every surviving line, so a caller-specific placeholder token (there is nothing
// hint-related about ctrlKey() below -- CalloutManager has no notion of it) can be substituted on load
final List<String> hints = CalloutManager.loadTips(MyDialog.class, "hints.txt",
line -> line.replace("ctrlKey()", myPlatformSpecificCtrlKeyLabel));
// several resources can be merged into a single shuffled pool, e.g. tips common to every mode plus a set
// specific to the current one; a missing/unreadable resource is skipped rather than failing the whole load
final List<String> hints2 = CalloutManager.loadTips(MyDialog.class,
List.of("hints-common.txt", myDialog.isAdvancedMode() ? "hints-advanced.txt" : "hints-basic.txt"),
UnaryOperator.identity());
// cycle through the pool each time a "hints" button is clicked; a new tip for the same owner automatically
// replaces (rather than stacks on top of) whichever one is already showing there
final int[] index = {0};
hintsButton.addActionListener(e -> {
CalloutManager.showTip(hintsButton, hints.get(index[0]), CalloutManager.AUTO, 30000); // auto-dismiss in 30s
index[0] = (index[0] + 1) % hints.size();
});
// or a one-off tip, e.g. contextual feedback after some action, left on screen until dismissed (Escape, a
// click elsewhere, or the owner going away) since no auto-dismiss delay is given
CalloutManager.showTip(resultsPanel, "Nothing found -- try widening your search", SwingConstants.TOP);
Adapted from HintManager, part of the FlatLaf demo application (Apache License 2.0, Copyright 2020 FormDev
Software GmbH, author Karl Tauber): flatlaf-demo/.../com/formdev/flatlaf/demo/HintManager.java
Like the rest of Swing, this class is not thread-safe: every public method that touches on-screen state runs
on (or is redirected via invokeLater to) the event dispatch thread, so calls to add(Component, int, String)/showTip(Component, String, int) and friends are expected to originate there, same as
any other Swing call.
- Author:
- Tiago Ferreira
-
Field Summary
FieldsModifier and TypeFieldDescriptionstatic final intSentinelpositionvalue foradd(Component, int, String): the side to display the callout on is chosen automatically, at display time, based on which side ofownercurrently has the most free screen space -
Method Summary
Modifier and TypeMethodDescriptionstatic StringRegisters a callout for later display viashowAll(Object)orshowPending(Object); does not show anything by itself.static StringRegisters a callout for later display; does not show anything itself.static StringSame asadd(Component, int, String, String), but with an explicit position in the eventual display chain, for when the orderadd()calls happen to be made in (e.g., interleaved with unrelated GUI-building code) does not match the desired callout sequence.static voidaddStateListener(Runnable listener) Registers a listener invoked (on the EDT) whenever any chain starts, ends, or is paused/resumed, for any scope.static voidaddStateListener(Runnable listener, Object group) Same asaddStateListener(Runnable), butlisteneris also unregistered automatically whengroupis cleared viaclearGroup(Object).static voidclearGroup(Object group) Discards everyadd(java.awt.Component, int, java.lang.String)-registered callout belonging togroup, hiding its chain if currently on screen.static voidClears the dismissed flag of the given callout keys, so they will be shown again by a subsequentshowPending(Object)static voidClears every dismissed flag ever recorded by this classstatic StringReturns a String key derived frominstance's identity, e.g. for use as thegrouppassed toadd(Component, int, String, String)/static voidhideAll()Hides all currently visible callouts without marking them as dismissedstatic booleanstatic booleanloadTips(Class<?> anchor, String classpathResource, UnaryOperator<String> lineProcessor) Loads a shuffled list of tips/hints from a plain-text classpath resource: blank lines and lines starting with#(comments) are skipped, every other line is trimmed and passed throughlineProcessor(e.g., to substitute a placeholder token with a platform-specific key name), then the result is shuffled.AsloadTips(Class, String, UnaryOperator), but merges tips from several classpath resources into a single shuffled pool (e.g., a set of tips common to all modes plus a set specific to the current mode).static voidHides (without dismissing or advancing) whichever callout is currently on screen forscope, leaving the chain's position untouched soresume(Object)(orshowAllOrAdvance(Object)) shows it again exactly where it was.static voidremoveStateListener(Runnable listener) static voidReversespause(Object): re-shows whichever callout was hidden forscope.static voidDisplays, as a sequential "Got it!"static voidshowAllOrAdvance(Object scope) Same asshowAll(Object), except that if a chain forscopeis already on screen, this advances it instead (as if the user had pressed "Got It!"static voidshowPending(Object scope) Same asshowAll(Object), but skips (and does not re-show) callouts already dismissed in an earlier sessionstatic voidDisplays a single, standalone balloon pointing atowner-- e.g., a rotating one-liner tip cycled on each click of some ever-present control -- entirely outside theadd(Component, int, String)/showAll(Object)machinery: it is never registered, belongs to no group or chain, and its dismissal is never persisted.static voidSame asshowTip(Component, String, int), but auto-dismissed afterautoDismissMsif the user does not close it first;autoDismissMs <= 0means no timeout (dismissed only by its close button, or Escape)static intsize()static voidtogglePause(Object scope)
-
Field Details
-
AUTO
public static final int AUTOSentinelpositionvalue foradd(Component, int, String): the side to display the callout on is chosen automatically, at display time, based on which side ofownercurrently has the most free screen space- See Also:
-
-
Method Details
-
add
Registers a callout for later display viashowAll(Object)orshowPending(Object); does not show anything by itself. Equivalent toadd(owner, position, message, null): the callout's group defaults to the owner's own top-level window.positionmay beAUTOto pick the side automatically at display time -
add
Registers a callout for later display; does not show anything itself.Registering again for the exact same
ownerinstance (reference equality, regardless of whether it generates the same key) replaces the earlier registration in place, preserving its original position in the eventual display orderThe returned key is derived from
owner's position in its component tree, so it is only reliably unique onceownerhas at least been added to its parent container (it need not be showing yet); calladd()after that, not before, to avoid two distinct, still-unparented components of the same type generating the same persisted-dismissal key- Parameters:
owner- the component the callout will point atposition- theSwingConstantsside ofownerthe callout is displayed on, orAUTOto pick, at display time, whichever side currently has the most free screen spacemessage- the (HTML-capable) message to displaygroup- an explicit key callouts sharing it are displayed together by, as one chain;nulldefaults toowner's top-level window, so callers that both leavegroupunset are automatically swept into the same chain whenever their owners share a window, pass a distinct group key if that is not wanted.- Returns:
- a stable key identifying this registration, usable with
forget(String...) - See Also:
-
add
Same asadd(Component, int, String, String), but with an explicit position in the eventual display chain, for when the orderadd()calls happen to be made in (e.g., interleaved with unrelated GUI-building code) does not match the desired callout sequence.orderis only a sort key, not a list index: entries sharing a chain are sorted by it, ties (including every entry that omits an explicit order, viaadd(Component, int, String)oradd(Component, int, String, String)) broken by registration order. Values need not be contiguous, unique, or bounded by the eventual chain length; an "out of range" order simply sorts to whichever end it is closest to, it never throws- Parameters:
order- this callout's position in its group's chain, relative to other explicitly-ordered entries
-
showAll
Displays, as a sequential "Got it!"-dismissible chain, every callout registered forscope(aComponent, resolved to its top-level window, or an explicit group key used withadd(Component, int, String, String)), regardless of whether it was already dismissed in an earlier session -
showPending
Same asshowAll(Object), but skips (and does not re-show) callouts already dismissed in an earlier session -
showAllOrAdvance
Same asshowAll(Object), except that if a chain forscopeis already on screen, this advances it instead (as if the user had pressed "Got It!" on whichever callout is currently showing), rather than hiding it and restarting the chain.Wire a "tour"/"hints" button's action listener to this instead of
showAll(Object)directly: users routinely click such a button again to page to the next tip once a tour has started, andshowAll()would otherwise restart the whole chain on every click -
showTip
Displays a single, standalone balloon pointing atowner-- e.g., a rotating one-liner tip cycled on each click of some ever-present control -- entirely outside theadd(Component, int, String)/showAll(Object)machinery: it is never registered, belongs to no group or chain, and its dismissal is never persisted. Calling this repeatedly for the sameowner(e.g., a new tip string on every click) simply shows a new balloon each time; nothing here can be pulled into -- or interfere with -- an actual onboarding chain running viashowAll(Object)/showPending(Object), even one sharing the same window orowner.Rendered without the directional arrow
add(Component, int, String)callouts use: that arrow means "this text describes what I point to", which does not hold for a tip whose content is typically unrelated toowner(owner is merely where the tip happens to surface, e.g., the button that was clicked)Dismissed by its own close button, or by Escape (which, unlike its effect on a chain, closes the tip outright rather than merely pausing it -- a standalone tip has no "resume where I left off" state to preserve)
- Parameters:
owner- the component the tip is anchored nearmessage- the (HTML-capable) message to displayposition- theSwingConstantsside ofownerto display on, orAUTOto pick automatically
-
showTip
Same asshowTip(Component, String, int), but auto-dismissed afterautoDismissMsif the user does not close it first;autoDismissMs <= 0means no timeout (dismissed only by its close button, or Escape) -
loadTips
public static List<String> loadTips(Class<?> anchor, String classpathResource, UnaryOperator<String> lineProcessor) Loads a shuffled list of tips/hints from a plain-text classpath resource: blank lines and lines starting with#(comments) are skipped, every other line is trimmed and passed throughlineProcessor(e.g., to substitute a placeholder token with a platform-specific key name), then the result is shuffled. Deliberately agnostic about what, if anything, needs substituting in a line: that is entirely up to the caller-suppliedlineProcessor, so this class needs no knowledge of any particular token scheme- Parameters:
anchor- the resource is resolved via this class's class loader, notCalloutManager's own, nor the calling thread's context class loader: the resource lives in the caller's module/jar (e.g., SNT's), which this class -- by design -- knows nothing about, and a thread's context class loader is not guaranteed to see it either (it may benull, e.g. on a background worker thread, or scoped to some other module entirely). Pass, e.g.,SNTUI.classclasspathResource- the resource path (e.g.,"gui/hints.txt"), resolved the same wayanchor.getClassLoader().getResourceAsStream(...)wouldlineProcessor- applied to each surviving line before it is added to the result;null(orUnaryOperator.identity()) to leave lines unmodified- Returns:
- the shuffled tips, or a single-element fallback list if the resource could not be read
-
loadTips
public static List<String> loadTips(Class<?> anchor, List<String> classpathResources, UnaryOperator<String> lineProcessor) AsloadTips(Class, String, UnaryOperator), but merges tips from several classpath resources into a single shuffled pool (e.g., a set of tips common to all modes plus a set specific to the current mode). Each resource is read independently: one that is missing or unreadable is skipped (and logged) rather than aborting the whole load, so a single bad/renamed file does not take down the others. The fallback single-element list is only returned if none of the requested resources yielded any tips- Parameters:
anchor- seeloadTips(Class, String, UnaryOperator)classpathResources- the resource paths to load and merge, e.g.List.of("gui/hints-common.txt", "gui/hints-stream.txt")lineProcessor- applied to each surviving line before it is added to the result;null(orUnaryOperator.identity()) to leave lines unmodified- Returns:
- the shuffled, merged tips, or a single-element fallback list if no resource could be read
-
pause
Hides (without dismissing or advancing) whichever callout is currently on screen forscope, leaving the chain's position untouched soresume(Object)(orshowAllOrAdvance(Object)) shows it again exactly where it was. A no-op if no chain is currently active forscope -
resume
Reversespause(Object): re-shows whichever callout was hidden forscope. A no-op if no chain is currently active forscope, or it was not paused -
togglePause
-
isPaused
- Returns:
- whether the callout currently on screen for
scope(if any) is paused. Alwaysfalseif no chain is currently active forscope
-
isActive
- Returns:
- whether a chain is currently active (on screen, or paused/hidden mid-chain) for
scope. Handy for driving a "tour" button's icon between an idle/playing/paused state
-
addStateListener
Registers a listener invoked (on the EDT) whenever any chain starts, ends, or is paused/resumed, for any scope. Intended for driving a UI element, such as a "tour" button's icon, that needs to reflectisActive(Object)/isPaused(Object)accurately regardless of what triggered the change (this class' own API, or Escape, or a callout's own "Got It!" button) -
addStateListener
Same asaddStateListener(Runnable), butlisteneris also unregistered automatically whengroupis cleared viaclearGroup(Object).- Parameters:
group- the group whose cleanup should also unregisterlistener(seegroupFor(Object))
-
removeStateListener
-
hideAll
public static void hideAll()Hides all currently visible callouts without marking them as dismissed -
forget
Clears the dismissed flag of the given callout keys, so they will be shown again by a subsequentshowPending(Object)- Parameters:
prefsKeys- the preference keys to clear;nullis equivalent to callingforgetAll().
-
forgetAll
public static void forgetAll()Clears every dismissed flag ever recorded by this class -
clearGroup
Discards everyadd(java.awt.Component, int, java.lang.String)-registered callout belonging togroup, hiding its chain if currently on screen.Unlike
hideAll()(only hides whatever chain is currently visible, without forgetting it) orforget(String...)/forgetAll()(only clear persisted dismissal so a chain can be replayed), this permanently removesgroup's entries fromregistrations. Call it when the session that registered them is going away, e.g., when closing a window or shutting down a program: Without clearing the group, the group key itself (and anything reachable from it) is kept alive, as well as itsregistrationslist.Callouts registered without an explicit group (i.e., scoped to their owner's own top-level window) are unaffected unless that window itself is passed as
group.showTip(Component, String, int)balloons are always unaffected: they are never part ofregistrationsto begin with, having no group of their own.Also unregisters any listener added for
groupviaaddStateListener(Runnable, Object).- Parameters:
group- the group previously passed toadd(Component, int, String, String)(or resolved implicitly, if aComponent/Windowwhose owners were registered without an explicit group)
-
groupFor
Returns a String key derived frominstance's identity, e.g. for use as thegrouppassed toadd(Component, int, String, String)/A
groupis a plainString, precisely so this class can never be handed (and made to hold on to, for as long as the entry is registered) an arbitrary live object; this method is the sanctioned way to scope by such an object anyway. The returned key holds no reference back toinstance: it is derived once, frominstance's identity hash code and class name, and never looked at again. Two calls with the very same instance (while it is still alive) return equal keys.- Parameters:
instance- the object whose identity to derive a group key from; never retained by this class
-
size
public static int size()- Returns:
- the number of registered callouts (i.e., past
add(Component, int, String)calls, whether they have been displayed yet). Handy for assigning explicit, gap-freeordervalues to a batch ofadd()calls that should continue on from where an earlier batch (e.g., in a different class) left off
-