Пример #1
0
	def __init__(self, window):
		super().__init__()
		self.setupUi(self)

		# API init.
		self.control = api.control()

		# Panel init.
		self.setFixedSize(window.app.primaryScreen().virtualSize())
		self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
		self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
		self.uiCalibratedOnTemplate = self.uiCalibratedOn.text()
		
		# Button binding.
		api.observe('cameraSerial', self.recieveSerial)
		self.uiSetSerialBtn.clicked.connect(self.sendSerial)
	
		self.uiCal.clicked.connect(self.runCal)

		self.uiExportCalData.clicked.connect(self.runExportCalData)

		self.uiImportCalData.clicked.connect(self.runImportCalData)
		
		settings.observe('last factory cal', None, self.recieveFactoryCalDate)
		
		settings.observe('debug controls enabled', False, lambda x:
			self.uiShowDebugControls.setChecked(x) )
		self.uiShowDebugControls.stateChanged.connect(lambda x:
			settings.setValue('debug controls enabled', bool(x)) )
		
		self.uiDone.clicked.connect(lambda: window.show('main'))
Пример #2
0
	def updateMotionHeatmap(self) -> None:
		"""Repaint the motion heatmap when we enter this screen.
			
			We never record while on the playback screen, so we don't
			have to live-update here. This is partially due to the
			fact that the camera is modal around this, it can either
			record xor playback."""
		
		return #Heatmap got delayed. Just return for now…
		
		heatmapHeight = 16
		
		motionData = QByteArray.fromRawData(api.control('waterfallMotionMap', {'segment':'placeholder', 'startFrame':400})["heatmap"]) # 16×(n<1024) heatmap. motionData: {"startFrame": int, "endFrame": int, "heatmap": QByteArray}
		assert len(motionData) % heatmapHeight == 0, f"Incompatible heatmap size {len(motionData)}; must be a multiple of {heatmapHeight}."
		
		self.motionHeatmap = (
			QImage( #Rotated 90°, since the data is packed line-by-line. We'll draw it counter-rotated.
				heatmapHeight,
				len(motionData)//heatmapHeight,
				QImage.Format_Grayscale8)
			.transformed(QTransform().rotate(-90).scale(-1,1))
			.scaled(
				self.uiTimelineVisualization.width(),
				self.uiTimelineVisualization.height(),
				transformMode=QtCore.Qt.SmoothTransformation)
		)
		
		self.uiTimelineVisualization.update() #Invokes self.paintMotionHeatmap if needed.
Пример #3
0
	def __init__(self, window):
		super().__init__()
		self.setupUi(self)

		# API init.
		self.control = api.control()

		# Shipping mode timer init
		self.shippingModeStatusTimer = QTimer()
		self.shippingModeStatusTimer.timeout.connect(self.shippingModeStatus)
		self.shippingModeStatusTimer.setInterval(1000)
		self.shippingModeStatusTimer.start()
		
		# Panel init.
		self.setFixedSize(window.app.primaryScreen().virtualSize())
		self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
		self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
		self.window_ = window
		
		self.uiPassword.setText('') #Clear placeholder text.
		self.uiPassword.textChanged.connect(self.checkPassword)

		self.uiShippingMode.setChecked(self.control.getSync('shippingMode'))
		self.uiShippingMode.stateChanged.connect(self.setShippingMode)

		self.uiDone.clicked.connect(window.back)
Пример #4
0
	def __init__(self, window):
		super().__init__()
		self.setupUi(self)

		# API init.
		self.control = api.control()
		
		# Panel init.
		self.setFixedSize(window.app.primaryScreen().virtualSize())
		self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
		self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
		
		self.window_ = window
		self.state = 0
		self.aShortPeriodOfTime = QtCore.QTimer()
		self.aShortPeriodOfTime.timeout.connect(self.afterAShortPeriodOftime)
		self.aShortPeriodOfTime.start(16) #One frame is a short period of time.
		
		# Button binding.
		self.uiDebug.clicked.connect(lambda: self and dbg()) #"self" is needed here, won't be available otherwise.
		#self.uiDebug.clicked.connect(lambda: self.decimalspinbox_3.availableUnits()) #"self" is needed here, won't be available otherwise.
		self.uiBack.clicked.connect(window.back)
		
		rtl = self.control.callSync('getResolutionTimingLimits', self.control.getSync('resolution'))
		self.uiSlider.setMaximum(rtl['exposureMax'])
		self.uiSlider.setMinimum(rtl['exposureMin'])
		
		self.uiSlider.debounce.sliderMoved.connect(self.onExposureChanged)
		api.observe('exposurePeriod', self.updateExposureNs)
Пример #5
0
	def __init__(self, window):
		super().__init__()
		self.setupUi(self)
		
		# API init.
		self.control = api.control()

		# Panel init.
		self.setFixedSize(window.app.primaryScreen().virtualSize())
		self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
		self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
		
		#Color Temperature
		try:
			api.observe('wbTemperature', lambda temp:
				self.uiColorTemperature.setValue(temp) )
			self.uiColorTemperature.valueChanged.connect(lambda temp:
				self.control.set('wbTemperature', temp) )
		except AssertionError as e:
			log.error(f'Could not connect widgets to wbTemperature.\n{e}')
		
		#White Balance Matrix
		try:
			api.observe('wbColor', lambda color: (
				self.uiWBRed.setValue(color[0]), 
				self.uiWBGreen.setValue(color[1]), 
				self.uiWBBlue.setValue(color[2]),
			))
			for wbInput in [self.uiWBRed, self.uiWBGreen, self.uiWBBlue]:
				wbInput.valueChanged.connect(lambda *_:
					self.control.set('wbColor', [
						self.uiWBRed.value(), 
						self.uiWBGreen.value(), 
						self.uiWBBlue.value(),
					]) )
		except AssertionError as e:
			log.error(f'Could not connect widgets to wbColor.\n{e}')
		
		#Color Matrix Preset
		self.uiColorMatrixPreset.clear()
		self.uiColorMatrixPreset.insertItem(0, self.tr('CIECAM02/D55'), 'CIECAM02/D55')
		self.uiColorMatrixPreset.insertItem(1, self.tr('CIECAM16/D55'), 'CIECAM16/D55')
		self.uiColorMatrixPreset.insertItem(2, self.tr('Identity'), 'identity')
		self.uiColorMatrixPreset.insertItem(3, self.tr('Custom'), None)
		self.uiColorMatrixPreset.currentIndexChanged.connect(lambda index:
			self.setCM(
				presets[self.uiColorMatrixPreset.itemData(index)]
				if self.uiColorMatrixPreset.itemData(index) else
				settings.value('customColorMatrix', presets['identity'])
			)
		)
		
		#Color Matrix
		api.observe('colorMatrix', self.updateCMInputs)
		for cmInput in self.colorMatrixInputs():
			cmInput.valueChanged.connect(self.cmUpdated)
		
		#Navigation
		self.uiDone.clicked.connect(window.back)
Пример #6
0
	def __init__(self, window):
		super().__init__()
		self.setupUi(self)
		
		# API init.
		self.control = api.control()

		# Panel init.
		self.setFixedSize(window.app.primaryScreen().virtualSize())
		self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
		self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
		
		#Substitute constants into header bit.
		self.control.get(['cameraModel', 'cameraMemoryGB', 'sensorColorPattern', 'cameraSerial', 'cameraApiVersion', 'cameraFpgaVersion']).then(
			lambda values:
				self.uiText.setText(
					self.uiText.text()
					.replace('{MODEL}', f"{values['cameraModel']}, {values['cameraMemoryGB']}, {'mono' if values['sensorColorPattern'] == 'mono' else 'color'}")
					.replace('{SERIAL_NUMBER}', values['cameraSerial'])
					.replace('{UI_VERSION}', appVersion)
					.replace('{API_VERSION}', values['cameraApiVersion'])
					.replace('{FPGA_VERSION}', values['cameraFpgaVersion'])
				)
		)
		settings.observe('theme', 'dark', lambda name: (
			self.uiScrollArea.setStyleSheet(f"""
				color: {theme(name).text};
				background: {theme(name).base};
				border: 1px solid {theme(name).border};
			"""),
			self.uiText.setStyleSheet(f"""
				border: none;
				padding: 5px;
			""")
		))
		
		# Set scroll bar to scroll all text content. 
		self.uiScroll.setMaximum( 
			self.uiText.height() - self.uiScrollArea.height() )
		
		#Add drag-to-scroll to text content.
		self.uiScrollArea.setFocusPolicy(QtCore.Qt.NoFocus)
		QScroller.grabGesture(self.uiScrollArea.viewport(), QScroller.LeftMouseButtonGesture) #DDR 2019-01-15: Defaults to TouchGesture - which should work, according to WA_AcceptTouchEvents, but doesn't.
		scroller = QScroller.scroller(self.uiScrollArea.viewport())
		properties = scroller.scrollerProperties()
		properties.setScrollMetric(properties.AxisLockThreshold, 0.0)
		properties.setScrollMetric(properties.MousePressEventDelay, 0.0)
		properties.setScrollMetric(properties.DragStartDistance, 0.0) #default: 0.005 - tweaked for "feel", the platform defaults are overly dramatic.
		properties.setScrollMetric(properties.OvershootDragDistanceFactor, 0.3) #default: 1
		properties.setScrollMetric(properties.OvershootScrollDistanceFactor, 0.3) #default: 1
		properties.setScrollMetric(properties.OvershootScrollTime, 0.5) #default: 0.7
		scroller.setScrollerProperties(properties)
		
		# Button binding.
		self.uiScroll.valueChanged.connect(self.scrollPane)
		self.uiScrollArea.verticalScrollBar().valueChanged.connect(self.scrollKnob)
		
		self.uiDone.clicked.connect(window.back)
Пример #7
0
	def linkedValueName(self, newLinkedValueName):
		self._linkedValueName = newLinkedValueName
		if newLinkedValueName and api: #API may not load in Qt Designer if it's not been set up.
			api.observe(newLinkedValueName, self.__updateValue)
			
			if hasattr(self, 'setValue'): #Most inputs.
				self.valueChanged.connect(
					lambda val: api.control().set({
						self._linkedValueName: self.realValue() }) )
			elif hasattr(self, 'setChecked'): #Checkbox
				self.stateChanged.connect(
					lambda val: api.control().set({
						self._linkedValueName: val != 0 }) )
			elif hasattr(self, 'setText'): #Line edit
				self.editingFinished.connect(
					lambda: api.control().set({
						self._linkedValueName: self.text() }) )
			else:
				raise ValueError(f'Unknown type of widget to observe. (send on ${self})')
Пример #8
0
	def __init__(self, window):
		super().__init__()
		self.setupUi(self)

		# API init.
		self.control = api.control()
		
		# Panel init.
		self.setFixedSize(window.app.primaryScreen().virtualSize())
		self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
		self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
		
		# Set up panel switching.
		#DDR 2018-07-24: It's impossible to associate an identifier with anything in QT Designer. Painfully load the identifiers here. Also check everything because I will mess this up next time I add a trigger.
		#DDR 2019-08-01: Aaaand the safety check paid off. Told myself.
		if(self.uiRecordMode.count() != len(self.availableRecordModeIds)):
			raise Exception("Record mode screen available record mode IDs does not match the number of textual entries in uiRecordMode.")
		if(self.uiRecordModePanes.count() != len(self.availableRecordModeIds)):
			raise Exception("Record mode screen available record mode IDs does not match the number of uiRecordModePanes panes.")
		
		currentScreenId = settings.value('active record mode', self.availableRecordModeIds[0])
		if(currentScreenId not in self.availableRecordModeIds):
			print(f'{currentScreenId} is not a known record mode ID, defaulting to {self.availableRecordModeIds[0]}')
			currentScreenId = self.availableRecordModeIds[0]
		
		#Disable run-n-gun mode screen until it's added.
		self.uiRecordMode.removeItem(self.availableRecordModeIds.index('runAndGun'))
		
		self.uiRunNGunTimeInSeconds.template = self.uiRunNGunTimeInSeconds.text()
		self.uiRunNGunTimeInFrames.template = self.uiRunNGunTimeInFrames.text()
		self.uiRegularLengthInSeconds.template = self.uiRegularLengthInSeconds.text()
		self.uiRegularLengthInFrames.template = self.uiRegularLengthInFrames.text()
		self.uiBurstTimeInSeconds.template = self.uiBurstTimeInSeconds.text()
		self.uiBurstTimeInFrames.template = self.uiBurstTimeInFrames.text()
		
		# Widget behavour.
		api.observe('recMode', self.setCurrentScreenIndexFromRecordMode)
		self.uiRecordMode.currentIndexChanged.connect(self.changeShownTrigger)
		
		api.observe('cameraMaxFrames', self.recalculateEverything)
		api.observe('recSegments',     self.recalculateEverything)
		api.observe('recMaxFrames',    self.recalculateEverything)
		api.observe('framePeriod',     self.recalculateEverything)
		
		self.uiSegmentLengthInSeconds.valueChanged.connect(lambda sec:
			self.uiSegmentLengthInFrames.setValue(
				int(sec * 1e9 / api.apiValues.get('framePeriod') * api.apiValues.get('recSegments')) ) )
		self.uiSegmentLengthNumSegments.valueChanged.connect(lambda segments:
			self.control.setSync('recSegments', segments) )
		self.uiSegmentLengthInFrames.valueChanged.connect(lambda frames:
			self.control.setSync('recMaxFrames', frames * api.apiValues.get('recSegments')) )
		
		self.uiDone.clicked.connect(window.back)
Пример #9
0
def report(tag: str, data: dict):
	"""Report program statistics to an internal server, stats.node.js."""
	assert tag
	assert "tag" not in data
	assert "serial_number" not in data
		
	data["tag"] = tag
	data["serial_number"] = api.control().getSync('cameraSerial')
	try:
		urlopen(report_url, bytes(json.dumps(data), 'utf-8'), 0.1)
	except Exception:
		global contact_warned
		if not contact_warned:
			contact_warned = True
			log.warn(f'Could not contact the stats server at {report_url}.')
		pass
Пример #10
0
	def __init__(self, window):
		super().__init__()
		self.setupUi(self)

		# API init.
		self.control = api.control()
		
		# Panel init.
		self.setFixedSize(window.app.primaryScreen().virtualSize())
		self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
		self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
		
		# Button binding.
		self.window_ = window
		
		#Side and rotated are not quite correct, as askBeforeDiscarding is, but they are correct enough for now. Having the final result come from two values confused things a bit.
		self.uiInterfaceSide.setCurrentIndex(
			int(settings.value('interface handedness', None) == 'left'))
		self.uiInterfaceSide.currentIndexChanged.connect(lambda index:
			settings.setValue('interface handedness', 'left' if index else 'right') )
		settings.observe('interface handedness', None, self.updateInterfaceSide)
		
		self.uiInterfaceRotated.setCurrentIndex(
			int(settings.value('interface rotation', None) == '180'))
		self.uiInterfaceRotated.currentIndexChanged.connect(lambda index:
			settings.setValue('interface rotation', '180' if index else '0') )
		settings.observe('interface rotation', None, self.updateInterfaceSide)
		
		settings.observe('theme', 'dark', self.updateInterfaceSide)
		
		#Note the operations attached here:
		#	- We must observe a silenced callback to update the state. This prevents an infinite loop.
		#	- We update the state from a callback attached to the widget.
		settings.observe('ask before discarding', 'if not reviewed', self.updateAskBeforeDiscarding)
		self.uiAskBeforeDiscarding.currentIndexChanged.connect(lambda index:
			settings.setValue('ask before discarding',
				["always", "if not reviewed", "never"][index] ) )
		
		
		api.observe('dateTime', self.stopEditingDate) #When the date is changed, always display the update even if an edit is in progress. Someone probably set the date some other way instead of this, or this was being edited in error.
		self.uiSystemTime.focusInEvent = self.sysTimeFocusIn
		self.uiSystemTime.editingFinished.connect(self.sysTimeFocusOut)
		self._timeUpdateTimer = QtCore.QTimer()
		self._timeUpdateTimer.timeout.connect(self.updateDisplayedSystemTime)
		
		
		self.uiDone.clicked.connect(window.back)
Пример #11
0
	def __init__(self, window):
		super().__init__()
		self.setupUi(self)
		
		# Panel init.
		self.setFixedSize(window.app.primaryScreen().virtualSize())
		self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
		self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
		
		# API init.
		self.control = api.control()
		self.video = api.video()
		
		self.uiTriggerDelaySlider.setStyleSheet( #If this turns out to be too expensive to set, just fill in the tip and draw a red rectangle underneath.
			self.uiTriggerDelaySlider.styleSheet() + '\n' + """
				Slider::groove {
					background: qlineargradient( x1:0 y1:0, x2:1 y2:0, stop:0 transparent, stop:0.4999 transparent, stop:0.5001 red, stop:1 red);
				}
			"""
		)
		
		# Value init.
		self.availableDelayMultiplier = 1. #Used for "more" and "less" pre-record delay. Multiplies totalAvailableFrames.
		
		relevantValues = self.control.getSync(['cameraMaxFrames', 'framePeriod', 'recTrigDelay'] )
		self.cameraMaxFrames = relevantValues['cameraMaxFrames']
		self.framePeriod = relevantValues['framePeriod']
		self.recTrigDelay = relevantValues['recTrigDelay']
		
		api.observe_future_only('cameraMaxFrames', self.updateTotalAvailableFrames)
		api.observe_future_only('framePeriod', self.updateRecordingPeriod)
		api.observe_future_only('recTrigDelay', self.updateTriggerDelay)
		self.updateDisplayedValues()
		
		# Button binding.
		self.ui0Pct.clicked.connect(lambda:
			self.control.set({'recTrigDelay': 0}) )
		self.ui50Pct.clicked.connect(lambda:
			self.control.set({'recTrigDelay': self.cameraMaxFrames//2}) )
		self.ui100Pct.clicked.connect(lambda:
			self.control.set({'recTrigDelay': self.cameraMaxFrames}) )
		
		self.uiTriggerDelaySlider.valueChanged.connect(self.newSliderPosition)
		
		
		self.uiPreRecordDelayFrames.valueChanged.connect(lambda frames:
			self.control.set({'recTrigDelay': -frames}) )
		self.uiPreRecordDelaySec.valueChanged.connect(lambda seconds:
			self.control.set({'recTrigDelay': -self.secondsToFrames(seconds)}) )
		
		self.uiPreTriggerRecordingFrames.valueChanged.connect(lambda frames:
			self.control.set({'recTrigDelay': frames}) )
		self.uiPreTriggerRecordingSec.valueChanged.connect(lambda seconds:
			self.control.set({'recTrigDelay': self.secondsToFrames(seconds)}) )
		
		self.uiPostTriggerRecordingFrames.valueChanged.connect(lambda frames:
			self.control.set({'recTrigDelay': self.cameraMaxFrames - frames}) )
		self.uiPostTriggerRecordingSec.valueChanged.connect(lambda seconds:
			self.control.set({'recTrigDelay': self.cameraMaxFrames - self.secondsToFrames(seconds)}) )
		
		self.uiDone.clicked.connect(window.back)
Пример #12
0
	def __init__(self, window):
		super().__init__()
		self.setupUi(self)
		self.window_ = window

		# API init.
		self.control = api.control()
		self.video = api.video()
		
		# Panel init.
		self.setFixedSize(window.app.primaryScreen().virtualSize())
		self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
		self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
		
		settings.observe('debug controls enabled', False, lambda show:
			self.uiDebug.show() if show else self.uiDebug.hide() )
		self.uiDebug.clicked.connect(lambda: self and dbg())
		
		self.populatePresets()
		self.uiPresets.currentIndexChanged.connect(self.applyPreset)
		
		#Resolution & resolution preview
		invariants = self.control.getSync([
			'sensorVMax', 'sensorVMin', 'sensorVIncrement',
			'sensorHMax', 'sensorHMin', 'sensorHIncrement',
		])
		self.uiHRes.setMinimum(invariants['sensorHMin'])
		self.uiVRes.setMinimum(invariants['sensorVMin'])
		self.uiHRes.setMaximum(invariants['sensorHMax'])
		self.uiVRes.setMaximum(invariants['sensorVMax'])
		self.uiHRes.setSingleStep(invariants['sensorHIncrement'])
		self.uiVRes.setSingleStep(invariants['sensorVIncrement'])
		
		self.uiHRes.valueChanged.connect(self.updateForSensorHRes)
		self.uiVRes.valueChanged.connect(self.updateForSensorVRes)
		self.uiHOffset.valueChanged.connect(self.updateForSensorHOffset) #Offset min implicit, max set by resolution. Offset set after res because 0 is a good default to set up res at.
		self.uiVOffset.valueChanged.connect(self.updateForSensorVOffset)
		
		self._lastResolution = defaultdict(lambda: None) #Set up for dispatchResolutionUpdate.
		api.observe('resolution', self.dispatchResolutionUpdate)
		

		
		#Frame rate fps/µs binding
		self.uiFps.setMinimum(0.01)
		self.uiFps.valueChanged.connect(self.updateFps)
		self.uiFrameDuration.valueChanged.connect(self.updateFrameDuration)
		api.observe('frameRate', self.updateFpsFromAPI)
		
		#Analog gain
		self.populateUiLuxAnalogGain()
		api.observe('currentGain', self.setLuxAnalogGain)
		self.uiAnalogGain.currentIndexChanged.connect(self.luxAnalogGainChanged)
		
		# Button binding.
		self.uiCenterRecording.clicked.connect(self.centerRecording)
		
		self.uiCancel.clicked.connect(self.revertSettings)
		self.uiDone.clicked.connect(self.applySettings)
		self.uiDone.clicked.connect(lambda: self.window_.back())
		
		api.observe('exposureMin', self.setMinExposure)
		api.observe('exposureMax', self.setMaxExposure)
		api.observe('exposurePeriod', self.updateExposure)
		self.uiExposure.valueChanged.connect(
			lambda val: self.control.set('exposurePeriod', val) )
		self.uiMaximizeExposure.clicked.connect(lambda: 
			self.uiExposure.setValue(self.uiExposure.maximum()) )
		
		self.uiMaximizeFramerate.clicked.connect(lambda: 
			self.uiFps.setValue(self.uiFps.maximum()) )
		
		self.uiSavePreset.clicked.connect(self.savePreset)
		self.uiDeletePreset.clicked.connect(self.deletePreset)
		
		#Hack. Since we set each recording setting individually, we always
		#wind up with a 'custom' entry on our preset list. Now, this might be
		#legitimate - if we're still on Custom by this time, that's just the
		#configuration the camera's in. However, if we're not, we can safely
		#delete it, since it's just a garbage value from the time the second-
		#to-last setting was set during setup.
		if self.uiPresets.itemData(0)['temporary'] and not self.uiPresets.itemData(self.uiPresets.currentIndex())['temporary']:
			self.uiPresets.removeItem(0)
		
		#Set up ui writes after everything is done.
		self._dirty = False
		self.uiUnsavedChangesWarning.hide()
		self.uiCancel.hide()
		def markDirty(*_):
			self._dirty = True
			self.uiUnsavedChangesWarning.show()
			self.uiCancel.show()
		self.uiHRes.valueChanged.connect(markDirty)
		self.uiVRes.valueChanged.connect(markDirty)
		self.uiHOffset.valueChanged.connect(markDirty)
		self.uiVOffset.valueChanged.connect(markDirty)
Пример #13
0
	def __init__(self, window):
		super().__init__()
		self.setupUi(self)
		
		# API init.
		self.control = api.control()
		self.video = api.video()

		# Panel init.
		self.setFixedSize(window.app.primaryScreen().virtualSize()) #This is not a responsive design.
		self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
		self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
		
		self._window = window
		
		#Hide the fake borders if the buttons don't have borders.
		if self.uiBlackCal.hideBorder:
			self.uiBottomHorizontalLine.hide()
			self.uiBottomVerticalLine.hide()
		else:
			self.uiBottomHorizontalLine.show()
			self.uiBottomVerticalLine.show()
		
		self.uiFocusPeakingOriginalCustomStyleSheet = self.uiFocusPeaking.styleSheet()
		self.uiZebraStripesOriginalCustomStyleSheet = self.uiZebraStripes.styleSheet()
		settings.observe('theme', 'dark', lambda name: (
			self.uiFocusPeaking.setStyleSheet(
				self.uiFocusPeakingOriginalCustomStyleSheet + f"""
					CheckBox {{ background-color: {theme(name).background} }}
				"""
			),
			self.uiZebraStripes.setStyleSheet(
				self.uiZebraStripesOriginalCustomStyleSheet + f"""
					CheckBox {{ background-color: {theme(name).background} }}
				"""
			)
		))
		
		#Note start/end recording times, to display the timers.
		recordingStartTime = 0
		recordingEndTime = 0
		def updateRecordingStartTime(state):
			nonlocal recordingStartTime, recordingEndTime
			if state == 'recording':
				recordingStartTime = time()
				recordingEndTime = 0
				self.uiPlayAndSave.update()
				self.uiRecord.update()
			elif state == 'idle' and not recordingEndTime:
				recordingEndTime = time()
				self.uiPlayAndSave.update()
				self.uiRecord.update()
		api.observe('state', updateRecordingStartTime)
		
		totalFrames = self.control.getSync('totalFrames')
		if totalFrames == 0: #Set the length of the recording to 0, if nothing has been recorded. Otherwise, calculate what we've recorded.
			recordingStartTime = recordingEndTime
		else:
			recordingEndTime = recordingStartTime + totalFrames/self.control.getSync('frameRate')
		
		self.uiMenuBackground.hide()
		self.uiMenuBackground.move(0,0)
		
		lastOpenerButton = None
		
		def showMenu(button: QWidget, menu: QWidget, *_):
			nonlocal lastOpenerButton
			lastOpenerButton = button
			
			self.uiMenuBackground.show(),
			self.uiMenuBackground.raise_(),
			button.raise_(),
			menu.show(),
			menu.raise_(),
			self.focusRing.raise_(),
		self.showMenu = showMenu
			
		def hideMenu(*_):
			nonlocal lastOpenerButton
			if not lastOpenerButton:
				return
			
			self.uiMenuBackground.hide()
			self.uiFocusPeakingColorMenu.hide()
			self.uiMenuDropdown.hide()
			self.uiExposureMenu.hide()
			self.uiWhiteBalanceMenu.hide()
			lastOpenerButton.setFocus()
			
			lastOpenerButton = None
		self.hideMenu = hideMenu
		
		self.uiMenuBackground.mousePressEvent = hideMenu
		self.uiMenuBackground.focusInEvent = hideMenu
		
		
		#############################
		#   Button action binding   #
		#############################
		
		#Debug buttons. (These are toggled on the factory settings screen.)
		self.uiDebugA.clicked.connect(self.screenshotAllScreens)
		self.uiDebugB.clicked.connect(lambda: window.show('test'))
		self.uiDebugC.setFocusPolicy(QtCore.Qt.TabFocus) #Break into debugger without loosing focus, so you can debug focus issues.
		self.uiDebugC.clicked.connect(lambda: self and window and dbg()) #"self" is needed here, won't be available otherwise.
		self.uiDebugD.clicked.connect(QApplication.closeAllWindows)
		
		#Only show the debug controls if enabled in factory settings.
		settings.observe('debug controls enabled', False, lambda show: (
			self.uiDebugA.show() if show else self.uiDebugA.hide(),
			self.uiDebugB.show() if show else self.uiDebugB.hide(),
			self.uiDebugC.show() if show else self.uiDebugC.hide(),
			self.uiDebugD.show() if show else self.uiDebugD.hide(),
		))
		
		
		#Occasionally, the touch screen seems to report a spurious touch event on the top-right corner. This should prevent that. (Since the record button is there now, this is actually very important!)
		self.uiErrantClickCatcher.mousePressEvent = (lambda evt:
			log.warn('Errant click blocked. [WpeWCY]'))
		
		
		#Zeebs
		api.observe('zebraLevel', lambda intensity:
			self.uiZebraStripes.setCheckState(
				0 if not intensity else 2 ) )
		
		self.uiZebraStripes.stateChanged.connect(lambda state: 
			self.control.set({'zebraLevel': state/200}) )
		
		
		#Focus peaking
		#Use for focus peaking drop-down.
		#api.observe('focusPeakingLevel', lambda intensity:
		#	self.uiFocusPeakingIntensity.setCurrentIndex(
		#		round((1-intensity) * (self.uiFocusPeakingIntensity.count()-1)) ) )
		#
		#self.uiFocusPeakingIntensity.currentIndexChanged.connect(lambda index:
		#	self.control.set({'focusPeakingLevel': 1-(index/(self.uiFocusPeakingIntensity.count()-1))} ) )
		
		api.observe('focusPeakingLevel', lambda intensity:
			self.uiFocusPeaking.setCheckState(
				0 if not intensity else 2 ) )
		
		self.uiFocusPeaking.stateChanged.connect(lambda state: 
			self.control.set({'focusPeakingLevel': (state/2) * 0.2}) )
		
		
		#Focus peaking colour
		focusColor = ''
		def updateFocusColor(color):
			nonlocal focusColor
			target = getattr(self, f"ui{color.title()}FocusPeaking", None)
			if target: #Find the colour of the panel to be highlighted.
				match = regex_search(r'background:\s*?([#\w]+)', target.customStyleSheet)
				assert match, f"Could not find background color of {target.objectName()}. Check the background property of it's customStyleSheet."
				focusColor = match.group(1)
			else: #Just pass through whatever the colour is.
				focusColor = color
			
			self.uiFocusPeakingColor.update()
		api.observe('focusPeakingColor', updateFocusColor)
			
		def uiFocusPeakingColorPaintEvent(evt, rectSize=24):
			"""Draw the little coloured square on the focus peaking button."""
			midpoint = self.uiFocusPeakingColor.geometry().size()/2 + QSize(0, self.uiFocusPeakingColor.touchMargins()['top']/2)
			type(self.uiFocusPeakingColor).paintEvent(self.uiFocusPeakingColor, evt) #Invoke the superclass to - hopefully - paint the rest of the button before we deface it with our square.
			p = QPainter(self.uiFocusPeakingColor)
			p.setPen(QPen(QColor('black')))
			p.setBrush(QBrush(QColor(focusColor)))
			p.drawRect( #xywh
				midpoint.width() - rectSize/2, midpoint.height() - rectSize/2,
				rectSize, rectSize )
		self.uiFocusPeakingColor.paintEvent = uiFocusPeakingColorPaintEvent
		
		self.uiFocusPeakingColor.clicked.connect(
			self.toggleFocusPeakingColorMenu)
		
		self.uiFocusPeakingColorMenu.hide()
		self.uiFocusPeakingColorMenu.move(360, 330)
		
		
		#Loop focus peaking color menu focus, for the jog wheel.
		self.uiMagentaFocusPeaking.nextInFocusChain = (lambda *_: 
			self.uiFocusPeakingColor
			if self.uiFocusPeakingColorMenu.isVisible() else
			type(self.uiMagentaFocusPeaking).nextInFocusChain(self.uiMagentaFocusPeaking, *_)
		)
		self.uiRedFocusPeaking.previousInFocusChain = (lambda *_: 
			self.uiFocusPeakingColor
			if self.uiFocusPeakingColorMenu.isVisible() else
			type(self.uiRedFocusPeaking).previousInFocusChain(self.uiRedFocusPeaking, *_)
		)
		self.uiFocusPeakingColor.nextInFocusChain = (lambda *_:
			self.uiRedFocusPeaking
			if self.uiFocusPeakingColorMenu.isVisible() else
			type(self.uiFocusPeakingColor).nextInFocusChain(self.uiFocusPeakingColor, *_)
		)
		self.uiFocusPeakingColor.previousInFocusChain = (lambda *_:
			self.uiMagentaFocusPeaking
			if self.uiFocusPeakingColorMenu.isVisible() else
			type(self.uiFocusPeakingColor).previousInFocusChain(self.uiFocusPeakingColor, *_)
		)
		
		#Focus peaking color menu
		api.observe('focusPeakingColor', self.updateFocusPeakingColor)
		
		for child in self.uiFocusPeakingColorMenu.children():
			match = regex_match(r'^ui(.*?)FocusPeaking$', child.objectName())
			match and child.clicked.connect(
				(lambda color: #Capture color from for loop.
					lambda: self.control.set({'focusPeakingColor': color})
				)(match.group(1).lower()) )
		
		
		#Black Cal
		self.uiBlackCal.clicked.connect(lambda:
			self.control.call('startCalibration', {
				'blackCal': True ,
				'saveCal':  True }) )
		
		
		#White Bal & Trigger/IO
		whiteBalanceTemplate = self.uiWhiteBalance.text()
		api.observe('wbTemperature', lambda temp:
			self.uiWhiteBalance.setText(
				whiteBalanceTemplate.format(temp) ))
		
		self.uiTriggers.clicked.connect(lambda:
			window.show('triggers_and_io') )
		
		# You can't adjust the colour of a monochromatic image.
		# Hide white balance in favour of trigger/io button.
		if api.apiValues.get('sensorColorPattern') == 'mono':
			self.uiWhiteBalance.hide()
		else:
			self.uiTriggers.hide()
		
		self.uiWhiteBalanceMenu.hide()
		self.uiWhiteBalanceMenu.move(
			self.x(),
			self.uiWhiteBalance.y() - self.uiWhiteBalanceMenu.height() + self.uiWhiteBalance.touchMargins()['top'],
		)
		self.uiWhiteBalance.clicked.connect(lambda *_: 
			hideMenu()
			if self.uiWhiteBalanceMenu.isVisible() else
			showMenu(self.uiWhiteBalance, self.uiWhiteBalanceMenu)
		)
		
		#Loop white balance menu focus, for the jog wheel.
		self.uiFineTuneColor.nextInFocusChain = (lambda *_: 
			self.uiWhiteBalance
			if self.uiWhiteBalanceMenu.isVisible() else
			type(self.uiFineTuneColor).nextInFocusChain(self.uiFineTuneColor, *_)
		)
		self.uiWBPreset1.previousInFocusChain = (lambda *_: 
			self.uiWhiteBalance
			if self.uiWhiteBalanceMenu.isVisible() else
			type(self.uiWBPreset1).previousInFocusChain(self.uiWBPreset1, *_)
		)
		self.uiWhiteBalance.nextInFocusChain = (lambda *_:
			self.uiWBPreset1
			if self.uiWhiteBalanceMenu.isVisible() else
			type(self.uiWhiteBalance).nextInFocusChain(self.uiWhiteBalance, *_)
		)
		self.uiWhiteBalance.previousInFocusChain = (lambda *_:
			self.uiFineTuneColor
			if self.uiWhiteBalanceMenu.isVisible() else
			type(self.uiWhiteBalance).previousInFocusChain(self.uiWhiteBalance, *_)
		)
		
		self.uiWBPreset1.clicked.connect(lambda evt:
			self.control.set('wbTemperature', self.uiWBPreset1.property('temp')) )
		self.uiWBPreset2.clicked.connect(lambda evt:
			self.control.set('wbTemperature', self.uiWBPreset2.property('temp')) )
		self.uiWBPreset3.clicked.connect(lambda evt:
			self.control.set('wbTemperature', self.uiWBPreset3.property('temp')) )
		self.uiWBPreset4.clicked.connect(lambda evt:
			self.control.set('wbTemperature', self.uiWBPreset4.property('temp')) )
		self.uiWBPreset5.clicked.connect(lambda evt:
			self.control.set('wbTemperature', self.uiWBPreset5.property('temp')) )
		self.uiFineTuneColor.clicked.connect(lambda: window.show('color'))
		
		
		#Exposure
		def updateExposureSliderLimits():
			"""Update exposure text to match exposure slider, and sets the slider step so clicking the gutter always moves 1%."""
			step1percent = (self.uiExposureSlider.minimum() + self.uiExposureSlider.maximum()) // 100
			self.uiExposureSlider.setSingleStep(step1percent)
			self.uiExposureSlider.setPageStep(step1percent*10)
		
		def onExposureSliderMoved(newExposureNs):
			nonlocal exposureNs
			
			linearRatio = (newExposureNs-self.uiExposureSlider.minimum()) / (self.uiExposureSlider.maximum()-self.uiExposureSlider.minimum())
			newExposureNs = math.pow(linearRatio, 2) * self.uiExposureSlider.maximum()
			self.control.call('set', {'exposurePeriod': newExposureNs})
			
			#The signal takes too long to return, as it's masked by the new value the slider sets.
			exposureNs = newExposureNs
			updateExposureText()
		
		def updateExposureMax(newExposureNs):
			self.uiExposureSlider.setMaximum(newExposureNs)
			updateExposureSliderLimits()
		
		def updateExposureMin(newExposureNs):
			self.uiExposureSlider.setMinimum(newExposureNs)
			updateExposureSliderLimits()
		
		#Must set slider min/max before value.
		api.observe('exposureMax', updateExposureMax)
		api.observe('exposureMin', updateExposureMin)
		
		exposureUnit = 'µs' #One of 'µs', 'deg', or 'pct'.
		exposureTemplate = self.uiExposure.text()
		uiExposureInDegreesTemplate = self.uiExposureInDegrees.text()
		uiExposureInMsTemplate = self.uiExposureInMs.text()
		uiExposureInPercentTemplate = self.uiExposureInPercent.text()
		
		exposureNsMin = 0
		exposureNs = 0
		exposureNsMax = 0
		
		def updateExposureText(*_):
			exposureDeg = exposureNs/api.apiValues.get('framePeriod')*360
			exposurePct = exposureNs/(exposureNsMax or 1)*100
			exposureMs = exposureNs/1e3
			
			if exposurePct < 0:
				dbg()
			
			self.uiExposure.setText(
				exposureTemplate.format(
					name = {
						'µs': 'Exposure',
						'pct': 'Exposure',
						'deg': 'Shutter Angle',
					}[exposureUnit],
					exposure = {
						'deg': f'{exposureDeg:1.0f}°', #TODO DDR 2019-09-27: Is this actually the way to calculate shutter angle?
						'pct': f'{exposurePct:1.1f}%',
						'µs': f'{exposureMs:1.1f}µs',
					}[exposureUnit],
				)
			)
			
			self.uiExposureInDegrees.setText(
				uiExposureInDegreesTemplate.format(
					degrees = exposureDeg ) )
			
			self.uiExposureInPercent.setText(
				uiExposureInPercentTemplate.format(
					percent = exposurePct ) )
			
			self.uiExposureInMs.setText(
				uiExposureInMsTemplate.format(
					duration = exposureMs ) )
			
			linearRatio = exposureNs/(exposureNsMax-exposureNsMin)
			try:
				exponentialRatio = math.sqrt(linearRatio)
			except ValueError:
				exponentialRatio = 0
			if not self.uiExposureSlider.beingHeld:
				self.uiExposureSlider.setValue(exponentialRatio * (self.uiExposureSlider.maximum()-self.uiExposureSlider.minimum()) + self.uiExposureSlider.minimum())
			updateExposureSliderLimits()
		
		# In Python 3.7: Use api.observe('exposureMin', lambda ns: exposureNSMin := ns) and give exposureNSMin a setter?
		def updateExposureNsMin(ns):
			nonlocal exposureNsMin
			exposureNsMin = ns
			updateExposureText()
		api.observe('exposureMin', updateExposureNsMin)
		
		def updateExposureNs(ns):
			nonlocal exposureNs
			exposureNs = ns
			updateExposureText()
		api.observe('exposurePeriod', updateExposureNs)
		
		def updateExposureNsMax(ns):
			nonlocal exposureNsMax
			exposureNsMax = ns
			updateExposureText()
		api.observe('exposureMax', updateExposureNsMax)
		
		api.observe('framePeriod', updateExposureText)
		
		self.uiExposureMenu.hide()
		self.uiExposureMenu.move(
			self.x(),
			self.uiExposure.y() - self.uiExposureMenu.height() + self.uiExposure.touchMargins()['top'],
		)
		self.uiExposure.clicked.connect(lambda *_: 
			hideMenu()
			if self.uiExposureMenu.isVisible() else
			showMenu(self.uiExposure, self.uiExposureMenu)
		)
		
		#Loop exposure menu focus, for the jog wheel.
		self.uiExposureSlider.nextInFocusChain = (lambda *_: 
			self.uiExposure
			if self.uiExposureMenu.isVisible() else
			type(self.uiExposureSlider).nextInFocusChain(self.uiExposureSlider, *_)
		)
		self.uiExposureInDegrees.previousInFocusChain = (lambda *_: 
			self.uiExposure
			if self.uiExposureMenu.isVisible() else
			type(self.uiExposureInDegrees).previousInFocusChain(self.uiExposureInDegrees, *_)
		)
		self.uiExposure.nextInFocusChain = (lambda *_:
			self.uiExposureInDegrees
			if self.uiExposureMenu.isVisible() else
			type(self.uiExposure).nextInFocusChain(self.uiExposure, *_)
		)
		self.uiExposure.previousInFocusChain = (lambda *_:
			self.uiExposureSlider
			if self.uiExposureMenu.isVisible() else
			type(self.uiExposure).previousInFocusChain(self.uiExposure, *_)
		)
		
		def uiExposureInDegreesClicked(*_):
			nonlocal exposureUnit
			exposureUnit = 'deg'
			updateExposureText()
		self.uiExposureInDegrees.clicked.connect(
			uiExposureInDegreesClicked )
		
		def uiExposureInMsClicked(*_):
			nonlocal exposureUnit
			exposureUnit = 'µs'
			updateExposureText()
		self.uiExposureInMs.clicked.connect(
			uiExposureInMsClicked )
		
		def uiExposureInPercentClicked(*_):
			nonlocal exposureUnit
			exposureUnit = 'pct'
			updateExposureText()
		self.uiExposureInPercent.clicked.connect(
			uiExposureInPercentClicked )
		
		
		#Exposure Slider - copied from the original main.py.
		self.uiExposureSlider.debounce.sliderMoved.connect(onExposureSliderMoved)
		self.uiExposureSlider.touchMargins = lambda: {
			"top": 10, "left": 10, "bottom": 10, "right": 10
		}
		
		
		
		
		
		#Resolution
		resolutionTemplate = self.uiResolution.text()
		
		hRes = 0
		vRes = 0
		fps = 0
		
		def updateResolutionText():
			self.uiResolution.setText(
				resolutionTemplate.format(
					hRes=hRes, vRes=vRes, fps=fps ) )
		
		def updateFps(framePeriodNs):
			nonlocal fps
			fps = 1e9 / framePeriodNs
			updateResolutionText()
		api.observe('framePeriod', updateFps)
		
		def updateResolution(resolution):
			nonlocal hRes, vRes
			hRes = resolution['hRes']
			vRes = resolution['vRes']
			updateResolutionText()
		api.observe('resolution', updateResolution)
		
		self.uiResolution.clicked.connect(lambda:
			window.show('recording_settings') )
		
		
		#Menu
		self.uiMenuDropdown.hide()
		self.uiMenuDropdown.move(
			self.uiMenuButton.x(),
			self.uiMenuButton.y() + self.uiMenuButton.height() -  self.uiMenuButton.touchMargins()['bottom'] - self.uiMenuFilter.touchMargins()['top'] - 1, #-1 to merge margins.
		)
		self.uiMenuButton.clicked.connect((lambda:
			hideMenu() 
			if self.uiMenuDropdown.isVisible() else
			showMenu(self.uiMenuButton, self.uiMenuDropdown)
		))
		
		#Loop main menu focus, for the jog wheel.
		self.uiMenuScroll.nextInFocusChain = (lambda *_: #DDR 2019-10-21: This doesn't work, and seems to break end-of-scroll progression in the menu scroll along the way.
			self.uiMenuButton
			if self.uiMenuDropdown.isVisible() else
			type(self.uiMenuScroll).nextInFocusChain(self.uiMenuScroll, *_)
		)
		self.uiMenuFilter.previousInFocusChain = (lambda *_: 
			self.uiMenuButton
			if self.uiMenuDropdown.isVisible() else
			type(self.uiMenuFilter).previousInFocusChain(self.uiMenuFilter, *_)
		)
		self.uiMenuButton.nextInFocusChain = (lambda *_:
			self.uiMenuFilter
			if self.uiMenuDropdown.isVisible() else
			type(self.uiMenuButton).nextInFocusChain(self.uiMenuButton, *_)
		)
		self.uiMenuButton.previousInFocusChain = (lambda *_:
			self.uiMenuScroll
			if self.uiMenuDropdown.isVisible() else
			type(self.uiMenuButton).previousInFocusChain(self.uiMenuButton, *_)
		)
		
		# Populate uiMenuScroll from actions.
		# Generally, anything which has a button on the main screen will be
		# hidden in this menu, which means it won't come up unless we search
		# for it. This should -- hopefully -- keep the clutter down without
		# being confusing.
		_whiteBalAvail = api.apiValues.get('sensorColorPattern') == 'mono' #Black and white models of the Chronos do not have colour to balance, so don't show that screen ever.
		_scriptsHidden = not [f for f in iglob('/var/camera/scripts/*')][:1] #Only show the scripts screen if there will be a script on it to run.
		log.debug(f'_scriptsHidden {_scriptsHidden}')
		main_menu_items = [
			{'name':"About Camera",          'open':lambda: window.show('about_camera'),          'hidden': False,           'synonyms':"kickstarter thanks name credits"},
			{'name':"App & Internet",        'open':lambda: window.show('remote_access'),         'hidden': False,           'synonyms':"remote access web client network control api"},
			{'name':"Battery & Power",       'open':lambda: window.show('power'),                 'hidden': True,            'synonyms':"charge wake turn off power down"},
			{'name':"Camera Settings",       'open':lambda: window.show('user_settings'),         'hidden': False,           'synonyms':"user operator save settings"},
			{'name':"Custom Scripts",        'open':lambda: window.show('scripts'),               'hidden': _scriptsHidden,  'synonyms':"scripting bash python"},
			{'name':"Factory Utilities",     'open':lambda: window.show('service_screen.locked'), 'hidden': False,           'synonyms':"utils"},
			{'name':"Format Storage",        'open':lambda: window.show('storage'),               'hidden': True,            'synonyms':"file saving save media df mounts mounted devices thumb drive ssd sd card usb stick filesystem reformat"},
			{'name':"Interface Options",     'open':lambda: window.show('primary_settings'),      'hidden': False,           'synonyms':"rotate rotation screen set time set date"},
			{'name':"Play & Save Recording", 'open':lambda: window.show('play_and_save'),         'hidden': True,            'synonyms':"mark region saving"},
			{'name':"Record Mode",           'open':lambda: window.show('record_mode'),           'hidden': False,           'synonyms':"segmented run n gun normal"},
			{'name':"Recording Settings",    'open':lambda: window.show('recording_settings'),    'hidden': True,            'synonyms':"resolution framerate offset gain boost brightness exposure"},
			{'name':"Review Saved Videos",   'open':lambda: window.show('replay'),                'hidden': False,           'synonyms':"playback show footage saved card movie replay"},
			#{'name':"Stamp Overlay",         'open':lambda: window.show('stamp'),                 'hidden': False,           'synonyms':"watermark"},
			#{'name':"Trigger Delay",        'open':lambda: window.show('trigger_delay'),         'hidden': False,           'synonyms':"wait"}, #Removed because we use the trigger/io delay block now.
			{'name':"Triggers & IO",         'open':lambda: window.show('triggers_and_io'),       'hidden': False,           'synonyms':"bnc green ~a1 ~a2 trig1 trig2 trig3 signal input output trigger delay gpio"},
			{'name':"Update Camera",         'open':lambda: window.show('update_firmware'),       'hidden': False,           'synonyms':"firmware"},
			{'name':"Video Save Settings",   'open':lambda: window.show('file_settings'),         'hidden': True,            'synonyms':"file saving"},
		]
		if(_whiteBalAvail):
			main_menu_items += [
				{'name':"Color",             'open':lambda: window.show('white_balance'),         'hidden': True,            'synonyms':"matrix colour white balance temperature"},
			]
		
		
		menuScrollModel = QStandardItemModel(
			len(main_menu_items), 1, self.uiMenuScroll )
		for i in range(len(main_menu_items)):
			menuScrollModel.setItemData(menuScrollModel.index(i, 0), {
				Qt.DisplayRole: main_menu_items[i]['name'],
				Qt.UserRole: main_menu_items[i],
				Qt.DecorationRole: None, #Icon would go here.
			})
		self.uiMenuScroll.setModel(menuScrollModel)
		self.uiMenuScroll.clicked.connect(self.showOptionOnTap)
		self.uiMenuScroll.jogWheelClick.connect(self.showOptionOnJogWheelClick)
		
		self.uiMenuFilterIcon.setAttribute(Qt.WA_TransparentForMouseEvents) #Allow clicking on the filter icon, 🔎, to filter.
		self.uiMenuFilter.textChanged.connect(self.filterMenu)
		self.filterMenu()
		
		self.uiMenuFilterX.clicked.connect(self.uiMenuFilter.clear)
		
		
		#Battery
		self._batteryCharge   = 1
		self._batteryCharging = 0
		self._batteryPresent  = 0
		self._batteryBlink = False
		self._theme = 'light'
		
		self._batteryTemplate = self.uiBattery.text()
		
		self._batteryPollTimer = QtCore.QTimer()
		self._batteryPollTimer.timeout.connect(self.updateBatteryCharge)
		self._batteryPollTimer.setTimerType(QtCore.Qt.VeryCoarseTimer) #Infrequent, wake as little as possible.
		self._batteryPollTimer.setInterval(3600)
		
		self._batteryBlinkTimer = QtCore.QTimer()
		self._batteryBlinkTimer.timeout.connect(lambda: (
			setattr(self, '_batteryBlink', not self._batteryBlink),
			self.updateBatteryIcon(),
		))
		self._batteryBlinkTimer.setInterval(500) #We display percentages. We update in tenth-percentage increments.
		
		self.uiBattery.clicked.connect(lambda: window.show('power'))
		
		self.uiBatteryIcon.setAttribute(Qt.WA_TransparentForMouseEvents)
		self.uiBatteryIcon.setStyleSheet('')
		api.observe('externalPower', lambda state: (
			setattr(self, '_batteryCharging', state),
			state and (
				self._batteryBlinkTimer.stop(),
				setattr(self, '_batteryBlink', False),
			),
			self.updateBatteryIcon(),
		) )
		api.observe('batteryPresent', lambda state: (
			setattr(self, '_batteryPresent', state),
			state and (
				self._batteryBlinkTimer.stop(),
				setattr(self, '_batteryBlink', False),
			),
			self.updateBatteryIcon(),
		) )
		def uiBatteryIconPaintEvent(evt, rectSize=24):
			"""Draw the little coloured square on the focus peaking button."""
			if self._batteryPresent and (self._batteryCharging or not self._batteryBlink):
				powerDownLevel = api.apiValues.get('powerOffWhenMainsLost') * self.uiPowerDownThreshold
				warningLevel = powerDownLevel + 0.15
				
				x,y,w,h = (
					1,
					1,
					self.uiBatteryIcon.width() - 2,
					self.uiBatteryIcon.height() - 1,
				)
				
				p = QPainter(self.uiBatteryIcon)
				
				#Cut out the battery outline, so the battery fill level doesn't show by
				#outside the "nub". Nextly, this was taken care of by an opaque box
				#outside the battery nub in the SVG image, but this didn't work so well
				#when the button was pressed or when themes were changed. We can't fill
				#a polygon a percentage of the way very easily, and we can't just go in
				#and muck with the SVG to achieve this either like we would in browser.
				batteryOutline = QPainterPath()
				batteryOutline.addPolygon(QPolygonF([
					QPoint(x+3,y),
					QPoint(x+3,y+2), #Left battery nub chunk.
					QPoint(x,y+2),
					QPoint(x,y+h), #Bottom
					QPoint(x+w,y+h),
					QPoint(x+w,y+2),
					QPoint(x+w-3,y+2), #Right battery nub chunk.
					QPoint(x+w-3,y),
				]))
				batteryOutline.closeSubpath() #Top of battery nub.
				p.setClipPath(batteryOutline, Qt.IntersectClip)
				
				p.setPen(QPen(QColor('transparent')))
				
				if self._batteryCharge > warningLevel or self._batteryCharging:
					p.setBrush(QBrush(QColor('#00b800')))
				else:
					p.setBrush(QBrush(QColor('#f20000')))
				p.drawRect(
					x, y + h * (1-self._batteryCharge),
					w, h * self._batteryCharge )
			type(self.uiBatteryIcon).paintEvent(self.uiBatteryIcon, evt) #Invoke the superclass to paint the battery overlay image on our new rect.
		self.uiBatteryIcon.paintEvent = uiBatteryIconPaintEvent
		
		
		#Record / stop
		self.uiRecordTemplateWithTime = self.uiRecord.text()
		self.uiRecordTemplateNoTime = self.uiRecordTemplateWithTime.split('\n')[0][2:]
		self.uiRecord.clicked.connect(self.toggleRecording)
		
		def uiRecordPaintEventRecord(evt, iconSize=24, offsetX=32):
			midpoint = self.uiRecord.geometry().size()/2 - QSize(0, self.uiRecord.touchMargins()['bottom']/2)
			p = QPainter(self.uiRecord)
			p.setPen(QPen(QColor('#000000')))
			p.setBrush(QBrush(QColor('#f20000')))
			p.setRenderHint(QPainter.Antialiasing, True)
			p.drawChord( #xy/wh
				midpoint.width()-iconSize/2-offsetX, midpoint.height()-iconSize/2,
				iconSize, iconSize,
				0, 16*360, #start, end angle
			)
		def uiRecordPaintEventPause(evt, iconSize=20, offsetX=24):
			midpoint = self.uiRecord.geometry().size()/2 - QSize(0, self.uiRecord.touchMargins()['bottom']/2)
			p = QPainter(self.uiRecord)
			p.setPen(QPen(QColor('#ffffff')))
			p.setBrush(QBrush(QColor('#000000')))
			p.drawRect( #xy/wh
				midpoint.width()-iconSize/2-offsetX, midpoint.height()-iconSize/2,
				iconSize/3, iconSize,
			)
			p.drawRect( #xy/wh
				midpoint.width()-iconSize/2-offsetX+iconSize/3*2, midpoint.height()-iconSize/2,
				iconSize/3, iconSize,
			)
		def uiRecordPaintEventStop(evt, iconSize=20, offsetX=24):
			midpoint = self.uiRecord.geometry().size()/2 - QSize(0, self.uiRecord.touchMargins()['bottom']/2)
			p = QPainter(self.uiRecord)
			p.setPen(QPen(QColor('#ffffff')))
			p.setBrush(QBrush(QColor('#000000')))
			p.drawRect( #xy/wh
				midpoint.width()-iconSize/2-offsetX, midpoint.height()-iconSize/2,
				iconSize, iconSize,
			)
			self.uiRecord.setText( #Do the timer.
				self.uiRecordTemplateWithTime.format(
					state="Stop",
					timeRecorded=(recordingEndTime or time()) - recordingStartTime,
				)
			)
		def uiRecordPaintEvent(evt):
			type(self.uiRecord).paintEvent(self.uiRecord, evt)
			#TODO DDR 2019-10-07: Add pause icon, when we are able to pause recording and resume again, for run 'n' gun mode.
			if self.uiRecord.isRecording:
				uiRecordPaintEventRecord(evt)
			else:
				uiRecordPaintEventStop(evt)
		self.uiRecord.paintEvent = uiRecordPaintEvent
		
		api.observe('state', self.onStateChange)
		
		#Play & save
		#TODO DDR 2019-09-27 fill in play and save
		uiPlayAndSaveTemplate = self.uiPlayAndSave.text()
		self.uiPlayAndSave.setText("Play && Save\n-1s RAM\n-1s Avail.")
		
		playAndSaveData = self.control.getSync(['cameraMaxFrames', 'frameRate', 'recSegments'])
		def updatePlayAndSaveText(*_):
			data = playAndSaveData
			segmentMaxRecTime = data['cameraMaxFrames'] / data['frameRate'] / data['recSegments']
			segmentCurrentRecTime = min(
				segmentMaxRecTime, 
				(recordingEndTime or time()) - recordingStartTime
			)
			self.uiPlayAndSave.setText(
				uiPlayAndSaveTemplate.format(
					ramUsed=segmentCurrentRecTime, ramTotal=segmentMaxRecTime ) )
		updatePlayAndSaveText()
			
		def updatePlayAndSaveDataMaxFrames(value):
			playAndSaveData['cameraMaxFrames'] = value
		api.observe_future_only('cameraMaxFrames', updatePlayAndSaveDataMaxFrames)
		api.observe_future_only('cameraMaxFrames', updatePlayAndSaveText)
		
		def updatePlayAndSaveDataFrameRate(_):
			playAndSaveData['frameRate'] = self.control.getSync('frameRate')
		api.observe_future_only('framePeriod', updatePlayAndSaveDataFrameRate)
		api.observe_future_only('framePeriod', updatePlayAndSaveText)
		
		def updatePlayAndSaveDataRecSegments(value):
			playAndSaveData['recSegments'] = value
		api.observe_future_only('recSegments', updatePlayAndSaveDataRecSegments)
		api.observe_future_only('recSegments', updatePlayAndSaveText)
		
		def uiPlayAndSaveDraw(evt):
			type(self.uiPlayAndSave).paintEvent(self.uiPlayAndSave, evt)
			updatePlayAndSaveText() #Gotta schedule updates like this, because using a timer clogs the event pipeline full of repaints and updates wind up being extremely slow.
		self.uiPlayAndSave.paintEvent = uiPlayAndSaveDraw
		
		self.uiPlayAndSave.clicked.connect(lambda:
			window.show('play_and_save')) #This should prompt to record if no footage is recorded, and explain itself.
		
		
		#Storage media
		uiExternalMediaTemplate = self.uiExternalMedia.text()
		externalMediaUUID = ''
		externalMediaRatioFull = -1 #Don't draw the bar if negative.
		
		def updateExternalMediaPercentFull(percent):
			nonlocal externalMediaRatioFull
			if externalMediaRatioFull != percent:
				externalMediaRatioFull = percent
				self.uiExternalMedia.update()
		
		
		self.uiExternalMedia.setText("No Save\nMedia Found") #Without this, there is an unavoidable FOUC unless we call df sychronously. So we lie and just don't detect it at first. 😬
		def updateExternalMediaText():
			"""Update the external media text. This will called every few seconds to update the %-free display. Also repaints %-bar."""
			partitions = ([
				partition
				for partition in api.externalPartitions.list()
				if partition['uuid'] == externalMediaUUID
			] or api.externalPartitions.list())[:1]
			if not partitions:
				updateExternalMediaPercentFull(-1)
				self.uiExternalMedia.setText(
					"No Save\nMedia Found" )
			else:
				partition = partitions[0]
				def updateExternalMediaTextCallback(space):
					saved = estimateFile.duration(space['used'] * 1000)
					total = estimateFile.duration(space['total'] * 1000)
					updateExternalMediaPercentFull(space['used']/space['total']),
					
					self.uiExternalMedia.setText(
						uiExternalMediaTemplate.format(
							externalStorageIdentifier = partition['name'] or f"{round(partition['size'] / 1e9):1.0f}GB Storage Media",
							percentFull = round(space['used']/space['total'] * 100),
							footageSavedDuration = '-1', #TODO: Calculate bits per second recorded and apply it here to the partition usage and total.
							hoursSaved = saved.days*24 + saved.seconds/60/60,
							minutesSaved = (saved.seconds/60) % 60,
							secondsSaved = saved.seconds % 60,
							hoursTotal = total.days*24 + total.seconds/60/60,
							minutesTotal = (total.seconds/60) % 60,
							secondsTotal = total.seconds % 60,
						)
					)
				api.externalPartitions.usageFor(partition['device'], 
					updateExternalMediaTextCallback )
		self.updateExternalMediaText = updateExternalMediaText #oops, assign this to self so we can pre-call the timer on show.
		
		def updateExternalMediaUUID(uuid):
			self.externalMediaUUID = uuid
			updateExternalMediaText()
		settings.observe('preferredFileSavingUUID', '', updateExternalMediaUUID)
		
		api.externalPartitions.observe(lambda partitions:
			updateExternalMediaText() )
		
		self._externalMediaUsagePollTimer = QtCore.QTimer()
		self._externalMediaUsagePollTimer.timeout.connect(updateExternalMediaText)
		self._externalMediaUsagePollTimer.setTimerType(QtCore.Qt.VeryCoarseTimer) #Infrequent, wake as little as possible.
		self._externalMediaUsagePollTimer.setInterval(15000) #This should almost never be needed, it just covers if another program is writing or deleting something from the disk.
		
		def uiExternalMediaPaintEvent(evt, meterPaddingX=15, meterOffsetY=2, meterHeight=10):
			"""Draw the disk usage bar on the external media button."""
			type(self.uiExternalMedia).paintEvent(self.uiExternalMedia, evt) #Invoke the superclass to - hopefully - paint the rest of the button before we deface it with our square.
			if externalMediaRatioFull == -1:
				return
			
			midpoint = self.uiExternalMedia.geometry().size()/2 + QSize(0, self.uiExternalMedia.touchMargins()['top']/2)
			type(self.uiExternalMedia).paintEvent(self.uiExternalMedia, evt) #Invoke the superclass to - hopefully - paint the rest of the button before we deface it with our square.
			p = QPainter(self.uiExternalMedia)
			p.fillRect( #xywh
				meterPaddingX,
				midpoint.height() + meterOffsetY,
				#TODO DDR 2019-09-27: When we know how long the current recorded clip is, in terms of external media capacity, add it as a white rectangle here.
				0, #(midpoint.width() - meterPaddingX) * 2 * externalMediaRatioFull + ???,
				meterHeight,
				QColor('white')
			)
			p.fillRect( #xywh
				meterPaddingX,
				midpoint.height() + meterOffsetY,
				(midpoint.width() - meterPaddingX) * 2 * externalMediaRatioFull,
				meterHeight,
				QColor('#00b800')
			)
			p.setPen(QPen(QColor('black')))
			p.setBrush(QBrush(QColor('transparent')))
			p.drawRect( #xywh
				meterPaddingX,
				midpoint.height() + meterOffsetY,
				(midpoint.width() - meterPaddingX) * 2,
				meterHeight
			)
		self.uiExternalMedia.paintEvent = uiExternalMediaPaintEvent
		
		self.uiExternalMedia.clicked.connect(lambda:
			window.show('file_settings') )
Пример #14
0
# -*- coding: future_fstrings -*-
"""Speed test PyQt5's D-Bus implementation."""

from PyQt5 import QtWidgets
from debugger import *; dbg
import chronosGui2.api as api
import time

TEST_ITERATIONS = 100



app = QtWidgets.QApplication(sys.argv)

t1 = time.perf_counter()
controlAPI = api.control()
print('test 1: simple calls to control api')
for x in range(TEST_ITERATIONS):
	controlAPI.callSync('get', ['batteryVoltage'])
	print('.', end='', flush=True)
print(f"""
Time: {time.perf_counter()-t1}s total, {(time.perf_counter()-t1)/TEST_ITERATIONS*1000}ms per call.
""")

t2 = time.perf_counter()
print('test 2: async calls to control api')
counter = 0
def configureValue(*_):
	global counter
	global t3
	counter += 1
Пример #15
0
	def __init__(self, window):
		super().__init__()
		self.setupUi(self)
		self.video = api.video()
		self.control = api.control()
		self._window = window
		
		self.recordedSegments = []
		self.totalRecordedFrames = 0
		
		self.videoState = None
		self.regionBeingSaved = None
		
		self.saveCancelled = False
		
		#Use get and set marked regions, they redraw.
		self.markedRegions = [
			{'region id': 'aaaaaaaa', 'hue': 240, 'mark end': 19900, 'mark start': 13002, 'saved': 0.0, 'highlight': 0, 'segment ids': ['KxIjG09V'], 'region name': 'Clip 1'},
			{'region id': 'aaaaaaab', 'hue': 300, 'mark end': 41797, 'mark start': 40597, 'saved': 0.0, 'highlight': 0, 'segment ids': ['KxIjG09V'], 'region name': 'Clip 2'},
			{'region id': 'aaaaaaac', 'hue': 420, 'mark end': 43897, 'mark start': 41797, 'saved': 0.0, 'highlight': 0, 'segment ids': ['KxIjG09V'], 'region name': 'Clip 3'},
			{'region id': 'aaaaaaad', 'hue': 180, 'mark end': 53599, 'mark start': 52699, 'saved': 0.0, 'highlight': 0, 'segment ids': ['KxIjG09V'], 'region name': 'Clip 4'},
			{'region id': 'aaaaaaae', 'hue': 360, 'mark end': 52699, 'mark start': 51799, 'saved': 0.0, 'highlight': 0, 'segment ids': ['KxIjG09V'], 'region name': 'Clip 5'},
			{'region id': 'aaaaaaaf', 'hue': 210, 'mark end': 80000, 'mark start': 35290, 'saved': 0.0, 'highlight': 0, 'segment ids': ['KxIjG09V'], 'region name': 'Clip 6'},
			{'region id': 'aaaaaaag', 'hue': 390, 'mark end': 42587, 'mark start': 16716, 'saved': 0.0, 'highlight': 0, 'segment ids': ['KxIjG09V'], 'region name': 'Clip 7'},
			{'region id': 'aaaaaaah', 'hue': 270, 'mark end': 25075, 'mark start': 17016, 'saved': 0.0, 'highlight': 0, 'segment ids': ['KxIjG09V'], 'region name': 'Clip 8'},
			{'region id': 'aaaaaaai', 'hue': 330, 'mark end': 36617, 'mark start': 28259, 'saved': 0.0, 'highlight': 0, 'segment ids': ['KxIjG09V'], 'region name': 'Clip 9'},
			{'region id': 'aaaaaaaj', 'hue': 240, 'mark end': 39005, 'mark start': 32637, 'saved': 0.0, 'highlight': 0, 'segment ids': ['KxIjG09V'], 'region name': 'Clip 10'},
			{'region id': 'aaaaaaak', 'hue': 300, 'mark end': 39668, 'mark start': 36219, 'saved': 0.0, 'highlight': 0, 'segment ids': ['KxIjG09V'], 'region name': 'Clip 11'},
			{'region id': 'aaaaaaal', 'hue': 420, 'mark end': 39068, 'mark start': 37868, 'saved': 0.0, 'highlight': 0, 'segment ids': ['KxIjG09V'], 'region name': 'Clip 12'},
			{'region id': 'aaaaaaam', 'hue': 180, 'mark end': 13930, 'mark start': 0,     'saved': 0.0, 'highlight': 0, 'segment ids': ['ldPxTT5R', 'KxIjG09V'], 'region name': 'Clip 13'},
		]
		self.markedRegions = [
			{'region id': 'aaaaaaaa', 'hue': 240, 'mark end': 199, 'mark start': 130, 'saved': 0.0, 'highlight': 0, 'segment ids': ['KxIjG09V'], 'region name': 'Clip 1'},
			{'region id': 'aaaaaaab', 'hue': 300, 'mark end': 417, 'mark start': 105, 'saved': 0.0, 'highlight': 0, 'segment ids': ['KxIjG09V'], 'region name': 'Clip 2'},
		]
		self.markedRegions = []
		self.markedStart = None #Note: Mark start/end are reversed if start is after end.
		self.markedEnd = None
		
		# Panel init.
		self.setFixedSize(window.app.primaryScreen().virtualSize())
		self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
		self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
		
		#Put the video here.
		self.videoArea = self.uiVideoArea.geometry()
		self.uiVideoArea.deleteLater() #Don't need this any more!
		
		self.uiBatteryReadout.anchorPoint = self.uiBatteryReadout.rect()
		self.uiBatteryReadout.formatString = self.uiBatteryReadout.text()
		self.uiBatteryReadout.clicked.connect(lambda: window.show('power'))
		self.updateBatteryTimer = QtCore.QTimer()
		self.updateBatteryTimer.timeout.connect(self.updateBattery)
		self.updateBatteryTimer.setInterval(2000) #ms
		
		self.labelUpdateIdleDelayTimer = QtCore.QTimer() #Used to skip calling the api alltogether when seeking.
		self.labelUpdateIdleDelayTimer.setInterval(32)
		self.labelUpdateIdleDelayTimer.setSingleShot(True)
		self.labelUpdateTimer = QtCore.QTimer()
		self.labelUpdateTimer.setInterval(32) #ms, cap at 60fps. (Technically this is just a penalty, we need to *race* the timer and the dbus call but we can't easily do that because we need something like .~*Promise.all()*~. for that and it's a bit of a pain in the neck to construct right now.)
		self.labelUpdateTimer.setSingleShot(True) #Start the timer again after the update.
		lastKnownFrame = -1
		lastKnownFilesaveStatus = False
		iteration = 0
		noLoopUpdateCounter = 0 #When < 0, don't update slider to avoid the following issue: 1) Slider is updated. 2) D-Bus message is sent. 3) Slider is updated several times more. 4) D-Bus message returns and updates slider. 5) Slider is updated from old position, producing a jerk or a jump.
		def checkLastKnownFrame(status=None):
			nonlocal iteration
			nonlocal lastKnownFrame
			nonlocal lastKnownFilesaveStatus
			nonlocal noLoopUpdateCounter
			
			if not self.isVisible(): #Stop updates if screen has been exited.
				return
			
			iteration += 1
			noLoopUpdateCounter += 1
			#log.debug(f'iteration {iteration} (f{lastKnownFrame}, {lastKnownFilesaveStatus})')
			#log.debug(f'loop {noLoopUpdateCounter}')
			
			if status and self.videoState in ('play', 'filesave'):
				if status['position'] != lastKnownFrame:
					lastKnownFrame = status['position']
					self.uiCurrentFrame.setValue(lastKnownFrame)
					if noLoopUpdateCounter > 0:
						self.uiSeekSlider.blockSignals(True)
						self.uiSeekSlider.setValue(lastKnownFrame)
						self.uiSeekSlider.blockSignals(False)
				
				if status['filesave'] != lastKnownFilesaveStatus:
					lastKnownFilesaveStatus = status['filesave']
					if not lastKnownFilesaveStatus:
						#Restore the seek rate display to the manual play rate.
						self.uiSeekRate.setValue(self.seekRate)
				
				if self.videoState == 'filesave':
					region = [r for r in self.markedRegions if r['region id'] == self.regionBeingSaved][:1]
					if region: #Protect against resets in the middle of saving.
						region = region[0]
						region['saved'] = max( #We seem to get a few frames where we're incorrectly positioned before ending. Disallow negative progress.
							region['saved'],
							(lastKnownFrame - region['mark start']) / (region['mark end'] - region['mark start'])
						)
				
				#Set the seek rate counter back to what the camera operator set it to.
				if lastKnownFilesaveStatus:
					self.uiSeekRate.setValue(status['framerate'])
			
			#Loop after a short timeout, if the screen is still visible.
			if noLoopUpdateCounter > 0:
				self.labelUpdateTimer.start()
			else:
				self.labelUpdateIdleDelayTimer.start()
		self.labelUpdateIdleDelayTimer.timeout.connect(checkLastKnownFrame)
		self.labelUpdateTimer.timeout.connect(lambda: #Now, the timer is not running, so we can't just stop it to stop this process. We may be waiting on the dbus call instead.
			self.video.call('status').then(checkLastKnownFrame) )
		
		self.uiCurrentFrame.suffixFormatString = self.uiCurrentFrame.suffix()
		self.uiCurrentFrame.valueChanged.connect(lambda f: 
			self.uiCurrentFrame.hasFocus() and self.video.call('playback', {'position':f}) )
		
		self.seekRate = 60
		self.uiSeekRate.setValue(self.seekRate)
		
		self.uiSeekBackward.pressed.connect( lambda: self.video.call('playback', {'framerate': -self.seekRate}))
		self.uiSeekBackward.released.connect(lambda: self.video.call('playback', {'framerate': 0}))
		self.uiSeekForward.pressed.connect(  lambda: self.video.call('playback', {'framerate': +self.seekRate}))
		self.uiSeekForward.released.connect( lambda: self.video.call('playback', {'framerate': 0}))
		
		self.uiSeekFaster.clicked.connect(self.seekFaster)
		self.uiSeekSlower.clicked.connect(self.seekSlower)
		
		self.uiMarkStart.clicked.connect(self.markStart)
		self.uiMarkEnd.clicked.connect(self.markEnd)
		
		self.uiSave.clicked.connect(self.onSaveClicked)
		self.uiSaveCancel.clicked.connect(self.cancelSave)
		self.uiSaveCancel.hide()
		
		self.uiSavedFileSettings.clicked.connect(lambda: window.show('file_settings'))
		self.uiDone.clicked.connect(window.back)
		
		self.uiSeekSliderBaseStyleSheet = self.uiSeekSlider.styleSheet()
		settings.observe('theme', 'dark', lambda name:
			self.uiSeekSlider.setStyleSheet(
				self.uiSeekSliderBaseStyleSheet + f"""
					/* ----- Play And Save Screen Styling ----- */
					
					
					Slider::handle:horizontal {{
						image: url(:/assets/images/{theme(name).slider.videoSeekHandle}); /* File name fields: width x height + horizontal padding. */
						margin: -200px -40px; /* y: -slider groove margin. x: touch padding outsidet the groove. Clipped by Slider width. Should be enough for most customizations if we move stuff around. */
					}}
					
					Slider::groove {{
						border: none;
						background-color: none;
					}}
				"""
			)
		)
		#Heatmap got delayed. Don't report different size/touchMargins for heatmap styling.
		self.uiSeekSlider.sliderSize = lambda: QtCore.QSize(156, 61) #Line up focus ring.
		#self.uiSeekSlider.touchMargins = lambda: { "top": 10, "left": 10, "bottom": 10, "right": 10, } #Report real margins.
		#self.uiSeekSlider.focusGeometryNudge = (0,0,0,0)
		self.uiSeekSlider.touchMargins = lambda: { "top": 10, "left": 0, "bottom": 10, "right": 0, } #Report real margins.
		self.uiSeekSlider.debounce.sliderMoved.connect(lambda frame: 
			self.video.callSync('playback', {'position': frame}) )
		self.uiSeekSlider.debounce.sliderMoved.connect(lambda frame: 
			self.uiCurrentFrame.setValue(frame) )
		#last_perf = perf_counter()
		#def countPerfs(*_):
		#	nonlocal last_perf
		#	log.debug(f'update took {(perf_counter() - last_perf)*1000}ms')
		#	last_perf = perf_counter()
		#self.uiSeekSlider.debounce.sliderMoved.connect(countPerfs)
		def updateNoLoopUpdateCounter(*_):
			nonlocal noLoopUpdateCounter
			noLoopUpdateCounter = -10 #Delay updating until the d-bus call has had a chance to return.
		self.uiSeekSlider.debounce.sliderMoved.connect(updateNoLoopUpdateCounter)
		
		self.motionHeatmap = QImage() #Updated by updateMotionHeatmap, used by self.paintMotionHeatmap.
		self.uiTimelineVisualization.paintEvent = self.paintMotionHeatmap
		self.uiTimelineVisualization.hide() #Heatmap got delayed. Hide for now, some logic still depends on it.
		
		#Set up for marked regions.
		self._tracks = [] #Used as cache for updateMarkedRegions / paintMarkedRegions.
		self.uiEditMarkedRegions.formatString = self.uiEditMarkedRegions.text()
		self.uiMarkedRegionVisualization.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, True)
		self.uiMarkedRegionVisualization.paintEvent = self.paintMarkedRegions
		self.regionsListModel = QStandardItemModel(parent=self.uiMarkedRegions)
		self.uiMarkedRegions.setModel(self.regionsListModel)
		self.regionsListModel.rowsRemoved.connect(self.regionListElementDeleted)
		self.regionsListModel.dataChanged.connect(self.regionListElementChanged)
		self.updateMarkedRegions()
		
		self.markedRegionMenu = MenuToggle(
			menu = self.uiMarkedRegionsPanel,
			button = self.uiEditMarkedRegions,
			focusTarget = self.uiMarkedRegions,
			xRange = (-self.uiMarkedRegionsPanel.width(), -1),
			duration = 30,
		)
		#delay(self, 1, self.markedRegionMenu.toggle) #mmm, just like a crappy javascript app - work around a mysterious black bar appearing on the right-hand side of the window.
		#This doesn't work, never fires.
		#self.uiMarkedRegionsPanel.focusInEvent = self.uiMarkedRegions.setFocus
		
		self.uiMarkedRegionsPanelHeader.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
		self.uiMarkedRegionsPanelHeaderX.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
		self.uiMarkedRegionPanelClose.clicked.connect(self.markedRegionMenu.forceHide)
		self.uiMarkedRegions.setItemDelegate(EditMarkedRegionsItemDelegate())
		self.lastSelectedRegion = None
		self.uiMarkedRegions.clicked.connect(self.selectMarkedRegion)
		
		api.observe('videoState', self.onVideoStateChangeAlways)
		api.observe('state', self.onStateChangeAlways)
		
		api.signal.observe('sof', self.onSOF)
		api.signal.observe('eof', self.onEOF)
Пример #16
0
	def __init__(self, window):
		super().__init__()
		self.setupUi(self)
		
		# Panel init.
		self.setFixedSize(window.app.primaryScreen().virtualSize())
		self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
		self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
		
		# API init.
		self.control = api.control()
		self.video = api.video()
		
		# State init, screen loads in superposition.
		self.markStateClean()
		#Only needed when trigger for action is set to Never, because the index changed event will never fire then and we'll be stuck with whatever index was set in the .ui file. Ideally, this would not be set in the .ui file in the first place, but realistically since it's set when we change "panels" with the little arrows at the top of the pane we're not gonna remember to clear it every time in the property editor and it'll just be a stupid recurring bug. So fix it here.
		self.uiIndividualTriggerConfigurationPanes.setCurrentIndex(0) 
		
		self.load(actions=actionData, triggers=triggerData)
		
		self.oldIOMapping = defaultdict(lambda: defaultdict(lambda: None)) #Set part and parcel to whatever the most recent mapping is.
		self.newIOMapping = defaultdict(lambda: defaultdict(lambda: None)) #Set piece-by-piece to the new mapping.
		api.observe('ioMapping', self.onNewIOMapping)
		
		
		self.uiActionList.selectionModel().selectionChanged.connect(
			self.onActionChanged)
		self.uiTriggerList.currentIndexChanged.connect(lambda index:
			self.uiIndividualTriggerConfigurationPanes.setCurrentIndex(
				self.uiTriggerList.itemData(index) ) )
		self.uiActionList.selectionModel().setCurrentIndex(
			self.uiActionList.model().index(0,0),
			QItemSelectionModel.ClearAndSelect )
		self.uiActionList.selectionModel().selectionChanged.connect(
			self.uiPreview.update )
		
		self.uiTriggerList.currentIndexChanged.connect(self.onTriggerChanged)
		self.uiTriggerList.currentIndexChanged.connect(self.uiPreview.update)
		self.uiInvertCondition.stateChanged.connect(self.onInvertChanged)
		self.uiDebounce.stateChanged.connect(self.onDebounceChanged)
		
		#When we change an input, mark the current state dirty until we save.
		self.uiTriggerList   .currentIndexChanged.connect(self.markStateDirty)
		self.uiInvertCondition      .stateChanged.connect(self.markStateDirty)
		self.uiDebounce             .stateChanged.connect(self.markStateDirty)
		self.uiIo1ThresholdVoltage  .valueChanged.connect(self.markStateDirty)
		self.uiIo11MAPullup         .stateChanged.connect(self.markStateDirty)
		self.uiIo120MAPullup        .stateChanged.connect(self.markStateDirty)
		self.uiIo2ThresholdVoltage  .valueChanged.connect(self.markStateDirty)
		self.uiIo220MAPullup        .stateChanged.connect(self.markStateDirty)
		#self.uiAudioTriggerDB      .valueChanged.connect(self.markStateDirty)
		#self.uiAudioTriggerPercent .valueChanged.connect(self.markStateDirty)
		#self.uiAudioTriggerDuration.valueChanged.connect(self.markStateDirty)
		self.uiDelayAmount          .valueChanged.connect(self.markStateDirty)
		
		#Set the appropriate value in the newIOMapping when a custom updates.
		self.uiIo1ThresholdVoltage  .valueChanged.connect(self.onIo1ThresholdVoltageChanged)
		self.uiIo11MAPullup         .stateChanged.connect(self.onIo11MAPullupChanged)
		self.uiIo120MAPullup        .stateChanged.connect(self.onIo120MAPullupChanged)
		self.uiIo2ThresholdVoltage  .valueChanged.connect(self.onIo2ThresholdVoltageChanged)
		self.uiIo220MAPullup        .stateChanged.connect(self.onIo220MAPullupChanged)
		#self.uiAudioTriggerDB      .valueChanged.connect(self.onAudioTriggerDBChanged)
		#self.uiAudioTriggerPercent .valueChanged.connect(self.onAudioTriggerPercentChanged)
		#self.uiAudioTriggerDuration.valueChanged.connect(self.onAudioTriggerDurationChanged)
		self.uiDelayAmount          .valueChanged.connect(self.onDelayAmountChanged)
		
		self.uiSave.clicked.connect(self.saveChanges)
		self.uiCancel.clicked.connect(self.resetChanges)
		self.uiCancel.clicked.connect(self.markStateClean)
		self.uiDone.clicked.connect(window.back)
		
		settings.observe('debug controls enabled', False, lambda show:
			self.uiDebug.show() if show else self.uiDebug.hide() )
		self.uiDebug.clicked.connect(self.debug)
		
		#TODO: Add little visualisation showing what connects and what is connected to the current action.
		self.uiPreview.paintEvent = self.paintPreview

		# Live IO status update timer
		self.liveIoTimer = QTimer()
		self.liveIoTimer.timeout.connect(self.updateLiveIoStatus)
		self.liveIoTimer.setInterval(100)
		self.liveIoTimer.start()
Пример #17
0
	def __init__(self, window):
		super().__init__()
		self.setupUi(self)

		# API init.
		self.control = api.control()
		self.video = api.video()
		
		# Panel init.
		self.move(0, 0)
		self.setFixedSize(800, 480) #hide menus, which are defined off-screen to the right
		self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
		self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
		
		self._window = window
		
		self.control.set('cameraTallyMode', 'auto')
		
		#Set the kerning to false because it looks way better.
		#Doesn't seem to be working? --DDR 2019-05-29
		font = self.uiResolutionOverlay.font()
		font.setKerning(False)
		self.uiResolutionOverlay.setFont(font)
		self.uiExposureOverlay.setFont(font)
		self.uiResolutionOverlayTemplate = self.uiResolutionOverlay.text()
		self.uiExposureOverlayTemplate = self.uiExposureOverlay.text()
		
		# Widget behavour.
		self.uiRecord.clicked.connect(self.toggleRecording)
		self.uiRecord.pressed.connect(lambda: self.setVirtualTrigger(True))
		self.uiRecord.released.connect(lambda: self.setVirtualTrigger(False))
		
		api.observe('state', self.onStateChange)
		
		self.uiDebugA.clicked.connect(self.makeFailingCall)
		self.uiDebugB.clicked.connect(lambda: window.show('test'))
		self.uiDebugC.setFocusPolicy(QtCore.Qt.NoFocus) #Break into debugger without loosing focus, so you can debug focus issues.
		self.uiDebugC.clicked.connect(lambda: self and window and dbg()) #"self" is needed here, won't be available otherwise.
		self.uiClose.clicked.connect(QApplication.closeAllWindows)
		
		#Only show the debug controls if enabled in factory settings.
		settings.observe('debug controls enabled', False, lambda show:
			self.uiDebugControls.show() if show else self.uiDebugControls.hide() )
		
		
		self.uiBattery.clicked.connect(lambda: window.show('power'))
		
		self.uiPrefsAndUtils.clicked.connect(lambda: window.show('primary_settings'))
		
		
		closeRecordingAndTriggersMenu = self.linkButtonToMenu(
			self.uiRecordingAndTriggers, 
			self.uiRecordingAndTriggersMenu )
		
		self.uiRecordModes.clicked.connect(closeRecordingAndTriggersMenu)
		self.uiRecordingSettings.clicked.connect(closeRecordingAndTriggersMenu)
		self.uiTriggerDelay.clicked.connect(closeRecordingAndTriggersMenu)
		self.uiTriggerIOSettings.clicked.connect(closeRecordingAndTriggersMenu)
		
		
		closeShotAssistMenu = self.linkButtonToMenu(
			self.uiShotAssist, 
			self.uiShotAssistMenu )
		
		self.uiShotAssist.clicked.connect(closeRecordingAndTriggersMenu)
		
		self.uiShowWhiteClipping.stateChanged.connect(
			lambda state: self.control.set(
				{'zebraLevel': state/200} ) )
		api.observe('zebraLevel', self.updateWhiteClipping)
		
		api.observe('focusPeakingLevel', self.updateFocusPeakingIntensity)
		
		self.uiFocusPeakingIntensity.currentIndexChanged.connect(
			lambda index: self.control.set(
				{'focusPeakingLevel': index/(self.uiFocusPeakingIntensity.count()-1) } ) )
		
		self.uiFocusPeakingIntensity.currentIndexChanged.connect(
			self.uiShotAssistMenu.setFocus )
		
		api.observe('focusPeakingColor', self.updateFocusPeakingColor)
		
		self.uiBlueFocusPeaking.clicked.connect(lambda:
			self.control.set({'focusPeakingColor': 'blue'} ) )
		self.uiPinkFocusPeaking.clicked.connect(lambda:
			self.control.set({'focusPeakingColor': 'magenta'} ) )
		self.uiRedFocusPeaking.clicked.connect(lambda:
			self.control.set({'focusPeakingColor': 'red'} ) )
		self.uiYellowFocusPeaking.clicked.connect(lambda:
			self.control.set({'focusPeakingColor': 'yellow'} ) )
		self.uiGreenFocusPeaking.clicked.connect(lambda:
			self.control.set({'focusPeakingColor': 'green'} ) )
		self.uiCyanFocusPeaking.clicked.connect(lambda:
			self.control.set({'focusPeakingColor': 'cyan'} ) )
		self.uiBlackFocusPeaking.clicked.connect(lambda:
			self.control.set({'focusPeakingColor': 'black'} ) )
		self.uiWhiteFocusPeaking.clicked.connect(lambda:
			self.control.set({'focusPeakingColor': 'white'} ) )
		
		self.uiBlueFocusPeaking.clicked.connect(self.uiShotAssistMenu.setFocus)
		self.uiPinkFocusPeaking.clicked.connect(self.uiShotAssistMenu.setFocus)
		self.uiRedFocusPeaking.clicked.connect(self.uiShotAssistMenu.setFocus)
		self.uiYellowFocusPeaking.clicked.connect(self.uiShotAssistMenu.setFocus)
		self.uiGreenFocusPeaking.clicked.connect(self.uiShotAssistMenu.setFocus)
		self.uiCyanFocusPeaking.clicked.connect(self.uiShotAssistMenu.setFocus)
		
		
		#Twiddle the calibration menu so it shows the right thing. It's pretty context-sensitive - you can't white-balance a black-and-white camera, and you can't do motion trigger calibration when there's no motion trigger set up.
		#I think the sanest approach is to duplicate the button, one for each menu, since opening the menu is pretty complex and I don't want to try dynamically rebind menus.
		if self.control.getSync('sensorColorPattern') == 'mono':
			self.uiCalibration = self.uiCalibrationOrBlackCal
			self.uiBlackCal0 = Button(parent=self.uiCalibrationOrBlackCal.parent())
			self.copyButton(src=self.uiCalibrationOrBlackCal, dest=self.uiBlackCal0)
			self.uiBlackCal0.setText(self.uiBlackCal1.text())
			
			self.closeCalibrationMenu = self.linkButtonToMenu(
				self.uiCalibration, 
				self.uiCalibrationMenu )
			
			self.uiCalibration.clicked.connect(self.closeShotAssistMenu)
			self.uiCalibration.clicked.connect(self.closeRecordingAndTriggersMenu)
			self.uiRecordingAndTriggers.clicked.connect(self.closeCalibrationMenu)
			self.uiShotAssist.clicked.connect(self.closeCalibrationMenu)
			
			#WB is either removed or becomes recalibrate motion trigger in this mode.
			self.uiWhiteBalance1.setText(self.uiRecalibrateMotionTrigger.text())
			self.uiWhiteBalance1.clicked.connect(self.closeCalibrationMenu)
			self.uiWhiteBalance1.clicked.connect(self.closeShotAssistMenu)
			self.uiWhiteBalance1.clicked.connect(self.closeRecordingAndTriggersMenu)
			self.uiWhiteBalance1.clicked.connect(lambda: self.control.call('startAutoWhiteBalance', {}))
			
			self.uiBlackCal0.clicked.connect(self.closeCalibrationMenu)
			self.uiBlackCal0.clicked.connect(lambda: 
				self.control.call('startCalibration', {'blackCal': True}) ) #may time out if already in progress - check state is 'idle' before issuing call!
			self.uiBlackCal1.clicked.connect(self.closeCalibrationMenu)
			self.uiBlackCal1.clicked.connect(lambda: 
				self.control.call('startCalibration', {'blackCal': True}) )
			
			self.updateBaWTriggers()
		else:
			self.uiCalibration1 = self.uiCalibrationOrBlackCal
			self.uiCalibration2 = Button(parent=self.uiCalibrationOrBlackCal.parent())
			self.copyButton(src=self.uiCalibration1, dest=self.uiCalibration2)
			
			self.closeCalibrationMenu1 = self.linkButtonToMenu(
				self.uiCalibration1, 
				self.uiCalibrationMenu )
			self.closeCalibrationMenu2 = self.linkButtonToMenu(
				self.uiCalibration2, 
				self.uiCalibrationMenuWithMotion )
			
			#Calibration either opens the uiCalibrationMenu or the uiCalibrationMenuWithMotion [trigger button].
			self.uiWhiteBalance1.clicked.connect(self.closeCalibrationMenu1)
			self.uiWhiteBalance1.clicked.connect(self.closeCalibrationMenu2)
			self.uiWhiteBalance1.clicked.connect(lambda: self.control.call('startAutoWhiteBalance', {}))
			self.uiWhiteBalance2.clicked.connect(self.closeCalibrationMenu1)
			self.uiWhiteBalance2.clicked.connect(self.closeCalibrationMenu2)
			self.uiWhiteBalance2.clicked.connect(lambda: self.control.call('startAutoWhiteBalance', {}))
			self.uiBlackCal1.clicked.connect(self.closeCalibrationMenu1)
			self.uiBlackCal1.clicked.connect(self.closeCalibrationMenu2)
			self.uiBlackCal1.clicked.connect(lambda: self.control.call('startCalibration', {'blackCal': True}))
			self.uiBlackCal2.clicked.connect(self.closeCalibrationMenu1)
			self.uiBlackCal2.clicked.connect(self.closeCalibrationMenu2)
			self.uiBlackCal2.clicked.connect(lambda: self.control.call('startCalibration', {'blackCal': True}))
			self.uiRecalibrateMotionTrigger.clicked.connect(self.closeCalibrationMenu1)
			self.uiRecalibrateMotionTrigger.clicked.connect(self.closeCalibrationMenu2)
			#self.uiRecalibrateMotionTrigger.clicked.connect(lambda: self.control('takeStillReferenceForMotionTriggering'))
			
			#Close other menus and vice-versa when menu opened.
			self.uiRecordingAndTriggers.clicked.connect(self.closeCalibrationMenu1)
			self.uiRecordingAndTriggers.clicked.connect(self.closeCalibrationMenu2)
			self.uiRecordingAndTriggers.clicked.connect(closeShotAssistMenu)
			self.uiShotAssist.clicked.connect(self.closeCalibrationMenu1)
			self.uiShotAssist.clicked.connect(self.closeCalibrationMenu2)
			self.uiCalibration1.clicked.connect(closeRecordingAndTriggersMenu)
			self.uiCalibration2.clicked.connect(closeRecordingAndTriggersMenu)
			self.uiCalibration1.clicked.connect(closeShotAssistMenu)
			self.uiCalibration2.clicked.connect(closeShotAssistMenu)
			
			#[TODO DDR 2018-09-13] This widget needs to support being clicked on / focussed in on, so it can close all the menus.
			#self.uiPinchToZoomGestureInterceptionPanel.clicked.connect(self.closeCalibrationMenu1)
			#self.uiPinchToZoomGestureInterceptionPanel.clicked.connect(self.closeCalibrationMenu2)
			#self.uiPinchToZoomGestureInterceptionPanel.clicked.connect(closeRecordingAndTriggersMenu)
			#self.uiPinchToZoomGestureInterceptionPanel.clicked.connect(closeShotAssistMenu)
			
			self.updateColorTriggers()
		
		
		self.uiRecordModes.clicked.connect(lambda: window.show('record_mode'))
		self.uiRecordingSettings.clicked.connect(lambda: window.show('recording_settings'))
		self.uiTriggerIOSettings.clicked.connect(lambda: window.show('triggers_and_io'))
		self.uiTriggerDelay.clicked.connect(lambda: window.show('trigger_delay'))
		
		self.uiPlayAndSave.clicked.connect(lambda: window.show('play_and_save'))
		
		# Polling-based updates.
		self.updateBatteryCharge()
		self._batteryChargeUpdateTimer = QtCore.QTimer()
		self._batteryChargeUpdateTimer.timeout.connect(self.updateBatteryCharge)
		self._batteryChargeUpdateTimer.setTimerType(QtCore.Qt.VeryCoarseTimer)
		self._batteryChargeUpdateTimer.setInterval(3600) #We display percentages. We update in tenth-percentage increments.
		
		#Set up exposure slider.
		# This slider is significantly more responsive to mouse than to touch. 🤔
		api.observe('exposureMax', self.updateExposureMax)
		api.observe('exposureMin', self.updateExposureMin)
		api.observe('exposurePeriod', self.updateExposureNs)
		#[TODO DDR 2018-09-13] This valueChanged event is really quite slow, for some reason.
		self.uiExposureSlider.debounce.sliderMoved.connect(self.onExposureSliderMoved)
		self.uiExposureSlider.touchMargins = lambda: {
			"top": 10, "left": 30, "bottom": 10, "right": 30
		}
		self.uiExposureSlider.focusGeometryNudge = (1,1,1,1)
		
		
		
		self._framerate = None
		self._resolution = None
		api.observe('exposurePeriod', lambda ns: 
			setattr(self, '_framerate', self.control.getSync('frameRate')) )
		api.observe('resolution', lambda res: 
			setattr(self, '_resolution', res) )
		api.observe('exposurePeriod', self.updateResolutionOverlay)
		api.observe('resolution', self.updateResolutionOverlay)
		
		
		#Oh god this is gonna mess up scroll wheel selection so badly. 😭
		self.uiShowWhiteClipping.stateChanged.connect(self.uiShotAssistMenu.setFocus)
Пример #18
0
class RecordingSettings(QtWidgets.QDialog, Ui_RecordingSettings):
	"""The recording settings is one of the few windows that doesn't update 
		the camera settings directly. Instead, it has a preview which utilizes
		these settings under the hood, and the settings are actually only
		applied when the "done" button is pressed. The camera is strictly modal
		in its configuration, so there will be some weirdness around this.
	"""
	
	def __init__(self, window):
		super().__init__()
		self.setupUi(self)
		self.window_ = window

		# API init.
		self.control = api.control()
		self.video = api.video()
		
		# Panel init.
		self.setFixedSize(window.app.primaryScreen().virtualSize())
		self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
		self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
		
		settings.observe('debug controls enabled', False, lambda show:
			self.uiDebug.show() if show else self.uiDebug.hide() )
		self.uiDebug.clicked.connect(lambda: self and dbg())
		
		self.populatePresets()
		self.uiPresets.currentIndexChanged.connect(self.applyPreset)
		
		#Resolution & resolution preview
		invariants = self.control.getSync([
			'sensorVMax', 'sensorVMin', 'sensorVIncrement',
			'sensorHMax', 'sensorHMin', 'sensorHIncrement',
		])
		self.uiHRes.setMinimum(invariants['sensorHMin'])
		self.uiVRes.setMinimum(invariants['sensorVMin'])
		self.uiHRes.setMaximum(invariants['sensorHMax'])
		self.uiVRes.setMaximum(invariants['sensorVMax'])
		self.uiHRes.setSingleStep(invariants['sensorHIncrement'])
		self.uiVRes.setSingleStep(invariants['sensorVIncrement'])
		
		self.uiHRes.valueChanged.connect(self.updateForSensorHRes)
		self.uiVRes.valueChanged.connect(self.updateForSensorVRes)
		self.uiHOffset.valueChanged.connect(self.updateForSensorHOffset) #Offset min implicit, max set by resolution. Offset set after res because 0 is a good default to set up res at.
		self.uiVOffset.valueChanged.connect(self.updateForSensorVOffset)
		
		self._lastResolution = defaultdict(lambda: None) #Set up for dispatchResolutionUpdate.
		api.observe('resolution', self.dispatchResolutionUpdate)
		

		
		#Frame rate fps/µs binding
		self.uiFps.setMinimum(0.01)
		self.uiFps.valueChanged.connect(self.updateFps)
		self.uiFrameDuration.valueChanged.connect(self.updateFrameDuration)
		api.observe('frameRate', self.updateFpsFromAPI)
		
		#Analog gain
		self.populateUiLuxAnalogGain()
		api.observe('currentGain', self.setLuxAnalogGain)
		self.uiAnalogGain.currentIndexChanged.connect(self.luxAnalogGainChanged)
		
		# Button binding.
		self.uiCenterRecording.clicked.connect(self.centerRecording)
		
		self.uiCancel.clicked.connect(self.revertSettings)
		self.uiDone.clicked.connect(self.applySettings)
		self.uiDone.clicked.connect(lambda: self.window_.back())
		
		api.observe('exposureMin', self.setMinExposure)
		api.observe('exposureMax', self.setMaxExposure)
		api.observe('exposurePeriod', self.updateExposure)
		self.uiExposure.valueChanged.connect(
			lambda val: self.control.set('exposurePeriod', val) )
		self.uiMaximizeExposure.clicked.connect(lambda: 
			self.uiExposure.setValue(self.uiExposure.maximum()) )
		
		self.uiMaximizeFramerate.clicked.connect(lambda: 
			self.uiFps.setValue(self.uiFps.maximum()) )
		
		self.uiSavePreset.clicked.connect(self.savePreset)
		self.uiDeletePreset.clicked.connect(self.deletePreset)
		
		#Hack. Since we set each recording setting individually, we always
		#wind up with a 'custom' entry on our preset list. Now, this might be
		#legitimate - if we're still on Custom by this time, that's just the
		#configuration the camera's in. However, if we're not, we can safely
		#delete it, since it's just a garbage value from the time the second-
		#to-last setting was set during setup.
		if self.uiPresets.itemData(0)['temporary'] and not self.uiPresets.itemData(self.uiPresets.currentIndex())['temporary']:
			self.uiPresets.removeItem(0)
		
		#Set up ui writes after everything is done.
		self._dirty = False
		self.uiUnsavedChangesWarning.hide()
		self.uiCancel.hide()
		def markDirty(*_):
			self._dirty = True
			self.uiUnsavedChangesWarning.show()
			self.uiCancel.show()
		self.uiHRes.valueChanged.connect(markDirty)
		self.uiVRes.valueChanged.connect(markDirty)
		self.uiHOffset.valueChanged.connect(markDirty)
		self.uiVOffset.valueChanged.connect(markDirty)
	
	__potentialPresetGeometries = [
		[1920, 1080],
		[1280, 1024],
		[1280, 720],
		[1280, 512],
		[1280, 360],
		[1280, 240],
		[1280, 120],
		[1280, 96],
		[1024, 768],
		[1024, 576],
		[800, 600],
		[800, 480],
		[640, 480],
		[640, 360],
		[640, 240],
		[640, 120],
		[640, 96],
		[336, 240],
		[336, 120],
		[336, 96],
	]
	presets = []
	for geometry_ in __potentialPresetGeometries: #Fix bug where this overrode the screen's geometry property, preventing any keyboard from opening.
		hRes, vRes = geometry_[0], geometry_[1]
		geometryTimingLimits = api.control().callSync('getResolutionTimingLimits', {'hRes':hRes, 'vRes':vRes})
		if 'error' not in geometryTimingLimits:
			presets += [{
				'hRes': hRes, 
				'vRes': vRes, 
				'framerate': 1e9/geometryTimingLimits['minFramePeriod'],
			}]
		else:
			log.debug(f'Rejected preset resolution {hRes}×{vRes}.')
	
	allRecordingGeometrySettings = ['uiHRes', 'uiVRes', 'uiHOffset', 'uiVOffset', 'uiFps'] #'uiFrameDuration' and 'uiAnalogGain' are not part of the preset, since they're not geometries.
	
	
	#The following usually crashes the HDVPSS core, which is responsible for
	#back-of-camera video. (Specifically, in this case, the core crashes if told
	#to render video smaller than 96px tall.) This function was intended to put
	#the recorded image inside the passepartout, to show you what you've got and
	#what you'll be getting.
	def __disabled__onShow(self):
		pos = self.uiPassepartoutInnerBorder.mapToGlobal(
			self.uiPassepartoutInnerBorder.pos() )
		self.video.call('configure', {
			'xoff': pos.x(),
			'yoff': pos.y(),
			'hres': self.uiPassepartoutInnerBorder.width(),
			'vres': self.uiPassepartoutInnerBorder.height(),
		})
	
	
	def dispatchResolutionUpdate(self, newResolution):
		for key, callbacks in (
			('hRes', [self.updateUiHRes, self.updateForSensorHRes]),
			('vRes', [self.updateUiVRes, self.updateForSensorVRes]),
			('hOffset', [self.updateUiHOffset, self.updateForSensorHOffset]),
			('vOffset', [self.updateUiVOffset, self.updateForSensorVOffset]),
			('minFrameTime', [self.updateMaximumFramerate]),
		):
			if self._lastResolution[key] == newResolution[key]:
				continue
			self._lastResolution[key] = newResolution[key]
			for callback in callbacks:
				callback(newResolution[key])
	
	
	def populatePresets(self):
		formatString = self.uiPresets.currentText()
		self.uiPresets.clear()
		
		for preset in settings.value('customRecordingPresets', []):
			self.uiPresets.insertItem(9999, preset['name'], preset)
		
		#Load from API.
		for preset in self.presets:
			self.uiPresets.insertItem(
				9999,
				formatString % (preset["hRes"], preset["vRes"], preset["framerate"]),
				{
					'custom': False, #Indicates value was saved by user.
					'temporary': False, #Indicates the "custom", the unsaved, preset.
					'values': {
						'uiHRes': preset["hRes"],
						'uiVRes': preset["vRes"],
						'uiFps': preset["framerate"],
					},
				}
			)
	
	
	def applyPreset(self, presetNumber: int):
		preset = self.uiPresets.itemData(presetNumber)
		
		#Maximum may be constrained. Disable maximums for preset read-in.
		#Maximums are restored by updateOffsetFromResolution.
		self.uiHOffset.setMaximum(999999)
		self.uiVOffset.setMaximum(999999)
		self.uiFps.setMaximum(999999)
		self.uiFrameDuration.setMinimum(0) #TODO: This gets re-set, right?
		for key, value in preset.get('values', {}).items():
			elem = getattr(self, key)
			elem.blockSignals(True) #Don't fire around a bunch of updates as we set values.
			elem.setValue(value)
			elem.blockSignals(False)
		
		self.updateMaximumFramerate()
		self.updateOffsetFromResolution()
		preset['custom'] or self.centerRecording() #All non-custom presets are assumed centered.
		self.updatePresetDropdownButtons()
		self.updatePassepartout()
		
		#Update frame duration and exposure from frame rate, which we just updated.
		self.uiFrameDuration.setValue(1/self.uiFps.value())
		self.updateExposureLimits()
		
		self._dirty = True
		self.uiUnsavedChangesWarning.show()
		self.uiCancel.show()
		
	
	def updatePresetDropdownButtons(self):
		if self.uiPresets.currentData()['custom']:
			if self.uiPresets.currentData()['temporary']: #The "Custom" preset. Shows when resolution/offset/framerate do not match a preset.
				self.uiSavePreset.show()
				self.uiDeletePreset.hide()
			else: #Saved presets can be deleted.
				self.uiSavePreset.hide()
				self.uiDeletePreset.show()
		else: 
			self.uiSavePreset.hide()
			self.uiDeletePreset.hide()
	
	
	def selectCorrectPreset(self):
		try:
			self.uiPresets.blockSignals(True) #When selecting a preset, don't try to apply it. This causes framerate to not track to maximum.
			
			#Select the first available preset.
			for index in range(self.uiPresets.count()):
				itemData = self.uiPresets.itemData(index)
				if itemData['temporary']: #Ignore the fake "Custom" preset.
					continue
				
				#Check preset values equal what's on screen. If anything isn't, this isn't our preset index.
				if False in [
					abs(int(getattr(self, key).value()) - int(value)) <= 1
					for key, value in itemData['values'].items()
				]: 
					continue
				
				self.uiPresets.setCurrentIndex(index)
				return
			
			#OK, not one of the available presets.
			#Add the "custom" preset if it doesn't exist. This is expected to always be in slot 0.
			if not self.uiPresets.itemData(0)['temporary']:
				log.info('adding temporary')
				self.uiPresets.insertItem(0, 'Custom', {
					'custom': True, #Indicates value was saved by user.
					'temporary': True, #Indicates the "custom", the unsaved, preset.
				})
			
			#Mogrify the custom values to match what is set.
			itemData = self.uiPresets.itemData(0) #read modify write, no in-place merging
			itemData['values'] = {
				elem: getattr(self, elem).value()
				for elem in self.allRecordingGeometrySettings
			}
			self.uiPresets.setItemData(0, itemData)
			
			#Select the custom preset and check for changes to the save/load preset buttons.
			self.uiPresets.setCurrentIndex(0)
		finally:
			self.updatePresetDropdownButtons()
			self.uiPresets.blockSignals(False)
	
	
	def savePreset(self):
		itemData = self.uiPresets.currentData()
		itemData['temporary'] = False
		itemData['name'] = f"{self.uiHRes.value()}×{self.uiVRes.value()} @ {int(self.uiFps.value())}fps" #Store name, probably will be editable one day.
		presets = [itemData] + settings.value('customRecordingPresets', [])
		settings.setValue('customRecordingPresets', presets)
		self.uiPresets.setItemData(self.uiPresets.currentIndex(), itemData)
		self.uiPresets.setItemText(self.uiPresets.currentIndex(), itemData['name'])
		
		self.updatePresetDropdownButtons()
	
	def deletePreset(self, *_):
		settings.setValue('customRecordingPresets', [
			setting
			for setting in settings.value('customRecordingPresets', [])
			if setting != self.uiPresets.currentData()
		])
		self.uiPresets.removeItem(self.uiPresets.currentIndex())
		self.selectCorrectPreset() #Select Custom again.
	
	
	
	#xywh accessor callbacks, just update the spin box values since these values require a lengthy pipeline rebuild.
	
	@pyqtSlot(int, name="updateUiHRes")
	def updateUiHRes(self, px: int):
		self.uiHRes.setValue(px)
		
	
	@pyqtSlot(int, name="updateUiVRes")
	def updateUiVRes(self, px: int):
		self.uiVRes.setValue(px)
		self.updateMaximumFramerate()
		
	
	@pyqtSlot(int, name="updateUiHOffset")
	def updateUiHOffset(self, px: int):
		self.uiHOffset.setValue(px)
	
	@pyqtSlot(int, name="updateUiVOffset")
	def updateUiVOffset(self, px: int):
		self.uiVOffset.setValue(px)
	
	
	#side-effect callbacks, update everything *but* the spin box values
	@pyqtSlot(int, name="updateForSensorHOffset")
	def updateForSensorHOffset(self, px: int):
		self.updatePassepartout()
		self.selectCorrectPreset()
	
	@pyqtSlot(int, name="updateForSensorVOffset")
	def updateForSensorVOffset(self, px: int):
		self.updatePassepartout()
		self.selectCorrectPreset()
	
	@pyqtSlot(int, name="updateForSensorHRes")
	def updateForSensorHRes(self, px: int):
		wasCentered = self.uiHOffset.value() == self.uiHOffset.maximum()//2
		self.uiHOffset.setMaximum(self.uiHRes.maximum() - px) #Can't capture off-sensor.
		wasCentered and self.uiHOffset.setValue(self.uiHOffset.maximum()//2)
		self.updateMaximumFramerate()
		self.updatePassepartout()
		self.selectCorrectPreset()
	
	@pyqtSlot(int, name="updateForSensorVRes")
	def updateForSensorVRes(self, px: int):
		wasCentered = self.uiVOffset.value() == self.uiVOffset.maximum()//2
		self.uiVOffset.setMaximum(self.uiVRes.maximum() - px) #Can't capture off-sensor.
		wasCentered and self.uiVOffset.setValue(self.uiVOffset.maximum()//2)
		self.updateMaximumFramerate()
		self.updatePassepartout()
		self.selectCorrectPreset()
	
	def updateOffsetFromResolution(self):
		self.uiHOffset.setMaximum(self.uiHRes.maximum() - self.uiHRes.value())
		self.uiVOffset.setMaximum(self.uiVRes.maximum() - self.uiVRes.value())
	
	_lastKnownFramerateOverheadNs = 5000
	def updateMaximumFramerate(self, minFrameTime=None):
		if minFrameTime:
			#Shortcut. We can do this because the exposure values set below by the real call are not required when an API-driven update is fired, since the API-driven update will also update the exposure. I think. 🤞
			limits = {'minFramePeriod': minFrameTime*1e9}
		else:
			limits = self.control.callSync('getResolutionTimingLimits', {
				'hRes': self.uiHRes.value(),
				'vRes': self.uiVRes.value(),
			})
			
			if 'error' in limits:
				log.error(f"Error retrieving maximum framerate for {hRes}×{vRes}: {limits['error']}")
				return
			
			#Note this down for future use by `updateExposureLimits`.
			self._lastKnownFramerateOverheadNs = limits['minFramePeriod'] - limits['exposureMax']
			self.uiExposure.setMinimum(limits['exposureMin'])
		
		log.debug(f"Framerate for {self.uiHRes.value()}×{self.uiVRes.value()}: {1e9 / limits['minFramePeriod']}")
		
		framerateIsMaxed = abs(self.uiFps.maximum() - self.uiFps.value()) <= 1 #There is a bit of uncertainty here, occasionally, of about 0.1 fps.
		self.uiFps.setMaximum(1e9 / limits['minFramePeriod'])
		self.uiFrameDuration.setMinimum(limits['minFramePeriod'] / 1e9) #ns→s
		framerateIsMaxed and self.uiFps.setValue(self.uiFps.maximum())
		framerateIsMaxed and self.uiFrameDuration.setValue(1/self.uiFps.maximum())
		self.updateExposureLimits()
	
	
	_sensorWidth = api.control().getSync('sensorHMax')
	_sensorHeight = api.control().getSync('sensorVMax')
	def updatePassepartout(self):
		previewTop = 1
		previewLeft = 1
		previewWidth = self.uiPreviewPanel.geometry().right() - self.uiPreviewPanel.geometry().left()
		previewHeight = self.uiPreviewPanel.geometry().bottom() - self.uiPreviewPanel.geometry().top()
		
		recordingTop = self.uiVOffset.value()
		recordingLeft = self.uiHOffset.value()
		recordingRight = self.uiHOffset.value() + self.uiHRes.value()
		recordingBottom = self.uiVOffset.value() + self.uiVRes.value()
		
		passepartoutTop = round(recordingTop / self._sensorHeight * previewHeight)
		passepartoutLeft = round(recordingLeft / self._sensorWidth * previewWidth)
		passepartoutWidth = round((recordingRight - recordingLeft) / self._sensorWidth * previewWidth)
		passepartoutHeight = round((recordingBottom - recordingTop) / self._sensorHeight * previewHeight)
		
		self.uiPassepartoutTop.setGeometry(
			previewLeft,
			previewTop,
			previewWidth - 1,
			passepartoutTop )
		self.uiPassepartoutLeft.setGeometry(
			previewLeft,
			passepartoutTop + 1,
			passepartoutLeft - 1,
			passepartoutHeight - 1 )
		self.uiPassepartoutRight.setGeometry(
			passepartoutLeft + passepartoutWidth + 1,
			passepartoutTop + 1,
			previewWidth - passepartoutLeft - passepartoutWidth - 1,
			passepartoutHeight - 1 )
		self.uiPassepartoutBottom.setGeometry(
			previewLeft,
			passepartoutTop + passepartoutHeight,
			previewWidth - 1,
			previewHeight - passepartoutTop - passepartoutHeight )
		self.uiPassepartoutInnerBorder.setGeometry(
			passepartoutLeft,
			passepartoutTop,
			passepartoutWidth + 1,
			passepartoutHeight + 1 )
	
	
	@pyqtSlot(float, name="updateFps")
	def updateFps(self, fps: float):
		self.uiFrameDuration.setValue(1/fps)
		self.selectCorrectPreset()
		self._dirty = True
		self.uiUnsavedChangesWarning.show()
		self.uiCancel.show()
		self.updateExposureLimits()
		
		
	@pyqtSlot(float, name="updateFrameDuration")
	def updateFrameDuration(self, seconds: float):
		self.uiFps.setValue(1/seconds)
		self.selectCorrectPreset()
		self._dirty = True
		self.uiUnsavedChangesWarning.show()
		self.uiCancel.show()
		self.updateExposureLimits()
		
	@pyqtSlot(float, name="updateFpsFromAPI")
	def updateFpsFromAPI(self, fps):
		self.uiFps.setValue(fps)
		self.uiFrameDuration.setValue(1/fps)
		self.selectCorrectPreset()
		self.updateExposureLimits()
	
	
	def updateExposureLimits(self):
		exposureIsMaxed = self.uiExposure.value() == self.uiExposure.maximum()
		self.uiExposure.setMaximum(
			self.uiFrameDuration.value()*1e9 - self._lastKnownFramerateOverheadNs )
		exposureIsMaxed and self.uiExposure.setValue(self.uiExposure.maximum())
		
	
	def centerRecording(self):
		self.uiHOffset.setValue(self.uiHOffset.maximum() // 2)
		self.uiVOffset.setValue(self.uiVOffset.maximum() // 2)
		self.selectCorrectPreset()
		
	
	luxRecordingAnalogGains = [{'multiplier':2**i, 'dB':6*i} for i in range(0,5)]
	
	def populateUiLuxAnalogGain(self):
		formatString = self.uiAnalogGain.currentText()
		self.uiAnalogGain.clear()
		self.uiAnalogGain.insertItems(0, [
			formatString.format(multiplier=gain['multiplier'], dB=gain['dB'])
			for gain in self.luxRecordingAnalogGains
		])
	
	
	def luxAnalogGainChanged(self, index):
		self.uiAnalogGain.setCurrentIndex(index)
		self.control.set({'currentGain': 
			self.luxRecordingAnalogGains[index]['multiplier']})
	
	@pyqtSlot(int, name="setLuxAnalogGain")
	def setLuxAnalogGain(self, gainMultiplier):
		self.uiAnalogGain.setCurrentIndex(
			list(map(lambda availableGain: availableGain['multiplier'],
				self.luxRecordingAnalogGains))
			.index(floor(gainMultiplier))
		)
	
	@pyqtSlot(int, name="setMaxExposure")
	def setMaxExposure(self, ns):
		self.uiExposure.setMaximum(ns)
	
	@pyqtSlot(int, name="setMinExposure")
	def setMinExposure(self, ns):
		self.uiExposure.setMaximum(ns)
	
	@pyqtSlot(int, name="updateExposure")
	def updateExposure(self, ns):
		self.uiExposure.setValue(ns)
	
	
	def applySettings(self):
		"""Save all setting to the API, which will take some time applying them."""
		
		if self._dirty:
			self._dirty = False
			self.uiUnsavedChangesWarning.hide()
			self.uiCancel.hide()
			self.control.call('set', {
				'resolution': {
					#'vDarkRows': 0, #Don't reset what we don't show. That's annoying if you did manually set it.
					'hRes': self.uiHRes.value(),
					'vRes': self.uiVRes.value(),
					'hOffset': self.uiHOffset.value(),
					'vOffset': self.uiVOffset.value(),
					'minFrameTime': 1/self.uiFps.value(), #This locks the fps in at the lower framerate until you reset it.
				},
				'framePeriod': self.uiFrameDuration.value()*1e9, #s→ns
			})
	
	
	def revertSettings(self):
		"""Set resolution settings back to the API."""
		
		self.updateUiHRes(api.apiValues.get('resolution')['hRes'])
		self.updateUiVRes(api.apiValues.get('resolution')['vRes'])
		self.updateUiHOffset(api.apiValues.get('resolution')['hOffset'])
		self.updateUiVOffset(api.apiValues.get('resolution')['vOffset'])
		self.updateMaximumFramerate(api.apiValues.get('resolution')['minFrameTime'])
		self.updateFpsFromAPI(api.apiValues.get('frameRate'))
		
		self._dirty = False
		self.uiUnsavedChangesWarning.hide()
		self.uiCancel.hide()