limn
    ↑↓ ChooseEnter OpenEsc Close
    GitHub

    Documentation/Guided tours

    Guided tours

    How to point at controls that already exist and explain them one at a time: a first-run tour, or a single hint on a new feature.

    CoachMark

    A bubble with a caret towards a control and a ring around it. Use it to teach; for a hint on hover use a Tooltip, and for a question use an AlertDialog.

    Example

    let root = CoachMark::new(root, "tour", "new-task", palette)
        .title("Nueva tarea")
        .body("Cada tarea es una conversación con un agente sobre tu proyecto.")
        .placement(CoachSide::Right, CoachAlign::Start)
        .step(0, 3)
        .render();

    It wraps the window’s root, like Modal. The target is the ID of a button, a clickable, an input or a scroll area built anywhere under that root. The place where the control is built does not change.

    Options

    BuilderEffect
    .title(text), .body(text)The heading and one or two sentences. The body wraps and is never cut.
    .illustration(element)A picture above the title.
    .step(index, count)Counts from zero. Shows “2 of 5”, hides the secondary action on the first step and labels the primary action “Got it” on the last.
    .placement(side, align)CoachSide::{Above, Below, Left, Right} and CoachAlign::{Start, Center, End}. Below and start unless set.
    .also(id)Rings the area the targets cover together, such as a group of buttons.
    .accent(fill, ink)Paints the bubble, the caret and the ring in a statement color of the host.
    .dim(color)Dims the window around the target.
    .skip(label)Adds an action to leave the tour, on every step but the last.
    .next_label, .done_label, .back_label, .counter(text)Replace “Next”, “Got it”, “Back” and the counter. Without them the words come from the library’s labels.
    .on_next(action), .on_back(action), .on_skip(action)Report another action. The controls keep their own IDs.
    .width(px)320 unless set, never under 220.
    .key(text)Names the step when two steps share a target.
    .open(bool)Renders the root alone while false.

    Without .accent the bubble takes the palette’s solid role: palette.text as its fill and palette.surface as its ink. It is light in a dark theme and dark in a light one, so it never reads as a menu or a tooltip.

    Actions

    ActionWhen
    Clicked { id }The close button, or Escape.
    Clicked { "{id}:next" }The primary action, Enter, or the Right arrow. On the last step it means the tour is over.
    Clicked { "{id}:back" }The secondary action, or the Left arrow. Not on the first step.
    Clicked { "{id}:skip" }The skip action.

    Keyboard

    KeyEffect
    EscapeReports id.
    Right, LeftReport the next and the back action, whichever button has focus.
    Enter, SpaceActivate the focused button; a new step focuses the primary action.
    TabMoves among the bubble’s own buttons.

    Motion

    WhatDuration
    The bubble and the ring appear and leave like any popover.enter_ms, exit_ms
    On a new step both spring from the last target to the new one.move_ms

    Buttons take input at the new place at once. A resize moves the ring with its target without a trail. With reduced motion a step is in place on its first frame.

    Notes

    • While a step shows, the rest of the window takes no input, and a click outside the bubble does nothing. A stray click neither loses the tour nor acts on what is behind it. The close button and Escape always end it.
    • Focus returns to the control that had it when the bubble goes.
    • A target inside a scroll area is scrolled into view when its step starts.
    • The ring takes the target’s own corner radius.
    • When no control with the target’s ID is laid out in view, nothing is painted, the window stays usable, and the frame names the target. See A target that is not there.
    • When no side of the target has room, the bubble stays whole inside the window, covers part of the target and has no caret.

    Tour

    A sequence of coach marks. The host owns the index of the current step.

    let steps = vec![
        TourStep::new("new-task", "Nueva tarea", "Cada tarea es una conversación."),
        TourStep::new("composer", "Escribe aquí", "Cuéntale al agente qué necesitas.")
            .placement(CoachSide::Above, CoachAlign::Center),
    ];
    let root = Tour::new(root, "tour", steps, state.tour_step, palette).render();
    match action {
        UiAction::Clicked { id } if id == "tour:next" => {
            state.tour_step = state.tour_step.map(|step| step + 1).filter(|step| *step < STEPS);
        }
        UiAction::Clicked { id } if id == "tour:back" => {
            state.tour_step = state.tour_step.map(|step| step.saturating_sub(1));
        }
        UiAction::Clicked { id } if id == "tour" || id == "tour:skip" => state.tour_step = None,
        _ => {}
    }

    With the step at None, or past the end, Tour renders the root alone. Tour::coach_mark() returns the current step as a CoachMark, to set what the whole tour shares: .accent, .dim, .skip and the labels.

    A target that is not there

    A step may point at a control the window is not showing: a closed panel, a button a narrow window hides. After each layout, ask the frame:

    let frame = tree.layout(size);
    if let Some(target) = frame.missing_coach_target() {
        // Open what holds the control, or move on in the direction of travel.
        state.tour_step = state.tour_step.map(|step| step + 1);
    }

    Nothing is painted for that frame, so a skipped step never flashes a bubble in a corner.

    Writing a tour

    • One idea per step. Say what the control is for, not what it is called.
    • Keep the body under about 140 characters. Two short lines read at a glance.
    • Do not start a tour over work in progress. Offer it, and keep a way to start it again later.
    • Remember that a tour was finished or dismissed, so it is offered once.

    See also