datalad_ui.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. import threading
  2. from textwrap import wrap
  3. from types import MappingProxyType
  4. from typing import (
  5. List,
  6. Tuple,
  7. )
  8. from queue import Queue
  9. from PySide6.QtCore import (
  10. QObject,
  11. Signal,
  12. Slot,
  13. )
  14. from PySide6.QtWidgets import (
  15. QInputDialog,
  16. QLineEdit,
  17. )
  18. from datalad.ui.dialog import DialogUI
  19. class _DataladQtUIBridge(QObject):
  20. """Private class handling the DataladUI->QtUI bridging
  21. This is meant to be used by the GooeyUI singleton.
  22. """
  23. # signal to be emmitted when message() was called
  24. message_received = Signal(str)
  25. question_asked = Signal(MappingProxyType)
  26. def __init__(self, app):
  27. super().__init__()
  28. self._app = app
  29. self._conlog = app.get_widget('commandLog')
  30. # establishing this signal/slot connection is the vehicle
  31. # with which worker threads can thread-safely send messages
  32. # to the UI for display
  33. self.message_received.connect(self.show_message)
  34. # do not use this queue without an additional global lock
  35. # that covers the entire time from emitting the signal
  36. # that will lead to queue usage, until the queue is emptied
  37. # again
  38. self.messageq = Queue(maxsize=1)
  39. self.question_asked.connect(self.get_answer)
  40. @Slot(str)
  41. def show_message(self, msg):
  42. self._conlog.appendPlainText(msg)
  43. @Slot(str)
  44. def get_answer(self, props: dict):
  45. if props.get('choices') is None:
  46. # we are asking for a string
  47. response, ok = self._get_text_answer(
  48. props['title'],
  49. props['text'],
  50. props.get('default'),
  51. props.get('hidden', False),
  52. )
  53. # TODO implement internal repeat on request
  54. else:
  55. response, ok = self._get_choice(
  56. props['title'],
  57. props['text'],
  58. props['choices'],
  59. props.get('default'),
  60. )
  61. # place in message Q for the asking thread to retrieve
  62. self.messageq.put((ok, response))
  63. def _get_text_answer(self, title: str, label: str, default: str = None,
  64. hidden: bool = False) -> Tuple:
  65. return QInputDialog.getText(
  66. # parent
  67. self._app.main_window,
  68. # dialog title
  69. title,
  70. # input widget label
  71. # we have to perform manual wrapping, QInputDialog won't do it
  72. '\n'.join(wrap(label, 70)),
  73. # input widget echo mode
  74. # this could also be QLineEdit.Password for more hiding
  75. QLineEdit.Password if hidden else QLineEdit.Normal,
  76. # input widget default text
  77. default or '',
  78. # TODO look into the following for UI-internal input validation
  79. #inputMethodHints=
  80. )
  81. def _get_choice(self, title: str, label: str, choices: List,
  82. default: str = None) -> Tuple:
  83. return QInputDialog.getItem(
  84. # parent
  85. self._app.main_window,
  86. # dialog title
  87. title,
  88. # input widget label
  89. # we have to perform manual wrapping, QInputDialog won't do it
  90. '\n'.join(wrap(label, 70)),
  91. choices,
  92. # input widget default choice id
  93. choices.index(default) if default else 0,
  94. )
  95. class GooeyUI(DialogUI):
  96. # It may be possible to not derive from datalad class here, but for datalad
  97. # commands to not fail to talk to the UI (specially progressbars),
  98. # need to figure a bit more details of what to implement here. So, just derive
  99. # to not have most commands fail with AttributeError etc.
  100. #class GooeyUI:
  101. """Adapter between the Gooey Qt UI and DataLad's UI API"""
  102. _singleton = None
  103. _threadlock = threading.Lock()
  104. def __new__(cls, *args, **kwargs):
  105. if not cls._singleton:
  106. with cls._threadlock:
  107. if not cls._singleton:
  108. cls._singleton = super().__new__(cls)
  109. return cls._singleton
  110. def __init__(self):
  111. super().__init__()
  112. self._app = None
  113. def set_app(self, gooey_app) -> None:
  114. """Connect the UI to a Gooey app providing the UI to use"""
  115. self._uibridge = _DataladQtUIBridge(gooey_app)
  116. #
  117. # the datalad API needed
  118. #
  119. def is_interactive(self):
  120. return True
  121. def message(self, msg: str, cr: str = '\n'):
  122. self._uibridge.message_received.emit(msg)
  123. # TODO handle `cr`, but this could be trickier...
  124. # handling a non-block addition, may require custom insertion
  125. # via cursor, see below for code that would need
  126. # to go in DataladQtUIBridge.show_message()
  127. #cursor = self._conlog.textCursor()
  128. #cursor.insertText(msg)
  129. #self._conlog.cursorPositionChanged.emit()
  130. #self._conlog.textChanged.emit()
  131. def question(self, text,
  132. title=None, choices=None,
  133. default=None,
  134. hidden=False,
  135. repeat=None):
  136. with self._threadlock:
  137. assert self._uibridge.messageq.empty()
  138. # acquire the lock before we emit the signal
  139. # to make sure that our signal is the only one putting an answer in
  140. # the queue
  141. self._uibridge.question_asked.emit(MappingProxyType(dict(
  142. title=title,
  143. text=text,
  144. choices=choices,
  145. default=default,
  146. hidden=hidden,
  147. repeat=repeat,
  148. )))
  149. # this will block until the queue has the answer
  150. ok, answer = self._uibridge.messageq.get()
  151. if not ok:
  152. # This would happen if the user has pressed the CANCEL button.
  153. # DataLadUI seems to have no means to deal with this other than
  154. # exception, so here we behave as if the user had Ctrl+C'ed the
  155. # CLI.
  156. # MIH is not confident that this is how it is supposed to be
  157. raise KeyboardInterrupt
  158. return answer
  159. #def error
  160. #def input
  161. #def yesno
  162. #def get_progressbar