xref: /aosp_15_r20/external/tensorflow/tensorflow/python/debug/cli/curses_ui.py (revision b6fb3261f9314811a0f4371741dbb8839866f948)
1# Copyright 2016 The TensorFlow Authors. All Rights Reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14# ==============================================================================
15"""Curses-Based Command-Line Interface of TensorFlow Debugger (tfdbg)."""
16import collections
17import curses
18from curses import textpad
19import os
20import signal
21import sys
22import threading
23
24
25from tensorflow.python.debug.cli import base_ui
26from tensorflow.python.debug.cli import cli_shared
27from tensorflow.python.debug.cli import command_parser
28from tensorflow.python.debug.cli import curses_widgets
29from tensorflow.python.debug.cli import debugger_cli_common
30from tensorflow.python.debug.cli import tensor_format
31
32
33_SCROLL_REFRESH = "refresh"
34_SCROLL_UP = "up"
35_SCROLL_DOWN = "down"
36_SCROLL_UP_A_LINE = "up_a_line"
37_SCROLL_DOWN_A_LINE = "down_a_line"
38_SCROLL_HOME = "home"
39_SCROLL_END = "end"
40_SCROLL_TO_LINE_INDEX = "scroll_to_line_index"
41
42_COLOR_READY_COLORTERMS = ["gnome-terminal", "xfce4-terminal"]
43_COLOR_ENABLED_TERM = "xterm-256color"
44
45
46def _get_command_from_line_attr_segs(mouse_x, attr_segs):
47  """Attempt to extract command from the attribute segments of a line.
48
49  Args:
50    mouse_x: (int) x coordinate of the mouse event.
51    attr_segs: (list) The list of attribute segments of a line from a
52      RichTextLines object.
53
54  Returns:
55    (str or None) If a command exists: the command as a str; otherwise, None.
56  """
57
58  for seg in attr_segs:
59    if seg[0] <= mouse_x < seg[1]:
60      attributes = seg[2] if isinstance(seg[2], list) else [seg[2]]
61      for attr in attributes:
62        if isinstance(attr, debugger_cli_common.MenuItem):
63          return attr.content
64
65
66class ScrollBar(object):
67  """Vertical ScrollBar for Curses-based CLI.
68
69  An object of this class has knowledge of the location of the scroll bar
70  in the screen coordinates, the current scrolling position, and the total
71  number of text lines in the screen text. By using this information, it
72  can generate text rendering of the scroll bar, which consists of and UP
73  button on the top and a DOWN button on the bottom, in addition to a scroll
74  block in between, whose exact location is determined by the scrolling
75  position. The object can also calculate the scrolling command (e.g.,
76  _SCROLL_UP_A_LINE, _SCROLL_DOWN) from the coordinate of a mouse click
77  event in the screen region it occupies.
78  """
79
80  BASE_ATTR = cli_shared.COLOR_BLACK + "_on_" + cli_shared.COLOR_WHITE
81
82  def __init__(self,
83               min_x,
84               min_y,
85               max_x,
86               max_y,
87               scroll_position,
88               output_num_rows):
89    """Constructor of ScrollBar.
90
91    Args:
92      min_x: (int) left index of the scroll bar on the screen (inclusive).
93      min_y: (int) top index of the scroll bar on the screen (inclusive).
94      max_x: (int) right index of the scroll bar on the screen (inclusive).
95      max_y: (int) bottom index of the scroll bar on the screen (inclusive).
96      scroll_position: (int) 0-based location of the screen output. For example,
97        if the screen output is scrolled to the top, the value of
98        scroll_position should be 0. If it is scrolled to the bottom, the value
99        should be output_num_rows - 1.
100      output_num_rows: (int) Total number of output rows.
101
102    Raises:
103      ValueError: If the width or height of the scroll bar, as determined
104       by min_x, max_x, min_y and max_y, is too small.
105    """
106
107    self._min_x = min_x
108    self._min_y = min_y
109    self._max_x = max_x
110    self._max_y = max_y
111    self._scroll_position = scroll_position
112    self._output_num_rows = output_num_rows
113    self._scroll_bar_height = max_y - min_y + 1
114
115    if self._max_x < self._min_x:
116      raise ValueError("Insufficient width for ScrollBar (%d)" %
117                       (self._max_x - self._min_x + 1))
118    if self._max_y < self._min_y + 3:
119      raise ValueError("Insufficient height for ScrollBar (%d)" %
120                       (self._max_y - self._min_y + 1))
121
122  def _block_y(self, screen_coord_sys=False):
123    """Get the 0-based y coordinate of the scroll block.
124
125    This y coordinate takes into account the presence of the UP and DN buttons
126    present at the top and bottom of the ScrollBar. For example, at the home
127    location, the return value will be 1; at the bottom location, the return
128    value will be self._scroll_bar_height - 2.
129
130    Args:
131      screen_coord_sys: (`bool`) whether the return value will be in the
132        screen coordinate system.
133
134    Returns:
135      (int) 0-based y coordinate of the scroll block, in the ScrollBar
136        coordinate system by default. For example,
137        when scroll position is at the top, this return value will be 1 (not 0,
138        because of the presence of the UP button). When scroll position is at
139        the bottom, this return value will be self._scroll_bar_height - 2
140        (not self._scroll_bar_height - 1, because of the presence of the DOWN
141        button).
142    """
143
144    rel_block_y = int(
145        float(self._scroll_position) / (self._output_num_rows - 1) *
146        (self._scroll_bar_height - 3)) + 1
147    return rel_block_y + self._min_y if screen_coord_sys else rel_block_y
148
149  def layout(self):
150    """Get the RichTextLines layout of the scroll bar.
151
152    Returns:
153      (debugger_cli_common.RichTextLines) The text layout of the scroll bar.
154    """
155    width = self._max_x - self._min_x + 1
156    empty_line = " " * width
157    foreground_font_attr_segs = [(0, width, self.BASE_ATTR)]
158
159    if self._output_num_rows > 1:
160      block_y = self._block_y()
161
162      if width == 1:
163        up_text = "U"
164        down_text = "D"
165      elif width == 2:
166        up_text = "UP"
167        down_text = "DN"
168      elif width == 3:
169        up_text = "UP "
170        down_text = "DN "
171      else:
172        up_text = " UP "
173        down_text = "DOWN"
174
175      layout = debugger_cli_common.RichTextLines(
176          [up_text], font_attr_segs={0: [(0, width, self.BASE_ATTR)]})
177      for i in range(1, self._scroll_bar_height - 1):
178        font_attr_segs = foreground_font_attr_segs if i == block_y else None
179        layout.append(empty_line, font_attr_segs=font_attr_segs)
180      layout.append(down_text, font_attr_segs=foreground_font_attr_segs)
181    else:
182      layout = debugger_cli_common.RichTextLines(
183          [empty_line] * self._scroll_bar_height)
184
185    return layout
186
187  def get_click_command(self, mouse_y):
188    if self._output_num_rows <= 1:
189      return None
190    elif mouse_y == self._min_y:
191      return _SCROLL_UP_A_LINE
192    elif mouse_y == self._max_y:
193      return _SCROLL_DOWN_A_LINE
194    elif (mouse_y > self._block_y(screen_coord_sys=True) and
195          mouse_y < self._max_y):
196      return _SCROLL_DOWN
197    elif (mouse_y < self._block_y(screen_coord_sys=True) and
198          mouse_y > self._min_y):
199      return _SCROLL_UP
200    else:
201      return None
202
203
204class CursesUI(base_ui.BaseUI):
205  """Curses-based Command-line UI.
206
207  In this class, the methods with the prefix "_screen_" are the methods that
208  interact with the actual terminal using the curses library.
209  """
210
211  CLI_TERMINATOR_KEY = 7  # Terminator key for input text box.
212  CLI_TAB_KEY = ord("\t")
213  BACKSPACE_KEY = ord("\b")
214  REGEX_SEARCH_PREFIX = "/"
215  TENSOR_INDICES_NAVIGATION_PREFIX = "@"
216
217  _NAVIGATION_FORWARD_COMMAND = "next"
218  _NAVIGATION_BACK_COMMAND = "prev"
219
220  # Limit screen width to work around the limitation of the curses library that
221  # it may return invalid x coordinates for large values.
222  _SCREEN_WIDTH_LIMIT = 220
223
224  # Possible Enter keys. 343 is curses key code for the num-pad Enter key when
225  # num lock is off.
226  CLI_CR_KEYS = [ord("\n"), ord("\r"), 343]
227
228  _KEY_MAP = {
229      127: curses.KEY_BACKSPACE,  # Backspace
230      curses.KEY_DC: 4,  # Delete
231  }
232
233  _FOREGROUND_COLORS = {
234      cli_shared.COLOR_WHITE: curses.COLOR_WHITE,
235      cli_shared.COLOR_RED: curses.COLOR_RED,
236      cli_shared.COLOR_GREEN: curses.COLOR_GREEN,
237      cli_shared.COLOR_YELLOW: curses.COLOR_YELLOW,
238      cli_shared.COLOR_BLUE: curses.COLOR_BLUE,
239      cli_shared.COLOR_CYAN: curses.COLOR_CYAN,
240      cli_shared.COLOR_MAGENTA: curses.COLOR_MAGENTA,
241      cli_shared.COLOR_BLACK: curses.COLOR_BLACK,
242  }
243  _BACKGROUND_COLORS = {
244      "transparent": -1,
245      cli_shared.COLOR_WHITE: curses.COLOR_WHITE,
246      cli_shared.COLOR_BLACK: curses.COLOR_BLACK,
247  }
248
249  # Font attribute for search and highlighting.
250  _SEARCH_HIGHLIGHT_FONT_ATTR = (
251      cli_shared.COLOR_BLACK + "_on_" + cli_shared.COLOR_WHITE)
252  _ARRAY_INDICES_COLOR_PAIR = (
253      cli_shared.COLOR_BLACK + "_on_" + cli_shared.COLOR_WHITE)
254  _ERROR_TOAST_COLOR_PAIR = (
255      cli_shared.COLOR_RED + "_on_" + cli_shared.COLOR_WHITE)
256  _INFO_TOAST_COLOR_PAIR = (
257      cli_shared.COLOR_BLUE + "_on_" + cli_shared.COLOR_WHITE)
258  _STATUS_BAR_COLOR_PAIR = (
259      cli_shared.COLOR_BLACK + "_on_" + cli_shared.COLOR_WHITE)
260  _UI_WAIT_COLOR_PAIR = (
261      cli_shared.COLOR_MAGENTA + "_on_" + cli_shared.COLOR_WHITE)
262  _NAVIGATION_WARNING_COLOR_PAIR = (
263      cli_shared.COLOR_RED + "_on_" + cli_shared.COLOR_WHITE)
264
265  _UI_WAIT_MESSAGE = "Processing..."
266
267  # The delay (in ms) between each update of the scroll bar when the mouse
268  # button is held down on the scroll bar. Controls how fast the screen scrolls.
269  _MOUSE_SCROLL_DELAY_MS = 100
270
271  _single_instance_lock = threading.Lock()
272
273  def __init__(self, on_ui_exit=None, config=None):
274    """Constructor of CursesUI.
275
276    Args:
277      on_ui_exit: (Callable) Callback invoked when the UI exits.
278      config: An instance of `cli_config.CLIConfig()` carrying user-facing
279        configurations.
280    """
281
282    base_ui.BaseUI.__init__(self, on_ui_exit=on_ui_exit, config=config)
283
284    self._screen_init()
285    self._screen_refresh_size()
286    # TODO(cais): Error out if the size of the screen is too small.
287
288    # Initialize some UI component size and locations.
289    self._init_layout()
290
291    self._command_history_store = debugger_cli_common.CommandHistory()
292
293    # Active list of command history, used in history navigation.
294    # _command_handler_registry holds all the history commands the CLI has
295    # received, up to a size limit. _active_command_history is the history
296    # currently being navigated in, e.g., using the Up/Down keys. The latter
297    # can be different from the former during prefixed or regex-based history
298    # navigation, e.g., when user enter the beginning of a command and hit Up.
299    self._active_command_history = []
300
301    # Pointer to the current position in the history sequence.
302    # 0 means it is a new command being keyed in.
303    self._command_pointer = 0
304
305    self._command_history_limit = 100
306
307    self._pending_command = ""
308
309    self._nav_history = curses_widgets.CursesNavigationHistory(10)
310
311    # State related to screen output.
312    self._output_pad = None
313    self._output_pad_row = 0
314    self._output_array_pointer_indices = None
315    self._curr_unwrapped_output = None
316    self._curr_wrapped_output = None
317
318    try:
319      # Register signal handler for SIGINT.
320      signal.signal(signal.SIGINT, self._interrupt_handler)
321    except ValueError:
322      # Running in a child thread, can't catch signals.
323      pass
324
325    self.register_command_handler(
326        "mouse",
327        self._mouse_mode_command_handler,
328        "Get or set the mouse mode of this CLI: (on|off)",
329        prefix_aliases=["m"])
330
331  def _init_layout(self):
332    """Initialize the layout of UI components.
333
334    Initialize the location and size of UI components such as command textbox
335    and output region according to the terminal size.
336    """
337
338    # NamedTuple for rectangular locations on screen
339    self.rectangle = collections.namedtuple("rectangle",
340                                            "top left bottom right")
341
342    # Height of command text box
343    self._command_textbox_height = 2
344
345    self._title_row = 0
346
347    # Row index of the Navigation Bar (i.e., the bar that contains forward and
348    # backward buttons and displays the current command line).
349    self._nav_bar_row = 1
350
351    # Top row index of the output pad.
352    # A "pad" is a curses object that holds lines of text and not limited to
353    # screen size. It can be rendered on the screen partially with scroll
354    # parameters specified.
355    self._output_top_row = 2
356
357    # Number of rows that the output pad has.
358    self._output_num_rows = (
359        self._max_y - self._output_top_row - self._command_textbox_height - 1)
360
361    # Row index of scroll information line: Taking into account the zero-based
362    # row indexing and the command textbox area under the scroll information
363    # row.
364    self._output_scroll_row = self._max_y - 1 - self._command_textbox_height
365
366    # Tab completion bottom row.
367    self._candidates_top_row = self._output_scroll_row - 4
368    self._candidates_bottom_row = self._output_scroll_row - 1
369
370    # Maximum number of lines the candidates display can have.
371    self._candidates_max_lines = int(self._output_num_rows / 2)
372
373    self.max_output_lines = 10000
374
375    # Regex search state.
376    self._curr_search_regex = None
377    self._unwrapped_regex_match_lines = []
378
379    # Size of view port on screen, which is always smaller or equal to the
380    # screen size.
381    self._output_pad_screen_height = self._output_num_rows - 1
382    self._output_pad_screen_width = self._max_x - 2
383    self._output_pad_screen_location = self.rectangle(
384        top=self._output_top_row,
385        left=0,
386        bottom=self._output_top_row + self._output_num_rows,
387        right=self._output_pad_screen_width)
388
389  def _screen_init(self):
390    """Screen initialization.
391
392    Creates curses stdscr and initialize the color pairs for display.
393    """
394    # If the terminal type is color-ready, enable it.
395    if os.getenv("COLORTERM") in _COLOR_READY_COLORTERMS:
396      os.environ["TERM"] = _COLOR_ENABLED_TERM
397    self._stdscr = curses.initscr()
398    self._command_window = None
399    self._screen_color_init()
400
401  def _screen_color_init(self):
402    """Initialization of screen colors."""
403    curses.start_color()
404    curses.use_default_colors()
405    self._color_pairs = {}
406    color_index = 0
407
408    # Prepare color pairs.
409    for fg_color in self._FOREGROUND_COLORS:
410      for bg_color in self._BACKGROUND_COLORS:
411        color_index += 1
412        curses.init_pair(color_index, self._FOREGROUND_COLORS[fg_color],
413                         self._BACKGROUND_COLORS[bg_color])
414
415        color_name = fg_color
416        if bg_color != "transparent":
417          color_name += "_on_" + bg_color
418
419        self._color_pairs[color_name] = curses.color_pair(color_index)
420
421    # Try getting color(s) available only under 256-color support.
422    try:
423      color_index += 1
424      curses.init_pair(color_index, 245, -1)
425      self._color_pairs[cli_shared.COLOR_GRAY] = curses.color_pair(color_index)
426    except curses.error:
427      # Use fall-back color(s):
428      self._color_pairs[cli_shared.COLOR_GRAY] = (
429          self._color_pairs[cli_shared.COLOR_GREEN])
430
431    # A_BOLD or A_BLINK is not really a "color". But place it here for
432    # convenience.
433    self._color_pairs["bold"] = curses.A_BOLD
434    self._color_pairs["blink"] = curses.A_BLINK
435    self._color_pairs["underline"] = curses.A_UNDERLINE
436
437    # Default color pair to use when a specified color pair does not exist.
438    self._default_color_pair = self._color_pairs[cli_shared.COLOR_WHITE]
439
440  def _screen_launch(self, enable_mouse_on_start):
441    """Launch the curses screen."""
442
443    curses.noecho()
444    curses.cbreak()
445    self._stdscr.keypad(1)
446
447    self._mouse_enabled = self.config.get("mouse_mode")
448    self._screen_set_mousemask()
449    self.config.set_callback(
450        "mouse_mode",
451        lambda cfg: self._set_mouse_enabled(cfg.get("mouse_mode")))
452
453    self._screen_create_command_window()
454
455  def _screen_create_command_window(self):
456    """Create command window according to screen size."""
457    if self._command_window:
458      del self._command_window
459
460    self._command_window = curses.newwin(
461        self._command_textbox_height, self._max_x - len(self.CLI_PROMPT),
462        self._max_y - self._command_textbox_height, len(self.CLI_PROMPT))
463
464  def _screen_refresh(self):
465    self._stdscr.refresh()
466
467  def _screen_terminate(self):
468    """Terminate the curses screen."""
469
470    self._stdscr.keypad(0)
471    curses.nocbreak()
472    curses.echo()
473    curses.endwin()
474
475    try:
476      # Remove SIGINT handler.
477      signal.signal(signal.SIGINT, signal.SIG_DFL)
478    except ValueError:
479      # Can't catch signals unless you're the main thread.
480      pass
481
482  def run_ui(self,
483             init_command=None,
484             title=None,
485             title_color=None,
486             enable_mouse_on_start=True):
487    """Run the CLI: See the doc of base_ui.BaseUI.run_ui for more details."""
488
489    # Only one instance of the Curses UI can be running at a time, since
490    # otherwise they would try to both read from the same keystrokes, and write
491    # to the same screen.
492    self._single_instance_lock.acquire()
493
494    self._screen_launch(enable_mouse_on_start=enable_mouse_on_start)
495
496    # Optional initial command.
497    if init_command is not None:
498      self._dispatch_command(init_command)
499
500    if title is not None:
501      self._title(title, title_color=title_color)
502
503    # CLI main loop.
504    exit_token = self._ui_loop()
505
506    if self._on_ui_exit:
507      self._on_ui_exit()
508
509    self._screen_terminate()
510
511    self._single_instance_lock.release()
512
513    return exit_token
514
515  def get_help(self):
516    return self._command_handler_registry.get_help()
517
518  def _addstr(self, *args):
519    try:
520      self._stdscr.addstr(*args)
521    except curses.error:
522      pass
523
524  def _refresh_pad(self, pad, *args):
525    try:
526      pad.refresh(*args)
527    except curses.error:
528      pass
529
530  def _screen_create_command_textbox(self, existing_command=None):
531    """Create command textbox on screen.
532
533    Args:
534      existing_command: (str) A command string to put in the textbox right
535        after its creation.
536    """
537
538    # Display the tfdbg prompt.
539    self._addstr(self._max_y - self._command_textbox_height, 0,
540                 self.CLI_PROMPT, curses.A_BOLD)
541    self._stdscr.refresh()
542
543    self._command_window.clear()
544
545    # Command text box.
546    self._command_textbox = textpad.Textbox(
547        self._command_window, insert_mode=True)
548
549    # Enter existing command.
550    self._auto_key_in(existing_command)
551
552  def _ui_loop(self):
553    """Command-line UI loop.
554
555    Returns:
556      An exit token of arbitrary type. The token can be None.
557    """
558
559    while True:
560      # Enter history command if pointer is in history (> 0):
561      if self._command_pointer > 0:
562        existing_command = self._active_command_history[-self._command_pointer]
563      else:
564        existing_command = self._pending_command
565      self._screen_create_command_textbox(existing_command)
566
567      try:
568        command, terminator, pending_command_changed = self._get_user_command()
569      except debugger_cli_common.CommandLineExit as e:
570        return e.exit_token
571
572      if not command and terminator != self.CLI_TAB_KEY:
573        continue
574
575      if terminator in self.CLI_CR_KEYS or terminator == curses.KEY_MOUSE:
576        exit_token = self._dispatch_command(command)
577        if exit_token is not None:
578          return exit_token
579      elif terminator == self.CLI_TAB_KEY:
580        tab_completed = self._tab_complete(command)
581        self._pending_command = tab_completed
582        self._cmd_ptr = 0
583      elif pending_command_changed:
584        self._pending_command = command
585
586    return
587
588  def _get_user_command(self):
589    """Get user command from UI.
590
591    Returns:
592      command: (str) The user-entered command.
593      terminator: (str) Terminator type for the command.
594        If command is a normal command entered with the Enter key, the value
595        will be the key itself. If this is a tab completion call (using the
596        Tab key), the value will reflect that as well.
597      pending_command_changed:  (bool) If the pending command has changed.
598        Used during command history navigation.
599    """
600
601    # First, reset textbox state variables.
602    self._textbox_curr_terminator = None
603    self._textbox_pending_command_changed = False
604
605    command = self._screen_get_user_command()
606    command = self._strip_terminator(command)
607    return (command, self._textbox_curr_terminator,
608            self._textbox_pending_command_changed)
609
610  def _screen_get_user_command(self):
611    return self._command_textbox.edit(validate=self._on_textbox_keypress)
612
613  def _strip_terminator(self, command):
614    if not command:
615      return command
616
617    for v in self.CLI_CR_KEYS:
618      if v < 256:
619        command = command.replace(chr(v), "")
620
621    return command.strip()
622
623  def _screen_refresh_size(self):
624    self._max_y, self._max_x = self._stdscr.getmaxyx()
625    if self._max_x > self._SCREEN_WIDTH_LIMIT:
626      self._max_x = self._SCREEN_WIDTH_LIMIT
627
628  def _navigate_screen_output(self, command):
629    """Navigate in screen output history.
630
631    Args:
632      command: (`str`) the navigation command, from
633        {self._NAVIGATION_FORWARD_COMMAND, self._NAVIGATION_BACK_COMMAND}.
634    """
635    if command == self._NAVIGATION_FORWARD_COMMAND:
636      if self._nav_history.can_go_forward():
637        item = self._nav_history.go_forward()
638        scroll_position = item.scroll_position
639      else:
640        self._toast("At the LATEST in navigation history!",
641                    color=self._NAVIGATION_WARNING_COLOR_PAIR)
642        return
643    else:
644      if self._nav_history.can_go_back():
645        item = self._nav_history.go_back()
646        scroll_position = item.scroll_position
647      else:
648        self._toast("At the OLDEST in navigation history!",
649                    color=self._NAVIGATION_WARNING_COLOR_PAIR)
650        return
651
652    self._display_output(item.screen_output)
653    if scroll_position != 0:
654      self._scroll_output(_SCROLL_TO_LINE_INDEX, line_index=scroll_position)
655
656  def _dispatch_command(self, command):
657    """Dispatch user command.
658
659    Args:
660      command: (str) Command to dispatch.
661
662    Returns:
663      An exit token object. None value means that the UI loop should not exit.
664      A non-None value means the UI loop should exit.
665    """
666
667    if self._output_pad:
668      self._toast(self._UI_WAIT_MESSAGE, color=self._UI_WAIT_COLOR_PAIR)
669
670    if command in self.CLI_EXIT_COMMANDS:
671      # Explicit user command-triggered exit: EXPLICIT_USER_EXIT as the exit
672      # token.
673      return debugger_cli_common.EXPLICIT_USER_EXIT
674    elif (command == self._NAVIGATION_FORWARD_COMMAND or
675          command == self._NAVIGATION_BACK_COMMAND):
676      self._navigate_screen_output(command)
677      return
678
679    if command:
680      self._command_history_store.add_command(command)
681
682    if (command.startswith(self.REGEX_SEARCH_PREFIX) and
683        self._curr_unwrapped_output):
684      if len(command) > len(self.REGEX_SEARCH_PREFIX):
685        # Command is like "/regex". Perform regex search.
686        regex = command[len(self.REGEX_SEARCH_PREFIX):]
687
688        self._curr_search_regex = regex
689        self._display_output(self._curr_unwrapped_output, highlight_regex=regex)
690      elif self._unwrapped_regex_match_lines:
691        # Command is "/". Continue scrolling down matching lines.
692        self._display_output(
693            self._curr_unwrapped_output,
694            is_refresh=True,
695            highlight_regex=self._curr_search_regex)
696
697      self._command_pointer = 0
698      self._pending_command = ""
699      return
700    elif command.startswith(self.TENSOR_INDICES_NAVIGATION_PREFIX):
701      indices_str = command[1:].strip()
702      if indices_str:
703        try:
704          indices = command_parser.parse_indices(indices_str)
705          omitted, line_index, _, _ = tensor_format.locate_tensor_element(
706              self._curr_wrapped_output, indices)
707          if not omitted:
708            self._scroll_output(
709                _SCROLL_TO_LINE_INDEX, line_index=line_index)
710        except Exception as e:  # pylint: disable=broad-except
711          self._error_toast(str(e))
712      else:
713        self._error_toast("Empty indices.")
714
715      return
716
717    try:
718      prefix, args, output_file_path = self._parse_command(command)
719    except SyntaxError as e:
720      self._error_toast(str(e))
721      return
722
723    if not prefix:
724      # Empty command: take no action. Should not exit.
725      return
726
727    # Take into account scroll bar width.
728    screen_info = {"cols": self._max_x - 2}
729    exit_token = None
730    if self._command_handler_registry.is_registered(prefix):
731      try:
732        screen_output = self._command_handler_registry.dispatch_command(
733            prefix, args, screen_info=screen_info)
734      except debugger_cli_common.CommandLineExit as e:
735        exit_token = e.exit_token
736    else:
737      screen_output = debugger_cli_common.RichTextLines([
738          self.ERROR_MESSAGE_PREFIX + "Invalid command prefix \"%s\"" % prefix
739      ])
740
741    # Clear active command history. Until next up/down history navigation
742    # occurs, it will stay empty.
743    self._active_command_history = []
744
745    if exit_token is not None:
746      return exit_token
747
748    self._nav_history.add_item(command, screen_output, 0)
749
750    self._display_output(screen_output)
751    if output_file_path:
752      try:
753        screen_output.write_to_file(output_file_path)
754        self._info_toast("Wrote output to %s" % output_file_path)
755      except Exception:  # pylint: disable=broad-except
756        self._error_toast("Failed to write output to %s" % output_file_path)
757
758    self._command_pointer = 0
759    self._pending_command = ""
760
761  def _screen_gather_textbox_str(self):
762    """Gather the text string in the command text box.
763
764    Returns:
765      (str) the current text string in the command textbox, excluding any
766      return keys.
767    """
768
769    txt = self._command_textbox.gather()
770    return txt.strip()
771
772  def _on_textbox_keypress(self, x):
773    """Text box key validator: Callback of key strokes.
774
775    Handles a user's keypress in the input text box. Translates certain keys to
776    terminator keys for the textbox to allow its edit() method to return.
777    Also handles special key-triggered events such as PgUp/PgDown scrolling of
778    the screen output.
779
780    Args:
781      x: (int) Key code.
782
783    Returns:
784      (int) A translated key code. In most cases, this is identical to the
785        input x. However, if x is a Return key, the return value will be
786        CLI_TERMINATOR_KEY, so that the text box's edit() method can return.
787
788    Raises:
789      TypeError: If the input x is not of type int.
790      debugger_cli_common.CommandLineExit: If a mouse-triggered command returns
791        an exit token when dispatched.
792    """
793    if not isinstance(x, int):
794      raise TypeError("Key validator expected type int, received type %s" %
795                      type(x))
796
797    if x in self.CLI_CR_KEYS:
798      # Make Enter key the terminator
799      self._textbox_curr_terminator = x
800      return self.CLI_TERMINATOR_KEY
801    elif x == self.CLI_TAB_KEY:
802      self._textbox_curr_terminator = self.CLI_TAB_KEY
803      return self.CLI_TERMINATOR_KEY
804    elif x == curses.KEY_PPAGE:
805      self._scroll_output(_SCROLL_UP_A_LINE)
806      return x
807    elif x == curses.KEY_NPAGE:
808      self._scroll_output(_SCROLL_DOWN_A_LINE)
809      return x
810    elif x == curses.KEY_HOME:
811      self._scroll_output(_SCROLL_HOME)
812      return x
813    elif x == curses.KEY_END:
814      self._scroll_output(_SCROLL_END)
815      return x
816    elif x in [curses.KEY_UP, curses.KEY_DOWN]:
817      # Command history navigation.
818      if not self._active_command_history:
819        hist_prefix = self._screen_gather_textbox_str()
820        self._active_command_history = (
821            self._command_history_store.lookup_prefix(
822                hist_prefix, self._command_history_limit))
823
824      if self._active_command_history:
825        if x == curses.KEY_UP:
826          if self._command_pointer < len(self._active_command_history):
827            self._command_pointer += 1
828        elif x == curses.KEY_DOWN:
829          if self._command_pointer > 0:
830            self._command_pointer -= 1
831      else:
832        self._command_pointer = 0
833
834      self._textbox_curr_terminator = x
835
836      # Force return from the textbox edit(), so that the textbox can be
837      # redrawn with a history command entered.
838      return self.CLI_TERMINATOR_KEY
839    elif x == curses.KEY_RESIZE:
840      # Respond to terminal resize.
841      self._screen_refresh_size()
842      self._init_layout()
843      self._screen_create_command_window()
844      self._redraw_output()
845
846      # Force return from the textbox edit(), so that the textbox can be
847      # redrawn.
848      return self.CLI_TERMINATOR_KEY
849    elif x == curses.KEY_MOUSE and self._mouse_enabled:
850      try:
851        _, mouse_x, mouse_y, _, mouse_event_type = self._screen_getmouse()
852      except curses.error:
853        mouse_event_type = None
854
855      if mouse_event_type == curses.BUTTON1_PRESSED:
856        # Logic for held mouse-triggered scrolling.
857        if mouse_x >= self._max_x - 2:
858          # Disable blocking on checking for user input.
859          self._command_window.nodelay(True)
860
861          # Loop while mouse button is pressed.
862          while mouse_event_type == curses.BUTTON1_PRESSED:
863            # Sleep for a bit.
864            curses.napms(self._MOUSE_SCROLL_DELAY_MS)
865            scroll_command = self._scroll_bar.get_click_command(mouse_y)
866            if scroll_command in (_SCROLL_UP_A_LINE, _SCROLL_DOWN_A_LINE):
867              self._scroll_output(scroll_command)
868
869            # Check to see if different mouse event is in queue.
870            self._command_window.getch()
871            try:
872              _, _, _, _, mouse_event_type = self._screen_getmouse()
873            except curses.error:
874              pass
875
876          self._command_window.nodelay(False)
877          return x
878      elif mouse_event_type == curses.BUTTON1_RELEASED:
879        # Logic for mouse-triggered scrolling.
880        if mouse_x >= self._max_x - 2:
881          scroll_command = self._scroll_bar.get_click_command(mouse_y)
882          if scroll_command is not None:
883            self._scroll_output(scroll_command)
884          return x
885        else:
886          command = self._fetch_hyperlink_command(mouse_x, mouse_y)
887          if command:
888            self._screen_create_command_textbox()
889            exit_token = self._dispatch_command(command)
890            if exit_token is not None:
891              raise debugger_cli_common.CommandLineExit(exit_token=exit_token)
892    else:
893      # Mark the pending command as modified.
894      self._textbox_pending_command_changed = True
895      # Invalidate active command history.
896      self._command_pointer = 0
897      self._active_command_history = []
898      return self._KEY_MAP.get(x, x)
899
900  def _screen_getmouse(self):
901    return curses.getmouse()
902
903  def _redraw_output(self):
904    if self._curr_unwrapped_output is not None:
905      self._display_nav_bar()
906      self._display_main_menu(self._curr_unwrapped_output)
907      self._display_output(self._curr_unwrapped_output, is_refresh=True)
908
909  def _fetch_hyperlink_command(self, mouse_x, mouse_y):
910    output_top = self._output_top_row
911    if self._main_menu_pad:
912      output_top += 1
913
914    if mouse_y == self._nav_bar_row and self._nav_bar:
915      # Click was in the nav bar.
916      return _get_command_from_line_attr_segs(mouse_x,
917                                              self._nav_bar.font_attr_segs[0])
918    elif mouse_y == self._output_top_row and self._main_menu_pad:
919      # Click was in the menu bar.
920      return _get_command_from_line_attr_segs(mouse_x,
921                                              self._main_menu.font_attr_segs[0])
922    else:
923      absolute_mouse_y = mouse_y + self._output_pad_row - output_top
924      if absolute_mouse_y in self._curr_wrapped_output.font_attr_segs:
925        return _get_command_from_line_attr_segs(
926            mouse_x, self._curr_wrapped_output.font_attr_segs[absolute_mouse_y])
927
928  def _title(self, title, title_color=None):
929    """Display title.
930
931    Args:
932      title: (str) The title to display.
933      title_color: (str) Color of the title, e.g., "yellow".
934    """
935
936    # Pad input title str with "-" and space characters to make it pretty.
937    self._title_line = "--- %s " % title
938    if len(self._title_line) < self._max_x:
939      self._title_line += "-" * (self._max_x - len(self._title_line))
940
941    self._screen_draw_text_line(
942        self._title_row, self._title_line, color=title_color)
943
944  def _auto_key_in(self, command, erase_existing=False):
945    """Automatically key in a command to the command Textbox.
946
947    Args:
948      command: The command, as a string or None.
949      erase_existing: (bool) whether existing text (if any) is to be erased
950          first.
951    """
952    if erase_existing:
953      self._erase_existing_command()
954
955    command = command or ""
956    for c in command:
957      self._command_textbox.do_command(ord(c))
958
959  def _erase_existing_command(self):
960    """Erase existing text in command textpad."""
961
962    existing_len = len(self._command_textbox.gather())
963    for _ in range(existing_len):
964      self._command_textbox.do_command(self.BACKSPACE_KEY)
965
966  def _screen_draw_text_line(self, row, line, attr=curses.A_NORMAL, color=None):
967    """Render a line of text on the screen.
968
969    Args:
970      row: (int) Row index.
971      line: (str) The line content.
972      attr: curses font attribute.
973      color: (str) font foreground color name.
974
975    Raises:
976      TypeError: If row is not of type int.
977    """
978
979    if not isinstance(row, int):
980      raise TypeError("Invalid type in row")
981
982    if len(line) > self._max_x:
983      line = line[:self._max_x]
984
985    color_pair = (self._default_color_pair if color is None else
986                  self._color_pairs[color])
987
988    self._addstr(row, 0, line, color_pair | attr)
989    self._screen_refresh()
990
991  def _screen_new_output_pad(self, rows, cols):
992    """Generate a new pad on the screen.
993
994    Args:
995      rows: (int) Number of rows the pad will have: not limited to screen size.
996      cols: (int) Number of columns the pad will have: not limited to screen
997        size.
998
999    Returns:
1000      A curses textpad object.
1001    """
1002
1003    return curses.newpad(rows, cols)
1004
1005  def _screen_display_output(self, output):
1006    """Actually render text output on the screen.
1007
1008    Wraps the lines according to screen width. Pad lines below according to
1009    screen height so that the user can scroll the output to a state where
1010    the last non-empty line is on the top of the screen. Then renders the
1011    lines on the screen.
1012
1013    Args:
1014      output: (RichTextLines) text lines to display on the screen. These lines
1015        may have widths exceeding the screen width. This method will take care
1016        of the wrapping.
1017
1018    Returns:
1019      (List of int) A list of line indices, in the wrapped output, where there
1020        are regex matches.
1021    """
1022
1023    # Wrap the output lines according to screen width.
1024    self._curr_wrapped_output, wrapped_line_indices = (
1025        debugger_cli_common.wrap_rich_text_lines(output, self._max_x - 2))
1026
1027    # Append lines to curr_wrapped_output so that the user can scroll to a
1028    # state where the last text line is on the top of the output area.
1029    self._curr_wrapped_output.lines.extend([""] * (self._output_num_rows - 1))
1030
1031    # Limit number of lines displayed to avoid curses overflow problems.
1032    if self._curr_wrapped_output.num_lines() > self.max_output_lines:
1033      self._curr_wrapped_output = self._curr_wrapped_output.slice(
1034          0, self.max_output_lines)
1035      self._curr_wrapped_output.lines.append("Output cut off at %d lines!" %
1036                                             self.max_output_lines)
1037      self._curr_wrapped_output.font_attr_segs[self.max_output_lines] = [
1038          (0, len(output.lines[-1]), cli_shared.COLOR_MAGENTA)
1039      ]
1040
1041    self._display_nav_bar()
1042    self._display_main_menu(self._curr_wrapped_output)
1043
1044    (self._output_pad, self._output_pad_height,
1045     self._output_pad_width) = self._display_lines(self._curr_wrapped_output,
1046                                                   self._output_num_rows)
1047
1048    # The indices of lines with regex matches (if any) need to be mapped to
1049    # indices of wrapped lines.
1050    return [
1051        wrapped_line_indices[line]
1052        for line in self._unwrapped_regex_match_lines
1053    ]
1054
1055  def _display_output(self, output, is_refresh=False, highlight_regex=None):
1056    """Display text output in a scrollable text pad.
1057
1058    This method does some preprocessing on the text lines, render them on the
1059    screen and scroll to the appropriate line. These are done according to regex
1060    highlighting requests (if any), scroll-to-next-match requests (if any),
1061    and screen refresh requests (if any).
1062
1063    TODO(cais): Separate these unrelated request to increase clarity and
1064      maintainability.
1065
1066    Args:
1067      output: A RichTextLines object that is the screen output text.
1068      is_refresh: (bool) Is this a refreshing display with existing output.
1069      highlight_regex: (str) Optional string representing the regex used to
1070        search and highlight in the current screen output.
1071    """
1072
1073    if not output:
1074      return
1075
1076    if highlight_regex:
1077      try:
1078        output = debugger_cli_common.regex_find(
1079            output, highlight_regex, font_attr=self._SEARCH_HIGHLIGHT_FONT_ATTR)
1080      except ValueError as e:
1081        self._error_toast(str(e))
1082        return
1083
1084      if not is_refresh:
1085        # Perform new regex search on the current output.
1086        self._unwrapped_regex_match_lines = output.annotations[
1087            debugger_cli_common.REGEX_MATCH_LINES_KEY]
1088      else:
1089        # Continue scrolling down.
1090        self._output_pad_row += 1
1091    else:
1092      self._curr_unwrapped_output = output
1093      self._unwrapped_regex_match_lines = []
1094
1095    # Display output on the screen.
1096    wrapped_regex_match_lines = self._screen_display_output(output)
1097
1098    # Now that the text lines are displayed on the screen scroll to the
1099    # appropriate line according to previous scrolling state and regex search
1100    # and highlighting state.
1101
1102    if highlight_regex:
1103      next_match_line = -1
1104      for match_line in wrapped_regex_match_lines:
1105        if match_line >= self._output_pad_row:
1106          next_match_line = match_line
1107          break
1108
1109      if next_match_line >= 0:
1110        self._scroll_output(
1111            _SCROLL_TO_LINE_INDEX, line_index=next_match_line)
1112      else:
1113        # Regex search found no match >= current line number. Display message
1114        # stating as such.
1115        self._toast("Pattern not found", color=self._ERROR_TOAST_COLOR_PAIR)
1116    elif is_refresh:
1117      self._scroll_output(_SCROLL_REFRESH)
1118    elif debugger_cli_common.INIT_SCROLL_POS_KEY in output.annotations:
1119      line_index = output.annotations[debugger_cli_common.INIT_SCROLL_POS_KEY]
1120      self._scroll_output(_SCROLL_TO_LINE_INDEX, line_index=line_index)
1121    else:
1122      self._output_pad_row = 0
1123      self._scroll_output(_SCROLL_HOME)
1124
1125  def _display_lines(self, output, min_num_rows):
1126    """Display RichTextLines object on screen.
1127
1128    Args:
1129      output: A RichTextLines object.
1130      min_num_rows: (int) Minimum number of output rows.
1131
1132    Returns:
1133      1) The text pad object used to display the main text body.
1134      2) (int) number of rows of the text pad, which may exceed screen size.
1135      3) (int) number of columns of the text pad.
1136
1137    Raises:
1138      ValueError: If input argument "output" is invalid.
1139    """
1140
1141    if not isinstance(output, debugger_cli_common.RichTextLines):
1142      raise ValueError(
1143          "Output is required to be an instance of RichTextLines, but is not.")
1144
1145    self._screen_refresh()
1146
1147    # Number of rows the output area will have.
1148    rows = max(min_num_rows, len(output.lines))
1149
1150    # Size of the output pad, which may exceed screen size and require
1151    # scrolling.
1152    cols = self._max_x - 2
1153
1154    # Create new output pad.
1155    pad = self._screen_new_output_pad(rows, cols)
1156
1157    for i in range(len(output.lines)):
1158      if i in output.font_attr_segs:
1159        self._screen_add_line_to_output_pad(
1160            pad, i, output.lines[i], color_segments=output.font_attr_segs[i])
1161      else:
1162        self._screen_add_line_to_output_pad(pad, i, output.lines[i])
1163
1164    return pad, rows, cols
1165
1166  def _display_nav_bar(self):
1167    nav_bar_width = self._max_x - 2
1168    self._nav_bar_pad = self._screen_new_output_pad(1, nav_bar_width)
1169    self._nav_bar = self._nav_history.render(
1170        nav_bar_width,
1171        self._NAVIGATION_BACK_COMMAND,
1172        self._NAVIGATION_FORWARD_COMMAND)
1173    self._screen_add_line_to_output_pad(
1174        self._nav_bar_pad, 0, self._nav_bar.lines[0][:nav_bar_width - 1],
1175        color_segments=(self._nav_bar.font_attr_segs[0]
1176                        if 0 in self._nav_bar.font_attr_segs else None))
1177
1178  def _display_main_menu(self, output):
1179    """Display main menu associated with screen output, if the menu exists.
1180
1181    Args:
1182      output: (debugger_cli_common.RichTextLines) The RichTextLines output from
1183        the annotations field of which the menu will be extracted and used (if
1184        the menu exists).
1185    """
1186
1187    if debugger_cli_common.MAIN_MENU_KEY in output.annotations:
1188      self._main_menu = output.annotations[
1189          debugger_cli_common.MAIN_MENU_KEY].format_as_single_line(
1190              prefix="| ", divider=" | ", enabled_item_attrs=["underline"])
1191
1192      self._main_menu_pad = self._screen_new_output_pad(1, self._max_x - 2)
1193
1194      # The unwrapped menu line may exceed screen width, in which case it needs
1195      # to be cut off.
1196      wrapped_menu, _ = debugger_cli_common.wrap_rich_text_lines(
1197          self._main_menu, self._max_x - 3)
1198      self._screen_add_line_to_output_pad(
1199          self._main_menu_pad,
1200          0,
1201          wrapped_menu.lines[0],
1202          color_segments=(wrapped_menu.font_attr_segs[0]
1203                          if 0 in wrapped_menu.font_attr_segs else None))
1204    else:
1205      self._main_menu = None
1206      self._main_menu_pad = None
1207
1208  def _pad_line_end_with_whitespace(self, pad, row, line_end_x):
1209    """Pad the whitespace at the end of a line with the default color pair.
1210
1211    Prevents spurious color pairs from appearing at the end of the lines in
1212    certain text terminals.
1213
1214    Args:
1215      pad: The curses pad object to operate on.
1216      row: (`int`) row index.
1217      line_end_x: (`int`) column index of the end of the line (beginning of
1218        the whitespace).
1219    """
1220    if line_end_x < self._max_x - 2:
1221      pad.addstr(row, line_end_x, " " * (self._max_x - 3 - line_end_x),
1222                 self._default_color_pair)
1223
1224  def _screen_add_line_to_output_pad(self, pad, row, txt, color_segments=None):
1225    """Render a line in a text pad.
1226
1227    Assumes: segments in color_segments are sorted in ascending order of the
1228    beginning index.
1229    Note: Gaps between the segments are allowed and will be fixed in with a
1230    default color.
1231
1232    Args:
1233      pad: The text pad to render the line in.
1234      row: Row index, as an int.
1235      txt: The text to be displayed on the specified row, as a str.
1236      color_segments: A list of 3-tuples. Each tuple represents the beginning
1237        and the end of a color segment, in the form of a right-open interval:
1238        [start, end). The last element of the tuple is a color string, e.g.,
1239        "red".
1240
1241    Raisee:
1242      TypeError: If color_segments is not of type list.
1243    """
1244
1245    if not color_segments:
1246      pad.addstr(row, 0, txt, self._default_color_pair)
1247      self._pad_line_end_with_whitespace(pad, row, len(txt))
1248      return
1249
1250    if not isinstance(color_segments, list):
1251      raise TypeError("Input color_segments needs to be a list, but is not.")
1252
1253    all_segments = []
1254    all_color_pairs = []
1255
1256    # Process the beginning.
1257    if color_segments[0][0] == 0:
1258      pass
1259    else:
1260      all_segments.append((0, color_segments[0][0]))
1261      all_color_pairs.append(self._default_color_pair)
1262
1263    for (curr_start, curr_end, curr_attrs), (next_start, _, _) in zip(
1264        color_segments, color_segments[1:] + [(len(txt), None, None)]):
1265      all_segments.append((curr_start, curr_end))
1266
1267      if not isinstance(curr_attrs, list):
1268        curr_attrs = [curr_attrs]
1269
1270      curses_attr = curses.A_NORMAL
1271      for attr in curr_attrs:
1272        if (self._mouse_enabled and
1273            isinstance(attr, debugger_cli_common.MenuItem)):
1274          curses_attr |= curses.A_UNDERLINE
1275        else:
1276          curses_attr |= self._color_pairs.get(attr, self._default_color_pair)
1277      all_color_pairs.append(curses_attr)
1278
1279      if curr_end < next_start:
1280        # Fill in the gap with the default color.
1281        all_segments.append((curr_end, next_start))
1282        all_color_pairs.append(self._default_color_pair)
1283
1284    # Finally, draw all the segments.
1285    for segment, color_pair in zip(all_segments, all_color_pairs):
1286      if segment[1] < self._max_x:
1287        pad.addstr(row, segment[0], txt[segment[0]:segment[1]], color_pair)
1288    if all_segments:
1289      self._pad_line_end_with_whitespace(pad, row, all_segments[-1][1])
1290
1291  def _screen_scroll_output_pad(self, pad, viewport_top, viewport_left,
1292                                screen_location_top, screen_location_left,
1293                                screen_location_bottom, screen_location_right):
1294    self._refresh_pad(pad, viewport_top, viewport_left, screen_location_top,
1295                      screen_location_left, screen_location_bottom,
1296                      screen_location_right)
1297    self._scroll_bar = ScrollBar(
1298        self._max_x - 2,
1299        3,
1300        self._max_x - 1,
1301        self._output_num_rows + 1,
1302        self._output_pad_row,
1303        self._output_pad_height - self._output_pad_screen_height)
1304
1305    (scroll_pad, _, _) = self._display_lines(
1306        self._scroll_bar.layout(), self._output_num_rows - 1)
1307    self._refresh_pad(scroll_pad, 0, 0, self._output_top_row + 1,
1308                      self._max_x - 2, self._output_num_rows + 1,
1309                      self._max_x - 1)
1310
1311  def _scroll_output(self, direction, line_index=None):
1312    """Scroll the output pad.
1313
1314    Args:
1315      direction: _SCROLL_REFRESH, _SCROLL_UP, _SCROLL_DOWN, _SCROLL_UP_A_LINE,
1316        _SCROLL_DOWN_A_LINE, _SCROLL_HOME, _SCROLL_END, _SCROLL_TO_LINE_INDEX
1317      line_index: (int) Specifies the zero-based line index to scroll to.
1318        Applicable only if direction is _SCROLL_TO_LINE_INDEX.
1319
1320    Raises:
1321      ValueError: On invalid scroll direction.
1322      TypeError: If line_index is not int and direction is
1323        _SCROLL_TO_LINE_INDEX.
1324    """
1325
1326    if not self._output_pad:
1327      # No output pad is present. Do nothing.
1328      return
1329
1330    if direction == _SCROLL_REFRESH:
1331      pass
1332    elif direction == _SCROLL_UP:
1333      # Scroll up.
1334      self._output_pad_row -= int(self._output_num_rows / 3)
1335      if self._output_pad_row < 0:
1336        self._output_pad_row = 0
1337    elif direction == _SCROLL_DOWN:
1338      # Scroll down.
1339      self._output_pad_row += int(self._output_num_rows / 3)
1340      if (self._output_pad_row >
1341          self._output_pad_height - self._output_pad_screen_height - 1):
1342        self._output_pad_row = (
1343            self._output_pad_height - self._output_pad_screen_height - 1)
1344    elif direction == _SCROLL_UP_A_LINE:
1345      # Scroll up a line
1346      if self._output_pad_row - 1 >= 0:
1347        self._output_pad_row -= 1
1348    elif direction == _SCROLL_DOWN_A_LINE:
1349      # Scroll down a line
1350      if self._output_pad_row + 1 < (
1351          self._output_pad_height - self._output_pad_screen_height):
1352        self._output_pad_row += 1
1353    elif direction == _SCROLL_HOME:
1354      # Scroll to top
1355      self._output_pad_row = 0
1356    elif direction == _SCROLL_END:
1357      # Scroll to bottom
1358      self._output_pad_row = (
1359          self._output_pad_height - self._output_pad_screen_height - 1)
1360    elif direction == _SCROLL_TO_LINE_INDEX:
1361      if not isinstance(line_index, int):
1362        raise TypeError("Invalid line_index type (%s) under mode %s" %
1363                        (type(line_index), _SCROLL_TO_LINE_INDEX))
1364      self._output_pad_row = line_index
1365    else:
1366      raise ValueError("Unsupported scroll mode: %s" % direction)
1367
1368    self._nav_history.update_scroll_position(self._output_pad_row)
1369
1370    # Actually scroll the output pad: refresh with new location.
1371    output_pad_top = self._output_pad_screen_location.top
1372    if self._main_menu_pad:
1373      output_pad_top += 1
1374    self._screen_scroll_output_pad(self._output_pad, self._output_pad_row, 0,
1375                                   output_pad_top,
1376                                   self._output_pad_screen_location.left,
1377                                   self._output_pad_screen_location.bottom,
1378                                   self._output_pad_screen_location.right)
1379    self._screen_render_nav_bar()
1380    self._screen_render_menu_pad()
1381
1382    self._scroll_info = self._compile_ui_status_summary()
1383    self._screen_draw_text_line(
1384        self._output_scroll_row,
1385        self._scroll_info,
1386        color=self._STATUS_BAR_COLOR_PAIR)
1387
1388  def _screen_render_nav_bar(self):
1389    if self._nav_bar_pad:
1390      self._refresh_pad(self._nav_bar_pad, 0, 0, self._nav_bar_row, 0,
1391                        self._output_pad_screen_location.top, self._max_x)
1392
1393  def _screen_render_menu_pad(self):
1394    if self._main_menu_pad:
1395      self._refresh_pad(
1396          self._main_menu_pad, 0, 0, self._output_pad_screen_location.top, 0,
1397          self._output_pad_screen_location.top, self._max_x)
1398
1399  def _compile_ui_status_summary(self):
1400    """Compile status summary about this Curses UI instance.
1401
1402    The information includes: scroll status and mouse ON/OFF status.
1403
1404    Returns:
1405      (str) A single text line summarizing the UI status, adapted to the
1406        current screen width.
1407    """
1408
1409    info = ""
1410    if self._output_pad_height > self._output_pad_screen_height + 1:
1411      # Display information about the scrolling of tall screen output.
1412      scroll_percentage = 100.0 * (min(
1413          1.0,
1414          float(self._output_pad_row) /
1415          (self._output_pad_height - self._output_pad_screen_height - 1)))
1416      if self._output_pad_row == 0:
1417        scroll_directions = " (PgDn)"
1418      elif self._output_pad_row >= (
1419          self._output_pad_height - self._output_pad_screen_height - 1):
1420        scroll_directions = " (PgUp)"
1421      else:
1422        scroll_directions = " (PgDn/PgUp)"
1423
1424      info += "--- Scroll%s: %.2f%% " % (scroll_directions, scroll_percentage)
1425
1426    self._output_array_pointer_indices = self._show_array_indices()
1427
1428    # Add array indices information to scroll message.
1429    if self._output_array_pointer_indices:
1430      if self._output_array_pointer_indices[0]:
1431        info += self._format_indices(self._output_array_pointer_indices[0])
1432      info += "-"
1433      if self._output_array_pointer_indices[-1]:
1434        info += self._format_indices(self._output_array_pointer_indices[-1])
1435      info += " "
1436
1437    # Add mouse mode information.
1438    mouse_mode_str = "Mouse: "
1439    mouse_mode_str += "ON" if self._mouse_enabled else "OFF"
1440
1441    if len(info) + len(mouse_mode_str) + 5 < self._max_x:
1442      info += "-" * (self._max_x - len(info) - len(mouse_mode_str) - 4)
1443      info += " "
1444      info += mouse_mode_str
1445      info += " ---"
1446    else:
1447      info += "-" * (self._max_x - len(info))
1448
1449    return info
1450
1451  def _format_indices(self, indices):
1452    # Remove the spaces to make it compact.
1453    return repr(indices).replace(" ", "")
1454
1455  def _show_array_indices(self):
1456    """Show array indices for the lines at the top and bottom of the output.
1457
1458    For the top line and bottom line of the output display area, show the
1459    element indices of the array being displayed.
1460
1461    Returns:
1462      If either the top of the bottom row has any matching array indices,
1463      a dict from line index (0 being the top of the display area, -1
1464      being the bottom of the display area) to array element indices. For
1465      example:
1466        {0: [0, 0], -1: [10, 0]}
1467      Otherwise, None.
1468    """
1469
1470    indices_top = self._show_array_index_at_line(0)
1471
1472    output_top = self._output_top_row
1473    if self._main_menu_pad:
1474      output_top += 1
1475    bottom_line_index = (
1476        self._output_pad_screen_location.bottom - output_top - 1)
1477    indices_bottom = self._show_array_index_at_line(bottom_line_index)
1478
1479    if indices_top or indices_bottom:
1480      return {0: indices_top, -1: indices_bottom}
1481    else:
1482      return None
1483
1484  def _show_array_index_at_line(self, line_index):
1485    """Show array indices for the specified line in the display area.
1486
1487    Uses the line number to array indices map in the annotations field of the
1488    RichTextLines object being displayed.
1489    If the displayed RichTextLines object does not contain such a mapping,
1490    will do nothing.
1491
1492    Args:
1493      line_index: (int) 0-based line index from the top of the display area.
1494        For example,if line_index == 0, this method will display the array
1495        indices for the line currently at the top of the display area.
1496
1497    Returns:
1498      (list) The array indices at the specified line, if available. None, if
1499        not available.
1500    """
1501
1502    # Examine whether the index information is available for the specified line
1503    # number.
1504    pointer = self._output_pad_row + line_index
1505    if (pointer in self._curr_wrapped_output.annotations and
1506        "i0" in self._curr_wrapped_output.annotations[pointer]):
1507      indices = self._curr_wrapped_output.annotations[pointer]["i0"]
1508
1509      array_indices_str = self._format_indices(indices)
1510      array_indices_info = "@" + array_indices_str
1511
1512      # TODO(cais): Determine line_index properly given menu pad status.
1513      #   Test coverage?
1514      output_top = self._output_top_row
1515      if self._main_menu_pad:
1516        output_top += 1
1517
1518      self._toast(
1519          array_indices_info,
1520          color=self._ARRAY_INDICES_COLOR_PAIR,
1521          line_index=output_top + line_index)
1522
1523      return indices
1524    else:
1525      return None
1526
1527  def _tab_complete(self, command_str):
1528    """Perform tab completion.
1529
1530    Obtains tab completion candidates.
1531    If there are no candidates, return command_str and take no other actions.
1532    If there are candidates, display the candidates on screen and return
1533    command_str + (common prefix of the candidates).
1534
1535    Args:
1536      command_str: (str) The str in the command input textbox when Tab key is
1537        hit.
1538
1539    Returns:
1540      (str) Completed string. Could be the same as command_str if no completion
1541      candidate is available. If candidate(s) are available, return command_str
1542      appended by the common prefix of the candidates.
1543    """
1544
1545    context, prefix, except_last_word = self._analyze_tab_complete_input(
1546        command_str)
1547    candidates, common_prefix = self._tab_completion_registry.get_completions(
1548        context, prefix)
1549
1550    if candidates and len(candidates) > 1:
1551      self._display_candidates(candidates)
1552    else:
1553      # In the case of len(candidates) == 1, the single completion will be
1554      # entered to the textbox automatically. So there is no need to show any
1555      # candidates.
1556      self._display_candidates([])
1557
1558    if common_prefix:
1559      # Common prefix is not None and non-empty. The completed string will
1560      # incorporate the common prefix.
1561      return except_last_word + common_prefix
1562    else:
1563      return except_last_word + prefix
1564
1565  def _display_candidates(self, candidates):
1566    """Show candidates (e.g., tab-completion candidates) on multiple lines.
1567
1568    Args:
1569      candidates: (list of str) candidates.
1570    """
1571
1572    if self._curr_unwrapped_output:
1573      # Force refresh screen output.
1574      self._scroll_output(_SCROLL_REFRESH)
1575
1576    if not candidates:
1577      return
1578
1579    candidates_prefix = "Candidates: "
1580    candidates_line = candidates_prefix + " ".join(candidates)
1581    candidates_output = debugger_cli_common.RichTextLines(
1582        candidates_line,
1583        font_attr_segs={
1584            0: [(len(candidates_prefix), len(candidates_line), "yellow")]
1585        })
1586
1587    candidates_output, _ = debugger_cli_common.wrap_rich_text_lines(
1588        candidates_output, self._max_x - 3)
1589
1590    # Calculate how many lines the candidate text should occupy. Limit it to
1591    # a maximum value.
1592    candidates_num_rows = min(
1593        len(candidates_output.lines), self._candidates_max_lines)
1594    self._candidates_top_row = (
1595        self._candidates_bottom_row - candidates_num_rows + 1)
1596
1597    # Render the candidate text on screen.
1598    pad, _, _ = self._display_lines(candidates_output, 0)
1599    self._screen_scroll_output_pad(
1600        pad, 0, 0, self._candidates_top_row, 0,
1601        self._candidates_top_row + candidates_num_rows - 1, self._max_x - 2)
1602
1603  def _toast(self, message, color=None, line_index=None):
1604    """Display a one-line message on the screen.
1605
1606    By default, the toast is displayed in the line right above the scroll bar.
1607    But the line location can be overridden with the line_index arg.
1608
1609    Args:
1610      message: (str) the message to display.
1611      color: (str) optional color attribute for the message.
1612      line_index: (int) line index.
1613    """
1614
1615    pad, _, _ = self._display_lines(
1616        debugger_cli_common.RichTextLines(
1617            message,
1618            font_attr_segs={
1619                0: [(0, len(message), color or cli_shared.COLOR_WHITE)]}),
1620        0)
1621
1622    right_end = min(len(message), self._max_x - 2)
1623
1624    if line_index is None:
1625      line_index = self._output_scroll_row - 1
1626    self._screen_scroll_output_pad(pad, 0, 0, line_index, 0, line_index,
1627                                   right_end)
1628
1629  def _error_toast(self, message):
1630    """Display a one-line error message on screen.
1631
1632    Args:
1633      message: The error message, without the preceding "ERROR: " substring.
1634    """
1635
1636    self._toast(
1637        self.ERROR_MESSAGE_PREFIX + message, color=self._ERROR_TOAST_COLOR_PAIR)
1638
1639  def _info_toast(self, message):
1640    """Display a one-line informational message on screen.
1641
1642    Args:
1643      message: The informational message.
1644    """
1645
1646    self._toast(
1647        self.INFO_MESSAGE_PREFIX + message, color=self._INFO_TOAST_COLOR_PAIR)
1648
1649  def _interrupt_handler(self, signal_num, frame):
1650    del signal_num  # Unused.
1651    del frame  # Unused.
1652
1653    if self._on_ui_exit:
1654      self._on_ui_exit()
1655
1656    self._screen_terminate()
1657    print("\ntfdbg: caught SIGINT; calling sys.exit(1).", file=sys.stderr)
1658    sys.exit(1)
1659
1660  def _mouse_mode_command_handler(self, args, screen_info=None):
1661    """Handler for the command prefix 'mouse'.
1662
1663    Args:
1664      args: (list of str) Arguments to the command prefix 'mouse'.
1665      screen_info: (dict) Information about the screen, unused by this handler.
1666
1667    Returns:
1668      None, as this command handler does not generate any screen outputs other
1669        than toasts.
1670    """
1671
1672    del screen_info
1673
1674    if not args or len(args) == 1:
1675      if args:
1676        if args[0].lower() == "on":
1677          enabled = True
1678        elif args[0].lower() == "off":
1679          enabled = False
1680        else:
1681          self._error_toast("Invalid mouse mode: %s" % args[0])
1682          return None
1683
1684        self._set_mouse_enabled(enabled)
1685
1686      mode_str = "on" if self._mouse_enabled else "off"
1687      self._info_toast("Mouse mode: %s" % mode_str)
1688    else:
1689      self._error_toast("mouse_mode: syntax error")
1690
1691    return None
1692
1693  def _set_mouse_enabled(self, enabled):
1694    if self._mouse_enabled != enabled:
1695      self._mouse_enabled = enabled
1696      self._screen_set_mousemask()
1697      self._redraw_output()
1698
1699  def _screen_set_mousemask(self):
1700    if self._mouse_enabled:
1701      curses.mousemask(curses.BUTTON1_RELEASED | curses.BUTTON1_PRESSED)
1702    else:
1703      curses.mousemask(0)
1704