datalad_ui.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  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. if not progress:
  95. # we have ignore a progress tracker and now we have nothing
  96. return
  97. progress = sum(progress) / len(progress)
  98. # default range setup is 0..100
  99. self._progress_bar.setValue(progress * 100)
  100. self._progress_bar.show()
  101. def _update_from_progressbar(self, pbar):
  102. # called from within exec threads
  103. pbar_id = id(pbar)
  104. self._progress_trackers[pbar_id] = (pbar.current, pbar.total)
  105. self.progress_update_received.emit()
  106. def start_progress_tracker(self, pbar, initial=0):
  107. # called from within exec threads
  108. # GooeyUIProgress has applied the update already
  109. self._update_from_progressbar(pbar)
  110. def update_progress_tracker(
  111. self, pbar, size, increment=False, total=None):
  112. # called from within exec threads
  113. # GooeyUIProgress has applied the update already
  114. self._update_from_progressbar(pbar)
  115. def finish_progress_tracker(self, pbar):
  116. # called from within exec threads
  117. with self._progress_threadlock:
  118. del self._progress_trackers[id(pbar)]
  119. self.progress_update_received.emit()
  120. def _get_text_answer(self, title: str, label: str, default: str = None,
  121. hidden: bool = False) -> Tuple:
  122. return QInputDialog.getText(
  123. # parent
  124. self._app.main_window,
  125. # dialog title
  126. title,
  127. # input widget label
  128. # we have to perform manual wrapping, QInputDialog won't do it
  129. '\n'.join(wrap(label, 70)),
  130. # input widget echo mode
  131. # this could also be QLineEdit.Password for more hiding
  132. QLineEdit.Password if hidden else QLineEdit.Normal,
  133. # input widget default text
  134. default or '',
  135. # TODO look into the following for UI-internal input validation
  136. #inputMethodHints=
  137. )
  138. def _get_choice(self, title: str, label: str, choices: List,
  139. default: str = None) -> Tuple:
  140. return QInputDialog.getItem(
  141. # parent
  142. self._app.main_window,
  143. # dialog title
  144. title,
  145. # input widget label
  146. # we have to perform manual wrapping, QInputDialog won't do it
  147. '\n'.join(wrap(label, 70)),
  148. choices,
  149. # input widget default choice id
  150. choices.index(default) if default else 0,
  151. )
  152. class GooeyUI(DialogUI):
  153. # It may be possible to not derive from datalad class here, but for datalad
  154. # commands to not fail to talk to the UI (specially progressbars),
  155. # need to figure a bit more details of what to implement here. So, just derive
  156. # to not have most commands fail with AttributeError etc.
  157. #class GooeyUI:
  158. """Adapter between the Gooey Qt UI and DataLad's UI API"""
  159. _singleton = None
  160. _threadlock = threading.Lock()
  161. def __new__(cls, *args, **kwargs):
  162. if not cls._singleton:
  163. with cls._threadlock:
  164. if not cls._singleton:
  165. cls._singleton = super().__new__(cls)
  166. return cls._singleton
  167. def __init__(self):
  168. super().__init__()
  169. self._app = None
  170. def set_app(self, gooey_app) -> DataladQtUIBridge:
  171. """Connect the UI to a Gooey app providing the UI to use"""
  172. self._uibridge = DataladQtUIBridge(gooey_app)
  173. return self._uibridge
  174. #
  175. # the datalad API needed
  176. #
  177. def is_interactive(self):
  178. return True
  179. def message(self, msg: str, cr: str = '\n'):
  180. self._uibridge.message_received.emit(msg)
  181. # TODO handle `cr`, but this could be trickier...
  182. # handling a non-block addition, may require custom insertion
  183. # via cursor, see below for code that would need
  184. # to go in DataladQtUIBridge.show_message()
  185. #cursor = self._conlog.textCursor()
  186. #cursor.insertText(msg)
  187. #self._conlog.cursorPositionChanged.emit()
  188. #self._conlog.textChanged.emit()
  189. def question(self, text,
  190. title=None, choices=None,
  191. default=None,
  192. hidden=False,
  193. repeat=None):
  194. with self._threadlock:
  195. assert self._uibridge.messageq.empty()
  196. # acquire the lock before we emit the signal
  197. # to make sure that our signal is the only one putting an answer in
  198. # the queue
  199. self._uibridge.question_asked.emit(MappingProxyType(dict(
  200. title=title,
  201. text=text,
  202. choices=choices,
  203. default=default,
  204. hidden=hidden,
  205. repeat=repeat,
  206. )))
  207. # this will block until the queue has the answer
  208. ok, answer = self._uibridge.messageq.get()
  209. if not ok:
  210. # This would happen if the user has pressed the CANCEL button.
  211. # DataLadUI seems to have no means to deal with this other than
  212. # exception, so here we behave as if the user had Ctrl+C'ed the
  213. # CLI.
  214. # MIH is not confident that this is how it is supposed to be
  215. raise KeyboardInterrupt
  216. return answer
  217. #def error
  218. #def input
  219. #def yesno
  220. def get_progressbar(self, *args, **kwargs):
  221. # all arguments are ignored
  222. return GooeyUIProgress(self._uibridge, *args, **kwargs)
  223. class GooeyUIProgress(ProgressBarBase):
  224. def __init__(self, uibridge, *args, **kwargs):
  225. # some of these do not make sense (e.g. `out`), just pass
  226. # them along and forget about them
  227. # but it also brings self.total
  228. super().__init__(*args, **kwargs)
  229. self._uibridge = uibridge
  230. def start(self, initial=0):
  231. super().start(initial=initial)
  232. self._uibridge.start_progress_tracker(self, initial)
  233. def update(self, size, increment=False, total=None):
  234. super().update(size, increment=increment, total=total)
  235. self._uibridge.update_progress_tracker(
  236. self, size, increment=increment, total=total)
  237. def finish(self, clear=False, partial=False):
  238. self._uibridge.finish_progress_tracker(self)