Skip to content

Commit 8165e87

Browse files
committed
TUI: enable arrows + Enter navigation: Assisted by AI
1 parent b58aef4 commit 8165e87

File tree

4 files changed

+199
-17
lines changed

4 files changed

+199
-17
lines changed

src/ansible_navigator/data/help.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,15 @@
3333
- `[0-9]` Go to menu item
3434
- `:<number> ` Go to menu item
3535
- `:{{ n|filter }} ` Template the menu item
36+
- `arrow up, arrow down` Move cursor
37+
- `enter` Select highlighted item
3638

3739
# Content and tasks
3840

3941
- `[0-9]` Go to task number
4042
- `:<number>` Go to task number
4143
- `+, - ` Next/Previous task
44+
- `arrow up, arrow down` Next/Previous task
4245
- `_, :_` Toggle hidden keys
4346
- `:{{ key|filter }}` Template the key's value
4447
- `:d, :doc` Show the doc for the current task's module

src/ansible_navigator/ui_framework/ui.py

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# cspell:ignore KEY_NPAGE, KEY_PPAGE
2+
# pylint: disable=too-many-lines
23
"""The main UI renderer."""
34

45
from __future__ import annotations
@@ -199,7 +200,12 @@ def __init__(
199200
self._status_color = 0
200201
self._screen: Window = curses.initscr()
201202
self._screen.timeout(refresh)
203+
self._screen.keypad(True) # Enable keypad mode for proper key handling
202204
self._one_line_input = FormHandlerText(screen=self._screen, ui_config=self._ui_config)
205+
# This tracks which visible row to highlight
206+
self._highlight_line_offset: int | None = None
207+
# This tracks the cursor position in menus
208+
self._menu_cursor_pos: int = 0
203209

204210
def clear(self) -> None:
205211
"""Clear the screen."""
@@ -464,7 +470,7 @@ def _display(
464470
index_width = len(str(count))
465471

466472
keypad = {str(x) for x in range(10)}
467-
other_valid_keys = ["+", "-", "_", "KEY_F(5)", "^[", "\x1b"]
473+
other_valid_keys = ["+", "-", "_", "KEY_F(5)", "^[", "\x1b", "CURSOR_ENTER"]
468474

469475
while True:
470476
self._screen.erase()
@@ -479,10 +485,29 @@ def _display(
479485
line_index = line_numbers[idx]
480486
line_index_str = str(line_index).rjust(index_width)
481487
prefix = f"{line_index_str}\u2502"
488+
# Apply highlight decoration when this is the selected row
489+
if (
490+
indent_heading
491+
and self._highlight_line_offset is not None
492+
and idx == self._highlight_line_offset
493+
):
494+
# Rebuild the line with reverse-video decoration added
495+
highlighted_parts = tuple(
496+
CursesLinePart(
497+
column=lp.column,
498+
string=lp.string,
499+
color=lp.color,
500+
decoration=lp.decoration | curses.A_REVERSE,
501+
)
502+
for lp in line
503+
)
504+
line_to_draw = CursesLine(highlighted_parts)
505+
else:
506+
line_to_draw = line
482507
self._add_line(
483508
window=self._screen,
484509
lineno=idx + len(heading),
485-
line=line,
510+
line=line_to_draw,
486511
prefix=prefix,
487512
)
488513

@@ -504,6 +529,14 @@ def _display(
504529
if await_input:
505530
char = self._screen.getch()
506531
key = "KEY_F(5)" if char == -1 else curses.keyname(char).decode()
532+
# Debug: log the raw char and converted key for troubleshooting
533+
self._logger.debug("Raw char: %s, Converted key: '%s'", char, key)
534+
# Check for Enter key codes and return a special value for cursor navigation
535+
if char in [10, 13]: # Enter key codes: 10=LF, 13=CR
536+
self._logger.debug(
537+
"Enter key detected! Raw char: %s, setting key to CURSOR_ENTER", char
538+
)
539+
key = "CURSOR_ENTER"
507540
else:
508541
key = "KEY_F(5)"
509542

@@ -915,6 +948,19 @@ def _show_menu(
915948
showing_indices,
916949
)
917950

951+
# Determine which row to highlight, if enabled
952+
self._highlight_line_offset = None
953+
if self._menu_indices:
954+
self._menu_cursor_pos = max(
955+
0,
956+
min(self._menu_cursor_pos, len(self._menu_indices) - 1),
957+
)
958+
selected_global_index = self._menu_indices[self._menu_cursor_pos]
959+
try:
960+
self._highlight_line_offset = showing_indices.index(selected_global_index)
961+
except ValueError:
962+
self._highlight_line_offset = None
963+
918964
entry = self._display(
919965
lines=menu_lines,
920966
line_numbers=line_numbers,
@@ -925,9 +971,52 @@ def _show_menu(
925971
await_input=await_input,
926972
)
927973

974+
# Debug: log what entry we received
975+
self._logger.debug("Received entry: '%s'", entry)
976+
977+
# Handle arrow navigation for menus when enabled
928978
if entry in ["KEY_RESIZE", "KEY_DOWN", "KEY_UP", "KEY_NPAGE", "KEY_PPAGE", "^F", "^B"]:
979+
if entry in ["KEY_DOWN", "KEY_UP"] and self._menu_indices:
980+
# Move the cursor position
981+
if entry == "KEY_DOWN" and self._menu_cursor_pos < len(self._menu_indices) - 1:
982+
self._menu_cursor_pos += 1
983+
# If moved past the last visible item, scroll down one
984+
if (
985+
self._highlight_line_offset is None
986+
or self._highlight_line_offset >= len(showing_indices) - 1
987+
):
988+
# Mimic _display scroll down
989+
viewport_height = self._screen_height - len(menu_heading) - 1
990+
self.scroll(
991+
max(
992+
min(self.scroll() + 1, len(self._menu_indices)),
993+
viewport_height,
994+
),
995+
)
996+
elif entry == "KEY_UP" and self._menu_cursor_pos > 0:
997+
self._menu_cursor_pos -= 1
998+
# If moved before the first visible item, scroll up one
999+
if self._highlight_line_offset is None or self._highlight_line_offset <= 0:
1000+
viewport_height = self._screen_height - len(menu_heading) - 1
1001+
self.scroll(max(self.scroll() - 1, viewport_height))
1002+
# Re-render with updated highlight
1003+
continue
1004+
# Otherwise, preserve prior behavior
9291005
continue
9301006

1007+
# Enter key should select the highlighted item for cursor navigation
1008+
if (
1009+
entry == "CURSOR_ENTER" or entry in ["^J", "^M", "KEY_ENTER", "KEY_RETURN"]
1010+
) and self._menu_indices:
1011+
self._logger.debug(
1012+
"Enter key selection triggered! Entry: '%s', Selecting index %s",
1013+
entry,
1014+
self._menu_cursor_pos,
1015+
)
1016+
index_to_select = self._menu_indices[self._menu_cursor_pos]
1017+
entry = str(index_to_select)
1018+
self._logger.debug("Changed entry to: '%s'", entry)
1019+
9311020
name, action = self._template_match_action(entry, current)
9321021
if name and action:
9331022
if name == "select":

tests/integration/_interactions.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from enum import Enum
88
from typing import NamedTuple
9+
from typing import TypedDict
910

1011

1112
class SearchFor(Enum):
@@ -61,6 +62,64 @@ def join(self) -> str:
6162
return cmd
6263

6364

65+
class Action(TypedDict):
66+
"""A definition of an action."""
67+
68+
#: The action to be performed
69+
value: str
70+
#: A comment to be added to the fixture file
71+
comment: str | None
72+
#: The key to be pressed
73+
keystroke: str
74+
75+
76+
# A list of actions that can be performed
77+
# The value is the name of the action, which is also the name of the file
78+
# The keystroke is the key that will be sent to the TUI
79+
# The comment is a comment that will be added to the fixture file
80+
# The last entry in the list is the one that will be used for the test
81+
# if the test is run with the --update-fixtures option
82+
ACTIONS: dict[str, list[Action]] = {
83+
"0": [{"value": "0", "keystroke": "0", "comment": None}],
84+
"1": [{"value": "1", "keystroke": "1", "comment": None}],
85+
"2": [{"value": "2", "keystroke": "2", "comment": None}],
86+
"3": [{"value": "3", "keystroke": "3", "comment": None}],
87+
"4": [{"value": "4", "keystroke": "4", "comment": None}],
88+
"5": [{"value": "5", "keystroke": "5", "comment": None}],
89+
"6": [{"value": "6", "keystroke": "6", "comment": None}],
90+
"7": [{"value": "7", "keystroke": "7", "comment": None}],
91+
"8": [{"value": "8", "keystroke": "8", "comment": None}],
92+
"9": [{"value": "9", "keystroke": "9", "comment": None}],
93+
"10": [{"value": "10", "keystroke": "10", "comment": None}],
94+
"11": [{"value": "11", "keystroke": "11", "comment": None}],
95+
"12": [{"value": "12", "keystroke": "12", "comment": None}],
96+
"13": [{"value": "13", "keystroke": "13", "comment": None}],
97+
"14": [{"value": "14", "keystroke": "14", "comment": None}],
98+
"15": [{"value": "15", "keystroke": "15", "comment": None}],
99+
"16": [{"value": "16", "keystroke": "16", "comment": None}],
100+
"17": [{"value": "17", "keystroke": "17", "comment": None}],
101+
"18": [{"value": "18", "keystroke": "18", "comment": None}],
102+
"19": [{"value": "19", "keystroke": "19", "comment": None}],
103+
"20": [{"value": "20", "keystroke": "20", "comment": None}],
104+
"21": [{"value": "21", "keystroke": "21", "comment": None}],
105+
"22": [{"value": "22", "keystroke": "22", "comment": None}],
106+
"23": [{"value": "23", "keystroke": "23", "comment": None}],
107+
"24": [{"value": "24", "keystroke": "24", "comment": None}],
108+
"25": [{"value": "25", "keystroke": "25", "comment": None}],
109+
"26": [{"value": "26", "keystroke": "26", "comment": None}],
110+
"27": [{"value": "27", "keystroke": "27", "comment": None}],
111+
"28": [{"value": "28", "keystroke": "28", "comment": None}],
112+
"29": [{"value": "29", "keystroke": "29", "comment": None}],
113+
"30": [{"value": "30", "keystroke": "30", "comment": None}],
114+
"down": [{"value": "down", "keystroke": "KEY_DOWN", "comment": "menu down"}],
115+
"up": [{"value": "up", "keystroke": "KEY_UP", "comment": "menu up"}],
116+
"esc": [{"value": "escape", "keystroke": "KEY_ESCAPE", "comment": "escape"}],
117+
"top": [{"value": "top", "keystroke": "KEY_HOME", "comment": "home"}],
118+
"bottom": [{"value": "bottom", "keystroke": "KEY_END", "comment": "end"}],
119+
"enter": [{"value": "enter", "keystroke": "KEY_ENTER", "comment": "enter"}],
120+
}
121+
122+
64123
class UiTestStep(NamedTuple):
65124
"""A simulated user interaction with the user interface."""
66125

tests/integration/actions/collections/base.py

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,27 +21,46 @@
2121
EXPECTED_COLLECTIONS = ["ansible.builtin", "company_name.coll_1", "company_name.coll_2"]
2222

2323
base_steps = (
24-
UiTestStep(user_input=":1", comment="Browse company_name.coll_1 plugins window"),
25-
UiTestStep(user_input=":0", comment="filter_1 plugin docs window"),
24+
# In collections list, start at ansible.builtin (0)
25+
# Select company_name.coll_1 (1)
26+
UiTestStep(user_input="down", comment="select company_name.coll_1"),
27+
UiTestStep(user_input="enter", comment="Browse company_name.coll_1 plugins window"),
28+
# In company_name.coll_1 plugins list, start at filter_1 (0)
29+
UiTestStep(user_input="enter", comment="filter_1 plugin docs window"),
2630
UiTestStep(user_input=":back", comment="Back to browse company_name.coll_1 plugins window"),
27-
UiTestStep(user_input=":1", comment="lookup_1 plugin docs window"),
31+
# from filter_1 (0) to lookup_1 (1)
32+
UiTestStep(user_input="down", comment="select lookup_1"),
33+
UiTestStep(user_input="enter", comment="lookup_1 plugin docs window"),
2834
UiTestStep(user_input=":back", comment="Back to browse company_name.coll_1 plugins window"),
29-
UiTestStep(user_input=":2", comment="mod_1 plugin docs window"),
35+
# from lookup_1 (1) to mod_1 (2)
36+
UiTestStep(user_input="down", comment="select mod_1"),
37+
UiTestStep(user_input="enter", comment="mod_1 plugin docs window"),
3038
UiTestStep(user_input=":back", comment="Back to browse company_name.coll_1 plugins window"),
31-
UiTestStep(user_input=":3", comment="role_full details window"),
39+
# from mod_1 (2) to role_full (3)
40+
UiTestStep(user_input="down", comment="select role_full"),
41+
UiTestStep(user_input="enter", comment="role_full details window"),
3242
UiTestStep(user_input=":back", comment="Back to browse company_name.coll_1 plugins window"),
33-
UiTestStep(user_input=":4", comment="role_minimal details window"),
43+
# from role_full (3) to role_minimal (4)
44+
UiTestStep(user_input="down", comment="select role_minimal"),
45+
UiTestStep(user_input="enter", comment="role_minimal details window"),
3446
UiTestStep(user_input=":back", comment="Back to browse company_name.coll_1 plugins window"),
47+
# Back to collections list window
3548
UiTestStep(
3649
user_input=":back",
3750
comment="Back to ansible-navigator collections browse window",
3851
present=EXPECTED_COLLECTIONS,
3952
),
40-
UiTestStep(user_input=":2", comment="Browse company_name.coll_2 plugins window"),
41-
UiTestStep(user_input=":0", comment="lookup_2 plugin docs window"),
53+
# From company_name.coll_1 (1) to company_name.coll_2 (2)
54+
UiTestStep(user_input="down", comment="select company_name.coll_2"),
55+
UiTestStep(user_input="enter", comment="Browse company_name.coll_2 plugins window"),
56+
# In company_name.coll_2, start at lookup_2 (0)
57+
UiTestStep(user_input="enter", comment="lookup_2 plugin docs window"),
4258
UiTestStep(user_input=":back", comment="Back to browse company_name.coll_2 plugins window"),
43-
UiTestStep(user_input=":1", comment="mod_2 plugin docs window"),
59+
# From lookup_2 (0) to mod_2 (1)
60+
UiTestStep(user_input="down", comment="select mod_2"),
61+
UiTestStep(user_input="enter", comment="mod_2 plugin docs window"),
4462
UiTestStep(user_input=":back", comment="Back to browse company_name.coll_2 plugins window"),
63+
# Back to collections list window
4564
UiTestStep(
4665
user_input=":back",
4766
comment="Back to ansible-navigator collections browse window",
@@ -56,28 +75,40 @@
5675
),
5776
# Dismiss the warning
5877
UiTestStep(
59-
user_input="Enter",
78+
user_input="enter",
6079
comment="ansible-navigator collections browse window",
6180
present=EXPECTED_COLLECTIONS,
6281
),
6382
# and repeat some basic browsing
64-
UiTestStep(user_input=":1", comment="Browse company_name.coll_1 plugins window"),
65-
UiTestStep(user_input=":0", comment="filter_1 plugin docs window"),
83+
# Select company_name.coll_1 (1)
84+
UiTestStep(user_input="down", comment="select company_name.coll_1"),
85+
UiTestStep(user_input="enter", comment="Browse company_name.coll_1 plugins window"),
86+
# In company_name.coll_1, start at filter_1 (0)
87+
UiTestStep(user_input="enter", comment="filter_1 plugin docs window"),
6688
UiTestStep(user_input=":back", comment="Back to browse company_name.coll_1 plugins window"),
67-
UiTestStep(user_input=":1", comment="lookup_1 plugin docs window"),
89+
# from filter_1 (0) to lookup_1 (1)
90+
UiTestStep(user_input="down", comment="select lookup_1"),
91+
UiTestStep(user_input="enter", comment="lookup_1 plugin docs window"),
6892
UiTestStep(user_input=":back", comment="Back to browse company_name.coll_1 plugins window"),
93+
# Back to collections list window
6994
UiTestStep(
7095
user_input=":back",
7196
comment="Back to ansible-navigator collections browse window",
7297
present=EXPECTED_COLLECTIONS,
7398
),
99+
# From company_name.coll_1 (1) to ansible.builtin (0)
100+
UiTestStep(user_input="up", comment="select ansible.builtin"),
74101
UiTestStep(
75-
user_input=":0",
102+
user_input="enter",
76103
comment="Browse ansible.builtin plugins window",
77104
present=["yum_repository"],
78105
),
79106
UiTestStep(
80-
user_input=":1",
107+
user_input="down",
108+
comment="Browse ansible.builtin.add_host module",
109+
),
110+
UiTestStep(
111+
user_input="enter",
81112
comment="Browse ansible.builtin.add_host module",
82113
present=["ansible.builtin.add_host"],
83114
),

0 commit comments

Comments
 (0)