datalad_ui.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  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. QProgressBar,
  18. )
  19. from datalad.ui.dialog import DialogUI
  20. from datalad.ui.progressbars import ProgressBarBase
  21. class DataladQtUIBridge(QObject):
  22. """Private class handling the DataladUI->QtUI bridging
  23. This is meant to be used by the GooeyUI singleton.
  24. """
  25. # signal to be emmitted when message() was called
  26. message_received = Signal(str)
  27. question_asked = Signal(MappingProxyType)
  28. progress_update_received = Signal()
  29. def __init__(self, app):
  30. super().__init__()
  31. self._app = app
  32. self._conlog = app.get_widget('commandLog')
  33. # establishing this signal/slot connection is the vehicle
  34. # with which worker threads can thread-safely send messages
  35. # to the UI for display
  36. self.message_received.connect(self.show_message)
  37. # do not use this queue without an additional global lock
  38. # that covers the entire time from emitting the signal
  39. # that will lead to queue usage, until the queue is emptied
  40. # again
  41. self.messageq = Queue(maxsize=1)
  42. self.question_asked.connect(self.get_answer)
  43. # progress reporting
  44. # there is a single progress bar that tracks overall progress.
  45. # if multiple processes report progress simultaneously,
  46. # if report average progress across all of them
  47. self._progress_trackers = {}
  48. pbar = QProgressBar(app.main_window)
  49. # hide by default
  50. pbar.hide()
  51. self._progress_bar = pbar
  52. self.progress_update_received.connect(self.update_progressbar)
  53. self._progress_threadlock = threading.Lock()
  54. @Slot(str)
  55. def show_message(self, msg):
  56. self._conlog.appendPlainText(msg)
  57. @Slot(str)
  58. def get_answer(self, props: dict):
  59. if props.get('choices') is None:
  60. # we are asking for a string
  61. response, ok = self._get_text_answer(
  62. props['title'],
  63. props['text'],
  64. props.get('default'),
  65. props.get('hidden', False),
  66. )
  67. # TODO implement internal repeat on request
  68. else:
  69. response, ok = self._get_choice(
  70. props['title'],
  71. props['text'],
  72. props['choices'],
  73. props.get('default'),
  74. )
  75. # place in message Q for the asking thread to retrieve
  76. self.messageq.put((ok, response))
  77. @property
  78. def progress_bar(self):
  79. return self._progress_bar
  80. def update_progressbar(self):
  81. with self._progress_threadlock:
  82. if not len(self._progress_trackers):
  83. self._progress_bar.hide()
  84. return
  85. progress = [
  86. # assuming numbers
  87. c / t
  88. for c, t in self._progress_trackers.values()
  89. # ignore any tracker that has no total
  90. # TODO QProgressBar could also be a busy indicator
  91. # for those
  92. if t
  93. ]
  94. progress = sum(progress) / len(progress)
  95. # default range setup is 0..100
  96. self._progress_bar.setValue(progress * 100)
  97. self._progress_bar.show()
  98. def _update_from_progressbar(self, pbar):
  99. # called from within exec threads
  100. pbar_id = id(pbar)
  101. self._progress_trackers[pbar_id] = (pbar.current, pbar.total)
  102. self.progress_update_received.emit()
  103. def start_progress_tracker(self, pbar, initial=0):
  104. # called from within exec threads
  105. # GooeyUIProgress has applied the update already
  106. self._update_from_progressbar(pbar)
  107. def update_progress_tracker(
  108. self, pbar, size, increment=False, total=None):
  109. # called from within exec threads
  110. # GooeyUIProgress has applied the update already
  111. self._update_from_progressbar(pbar)
  112. def finish_progress_tracker(self, pbar):
  113. # called from within exec threads
  114. with self._progress_threadlock:
  115. del self._progress_trackers[id(pbar)]
  116. self.progress_update_received.emit()
  117. def _get_text_answer(self, title: str, label: str, default: str = None,
  118. hidden: bool = False) -> Tuple:
  119. return QInputDialog.getText(
  120. # parent
  121. self._app.main_window,
  122. # dialog title
  123. title,
  124. # input widget label
  125. # we have to perform manual wrapping, QInputDialog won't do it
  126. '\n'.join(wrap(label, 70)),
  127. # input widget echo mode
  128. # this could also be QLineEdit.Password for more hiding
  129. QLineEdit.Password if hidden else QLineEdit.Normal,
  130. # input widget default text
  131. default or '',
  132. # TODO look into the following for UI-internal input validation
  133. #inputMethodHints=
  134. )
  135. def _get_choice(self, title: str, label: str, choices: List,
  136. default: str = None) -> Tuple:
  137. return QInputDialog.getItem(
  138. # parent
  139. self._app.main_window,
  140. # dialog title
  141. title,
  142. # input widget label
  143. # we have to perform manual wrapping, QInputDialog won't do it
  144. '\n'.join(wrap(label, 70)),
  145. choices,
  146. # input widget default choice id
  147. choices.index(default) if default else 0,
  148. )
  149. class GooeyUI(DialogUI):
  150. # It may be possible to not derive from datalad class here, but for datalad
  151. # commands to not fail to talk to the UI (specially progressbars),
  152. # need to figure a bit more details of what to implement here. So, just derive
  153. # to not have most commands fail with AttributeError etc.
  154. #class GooeyUI:
  155. """Adapter between the Gooey Qt UI and DataLad's UI API"""
  156. _singleton = None
  157. _threadlock = threading.Lock()
  158. def __new__(cls, *args, **kwargs):
  159. if not cls._singleton:
  160. with cls._threadlock:
  161. if not cls._singleton:
  162. cls._singleton = super().__new__(cls)
  163. return cls._singleton
  164. def __init__(self):
  165. super().__init__()
  166. self._app = None
  167. def set_app(self, gooey_app) -> DataladQtUIBridge:
  168. """Connect the UI to a Gooey app providing the UI to use"""
  169. self._uibridge = DataladQtUIBridge(gooey_app)
  170. return self._uibridge
  171. #
  172. # the datalad API needed
  173. #
  174. def is_interactive(self):
  175. return True
  176. def message(self, msg: str, cr: str = '\n'):
  177. self._uibridge.message_received.emit(msg)
  178. # TODO handle `cr`, but this could be trickier...
  179. # handling a non-block addition, may require custom insertion
  180. # via cursor, see below for code that would need
  181. # to go in DataladQtUIBridge.show_message()
  182. #cursor = self._conlog.textCursor()
  183. #cursor.insertText(msg)
  184. #self._conlog.cursorPositionChanged.emit()
  185. #self._conlog.textChanged.emit()
  186. def question(self, text,
  187. title=None, choices=None,
  188. default=None,
  189. hidden=False,
  190. repeat=None):
  191. with self._threadlock:
  192. assert self._uibridge.messageq.empty()
  193. # acquire the lock before we emit the signal
  194. # to make sure that our signal is the only one putting an answer in
  195. # the queue
  196. self._uibridge.question_asked.emit(MappingProxyType(dict(
  197. title=title,
  198. text=text,
  199. choices=choices,
  200. default=default,
  201. hidden=hidden,
  202. repeat=repeat,
  203. )))
  204. # this will block until the queue has the answer
  205. ok, answer = self._uibridge.messageq.get()
  206. if not ok:
  207. # This would happen if the user has pressed the CANCEL button.
  208. # DataLadUI seems to have no means to deal with this other than
  209. # exception, so here we behave as if the user had Ctrl+C'ed the
  210. # CLI.
  211. # MIH is not confident that this is how it is supposed to be
  212. raise KeyboardInterrupt
  213. return answer
  214. #def error
  215. #def input
  216. #def yesno
  217. def get_progressbar(self, *args, **kwargs):
  218. # all arguments are ignored
  219. return GooeyUIProgress(self._uibridge, *args, **kwargs)
  220. class GooeyUIProgress(ProgressBarBase):
  221. def __init__(self, uibridge, *args, **kwargs):
  222. # some of these do not make sense (e.g. `out`), just pass
  223. # them along and forget about them
  224. # but it also brings self.total
  225. super().__init__(*args, **kwargs)
  226. self._uibridge = uibridge
  227. def start(self, initial=0):
  228. super().start(initial=initial)
  229. self._uibridge.start_progress_tracker(self, initial)
  230. def update(self, size, increment=False, total=None):
  231. super().update(size, increment=increment, total=total)
  232. self._uibridge.update_progress_tracker(
  233. self, size, increment=increment, total=total)
  234. def finish(self, clear=False, partial=False):
  235. self._uibridge.finish_progress_tracker(self)