Keyboard Hints

Components for displaying keyboard shortcut hints to users.

Single Hint Badge


create_modifier_key_hint


def create_modifier_key_hint(
    modifier:str, # modifier key name (e.g., "shift", "ctrl")
    key_icon_or_text:Union[str, FT], # the main key icon or text
    description:str, # action description
    style:str='ghost', # badge style
)->Div: # hint badge with modifier + key

Create a hint badge with a modifier key and main key.


create_nav_icon_hint


def create_nav_icon_hint(
    icon_name:str, # lucide icon name (e.g., "arrow-down-up")
    description:str, # action description
    style:str='ghost', # badge style
)->Div: # hint badge with icon

Create a hint badge with a lucide icon.


render_hint_badge


def render_hint_badge(
    key_display:Union[str, FT], # formatted key string or icon component
    description:str, # action description
    style:str='ghost', # badge style (ghost, outline, soft, dash)
    auto_icon:bool=False, # auto-convert known keys to icons
)->Div: # hint badge component

Render a single keyboard hint as a badge.


get_key_icon


def get_key_icon(
    key_name:str, # key name to look up (case-insensitive)
    size:int=3, # icon size (V11 dense_inline role)
)->FT | None: # icon component or None if no icon mapping

Get a lucide icon for a key name, if one exists.

# Test hint badge with string
from fasthtml.common import to_xml

hint_badge = render_hint_badge("Space", "Select")
html = to_xml(hint_badge)
assert "Space" in html
assert "Select" in html
assert "badge" in html

# Test hint badge with icon
icon_hint = create_nav_icon_hint("arrow-down-up", "Navigate")
html = to_xml(icon_hint)
assert "Navigate" in html
assert "svg" in html  # Icon is an SVG

# Test auto_icon conversion
delete_hint = render_hint_badge("Delete", "Remove item", auto_icon=True)
html = to_xml(delete_hint)
assert "svg" in html  # Should convert to trash icon
assert "Remove item" in html

# Test get_key_icon
assert get_key_icon("shift") is not None
assert get_key_icon("delete") is not None
assert get_key_icon("unknown_key") is None

# Test modifier key hint (Shift + arrow)
shift_nav = create_modifier_key_hint("shift", lucide_icon("arrow-down-up", size=icons.dense_inline), "Reorder")
html = to_xml(shift_nav)
assert "svg" in html
assert "Reorder" in html

Hint Group


render_hint_group


def render_hint_group(
    group_name:str, # group header text
    hints:list[tuple[str, str]], # list of (key_display, description) tuples
    badge_style:str='ghost', # badge style for this group
)->Div: # group container with header and hints

Render a group of related keyboard hints.

# Test hint group with string keys
group = render_hint_group(
    "Navigation",
    [("W/S", "Move"), ("A/D", "Switch")]
)
html = to_xml(group)
assert "Navigation" in html
assert "Move" in html
assert "Switch" in html

Hints from Actions


group_actions_by_hint_group


def group_actions_by_hint_group(
    actions:tuple[KeyAction, ...], # actions to group
)->dict[str, list[KeyAction]]: # grouped actions

Group actions by their hint_group attribute.


mode_context_label


def mode_context_label(
    action:KeyAction, # action whose mode constraints to summarize
)->Optional[str]: # short chip-friendly label, or None for unrestricted actions

Derive a short mode-context label from a KeyAction’s mode constraints.

Returns None when the action has no mode restrictions (works in any mode — no chip should render). Returns the mode name(s) when mode_names is set. Returns ‘default’ when not_modes is set (action is excluded from specified modes, so it fires in the default mode).

Examples: mode_names=(“split”,) -> “split” mode_names=(“token-select”,) -> “token-select” mode_names=(“split”, “edit”) -> “split + edit” not_modes=(“split”,) -> “default” no mode constraints -> None


group_actions_by_zone_and_hint_group


def group_actions_by_zone_and_hint_group(
    manager:ZoneManager, # the zone manager whose actions to group
)->list[tuple[Optional[str], str, list[KeyAction]]]: # ordered (zone_label_or_None, hint_group, actions) tuples

Group actions for hint display, scoped by zone and hint_group.

Returns an ordered list of (zone_label, hint_group, actions) tuples:

  • Shared section (zone_label=None) appears FIRST, containing actions with zone_ids=None (truly global) or zone_ids covering all zones in the manager. Single-zone managers route all actions through this section, preserving single-zone consumer rendering (no zone-label prefix).
  • Per-zone sections follow, in the order zones are declared on the manager. zone_label is FocusZone.get_display_label() (label, falling back to id). Actions with zone_ids matching exactly one zone land here.
  • Within each section, hint_groups appear in insertion (first-seen) order. Actions are emitted in their original tuple order.

Actions with show_in_hints=False or empty description are excluded.

Edge cases: - Multi-zone partial coverage (zone_ids touches >1 zone but not all): routed to shared. Rare in practice; documents the action as shared. - zone_ids referencing a zone not in manager.zones: silently dropped.


derive_navigation_hints


def derive_navigation_hints(
    manager:ZoneManager, # the zone manager whose navigation to derive hints from
)->list[tuple[str, str]]: # ordered (display_key, description) tuples

Derive built-in navigation hint rows from manager.key_mapping + zone patterns.

Walks every zone with has_items() true, unions the navigable directions via each zone’s navigation.get_supported_directions(), and emits hint rows using the actual keys from manager.key_mapping. Replaces the earlier hardcoded ↑/↓ Navigate items row, which was wrong under custom key_mappings (wasd, vim, etc.).

Returns rows for each direction pair (up/down, left/right) whose keys are actually bound and actually used by some zone:

  • Vertical pair (up/down): emitted when any zone has a pattern that supports up/down navigation. Display uses key_mapping.up[0] / key_mapping.down[0].
  • Horizontal pair (left/right): emitted when any zone supports left/right navigation, UNLESS the in-zone horizontal keys collide with the manager’s zone-switch keys (prev_zone_key/next_zone_key). In that case the zone-switch row already documents those keys; emitting a second row would be misleading (the zone-switch path wins at runtime).

Returns an empty list when no zone supports key-based navigation (e.g., all zones are ScrollOnly, or no zone has an item_selector).


derive_mode_exit_hints


def derive_mode_exit_hints(
    manager:ZoneManager, # the zone manager whose mode exit keys to surface
)->list[tuple[str, str, Optional[str]]]: # ordered (display_key, description, mode_name) rows; mode_name drives the V13 mode chip

Derive built-in mode-exit hint rows for every non-default mode that defines an exit_key.

Mode exit is handled by the JS dispatcher (js_keyboard_handler’s currentModeConfig.exitKey === key branch at generators.py) when a mode has a non-empty exit_key. Like Escape-deactivate-child, this is not a declared KeyAction — so the renderer needs a derivation seam.

Returned rows include the mode_name as the third tuple element. The modal renderer uses this to attach the V13 mode chip (“split”, “edit”, etc.) to the row, so the user sees the row is only meaningful while in that mode.

Honors mode.exit_modifiers via format_key_combo so chord exits like Ctrl+Escape display correctly. Modes with empty exit_key (e.g., the implicit NAVIGATION_MODE whose exit_key is "") are skipped silently.


derive_hierarchy_hints


def derive_hierarchy_hints(
    manager:ZoneManager, # the zone manager whose hierarchy keys to surface
    is_child:bool=False, # True when this manager is rendered as a child in a hierarchical hints modal
)->list[tuple[str, str]]: # ordered (display_key, description) rows; fold into manager-derived Navigation group

Derive built-in hierarchy-key hint rows: Esc deactivation + Enter/Space activation.

These keys are baked into the JS dispatcher (js_keyboard_handler in generators.py), not declared as KeyActions — so a renderer iterating only manager.actions would miss them. This helper bridges that gap, analogous to derive_navigation_hints for navigation keys.

Returns rows that fold into the manager-derived “Navigation” group (callers extend nav_rows with the result before emitting _render_modal_group).

Emission rules:

  • Escape — Deactivate panel is emitted only when is_child=True. The JS dispatcher’s Escape→deactivate-child branch fires when the manager has a parent in the coordinator hierarchy at runtime; the renderer can’t know parent state at render time, but the modal’s multi-manager mode (child_managers=[...]) is the canonical signal that a given manager IS a child in the hierarchy being documented. Callers pass is_child=True for every entry in child_managers.

  • Enter / Space — Activate panel is emitted when the manager has at least one zone with activation wiring (activate_child_id or activate_child_callback) AND manager.activate_keys is non-empty. The description defaults to manager.activate_description (“Activate panel”); consumers override at the manager level for site-specific text. Empty activate_keys opts out entirely (no row emitted).

The hierarchy demo’s on-page legend has long listed both keys; this helper is what lets the same information land in the modal too.


render_hints_from_actions


def render_hints_from_actions(
    actions:tuple[KeyAction, ...], # actions to display hints for
    badge_style:str='ghost', # badge style
)->Div: # container with all hint groups

Render keyboard hints from action configurations.

# Test hints from actions
actions = (
    KeyAction(key=" ", htmx_trigger="x", description="Select", hint_group="Selection"),
    KeyAction(key="Delete", htmx_trigger="y", description="Remove", hint_group="Actions"),
    KeyAction(key="Enter", js_callback="z", description="Open", hint_group="Actions"),
    KeyAction(key="Backspace", htmx_trigger="w", description="Delete", show_in_hints=False),  # Hidden
)

hints_component = render_hints_from_actions(actions)
html = to_xml(hints_component)
assert "Selection" in html
assert "Actions" in html
assert "Select" in html
assert "Remove" in html
assert "Delete" not in html  # show_in_hints=False

Full Keyboard Hints


render_keyboard_hints


def render_keyboard_hints(
    manager:ZoneManager, # the zone manager
    include_navigation:bool=True, # include navigation hints
    include_zone_switch:bool=True, # include zone switching hints
    badge_style:str='ghost', # badge style
    container_id:str='kb-hints', # container element ID
    use_icons:bool=True, # use lucide icons for nav hints
)->Div: # complete hints component

Render complete keyboard hints for a zone manager.

# Test full keyboard hints with icons (default)
from cjm_fasthtml_keyboard_navigation.core.focus_zone import FocusZone

zone1 = FocusZone(id="z1")
zone2 = FocusZone(id="z2")

manager = ZoneManager(
    zones=(zone1, zone2),
    actions=(
        KeyAction(key=" ", htmx_trigger="toggle", description="Select", hint_group="Selection"),
    )
)

hints = render_keyboard_hints(manager)
html = to_xml(hints)

assert 'id="kb-hints"' in html
assert "Navigate" in html
assert "Switch Panel" in html  # Two zones = show zone switch
assert "Select" in html
assert "svg" in html  # Icons are SVGs

# Test without icons
hints_no_icons = render_keyboard_hints(manager, use_icons=False)
html_no_icons = to_xml(hints_no_icons)
assert "↑/↓" in html_no_icons  # Text arrows when icons disabled
# Single zone = no zone switch hint
single_manager = ZoneManager(zones=(zone1,), actions=())
hints = render_keyboard_hints(single_manager)
html = to_xml(hints)
assert "Switch Panel" not in html
# Tests for group_actions_by_zone_and_hint_group + mode_context_label
from cjm_fasthtml_keyboard_navigation.core.focus_zone import FocusZone

# --- mode_context_label ---

# Unrestricted action: no chip
unrestricted = KeyAction(key="x", htmx_trigger="btn", description="X")
assert mode_context_label(unrestricted) is None

# mode_names: shows the mode name(s)
split_only = KeyAction(key="Enter", htmx_trigger="x", mode_names=("split",), description="Split")
assert mode_context_label(split_only) == "split"

multi_mode = KeyAction(key="Enter", htmx_trigger="x", mode_names=("split", "edit"), description="Both")
assert mode_context_label(multi_mode) == "split + edit"

# not_modes: shows "default"
not_split = KeyAction(key="Enter", htmx_trigger="x", not_modes=("split",), description="NS")
assert mode_context_label(not_split) == "default"


# --- group_actions_by_zone_and_hint_group: single-zone manager ---
# Behavior contract: single-zone managers produce NO per-zone sections.
# All zone-scoped actions land in the shared section (zone_label=None).
# This preserves single-zone consumer rendering — no "ZoneId — GroupName" prefixes.

z1 = FocusZone(id="seg", label="Text Segmentation")
single_zone_mgr = ZoneManager(
    zones=(z1,),
    actions=(
        KeyAction(key=" ", htmx_trigger="x", description="Select", hint_group="Selection", zone_ids=("seg",)),
        KeyAction(key="Delete", htmx_trigger="y", description="Remove", hint_group="Actions"),  # zone_ids=None
    ),
)

single_groups = group_actions_by_zone_and_hint_group(single_zone_mgr)
assert all(zone_label is None for zone_label, _, _ in single_groups), \
    "Single-zone manager must produce no per-zone sections"
assert {gn for _, gn, _ in single_groups} == {"Selection", "Actions"}


# --- group_actions_by_zone_and_hint_group: dual-zone with shared factory ---
# This is the G4 regression-guard pattern — segment-align's exact shape.
# A shared factory emits identical hint_group + description per zone; without
# zone-scoping, those rows would duplicate inside a single header.

z_seg = FocusZone(id="seg", label="Text Segmentation")
z_align = FocusZone(id="align", label="VAD Alignment")

def _shared_nav_factory(zone_id: str) -> tuple[KeyAction, ...]:
    """Mirrors create_card_stack_nav_actions — same hint_group strings per zone."""
    return (
        KeyAction(key="ArrowUp",   htmx_trigger=f"{zone_id}-up",   zone_ids=(zone_id,),
                  description="Previous item", hint_group="Navigation"),
        KeyAction(key="ArrowDown", htmx_trigger=f"{zone_id}-down", zone_ids=(zone_id,),
                  description="Next item",     hint_group="Navigation"),
        KeyAction(key="[",         js_callback=f"{zone_id}_narrower",
                  zone_ids=(zone_id,), description="Narrower",     hint_group="View"),
    )

dual_mgr = ZoneManager(
    zones=(z_seg, z_align),
    actions=(
        *_shared_nav_factory("seg"),
        *_shared_nav_factory("align"),
        # A truly-shared action: zone_ids=None → goes to the top-of-modal shared section
        KeyAction(key="z", modifiers=frozenset({"ctrl"}), htmx_trigger="undo",
                  description="Undo", hint_group="General"),
    ),
)

dual_groups = group_actions_by_zone_and_hint_group(dual_mgr)

# Build a dict for ergonomic lookup (zone_label, hint_group) -> [descriptions]
group_index = {(zl, gn): [a.description for a in acts] for zl, gn, acts in dual_groups}

# Shared section: the Undo action with zone_ids=None
assert (None, "General") in group_index
assert group_index[(None, "General")] == ["Undo"]

# Per-zone sections: each zone has its OWN "Navigation" and "View" headers
assert ("Text Segmentation", "Navigation") in group_index
assert group_index[("Text Segmentation", "Navigation")] == ["Previous item", "Next item"]

assert ("VAD Alignment", "Navigation") in group_index
assert group_index[("VAD Alignment", "Navigation")] == ["Previous item", "Next item"]

assert ("Text Segmentation", "View") in group_index
assert ("VAD Alignment", "View") in group_index

# Regression guard: descriptions like "Previous item" / "Next item" must NOT appear
# duplicated inside a single section. The whole point of zone-scoping is to keep
# each zone's shared-factory rows in their own scoped section.
for (zl, gn), descriptions in group_index.items():
    assert len(descriptions) == len(set(descriptions)), \
        f"Duplicate descriptions inside section ({zl!r}, {gn!r}): {descriptions}"


# --- Hierarchy sentinel: zones must appear in manager.zones declaration order ---
# Encodes the structural invariant that future renderer changes can't silently
# re-order zone sections. Same shape as V13's hierarchy sentinel for text tiers.

per_zone_labels_seen = [zl for zl, _, _ in dual_groups if zl is not None]
# Each zone may appear in multiple group rows; collapse to unique declaration order
seen_order, _seen = [], set()
for zl in per_zone_labels_seen:
    if zl not in _seen:
        seen_order.append(zl)
        _seen.add(zl)
assert seen_order == ["Text Segmentation", "VAD Alignment"], \
    "Per-zone sections must appear in the order zones are declared on the manager"


# --- Shared section MUST appear before per-zone sections ---
# The shared section is the "stays in view at top" affordance — losing this
# ordering would push truly-global hints below per-zone clutter.
first_zl = dual_groups[0][0]
assert first_zl is None, "Shared section must come first in the ordered group list"


# --- show_in_hints=False and empty description are excluded ---
filter_mgr = ZoneManager(
    zones=(z_seg,),
    actions=(
        KeyAction(key="x", htmx_trigger="x", description="Visible",    hint_group="G", zone_ids=("seg",)),
        KeyAction(key="y", htmx_trigger="y", description="Hidden",     hint_group="G", zone_ids=("seg",), show_in_hints=False),
        KeyAction(key="z", htmx_trigger="z", description="",            hint_group="G", zone_ids=("seg",)),  # empty desc
    ),
)
filter_groups = group_actions_by_zone_and_hint_group(filter_mgr)
all_descs = [a.description for _, _, acts in filter_groups for a in acts]
assert all_descs == ["Visible"]


# --- zone_ids covering all zones routes to shared section ---
all_zones_action = KeyAction(key="?", js_callback="help",
                             zone_ids=("seg", "align"),  # covers all
                             description="Help", hint_group="Meta")
all_zones_mgr = ZoneManager(zones=(z_seg, z_align), actions=(all_zones_action,))
all_zones_groups = group_actions_by_zone_and_hint_group(all_zones_mgr)
assert all_zones_groups == [(None, "Meta", [all_zones_action])]


print("group_actions_by_zone_and_hint_group + mode_context_label tests passed")
group_actions_by_zone_and_hint_group + mode_context_label tests passed
# Tests for derive_navigation_hints — verifies the WASD regression fix
from cjm_fasthtml_keyboard_navigation.core.focus_zone import FocusZone
from cjm_fasthtml_keyboard_navigation.core.key_mapping import (
    ARROW_KEYS, WASD_KEYS, VIM_KEYS,
)
from cjm_fasthtml_keyboard_navigation.core.navigation import (
    LinearVertical, LinearHorizontal, ScrollOnly, Grid,
)

# --- ARROW_KEYS (default) + LinearVertical: shows ↑/↓ ---
z_arrow = FocusZone(id="arrow-z", item_selector="li", navigation=LinearVertical())
mgr_arrow = ZoneManager(zones=(z_arrow,), key_mapping=ARROW_KEYS)
assert derive_navigation_hints(mgr_arrow) == [("↑ / ↓", "Navigate items")]


# --- WASD_KEYS + LinearVertical: shows W/S (NOT ↑/↓) — fixes the WASD regression ---
z_wasd = FocusZone(id="wasd-z", item_selector="li", navigation=LinearVertical())
mgr_wasd = ZoneManager(zones=(z_wasd,), key_mapping=WASD_KEYS)
assert derive_navigation_hints(mgr_wasd) == [("w / s", "Navigate items")], \
    "WASD_KEYS must surface as 'w / s' (not '↑ / ↓') — the regression this function fixes"


# --- VIM_KEYS + Grid: shows BOTH vertical (k/j) AND horizontal (h/l) ---
z_grid = FocusZone(id="vim-z", item_selector="li", navigation=Grid())
mgr_vim = ZoneManager(zones=(z_grid,), key_mapping=VIM_KEYS)
vim_hints = derive_navigation_hints(mgr_vim)
assert ("k / j", "Navigate items") in vim_hints
assert ("h / l", "Navigate items") in vim_hints


# --- ScrollOnly zone: no nav hints emitted ---
z_scroll = FocusZone(id="scroll-z", item_selector=None, navigation=ScrollOnly())
mgr_scroll = ZoneManager(zones=(z_scroll,))
assert derive_navigation_hints(mgr_scroll) == []


# --- Zone without item_selector: no nav hints emitted (has_items() == False) ---
z_no_items = FocusZone(id="no-items-z")
mgr_no_items = ZoneManager(zones=(z_no_items,))
assert derive_navigation_hints(mgr_no_items) == []


# --- Multi-zone with LinearVertical + ARROW_KEYS: vertical only ---
# (horizontal arrows would collide with the default prev/next zone-switch keys)
z_seg = FocusZone(id="seg", item_selector="li", navigation=LinearVertical())
z_align = FocusZone(id="align", item_selector="li", navigation=LinearVertical())
mgr_dual = ZoneManager(zones=(z_seg, z_align), key_mapping=ARROW_KEYS)
assert derive_navigation_hints(mgr_dual) == [("↑ / ↓", "Navigate items")]


# --- Multi-zone with LinearHorizontal + ARROW_KEYS: collision suppresses the row ---
# Pattern wants left/right; key_mapping puts arrow keys there; manager uses
# ArrowLeft/ArrowRight for zone-switching → collision → suppress.
# (This is rare in practice — most multi-zone managers use vertical patterns,
# leaving the horizontal arrow keys free for zone-switching.)
z_h1 = FocusZone(id="h1", item_selector="li", navigation=LinearHorizontal())
z_h2 = FocusZone(id="h2", item_selector="li", navigation=LinearHorizontal())
mgr_h_collide = ZoneManager(zones=(z_h1, z_h2), key_mapping=ARROW_KEYS)
assert derive_navigation_hints(mgr_h_collide) == [], \
    "Horizontal keys colliding with zone-switch keys must be suppressed"


# --- Multi-zone with LinearHorizontal + WASD_KEYS: no collision, emits A/D ---
# WASD's left/right are "a"/"d", which differ from default ArrowLeft/ArrowRight
# zone-switch keys → no collision → row IS emitted.
mgr_h_wasd = ZoneManager(
    zones=(z_h1, z_h2),
    key_mapping=WASD_KEYS,
    prev_zone_key="ArrowLeft",
    next_zone_key="ArrowRight",
)
assert derive_navigation_hints(mgr_h_wasd) == [("a / d", "Navigate items")]


# --- Multi-zone with Grid + WASD_KEYS: BOTH pairs (no collision) ---
z_grid1 = FocusZone(id="g1", item_selector="li", navigation=Grid())
z_grid2 = FocusZone(id="g2", item_selector="li", navigation=Grid())
mgr_grid_wasd = ZoneManager(
    zones=(z_grid1, z_grid2),
    key_mapping=WASD_KEYS,
    prev_zone_key="ArrowLeft",
    next_zone_key="ArrowRight",
)
grid_wasd_hints = derive_navigation_hints(mgr_grid_wasd)
assert ("w / s", "Navigate items") in grid_wasd_hints
assert ("a / d", "Navigate items") in grid_wasd_hints


# --- Mixed-pattern union: one vertical zone, one ScrollOnly zone → vertical only ---
# Use a separate vertical zone with a unique id to combine with z_scroll.
z_mixed_v = FocusZone(id="mixed-v", item_selector="li", navigation=LinearVertical())
mgr_mixed = ZoneManager(zones=(z_mixed_v, z_scroll))
assert derive_navigation_hints(mgr_mixed) == [("↑ / ↓", "Navigate items")]


print("derive_navigation_hints tests passed (incl. WASD regression fix)")
derive_navigation_hints tests passed (incl. WASD regression fix)
# Tests for derive_hierarchy_hints (Esc + Enter/Space activation surface)
from cjm_fasthtml_keyboard_navigation.core.focus_zone import FocusZone
from cjm_fasthtml_keyboard_navigation.core.manager import ZoneManager

# --- is_child=False on plain manager (no activation wiring): zero rows ---
plain_zone = FocusZone(id="plain")
plain_mgr = ZoneManager(zones=(plain_zone,))
assert derive_hierarchy_hints(plain_mgr) == []
assert derive_hierarchy_hints(plain_mgr, is_child=False) == []


# --- is_child=True on a manager (the "child in a hierarchy" case): Esc row appears ---
# This is the path the hierarchy demo's modal was missing — the screenshot proved it.
child_mgr = ZoneManager(zones=(plain_zone,))
child_rows = derive_hierarchy_hints(child_mgr, is_child=True)
assert child_rows == [(format_key_for_display("Escape"), "Deactivate panel, return to parent")], \
    "Child manager must surface the Escape→deactivate-parent row in the modal"


# --- Manager with activation wiring: Enter/Space row appears (manager default description) ---
ghost_zone = FocusZone(id="ghost-browser", activate_child_id="sb-collection")
plain_zone_2 = FocusZone(id="plain-zone-2")  # alongside, no activation
mgr_with_activation = ZoneManager(zones=(plain_zone_2, ghost_zone))
parent_rows = derive_hierarchy_hints(mgr_with_activation, is_child=False)
# Display joins keys with " / "; format_key_for_display normalises Enter/Space
expected_keys = " / ".join(format_key_for_display(k) for k in mgr_with_activation.activate_keys)
assert parent_rows == [(expected_keys, "Activate panel")], \
    f"Manager with activatable zone must surface Enter/Space activation row; got {parent_rows!r}"


# --- Both is_child=True AND has activation: Esc first, then activation ---
# Order is load-bearing: Escape (exit) before Enter/Space (enter) reads naturally
# in the modal alongside the existing ←/→ Switch-panel row built in _render_manager_groups.
both_rows = derive_hierarchy_hints(mgr_with_activation, is_child=True)
assert len(both_rows) == 2
assert both_rows[0] == (format_key_for_display("Escape"), "Deactivate panel, return to parent")
assert both_rows[1][1] == "Activate panel"


# --- Manager-level activate_description override ---
mgr_custom_desc = ZoneManager(
    zones=(ghost_zone,),
    activate_description="Activate area",
)
custom_rows = derive_hierarchy_hints(mgr_custom_desc, is_child=False)
assert len(custom_rows) == 1
assert custom_rows[0][1] == "Activate area", \
    "Manager.activate_description should override the default 'Activate panel' text"


# --- Empty activate_keys opts out (consumer says "no library-baked activation") ---
mgr_opted_out = ZoneManager(zones=(ghost_zone,), activate_keys=())
opted_out_rows = derive_hierarchy_hints(mgr_opted_out, is_child=False)
assert opted_out_rows == [], "Empty activate_keys must suppress the activation row entirely"


# --- Empty activate_keys + is_child=True: Esc still emits (independent code paths) ---
# Ensures the two emission rules are decoupled — opting out of activation
# shouldn't accidentally suppress the Escape row.
opted_out_child = derive_hierarchy_hints(mgr_opted_out, is_child=True)
assert opted_out_child == [(format_key_for_display("Escape"), "Deactivate panel, return to parent")]


# --- Custom activate_keys (e.g., Enter only, no Space) ---
mgr_enter_only = ZoneManager(zones=(ghost_zone,), activate_keys=("Enter",))
enter_only_rows = derive_hierarchy_hints(mgr_enter_only, is_child=False)
assert len(enter_only_rows) == 1
assert "Enter" in enter_only_rows[0][0]
# No Space in the rendered key combo when activate_keys excludes it
assert " / " not in enter_only_rows[0][0], \
    "Single-key activate_keys must not emit a '/' separator"


print("derive_hierarchy_hints tests passed")
derive_hierarchy_hints tests passed
# Tests for derive_mode_exit_hints (surfaces mode.exit_key in the modal)
from cjm_fasthtml_keyboard_navigation.core.modes import KeyboardMode, NAVIGATION_MODE

# --- Manager with no custom modes: zero rows ---
no_modes_mgr = ZoneManager(zones=(FocusZone(id="z"),))
assert derive_mode_exit_hints(no_modes_mgr) == []


# --- Single non-default mode with default exit_key="Escape": one row tagged with mode_name ---
split_mode = KeyboardMode(name="split", enter_key="Enter")  # exit_key defaults to "Escape"
split_mgr = ZoneManager(zones=(FocusZone(id="z"),), modes=(split_mode,))
split_rows = derive_mode_exit_hints(split_mgr)
assert len(split_rows) == 1
display_key, description, mode_name = split_rows[0]
assert mode_name == "split", "Mode name must be the third tuple element for V13 chip rendering"
assert description == "Exit mode"
assert display_key == format_key_for_display("Escape")


# --- Multiple modes: one row each, in declaration order ---
mode_a = KeyboardMode(name="alpha", enter_key="a")
mode_b = KeyboardMode(name="beta", enter_key="b", exit_key="q")
multi_mgr = ZoneManager(zones=(FocusZone(id="z"),), modes=(mode_a, mode_b))
multi_rows = derive_mode_exit_hints(multi_mgr)
assert len(multi_rows) == 2
assert [mn for _, _, mn in multi_rows] == ["alpha", "beta"], \
    "Mode rows must appear in manager.modes declaration order"
assert multi_rows[1][0] == format_key_for_display("q"), \
    "Custom exit_key ('q') must render correctly"


# --- Empty exit_key on a mode: skipped silently ---
# NAVIGATION_MODE has exit_key="" — represents "cannot be exited". A consumer
# could similarly define a mode that's only exited programmatically (exit_key="").
programmatic_only = KeyboardMode(name="programmatic", enter_key=None, exit_key="")
mixed_mgr = ZoneManager(
    zones=(FocusZone(id="z"),),
    modes=(programmatic_only, split_mode),
)
mixed_rows = derive_mode_exit_hints(mixed_mgr)
assert len(mixed_rows) == 1
assert mixed_rows[0][2] == "split"  # only split-mode emits


# --- Mode with chord exit (modifiers): format_key_combo handles it ---
# Defensive — ensures the helper doesn't drop modifiers silently
chord_exit = KeyboardMode(
    name="chord",
    enter_key="c",
    exit_key="Escape",
    exit_modifiers=frozenset({"shift"}),
)
chord_mgr = ZoneManager(zones=(FocusZone(id="z"),), modes=(chord_exit,))
chord_rows = derive_mode_exit_hints(chord_mgr)
assert len(chord_rows) == 1
chord_display = chord_rows[0][0]
# The exact format depends on format_key_combo's convention; we just assert both parts appear
assert "Shift" in chord_display or "shift" in chord_display.lower(), \
    f"Chord modifier should appear in display: {chord_display!r}"


# --- Implicit NAVIGATION_MODE is NEVER iterated by manager.modes ---
# Sentinel: manager.modes excludes the implicit navigation mode (see get_all_modes).
# This guards against a future refactor accidentally folding navigation in.
assert NAVIGATION_MODE not in split_mgr.modes, \
    "manager.modes must NOT include NAVIGATION_MODE (it's implicit; use get_all_modes for the inclusive variant)"


print("derive_mode_exit_hints tests passed")
derive_mode_exit_hints tests passed