You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

202 lines
5.8 KiB

  1. from __future__ import annotations
  2. from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
  3. import click
  4. from rich.control import Control, ControlType
  5. from rich.live_render import LiveRender
  6. from rich.segment import Segment
  7. from ._input_handler import TextInputHandler
  8. from .element import Element
  9. if TYPE_CHECKING:
  10. from .styles import BaseStyle
  11. class Container(Element):
  12. def __init__(
  13. self,
  14. style: Optional[BaseStyle] = None,
  15. metadata: Optional[Dict[Any, Any]] = None,
  16. ):
  17. self.elements: List[Element] = []
  18. self.active_element_index = 0
  19. self.previous_element_index = 0
  20. self._live_render = LiveRender("")
  21. super().__init__(style=style, metadata=metadata)
  22. self.console = self.style.console
  23. def _refresh(self, done: bool = False):
  24. content = self.style.render_element(self, done=done)
  25. self._live_render.set_renderable(content)
  26. active_element = self.elements[self.active_element_index]
  27. should_show_cursor = (
  28. active_element.should_show_cursor
  29. if hasattr(active_element, "should_show_cursor")
  30. else False
  31. )
  32. # Always show cursor when done to restore terminal state
  33. if done:
  34. should_show_cursor = True
  35. self.console.print(
  36. Control.show_cursor(should_show_cursor),
  37. *self.move_cursor_at_beginning(),
  38. self._live_render,
  39. )
  40. if not done:
  41. self.console.print(
  42. *self.move_cursor_to_active_element(),
  43. )
  44. @property
  45. def _active_element(self) -> Element:
  46. return self.elements[self.active_element_index]
  47. def _get_size(self, element: Element) -> Tuple[int, int]:
  48. renderable = self.style.render_element(element, done=False, parent=self)
  49. lines = self.console.render_lines(renderable, self.console.options, pad=False)
  50. return Segment.get_shape(lines)
  51. def _get_element_position(self, element_index: int) -> int:
  52. position = 0
  53. for i in range(element_index + 1):
  54. current_element = self.elements[i]
  55. if i == element_index:
  56. position += self.style.get_cursor_offset_for_element(
  57. current_element, parent=self
  58. ).top
  59. else:
  60. size = self._get_size(current_element)
  61. position += size[1]
  62. return position
  63. @property
  64. def _active_element_position(self) -> int:
  65. return self._get_element_position(self.active_element_index)
  66. def get_offset_for_element(self, element_index: int) -> int:
  67. if self._live_render._shape is None:
  68. return 0
  69. position = self._get_element_position(element_index)
  70. _, height = self._live_render._shape
  71. return height - position
  72. def get_offset_for_active_element(self) -> int:
  73. return self.get_offset_for_element(self.active_element_index)
  74. def move_cursor_to_active_element(self) -> Tuple[Control, ...]:
  75. move_up = self.get_offset_for_active_element()
  76. move_cursor = (
  77. (Control((ControlType.CURSOR_UP, move_up)),) if move_up > 0 else ()
  78. )
  79. cursor_left = self.style.get_cursor_offset_for_element(
  80. self._active_element, parent=self
  81. ).left
  82. return (Control.move_to_column(cursor_left), *move_cursor)
  83. def move_cursor_at_beginning(self) -> Tuple[Control, ...]:
  84. if self._live_render._shape is None:
  85. return (Control(),)
  86. original = (self._live_render.position_cursor(),)
  87. # Use the previous element type and index for cursor positioning
  88. move_down = self.get_offset_for_element(self.previous_element_index)
  89. if move_down == 0:
  90. return original
  91. return (
  92. Control(
  93. (ControlType.CURSOR_DOWN, move_down),
  94. ),
  95. *original,
  96. )
  97. def handle_enter_key(self) -> bool:
  98. from .input import Input
  99. from .menu import Menu
  100. active_element = self.elements[self.active_element_index]
  101. if isinstance(active_element, (Input, Menu)):
  102. active_element.on_validate()
  103. if active_element.valid is False:
  104. return False
  105. return True
  106. def _focus_next(self) -> None:
  107. self.active_element_index += 1
  108. if self.active_element_index >= len(self.elements):
  109. self.active_element_index = 0
  110. if self._active_element.focusable is False:
  111. self._focus_next()
  112. def _focus_previous(self) -> None:
  113. self.active_element_index -= 1
  114. if self.active_element_index < 0:
  115. self.active_element_index = len(self.elements) - 1
  116. if self._active_element.focusable is False:
  117. self._focus_previous()
  118. def run(self):
  119. self._refresh()
  120. while True:
  121. try:
  122. key = click.getchar()
  123. self.previous_element_index = self.active_element_index
  124. if key in (TextInputHandler.SHIFT_TAB_KEY, TextInputHandler.TAB_KEY):
  125. if hasattr(self._active_element, "on_blur"):
  126. self._active_element.on_blur()
  127. if key == TextInputHandler.SHIFT_TAB_KEY:
  128. self._focus_previous()
  129. else:
  130. self._focus_next()
  131. active_element = self.elements[self.active_element_index]
  132. active_element.handle_key(key)
  133. if key == TextInputHandler.ENTER_KEY:
  134. if self.handle_enter_key():
  135. break
  136. self._refresh()
  137. except KeyboardInterrupt:
  138. for element in self.elements:
  139. element.on_cancel()
  140. self._refresh(done=True)
  141. exit()
  142. self._refresh(done=True)