p3lib 1.1.108__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
p3lib/ngt.py ADDED
@@ -0,0 +1,841 @@
1
+ # !/usr/bin/env python3
2
+
3
+ """NiceGui Tools
4
+ Responsible for providing helper classes for nicegui interfaces
5
+ aimed at reducing coding required for a GUI.
6
+ """
7
+
8
+ import traceback
9
+ import os
10
+ import platform
11
+
12
+ from time import sleep
13
+ from queue import Queue
14
+ from time import time, strftime, localtime
15
+
16
+ from nicegui import ui
17
+
18
+ class TabbedNiceGui(object):
19
+ """@brief Responsible for starting the providing a tabbed GUI.
20
+ This class is designed to ease the creation of a tabbed GUI interface.
21
+ The contents of each tab can be defined in the subclass.
22
+ The GUI includes a message log area below each tab. Tasks can send messages
23
+ to this log area.
24
+ If a subclass sets the self._logFile attributed then all messages sent to the
25
+ log area are written to a log file with timestamps."""
26
+
27
+ # This can be used in the markdown text for a TAB description to give slightly larger text
28
+ # than normal.
29
+ DESCRIP_STYLE = '<span style="font-size:1.2em;">'
30
+ ENABLE_BUTTONS = "ENABLE_BUTTONS"
31
+ NOTIFY_DIALOG = "NOTIFY_DIALOG"
32
+ UPDATE_SECONDS = "UPDATE_SECONDS"
33
+ INFO_MESSAGE = "INFO: "
34
+ WARN_MESSAGE = "WARN: "
35
+ ERROR_MESSAGE = "ERROR: "
36
+ DEBUG_MESSAGE = "DEBUG: "
37
+ MAX_PROGRESS_VALUE = 100
38
+ DEFAULT_SERVER_PORT = 9812
39
+ GUI_TIMER_SECONDS = 0.1
40
+ PROGRESS_TIMER_SECONDS = 1.0
41
+ UPDATE_SECONDS = "UPDATE_SECONDS"
42
+ DEFAULT_GUI_RESPONSE_TIMEOUT= 30.0
43
+ POETRY_CONFIG_FILE = "pyproject.toml"
44
+ LOCAL_PATH = os.path.dirname(os.path.abspath(__file__))
45
+
46
+ @staticmethod
47
+ def GetDateTimeStamp():
48
+ """@return The log file date/time stamp """
49
+ return strftime("%Y%m%d%H%M%S", localtime()).lower()
50
+
51
+ @staticmethod
52
+ def GetInstallFolder():
53
+ """@return The folder where the apps are installed."""
54
+ installFolder = os.path.dirname(__file__)
55
+ if not os.path.isdir(installFolder):
56
+ raise Exception(f"{installFolder} folder not found.")
57
+ return installFolder
58
+
59
+ @staticmethod
60
+ def GetLogFileName(logFilePrefix):
61
+ """@param logFilePrefix The text in the log file name before the timestamp.
62
+ @return The name of the logfile including datetime stamp."""
63
+ dateTimeStamp = TabbedNiceGui.GetDateTimeStamp()
64
+ logFileName = f"{logFilePrefix}_{dateTimeStamp}.log"
65
+ return logFileName
66
+
67
+ @staticmethod
68
+ def CheckPort(port):
69
+ """@brief Check the server port.
70
+ @param port The server port."""
71
+ if port < 1024:
72
+ raise Exception("The minimum TCP port that you can bind the GUI server to is 1024.")
73
+ if port > 65535:
74
+ raise Exception("The maximum TCP port that you can bind the GUI server to is 65535.")
75
+
76
+ @staticmethod
77
+ def GetProgramVersion():
78
+ """@brief Get the program version from the poetry pyproject.toml file.
79
+ @return The version of the installed program (string value)."""
80
+ poetryConfigFile = os.path.join(TabbedNiceGui.LOCAL_PATH, TabbedNiceGui.POETRY_CONFIG_FILE)
81
+ if not os.path.isfile(poetryConfigFile):
82
+ poetryConfigFile = os.path.join(TabbedNiceGui.LOCAL_PATH, ".." + os.sep + TabbedNiceGui.POETRY_CONFIG_FILE)
83
+ poetryConfigFile2 = poetryConfigFile
84
+ if not os.path.isfile(poetryConfigFile):
85
+ cwd = os.getcwd()
86
+ poetryConfigFile = os.path.join(cwd, TabbedNiceGui.POETRY_CONFIG_FILE)
87
+ if not os.path.isfile(poetryConfigFile):
88
+ raise Exception(f"{poetryConfigFile}, {poetryConfigFile2} and {poetryConfigFile} not found.")
89
+
90
+ programVersion = None
91
+ with open(poetryConfigFile, 'r') as fd:
92
+ lines = fd.readlines()
93
+ for line in lines:
94
+ line=line.strip("\r\n")
95
+ if line.startswith('version'):
96
+ elems = line.split("=")
97
+ if len(elems) == 2:
98
+ programVersion = elems[1].strip('" ')
99
+ break
100
+ if programVersion is None:
101
+ raise Exception(f"Failed to extract program version from '{line}' line of {poetryConfigFile} file.")
102
+ return programVersion
103
+
104
+ def __init__(self, debugEnabled, logPath=None):
105
+ """@brief Constructor
106
+ @param debugEnabled True if debugging is enabled.
107
+ @param logPath The path to store log files. If left as None then no log files are created."""
108
+ self._debugEnabled = debugEnabled
109
+ self._logFile = None # This must be defined in subclass if logging to a file is required.
110
+ self._buttonList = []
111
+ self._logMessageCount = 0
112
+ self._updateProgressOnTimer = False
113
+ self._progressStepValue = 0
114
+ self._progressBarStartMessage = ""
115
+ self._progressBarExpectedMessageList = []
116
+ self._expectedProgressBarMessageIndex = 0
117
+ self._expectedProgressBarMsgCount = 0
118
+ self._programVersion = TabbedNiceGui.GetProgramVersion()
119
+
120
+ self._logPath = None
121
+ if logPath:
122
+ self._logPath = os.path.join(os.path.expanduser('~'), logPath)
123
+ self._ensureLogPathExists()
124
+
125
+ self._isWindows = platform.system() == "Windows"
126
+ self._installFolder = TabbedNiceGui.GetInstallFolder()
127
+
128
+ # Make the install folder our current dir
129
+ os.chdir(self._installFolder)
130
+
131
+ # This queue is used to send commands from any thread to the GUI thread.
132
+ self._toGUIQueue = Queue()
133
+ # This queue is for the GUI thread to send messages to other threads
134
+ self._fromGUIQueue = Queue()
135
+
136
+ def _ensureLogPathExists(self):
137
+ """@brief Ensure that the log path exists."""
138
+ if not os.path.isdir(self._logPath):
139
+ os.makedirs(self._logPath)
140
+
141
+ def getLogPath(self):
142
+ """@return the Log file path if defined."""
143
+ return self._logPath
144
+
145
+ # Start ------------------------------
146
+ # Methods that allow the GUI to display standard UIO messages
147
+ # This allows the GUI to be used with code that was written
148
+ # to be used on the command line using UIO class instances
149
+ #
150
+ def info(self, msg):
151
+ """@brief Send a info message to be displayed in the GUI.
152
+ This can be called from outside the GUI thread.
153
+ @param msg The message to be displayed."""
154
+ msgDict = {TabbedNiceGui.INFO_MESSAGE: str(msg)}
155
+ self.updateGUI(msgDict)
156
+
157
+ def warn(self, msg):
158
+ """@brief Send a warning message to be displayed in the GUI.
159
+ This can be called from outside the GUI thread.
160
+ @param msg The message to be displayed."""
161
+ msgDict = {TabbedNiceGui.WARN_MESSAGE: str(msg)}
162
+ self.updateGUI(msgDict)
163
+
164
+ def error(self, msg):
165
+ """@brief Send a error message to be displayed in the GUI.
166
+ This can be called from outside the GUI thread.
167
+ @param msg The message to be displayed."""
168
+ msgDict = {TabbedNiceGui.ERROR_MESSAGE: str(msg)}
169
+ self.updateGUI(msgDict)
170
+
171
+ def infoDialog(self, msg):
172
+ """@brief Display an info level dialog.
173
+ @param msg The message dialog."""
174
+ msgDict = {TabbedNiceGui.NOTIFY_DIALOG: str(msg)}
175
+ self.updateGUI(msgDict)
176
+
177
+ def debug(self, msg):
178
+ """@brief Send a debug message to be displayed in the GUI.
179
+ This can be called from outside the GUI thread.
180
+ @param msg The message to be displayed."""
181
+ if self._debugEnabled:
182
+ msgDict = {TabbedNiceGui.DEBUG_MESSAGE: str(msg)}
183
+ self.updateGUI(msgDict)
184
+
185
+ async def getInput(self, prompt):
186
+ """@brief Allow the user to enter some text.
187
+ This can be called from outside the GUI thread.
188
+ @param prompt The user prompt."""
189
+ with ui.dialog() as dialog, ui.card():
190
+ inputObj = ui.input(label=prompt)
191
+ with ui.row():
192
+ ui.button('OK', on_click=lambda: dialog.submit('OK'))
193
+ ui.button('Cancel', on_click=lambda: dialog.submit('Cancel'))
194
+
195
+ result = await dialog
196
+ if result != 'OK':
197
+ returnText = None
198
+ else:
199
+ returnText = inputObj.value
200
+ return returnText
201
+
202
+ def reportException(self, exception):
203
+ """@brief Report an exception.
204
+ If debug is enabled a full stack trace is displayed.
205
+ If not then the exception message is displayed.
206
+ @param exception The exception instance."""
207
+ if self._debugEnabled:
208
+ lines = traceback.format_exc().split("\n")
209
+ for line in lines:
210
+ self.error(line)
211
+ if len(exception.args) > 0:
212
+ self.error(exception.args[0])
213
+
214
+ def _sendEnableAllButtons(self, state):
215
+ """@brief Send a message to the GUI to enable/disable all the GUI buttons.
216
+ This can be called from outside the GUI thread.
217
+ @param state If True enable the buttons, else disable them."""
218
+ msgDict = {TabbedNiceGui.ENABLE_BUTTONS: state}
219
+ self.updateGUI(msgDict)
220
+
221
+ def updateGUI(self, msgDict):
222
+ """@brief Send a message to the GUI so that it updates itself.
223
+ @param msgDict A dict containing details of how to update the GUI."""
224
+ # Record the seconds when we received the message
225
+ msgDict[TabbedNiceGui.UPDATE_SECONDS]=time()
226
+ self._toGUIQueue.put(msgDict)
227
+
228
+ def showTable(self, table, rowSeparatorChar = "-", colSeparatorChar = "|"):
229
+ """@brief Show the contents of a table to the user.
230
+ @param table This must be a list. Each list element must be a table row (list).
231
+ Each element in each row must be a string.
232
+ @param rowSeparatorChar The character used for horizontal lines to separate table rows.
233
+ @param colSeparatorChar The character used to separate table columns."""
234
+ columnWidths = []
235
+ # Check we have a table to display
236
+ if len(table) == 0:
237
+ raise Exception("No table rows to display")
238
+
239
+ # Check all rows have the same number of columns in the table
240
+ colCount = len(table[0])
241
+ for row in table:
242
+ if len(row) != colCount:
243
+ raise Exception(f"{str(row)} column count different from first row ({colCount})")
244
+
245
+ for row in table:
246
+ for col in row:
247
+ if not isinstance(col, str):
248
+ raise Exception(f"Table column is not a string: {col} in {row}")
249
+
250
+ # Get the max width for each column
251
+ for col in range(0,colCount):
252
+ maxWidth=0
253
+ for row in table:
254
+ if len(row[col]) > maxWidth:
255
+ maxWidth = len(row[col])
256
+ columnWidths.append(maxWidth)
257
+
258
+ tableWidth = 1
259
+ for columnWidth in columnWidths:
260
+ tableWidth += columnWidth + 3 # Space each side of the column + a column divider character
261
+
262
+ # Add the top line of the table
263
+ self.info(rowSeparatorChar*tableWidth)
264
+
265
+ # The starting row index
266
+ for rowIndex in range(0, len(table)):
267
+ rowText = colSeparatorChar
268
+ colIndex = 0
269
+ for col in table[rowIndex]:
270
+ colWidth = columnWidths[colIndex]
271
+ rowText = rowText + " " + f"{col:>{colWidth}s}" + " " + colSeparatorChar
272
+ colIndex += 1
273
+ self.info(rowText)
274
+ # Add the row separator line
275
+ self.info(rowSeparatorChar*tableWidth)
276
+
277
+ def logAll(self, enabled):
278
+ pass
279
+
280
+ def setLogFile(self, logFile):
281
+ pass
282
+
283
+ # End ------------------------------
284
+
285
+ def _saveLogMsg(self, msg):
286
+ """@brief Save the message to a log file.
287
+ @param msg The message text to be stored in the log file."""
288
+ # If a log file has been set
289
+ if self._logFile:
290
+ # If the log file does not exist
291
+ if not os.path.isfile(self._logFile):
292
+ with open(self._logFile, 'w') as fd:
293
+ pass
294
+ # Update the log file
295
+ with open(self._logFile, 'a') as fd:
296
+ dateTimeStamp = TabbedNiceGui.GetDateTimeStamp()
297
+ fd.write(dateTimeStamp + ": " + msg + '\n')
298
+
299
+ def _getDisplayMsg(self, msg, prefix):
300
+ """@brief Get the msg to display. If the msg does not already have a msg level we add one.
301
+ @param msg The source msg.
302
+ @param prefix The message prefix (level indcator) to add."""
303
+ if msg.startswith(TabbedNiceGui.INFO_MESSAGE) or \
304
+ msg.startswith(TabbedNiceGui.WARN_MESSAGE) or \
305
+ msg.startswith(TabbedNiceGui.ERROR_MESSAGE) or \
306
+ msg.startswith(TabbedNiceGui.DEBUG_MESSAGE):
307
+ _msg = msg
308
+ else:
309
+ _msg = prefix + msg
310
+ return _msg
311
+
312
+ def _handleMsg(self, msg):
313
+ """@brief Log a message.
314
+ @param msg the message to the log window and the log file."""
315
+ self._log.push(msg)
316
+ self._saveLogMsg(msg)
317
+ self._logMessageCount += 1
318
+ # We've received a log message so update progress bar if required.
319
+ self._updateProgressBar(msg)
320
+
321
+ def _infoGT(self, msg):
322
+ """@brief Update an info level message. This must be called from the GUI thread.
323
+ @param msg The message to display."""
324
+ _msg = self._getDisplayMsg(msg, TabbedNiceGui.INFO_MESSAGE)
325
+ self._handleMsg(_msg)
326
+
327
+ def _warnGT(self, msg):
328
+ """@brief Update an warning level message. This must be called from the GUI thread.
329
+ @param msg The message to display."""
330
+ _msg = self._getDisplayMsg(msg, TabbedNiceGui.WARN_MESSAGE)
331
+ self._handleMsg(_msg)
332
+
333
+ def _errorGT(self, msg):
334
+ """@brief Update an error level message. This must be called from the GUI thread.
335
+ @param msg The message to display."""
336
+ _msg = self._getDisplayMsg(msg, TabbedNiceGui.ERROR_MESSAGE)
337
+ self._handleMsg(_msg)
338
+
339
+ def _debugGT(self, msg):
340
+ """@brief Update an debug level message. This must be called from the GUI thread.
341
+ @param msg The message to display."""
342
+ _msg = self._getDisplayMsg(msg, TabbedNiceGui.DEBUG_MESSAGE)
343
+ self._handleMsg(_msg)
344
+
345
+ def _clearMessages(self):
346
+ """@brief Clear all messages from the log."""
347
+ self._log.clear()
348
+ self._logMessageCount = 0
349
+
350
+ def _getLogMessageCount(self):
351
+ """@return the number of messages written to the log window/file"""
352
+ return self._logMessageCount
353
+
354
+ def _enableAllButtons(self, enabled):
355
+ """@brief Enable/Disable all buttons.
356
+ @param enabled True if button is enabled."""
357
+ if enabled:
358
+ for button in self._buttonList:
359
+ button.enable()
360
+ # No buttons are enabled, any executed task must be complete therefor hide the progress bar.
361
+ self._stopProgress()
362
+
363
+ else:
364
+ for button in self._buttonList:
365
+ button.disable()
366
+ # If the caller has defined the number of log messages for normal completion
367
+ if self._progressStepValue > 0:
368
+ self._progress.set_visibility(True)
369
+
370
+ def guiTimerCallback(self):
371
+ """@called periodically (quickly) to allow updates of the GUI."""
372
+ while not self._toGUIQueue.empty():
373
+ rxMessage = self._toGUIQueue.get()
374
+ if isinstance(rxMessage, dict):
375
+ self._processRXDict(rxMessage)
376
+
377
+ def initGUI(self, tabNameList, tabMethodInitList, reload=True, address="0.0.0.0", port=DEFAULT_SERVER_PORT, pageTitle="NiceGUI"):
378
+ """@brief Init the tabbed GUI.
379
+ @param tabNameList A list of the names of each tab to be created.
380
+ @param tabMethodInitList A list of the methods to be called to init each of the above tabs.
381
+ The two lists must be the same size.
382
+ @param reload If reload is set False then changes to python files will not cause the server to be restarted.
383
+ @param address The address to bind the server to.
384
+ @param The TCP port to bind the server to.
385
+ @param pageTitle The page title that appears in the browser."""
386
+ # A bit of defensive programming.
387
+ if len(tabNameList) != len(tabMethodInitList):
388
+ raise Exception(f"initGUI: BUG: tabNameList ({len(tabNameList)}) and tabMethodInitList ({len(tabMethodInitList)}) are not the same length.")
389
+ tabObjList = []
390
+ with ui.row():
391
+ with ui.tabs().classes('w-full') as tabs:
392
+ for tabName in tabNameList:
393
+ tabObj = ui.tab(tabName)
394
+ tabObjList.append(tabObj)
395
+
396
+ with ui.tab_panels(tabs, value=tabObjList[0]).classes('w-full'):
397
+ for tabObj in tabObjList:
398
+ with ui.tab_panel(tabObj):
399
+ tabIndex = tabObjList.index(tabObj)
400
+ tabMethodInitList[tabIndex]()
401
+
402
+ guiLogLevel = "warning"
403
+ if self._debugEnabled:
404
+ guiLogLevel = "debug"
405
+
406
+ ui.label("Message Log")
407
+ self._progress = ui.slider(min=0,max=TabbedNiceGui.MAX_PROGRESS_VALUE,step=1)
408
+ self._progress.set_visibility(False)
409
+ self._progress.min = 0
410
+ # Don't allow user to adjust progress bar thumb
411
+ self._progress.disable()
412
+ self._log = ui.log(max_lines=2000)
413
+ self._log.set_visibility(True)
414
+
415
+ with ui.row():
416
+ ui.button('Clear Log', on_click=self._clearLog)
417
+ ui.button('Log Message Count', on_click=self._showLogMsgCount)
418
+ ui.button('Quit', on_click=self.close)
419
+
420
+ with ui.row():
421
+ ui.label(f"Software Version: {self._programVersion}")
422
+
423
+ ui.timer(interval=TabbedNiceGui.GUI_TIMER_SECONDS, callback=self.guiTimerCallback)
424
+ ui.timer(interval=TabbedNiceGui.PROGRESS_TIMER_SECONDS, callback=self.progressTimerCallback)
425
+ ui.run(host=address, port=port, title=pageTitle, dark=True, uvicorn_logging_level=guiLogLevel, reload=reload)
426
+
427
+ def progressTimerCallback(self):
428
+ """@brief Time to update the progress bar. We run the timer all the time because there appears to be a
429
+ bug in the ui.timer instance. Calling cancel() does not appear to cancel the timer."""
430
+ if self._updateProgressOnTimer and self._progress.visible:
431
+ # Increment the progress bar
432
+ self._progress.set_value( self._progress.value + self._progressStepValue )
433
+
434
+ def _startProgress(self, durationSeconds=0, startMessage=None, expectedMsgList=[], expectedMsgCount=0):
435
+ """@brief Start a timer that will update the progress bar.
436
+ The progress bar can simply update on a timer every second with durationSeconds set to the expected length
437
+ of the task.
438
+
439
+ If startMessage is set to a text string the progress time will not start until the log message area contains
440
+ the start message.
441
+
442
+ Alternatively if expectedMsgList contains a list of strings we expect to receive then the progress bar is
443
+ updated as each message is received. The messages may be the entire line of a log message or parts of a
444
+ log message line.
445
+
446
+ Alternatively if expectedMsgCount is set to a value > 0 then the progress bar is updated as each message is
447
+ added to the log and reaches 100% when the number of messages added to the log file reaches the expectedMsgCount.
448
+
449
+ @param startMessage The text of the log message we expect to receive to trigger the progress bar timer start.
450
+ @param expectedMsgList A list of the expected log file messages.
451
+ @param expectedMsgCount A int value that defines the number of log messages we expect to receive for normal progress
452
+ completion."""
453
+ self._progressValue = 0
454
+ self._progressBarStartMessage = ""
455
+ self._progressBarExpectedMessageList = []
456
+ self._expectedProgressBarMessageIndex = 0
457
+ self._expectedProgressBarMsgCount = 0
458
+ self._updateProgressOnTimer = False
459
+ self._progress.set_value( self._progressValue )
460
+ # If the caller wants to the progress bar to update as the log file message count increases.
461
+ if expectedMsgCount > 0:
462
+ self._expectedProgressBarMsgCount = expectedMsgCount
463
+ self._progressStepValue = TabbedNiceGui.MAX_PROGRESS_VALUE/float(self._expectedProgressBarMsgCount)
464
+
465
+ # If the caller wants to update the progress bar on expected messages.
466
+ elif len(expectedMsgList):
467
+ #Use the text of log messages to increment the progress bar.
468
+ self._expectedProgressBarMessageIndex = 0
469
+ self._progressBarExpectedMessageList = expectedMsgList
470
+ self._progressStepValue = TabbedNiceGui.MAX_PROGRESS_VALUE/float(len(expectedMsgList))
471
+
472
+ elif durationSeconds > 0:
473
+ # Calc the step size required to give the required duration
474
+ self._progressStepValue = TabbedNiceGui.MAX_PROGRESS_VALUE/float(durationSeconds)
475
+ if startMessage:
476
+ self._progressBarStartMessage = startMessage
477
+ else:
478
+ # Start updating the progress bar now.
479
+ self._updateProgressOnTimer = True
480
+
481
+ else:
482
+ raise Exception("BUG: _startProgressTimer() called. len(expectedMsgList)=0 and durationSeconds<=0.")
483
+
484
+ self._progress.set_visibility(True)
485
+
486
+ def _stopProgress(self):
487
+ """@brief Stop the progress bar being updated and hide it."""
488
+ self._updateProgressOnTimer = False
489
+ self._progress.set_visibility(False)
490
+
491
+ def _updateProgressBar(self, msg):
492
+ """@brief Update the progress bar if required when a log message is received. This is called as each message is added to the log.
493
+ @param msg The log message received."""
494
+ # If we update the progress bar as each message is received until we have a log with self._expectedProgressBarMsgCount many messages.
495
+ if self._expectedProgressBarMsgCount > 0:
496
+ self._progressValue = self._progressValue + self._progressStepValue
497
+ self._progress.set_value( self._progressValue )
498
+
499
+ # If we have a list of log messages to update the progress bar.
500
+ elif len(self._progressBarExpectedMessageList) > 0:
501
+ if self._expectedProgressBarMessageIndex < len(self._progressBarExpectedMessageList):
502
+ # Get the message we expect to receive next
503
+ expectedMsg = self._progressBarExpectedMessageList[self._expectedProgressBarMessageIndex]
504
+ if msg.find(expectedMsg) != -1:
505
+ self._progressValue = self._progressValue + self._progressStepValue
506
+ self._progress.set_value( self._progressValue )
507
+ self._expectedProgressBarMessageIndex += 1
508
+
509
+ # If we have a message that we expect to receive to start the progress bar timer.
510
+ elif self._progressBarStartMessage and len(self._progressBarStartMessage):
511
+ # If we found the start message in the message received.
512
+ if msg.find(self._progressBarStartMessage) != -1:
513
+ # Start updating the progress bar now on the timer.
514
+ self._updateProgressOnTimer = True
515
+
516
+ def _initTask(self):
517
+ """@brief Should be called before a task is started."""
518
+ self._enableAllButtons(False)
519
+ self._clearMessages()
520
+
521
+ def _clearLog(self):
522
+ """@brief Clear the log text"""
523
+ if self._log:
524
+ self._log.clear()
525
+
526
+ def _showLogMsgCount(self):
527
+ """@brief Show the number of log messages"""
528
+ ui.notify(f"{self._getLogMessageCount()} messages in the log.")
529
+
530
+ def close(self):
531
+ """@brief Close down the app server."""
532
+ ui.notify("Press 'CTRL C' at command line or close the terminal window to quit.")
533
+ # A subclass close() method can call
534
+ # app.shutdown()
535
+ # if reload=False on ui.run()
536
+
537
+ def _appendButtonList(self, button):
538
+ """@brief Add to the button list. These buttons are disabled during the progress of a task.
539
+ @param button The button instance."""
540
+ self._buttonList.append(button)
541
+
542
+ def _processRXDict(self, rxDict):
543
+ """@brief Process the dicts received from the GUI message queue.
544
+ @param rxDict The dict received from the GUI message queue."""
545
+ if TabbedNiceGui.INFO_MESSAGE in rxDict:
546
+ msg = rxDict[TabbedNiceGui.INFO_MESSAGE]
547
+ self._infoGT(msg)
548
+
549
+ elif TabbedNiceGui.WARN_MESSAGE in rxDict:
550
+ msg = rxDict[TabbedNiceGui.WARN_MESSAGE]
551
+ self._warnGT(msg)
552
+
553
+ elif TabbedNiceGui.ERROR_MESSAGE in rxDict:
554
+ msg = rxDict[TabbedNiceGui.ERROR_MESSAGE]
555
+ self._errorGT(msg)
556
+
557
+ elif TabbedNiceGui.DEBUG_MESSAGE in rxDict:
558
+ msg = rxDict[TabbedNiceGui.DEBUG_MESSAGE]
559
+ self._debugGT(msg)
560
+
561
+ elif TabbedNiceGui.ENABLE_BUTTONS in rxDict:
562
+ state = rxDict[TabbedNiceGui.ENABLE_BUTTONS]
563
+ self._enableAllButtons(state)
564
+
565
+ elif TabbedNiceGui.NOTIFY_DIALOG in rxDict:
566
+ message = rxDict[TabbedNiceGui.NOTIFY_DIALOG]
567
+ ui.notify(message, close_button='OK', type="positive", position="center")
568
+
569
+ else:
570
+
571
+ self._handleGUIUpdate(rxDict)
572
+
573
+ def _updateGUI(self, msgDict):
574
+ """@brief Send a message to the GUI so that it updates itself.
575
+ @param msgDict A dict containing details of how to update the GUI."""
576
+ # Record the seconds when we received the message
577
+ msgDict[TabbedNiceGui.UPDATE_SECONDS]=time()
578
+ self._toGUIQueue.put(msgDict)
579
+
580
+ def _updateExeThread(self, msgDict):
581
+ """@brief Send a message from the GUI thread to an external (non GUI thread).
582
+ @param msgDict A dict containing messages to be sent to the external thread."""
583
+ # Record the seconds when we received the message
584
+ msgDict[TabbedNiceGui.UPDATE_SECONDS]=time()
585
+ self._fromGUIQueue.put(msgDict)
586
+
587
+ def _updateGUIAndWaitForResponse(self, msgDict, timeout=DEFAULT_GUI_RESPONSE_TIMEOUT):
588
+ """@brief Send a message to the GUI and wait for a response.
589
+ @param msgDict The message dictionary to be sent to the GUI.
590
+ @param timeout The number of seconds to wait for a response.
591
+ @return The return dict."""
592
+ timeoutT = time()+timeout
593
+ rxDict = None
594
+ self._updateGUI(msgDict)
595
+ while True:
596
+ if not self._fromGUIQueue.empty():
597
+ rxMessage = self._fromGUIQueue.get()
598
+ if isinstance(rxMessage, dict):
599
+ rxDict = rxMessage
600
+ break
601
+
602
+ elif time() >= timeoutT:
603
+ raise Exception(f"{timeout} second GUI response timeout.")
604
+
605
+ else:
606
+ # Don't spin to fast
607
+ sleep(0.1)
608
+
609
+ return rxDict
610
+
611
+ def _handleGUIUpdate(self, rxDict):
612
+ """@brief Process the dicts received from the GUI message queue
613
+ that were not handled by the parent class.
614
+ @param rxDict The dict received from the GUI message queue."""
615
+ raise NotImplementedError("_handleGUIUpdate() is not implemented. Implement this method in a subclass of TabbedNiceGUI")
616
+
617
+
618
+ class YesNoDialog(object):
619
+ """@brief Responsible for displaying a dialog box to the user with a boolean (I.E yes/no, ok/cancel) response."""
620
+ TEXT_INPUT_FIELD_TYPE = 1
621
+ NUMBER_INPUT_FIELD_TYPE = 2
622
+ SWITCH_INPUT_FIELD_TYPE = 3
623
+ DROPDOWN_INPUT_FIELD = 4
624
+ COLOR_INPUT_FIELD = 5
625
+ DATE_INPUT_FIELD = 6
626
+ TIME_INPUT_FIELD = 7
627
+ KNOB_INPUT_FIELD = 8
628
+ HOUR_MIN_INPUT_FIELD_TYPE = 9
629
+ VALID_FIELD_TYPE_LIST = (TEXT_INPUT_FIELD_TYPE,
630
+ NUMBER_INPUT_FIELD_TYPE,
631
+ SWITCH_INPUT_FIELD_TYPE,
632
+ DROPDOWN_INPUT_FIELD,
633
+ COLOR_INPUT_FIELD,
634
+ DATE_INPUT_FIELD,
635
+ TIME_INPUT_FIELD,
636
+ KNOB_INPUT_FIELD,
637
+ HOUR_MIN_INPUT_FIELD_TYPE)
638
+
639
+ FIELD_TYPE_KEY = "FIELD_TYPE_KEY" # The type of field to be displayed.
640
+ VALUE_KEY = "VALUE_KEY" # The value to be displayed in the field when the dialog is displayed.
641
+ MIN_NUMBER_KEY = "MIN_NUMBER_KEY" # If the type is NUMBER_INPUT_FIELD_TYPE, the min value that can be entered.
642
+ MAX_NUMBER_KEY = "MAX_NUMBER_KEY" # If the type is NUMBER_INPUT_FIELD_TYPE, the max value that can be entered.
643
+ WIDGET_KEY = "WIDGET_KEY" # The key to the GUI widget (E.G ui.input, ui.number etc)
644
+ OPTIONS_KEY = "OPTIONS_KEY" # Some input fields require a list of options (E.G DROPDOWN_INPUT_FIELD).
645
+ STEP_KEY = "STEP_KEY" # The step size for numerical input fields
646
+
647
+ def __init__(self,
648
+ prompt,
649
+ successMethod,
650
+ failureMethod=None,
651
+ successButtonText="Yes",
652
+ failureButtonText="No"):
653
+ """@brief Constructor"""
654
+ self._dialog = None
655
+ self._selectedFile = None
656
+ self._successButtonText = None # The dialogs success button text
657
+ self._failureButtonText = None # The dialogs failure button text
658
+ self._prompt = None # The prompt to be displayed in the dialog
659
+ self._successMethod = None # The method to be called when the success button is selected.
660
+ self._failureMethod = None # The method to be called when the failure button is selected.
661
+ self._inputFieldDict = {} # A dict of input field details to be included in the dialog. Can be left as an empty dict if no input fields are required.
662
+ # The key in this dict is the name of the input field that the user sees.
663
+ # The value in this dict is another dict containing details of the input field which may be
664
+
665
+ self.setPrompt(prompt)
666
+ self.setSuccessMethod(successMethod)
667
+ self.setFailureMethod(failureMethod)
668
+ self.setSuccessButtonLabel(successButtonText)
669
+ self.setFailureButtonLabel(failureButtonText)
670
+
671
+
672
+ def addField(self, name, fieldType, value=None, minNumber=None, maxNumber=None, options=None, step=1):
673
+ """@brief Add a field to the dialog.
674
+ @param name The name of the field to be added.
675
+ @param fieldType The type of field to be entered.
676
+ @param value The optional initial value for the field when the dialog is displayed.
677
+ @param minNumber The optional min value if the fieldType = NUMBER_INPUT_FIELD_TYPE.
678
+ @param maxNumber The optional max value if the fieldType = NUMBER_INPUT_FIELD_TYPE.
679
+ @param step The step size for numerical input fields.
680
+ """
681
+ if name and len(name) > 0:
682
+ if fieldType in YesNoDialog.VALID_FIELD_TYPE_LIST:
683
+ self._inputFieldDict[name] = {YesNoDialog.FIELD_TYPE_KEY: fieldType,
684
+ YesNoDialog.VALUE_KEY: value,
685
+ YesNoDialog.MIN_NUMBER_KEY: minNumber,
686
+ YesNoDialog.MAX_NUMBER_KEY: maxNumber,
687
+ YesNoDialog.OPTIONS_KEY: options,
688
+ YesNoDialog.STEP_KEY: step}
689
+
690
+ else:
691
+ raise Exception(f"YesNoDialog.addField() {fieldType} is an invalid field type.")
692
+
693
+ else:
694
+ raise Exception("YesNoDialog.addField() name not set.")
695
+
696
+ def _init(self):
697
+ """@brief Init the dialog."""
698
+ with ui.dialog() as self._dialog, ui.card():
699
+ ui.label(self._prompt)
700
+ for fieldName in self._inputFieldDict:
701
+ fieldType = self._inputFieldDict[fieldName][YesNoDialog.FIELD_TYPE_KEY]
702
+ if fieldType == YesNoDialog.TEXT_INPUT_FIELD_TYPE:
703
+ widget = ui.input(label=fieldName).style('width: 200px;')
704
+
705
+ elif fieldType == YesNoDialog.NUMBER_INPUT_FIELD_TYPE:
706
+ value = self._inputFieldDict[fieldName][YesNoDialog.VALUE_KEY]
707
+ min = self._inputFieldDict[fieldName][YesNoDialog.MIN_NUMBER_KEY]
708
+ max = self._inputFieldDict[fieldName][YesNoDialog.MAX_NUMBER_KEY]
709
+ step = self._inputFieldDict[fieldName][YesNoDialog.STEP_KEY]
710
+ widget = ui.number(label=fieldName,
711
+ value=value,
712
+ min=min,
713
+ max=max,
714
+ step=step).style('width: 200px;')
715
+
716
+ elif fieldType == YesNoDialog.SWITCH_INPUT_FIELD_TYPE:
717
+ widget = ui.switch(fieldName)
718
+
719
+ elif fieldType == YesNoDialog.DROPDOWN_INPUT_FIELD:
720
+ #ui.label(fieldName)
721
+ options = self._inputFieldDict[fieldName][YesNoDialog.OPTIONS_KEY]
722
+ if options:
723
+ widget = ui.select(options)
724
+ widget.tooltip(fieldName)
725
+ else:
726
+ raise Exception("BUG: DROPDOWN_INPUT_FIELD defined without defining the options.")
727
+
728
+ elif fieldType == YesNoDialog.COLOR_INPUT_FIELD:
729
+ widget = ui.color_input(label=fieldName)
730
+
731
+ elif fieldType == YesNoDialog.DATE_INPUT_FIELD:
732
+ widget = ui.date()
733
+ widget.tooltip(fieldName)
734
+
735
+ elif fieldType == YesNoDialog.TIME_INPUT_FIELD:
736
+ widget = ui.time()
737
+ widget.tooltip(fieldName)
738
+
739
+ elif fieldType == YesNoDialog.KNOB_INPUT_FIELD:
740
+ widget = ui.knob(show_value=True)
741
+ widget.tooltip(fieldName)
742
+
743
+ elif fieldType == YesNoDialog.HOUR_MIN_INPUT_FIELD_TYPE:
744
+ widget = self._get_input_time_field(fieldName)
745
+ widget.tooltip(fieldName)
746
+
747
+ # Save a ref to the widet in the field dict
748
+ self._inputFieldDict[fieldName][YesNoDialog.WIDGET_KEY] = widget
749
+
750
+ # If we have an initial value then set it
751
+ value = self._inputFieldDict[fieldName][YesNoDialog.VALUE_KEY]
752
+ if value:
753
+ widget.value = value
754
+
755
+ with ui.row():
756
+ ui.button(self._successButtonText, on_click=self._internalSuccessMethod)
757
+ ui.button(self._failureButtonText, on_click=self._internalFailureMethod)
758
+
759
+ def _get_input_time_field(self, label):
760
+ """@brief Add a control to allow the user to enter the time as an hour and min.
761
+ @param label The label for the time field.
762
+ @return The input field containing the hour and minute entered."""
763
+ # Put this off the bottom of the mobile screen as most times it will not be needed
764
+ # and there is not enough room on the mobile screen above the plot pane.
765
+ with ui.row().classes('w-full'):
766
+ ui.label(label)
767
+ with ui.row().classes('w-full'):
768
+ time_input = ui.input("Time (HH:MM)")
769
+ with time_input as time:
770
+ with ui.menu().props('no-parent-event') as menu:
771
+ with ui.time().bind_value(time):
772
+ with ui.row().classes('justify-end'):
773
+ ui.button('Close', on_click=menu.close).props('flat')
774
+ with time.add_slot('append'):
775
+ ui.icon('access_time').on('click', menu.open).classes('cursor-pointer')
776
+ return time_input
777
+
778
+ def setPrompt(self, prompt):
779
+ """@brief Set the user prompt.
780
+ @param prompt The user prompt."""
781
+ self._prompt = prompt
782
+
783
+ def setSuccessMethod(self, successMethod):
784
+ """@brief Set the text of the success button.
785
+ @param successMethod The method called when the user selects the success button."""
786
+ self._successMethod = successMethod
787
+
788
+ def setFailureMethod(self, failureMethod):
789
+ """@brief Set the text of the success button.
790
+ @param failureMethod The method called when the user selects the failure button."""
791
+ self._failureMethod = failureMethod
792
+
793
+ def setSuccessButtonLabel(self, label):
794
+ """@brief Set the text of the success button.
795
+ @param label The success button text."""
796
+ self._successButtonText = label
797
+
798
+ def setFailureButtonLabel(self, label):
799
+ """@brief Set the text of the failure button.
800
+ @param label The failure button text."""
801
+ self._failureButtonText = label
802
+
803
+ def show(self):
804
+ """@brief Allow the user to select yes/no, ok/cancel etc in response to a question."""
805
+ self._init()
806
+ self._dialog.open()
807
+
808
+ def getValue(self, fieldName):
809
+ """@brief Get the value entered by the user.
810
+ @param fieldName The name of the field entered."""
811
+ value = None
812
+ widget = self._inputFieldDict[fieldName][YesNoDialog.WIDGET_KEY]
813
+ if hasattr(widget, 'value'):
814
+ value = widget.value
815
+
816
+ elif isinstance(widget, ui.upload):
817
+ value = self._selectedFile
818
+
819
+ return value
820
+
821
+ def _internalSuccessMethod(self):
822
+ """@brief Called when the user selects the success button."""
823
+ self.close()
824
+ # Save the entered values for all fields
825
+ for fieldName in self._inputFieldDict:
826
+ widget = self._inputFieldDict[fieldName][YesNoDialog.WIDGET_KEY]
827
+ if hasattr(widget, 'value'):
828
+ self._inputFieldDict[fieldName][YesNoDialog.VALUE_KEY] = self._inputFieldDict[fieldName][YesNoDialog.WIDGET_KEY].value
829
+ # If defined call the method
830
+ if self._successMethod:
831
+ self._successMethod()
832
+
833
+ def _internalFailureMethod(self):
834
+ """@brief Called when the user selects the failure button."""
835
+ self.close()
836
+ if self._failureMethod:
837
+ self._failureMethod()
838
+
839
+ def close(self):
840
+ """@brief Close the boolean dialog."""
841
+ self._dialog.close()