Class CalloutManager

java.lang.Object
sc.fiji.snt.gui.CalloutManager

public class CalloutManager extends Object
Displays dismissible "speech bubble" callouts anchored to a component, for building onboarding/first-run walkthroughs of a GUI.

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 Details

    • AUTO

      public static final int AUTO
      Sentinel position value for add(Component, int, String): the side to display the callout on is chosen automatically, at display time, based on which side of owner currently has the most free screen space
      See Also:
  • Method Details

    • add

      public static String add(Component owner, int position, String message)
      Registers a callout for later display via showAll(Object) or showPending(Object); does not show anything by itself. Equivalent to add(owner, position, message, null): the callout's group defaults to the owner's own top-level window. position may be AUTO to pick the side automatically at display time
    • add

      public static String add(Component owner, int position, String message, String group)
      Registers a callout for later display; does not show anything itself.

      Registering again for the exact same owner instance (reference equality, regardless of whether it generates the same key) replaces the earlier registration in place, preserving its original position in the eventual display order

      The returned key is derived from owner's position in its component tree, so it is only reliably unique once owner has at least been added to its parent container (it need not be showing yet); call add() 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 at
      position - the SwingConstants side of owner the callout is displayed on, or AUTO to pick, at display time, whichever side currently has the most free screen space
      message - the (HTML-capable) message to display
      group - an explicit key callouts sharing it are displayed together by, as one chain; null defaults to owner's top-level window, so callers that both leave group unset 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

      public static String add(Component owner, int position, String message, String group, int order)
      Same as add(Component, int, String, String), but with an explicit position in the eventual display chain, for when the order add() calls happen to be made in (e.g., interleaved with unrelated GUI-building code) does not match the desired callout sequence.

      order is 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, via add(Component, int, String) or add(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

      public static void showAll(Object scope)
      Displays, as a sequential "Got it!"-dismissible chain, every callout registered for scope (a Component, resolved to its top-level window, or an explicit group key used with add(Component, int, String, String)), regardless of whether it was already dismissed in an earlier session
    • showPending

      public static void showPending(Object scope)
      Same as showAll(Object), but skips (and does not re-show) callouts already dismissed in an earlier session
    • showAllOrAdvance

      public static void showAllOrAdvance(Object scope)
      Same as showAll(Object), except that if a chain for scope is 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, and showAll() would otherwise restart the whole chain on every click

    • showTip

      public static void showTip(Component owner, String message, int position)
      Displays a single, standalone balloon pointing at owner -- e.g., a rotating one-liner tip cycled on each click of some ever-present control -- entirely outside the add(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 same owner (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 via showAll(Object)/showPending(Object), even one sharing the same window or owner.

      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 to owner (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 near
      message - the (HTML-capable) message to display
      position - the SwingConstants side of owner to display on, or AUTO to pick automatically
    • showTip

      public static void showTip(Component owner, String message, int position, int autoDismissMs)
      Same as showTip(Component, String, int), but auto-dismissed after autoDismissMs if the user does not close it first; autoDismissMs <= 0 means 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 through lineProcessor (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-supplied lineProcessor, so this class needs no knowledge of any particular token scheme
      Parameters:
      anchor - the resource is resolved via this class's class loader, not CalloutManager'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 be null, e.g. on a background worker thread, or scoped to some other module entirely). Pass, e.g., SNTUI.class
      classpathResource - the resource path (e.g., "gui/hints.txt"), resolved the same way anchor.getClassLoader().getResourceAsStream(...) would
      lineProcessor - applied to each surviving line before it is added to the result; null (or UnaryOperator.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)
      As loadTips(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 - see loadTips(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 (or UnaryOperator.identity()) to leave lines unmodified
      Returns:
      the shuffled, merged tips, or a single-element fallback list if no resource could be read
    • pause

      public static void pause(Object scope)
      Hides (without dismissing or advancing) whichever callout is currently on screen for scope, leaving the chain's position untouched so resume(Object) (or showAllOrAdvance(Object)) shows it again exactly where it was. A no-op if no chain is currently active for scope
    • resume

      public static void resume(Object scope)
      Reverses pause(Object): re-shows whichever callout was hidden for scope. A no-op if no chain is currently active for scope, or it was not paused
    • togglePause

      public static void togglePause(Object scope)
      Toggles pause(Object)/resume(Object) for scope. A no-op if no chain is currently active for scope
    • isPaused

      public static boolean isPaused(Object scope)
      Returns:
      whether the callout currently on screen for scope (if any) is paused. Always false if no chain is currently active for scope
    • isActive

      public static boolean isActive(Object scope)
      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

      public static void addStateListener(Runnable listener)
      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 reflect isActive(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

      public static void addStateListener(Runnable listener, Object group)
      Same as addStateListener(Runnable), but listener is also unregistered automatically when group is cleared via clearGroup(Object).
      Parameters:
      group - the group whose cleanup should also unregister listener (see groupFor(Object))
    • removeStateListener

      public static void removeStateListener(Runnable listener)
    • hideAll

      public static void hideAll()
      Hides all currently visible callouts without marking them as dismissed
    • forget

      public static void forget(String... prefsKeys)
      Clears the dismissed flag of the given callout keys, so they will be shown again by a subsequent showPending(Object)
      Parameters:
      prefsKeys - the preference keys to clear; null is equivalent to calling forgetAll().
    • forgetAll

      public static void forgetAll()
      Clears every dismissed flag ever recorded by this class
    • clearGroup

      public static void clearGroup(Object group)
      Discards every add(java.awt.Component, int, java.lang.String)-registered callout belonging to group, hiding its chain if currently on screen.

      Unlike hideAll() (only hides whatever chain is currently visible, without forgetting it) or forget(String...)/forgetAll() (only clear persisted dismissal so a chain can be replayed), this permanently removes group's entries from registrations. 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 its registrations list.

      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 of registrations to begin with, having no group of their own.

      Also unregisters any listener added for group via addStateListener(Runnable, Object).

      Parameters:
      group - the group previously passed to add(Component, int, String, String) (or resolved implicitly, if a Component/Window whose owners were registered without an explicit group)
    • groupFor

      public static String groupFor(Object instance)
      Returns a String key derived from instance's identity, e.g. for use as the group passed to add(Component, int, String, String)/

      A group is a plain String, 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 to instance: it is derived once, from instance'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-free order values to a batch of add() calls that should continue on from where an earlier batch (e.g., in a different class) left off