#!/usr/bin/env python3
#
# -*- coding: utf-8 -*-
#
# piccol - PICk COLors
#
# Copyright 2017-2023 Michael Büsch <m@bues.ch>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#

PICCOL_VERSION		= "1.2"
PICCOL_HOME_URL		= "https://bues.ch/"

import sys
if sys.version_info[0] < 3:
	raise Exception("Python 3.x is required.")

import colorsys

loadFramework = "probe"
if len(sys.argv) >= 2:
	if sys.argv[1] == "--pyqt6":
		loadFramework = "pyqt6"
	elif sys.argv[1] == "--pyqt5":
		loadFramework = "pyqt5"
	elif sys.argv[1] == "--pyside2":
		loadFramework = "pyside2"

if loadFramework in ("probe", "pyqt6"):
	try:
		from PyQt6.QtCore import *
		from PyQt6.QtGui import *
		from PyQt6.QtWidgets import *
		Signal = pyqtSignal
		loadFramework = None
		print("Using PyQt6 framework.")
	except ImportError:
		pass
if loadFramework in ("probe", "pyqt5"):
	try:
		from PyQt5.QtCore import *
		from PyQt5.QtGui import *
		from PyQt5.QtWidgets import *
		Signal = pyqtSignal
		loadFramework = None
		print("Using PyQt5 framework.")
	except ImportError:
		pass
if loadFramework in ("probe", "pyside2"):
	try:
		from PySide2.QtCore import *
		from PySide2.QtGui import *
		from PySide2.QtWidgets import *
		loadFramework = None
		print("Using PySide2 framework.")
	except ImportError:
		pass
if loadFramework:
	raise Exception("Did not find PyQt6, PyQt5 or PySide2 Python modules.")

class ColorString(object):
	@staticmethod
	def rgb_to_hls(r, g, b):
		h, l, s = colorsys.rgb_to_hls(r / 255.0,
					      g / 255.0,
					      b / 255.0)
		return h * 360, l, s

	@staticmethod
	def hls_to_rgb(h, l, s):
		r, g, b = colorsys.hls_to_rgb(h / 360.0,
					      l,
					      s)
		return round(r * 255), round(g * 255), round(b * 255)

	@classmethod
	def fromRGB(cls, r, g, b):
		return cls(*cls.rgb_to_hls(r, g, b))

	def __init__(self, h, l, s):
		self.h = h
		self.l = l
		self.s = s

	@property
	def hue(self):
		if self.h > 344.0:
			return "red"
		if self.h > 327.0:
			return "rose"
		if self.h > 291.0:
			return "magenta"
		if self.h > 270.0:
			return "purple"
		if self.h > 260.0:
			return "violet"
		if self.h > 240.0:
			return "indigo"
		if self.h > 193.0:
			return "blue"
		if self.h > 163.0:
			return "cyan"
		if self.h > 79.0:
			return "green"
		if self.h > 70.0:
			return "lime"
		if self.h > 45.0:
			return "yellow"
		if self.h > 15.0:
			return "orange"
		if self.h > 14.0:
			return "reddish"
		return "red"

	@property
	def lightness(self):
		if self.l >= 1.0:
			return "white"
		if self.l > 0.94:
			return "almost white"
		if self.l > 0.8:
			return "very light"
		if self.l > 0.6:
			return "light"
		if self.l > 0.3:
			return ""
		if self.l > 0.22:
			return "dark"
		if self.l > 0.09:
			return "very dark"
		if self.l > 0.0:
			return "almost black"
		return "black"

	@property
	def saturation(self):
		if self.s > 0.9:
			return "very saturated"
		if self.s > 0.8:
			return "rather saturated"
		if self.s > 0.6:
			return ""
		if self.s > 0.46:
			return "rather unsaturated"
		if self.s > 0.3:
			return "unsaturated"
		if self.s > 0.1:
			return "very unsaturated"
		if self.s > 0.03:
			return "almost grey"
		return "grey"

	@property
	def string(self):
		strings = ()
		if self.l <= 0.0 or self.l >= 1.0:
			strings = (self.lightness, )
		elif self.s <= 0.03:
			strings = (self.lightness, self.saturation)
		elif self.l <= 0.09 or self.l > 0.94:
			strings = (self.lightness, self.hue)
		else:
			strings = (self.saturation, self.lightness, self.hue)
		return " ".join(s.strip() for s in strings if s.strip())

class PixmapDisplay(QLabel):
	pointerEvent = Signal(int, int)

	def __init__(self, parent=None):
		QLabel.__init__(self, parent)

		self.setFrameStyle(QFrame.Shape.Box | QFrame.Shadow.Raised)
		self.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
		self.setMouseTracking(True)

		self.__buttonPressed = False

	@staticmethod
	def __getXY(event):
		if hasattr(event, "position"):
			x, y = event.position().x(), event.position().y()
		else:
			x, y = event.x(), event.y()
		return int(round(x)), int(round(y))

	def mousePressEvent(self, event):
		self.__handlePointerEvent(*self.__getXY(event))
		self.__buttonPressed = True

	def mouseReleaseEvent(self, event):
		self.__handlePointerEvent(*self.__getXY(event))
		self.__buttonPressed = False

	def mouseMoveEvent(self, event):
		if self.__buttonPressed:
			self.__handlePointerEvent(*self.__getXY(event))
		QLabel.mouseMoveEvent(self, event)

	def __handlePointerEvent(self, x, y):
		pixmap = self.pixmap()
		if pixmap and\
		   x >= 0 and y >= 0 and\
		   x < pixmap.width() and y < pixmap.height():
			self.pointerEvent.emit(x, y)

class MainWidget(QWidget):
	def __init__(self, mainWindow):
		QWidget.__init__(self, mainWindow)
		self.mainWindow = mainWindow
		self.setLayout(QGridLayout())
		self.setMouseTracking(True)

		hbox = QHBoxLayout()
		self.pickButton = QPushButton("Pick color...", self)
		hbox.addWidget(self.pickButton)
		hbox.addStretch()
		self.onTopButton = QPushButton("S", self)
		self.onTopButton.setToolTip("Keep window always on top (sticky)")
		self.onTopButton.setCheckable(True)
		self.onTopButton.setMaximumWidth(20)
		hbox.addWidget(self.onTopButton)
		self.layout().addLayout(hbox, 0, 0)

		grid = QGridLayout()
		self.captureDisplay = PixmapDisplay(self)
		self.captureDisplay.setToolTip("Click here to select pixel.")
		grid.addWidget(self.captureDisplay, 0, 0, 2, 1)
		self.colorDisplay = PixmapDisplay(self)
		grid.addWidget(self.colorDisplay, 0, 1, 1, 1)
		grid.setRowStretch(1, 1)
		grid.setColumnStretch(2, 1)
		self.layout().addLayout(grid, 1, 0)

		colorGrid = QGridLayout()

		label = QLabel("RGB:", self)
		colorGrid.addWidget(label, 0, 0)
		self.colorR = QLineEdit(self)
		self.colorR.setValidator(QIntValidator(0, 255))
		colorGrid.addWidget(self.colorR, 0, 1)
		self.colorG = QLineEdit(self)
		self.colorG.setValidator(QIntValidator(0, 255))
		colorGrid.addWidget(self.colorG, 0, 2)
		self.colorB = QLineEdit(self)
		self.colorB.setValidator(QIntValidator(0, 255))
		colorGrid.addWidget(self.colorB, 0, 3)

		self.sliderR = QSlider(Qt.Orientation.Horizontal, self)
		self.sliderR.setRange(0, 255)
		colorGrid.addWidget(self.sliderR, 1, 1)
		self.sliderG = QSlider(Qt.Orientation.Horizontal, self)
		self.sliderG.setRange(0, 255)
		colorGrid.addWidget(self.sliderG, 1, 2)
		self.sliderB = QSlider(Qt.Orientation.Horizontal, self)
		self.sliderB.setRange(0, 255)
		colorGrid.addWidget(self.sliderB, 1, 3)

		label = QLabel("HLS:", self)
		colorGrid.addWidget(label, 2, 0)
		self.colorH = QLineEdit(self)
		self.colorH.setValidator(QDoubleValidator(0.0, 359.9, 1))
		colorGrid.addWidget(self.colorH, 2, 1)
		self.colorL = QLineEdit(self)
		self.colorL.setValidator(QDoubleValidator(0.0, 1.0, 3))
		colorGrid.addWidget(self.colorL, 2, 2)
		self.colorS = QLineEdit(self)
		self.colorS.setValidator(QDoubleValidator(0.0, 1.0, 3))
		colorGrid.addWidget(self.colorS, 2, 3)

		self.sliderH = QSlider(Qt.Orientation.Horizontal, self)
		self.sliderH.setRange(0, round(359.9 * 10))
		colorGrid.addWidget(self.sliderH, 3, 1)
		self.sliderL = QSlider(Qt.Orientation.Horizontal, self)
		self.sliderL.setRange(0, 100)
		colorGrid.addWidget(self.sliderL, 3, 2)
		self.sliderS = QSlider(Qt.Orientation.Horizontal, self)
		self.sliderS.setRange(0, 100)
		colorGrid.addWidget(self.sliderS, 3, 3)

		label = QLabel("RGB-hex:", self)
		colorGrid.addWidget(label, 4, 0)
		self.hex = QLineEdit(self)
		self.hex.setReadOnly(True)#TODO
		colorGrid.addWidget(self.hex, 4, 1)

		label = QLabel("Color:", self)
		colorGrid.addWidget(label, 5, 0)
		self.colorStr = QLineEdit(self)
		self.colorStr.setReadOnly(True)
		colorGrid.addWidget(self.colorStr, 5, 1, 1, 3)

		self.layout().addLayout(colorGrid, 2, 0)

		self.layout().setRowStretch(99, 1)

		self.rgb = (0, 0, 0)

		self.__blockChangeSignals = 0
		self.__pickActive = False
		self.__origCapturePixmap = QPixmap()
		self.__selectedPixel = (0, 0)

		self.pickButton.released.connect(self.__handlePick)
		self.onTopButton.toggled.connect(self.__stickyToggled)
		self.captureDisplay.pointerEvent.connect(self.__handleCaptureClick)
		self.colorR.textEdited.connect(self.__handleRGBEdit)
		self.colorG.textEdited.connect(self.__handleRGBEdit)
		self.colorB.textEdited.connect(self.__handleRGBEdit)
		self.sliderR.valueChanged.connect(self.__handleRGBSlide)
		self.sliderG.valueChanged.connect(self.__handleRGBSlide)
		self.sliderB.valueChanged.connect(self.__handleRGBSlide)
		self.colorH.textEdited.connect(self.__handleHLSEdit)
		self.sliderH.valueChanged.connect(self.__handleHLSSlide)
		self.colorL.textEdited.connect(self.__handleHLSEdit)
		self.sliderL.valueChanged.connect(self.__handleHLSSlide)
		self.colorS.textEdited.connect(self.__handleHLSEdit)
		self.sliderS.valueChanged.connect(self.__handleHLSSlide)

	def __stickyToggled(self, en):
		flags = self.mainWindow.windowFlags()
		self.mainWindow.hide()
		if en:
			flags |= (Qt.WindowType.CustomizeWindowHint |
				  Qt.WindowType.WindowStaysOnTopHint)
		else:
			flags &= ~(Qt.WindowType.CustomizeWindowHint |
				   Qt.WindowType.WindowStaysOnTopHint)
		self.mainWindow.setWindowFlags(flags)
		self.mainWindow.show()

	def __handlePick(self):
		self.mainWindow.statusBar().showMessage(
			"Use mouse to capture screen area. "
			"Press Ctrl, Shift or Alt to capture.")
		self.__pickActive = True
		self.grabMouse(Qt.CursorShape.CrossCursor)
		try:
			while self.__pickActive:
				QApplication.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 300)
				m = QApplication.keyboardModifiers()
				if m & (Qt.KeyboardModifier.ControlModifier |
					Qt.KeyboardModifier.ShiftModifier |
					Qt.KeyboardModifier.AltModifier):
					self.__endPick()
				else:
					self.mainWindow.pick()
		finally:
			self.releaseMouse()
			self.mainWindow.statusBar().showMessage("")
			self.mainWindow.show()

	def __endPick(self):
		self.__pickActive = False
		pixmap, hotSpot = MainWindow.makeCapturePixmap()
		self.setCapturePixmap(pixmap, hotSpot)

	def mousePressEvent(self, event):
		if self.__pickActive:
			self.__endPick()
		QWidget.mousePressEvent(self, event)

	def setCapturePixmap(self, pixmap, hotSpot=None):
		self.__origCapturePixmap = pixmap
		if hotSpot is None:
			hotSpot = (pixmap.width() // 2,
				   pixmap.height() // 2)
		self.__selectCapturePixel(hotSpot[0], hotSpot[1])

	def __selectCapturePixel(self, x, y):
		self.__selectedPixel = (x, y)
		image = self.__origCapturePixmap.toImage()
		pix = image.pixel(x, y)
		self.rgb = qRed(pix), qGreen(pix), qBlue(pix)
		self.__update()

	def __handleCaptureClick(self, x, y):
		if self.__blockChangeSignals:
			return
		self.__selectCapturePixel(x, y)

	def __convRGB(self, text):
		value = int(text)
		if value < 0 or value > 255:
			raise ValueError
		return value

	def __handleRGBEdit(self):
		if self.__blockChangeSignals:
			return
		try:
			r = self.__convRGB(self.colorR.text())
			g = self.__convRGB(self.colorG.text())
			b = self.__convRGB(self.colorB.text())
		except ValueError as e:
			return
		self.rgb = (r, g, b)
		self.__update(updateRGBText=False)

	def __handleRGBSlide(self):
		if self.__blockChangeSignals:
			return
		self.rgb = (self.sliderR.value(),
			    self.sliderG.value(),
			    self.sliderB.value())
		self.__update(updateRGBSlider=False)

	def __convHLS(self, text, maxVal):
		value = float(text)
		if value < 0.0 or value > maxVal:
			raise ValueError
		return value

	def __handleHLSEdit(self):
		if self.__blockChangeSignals:
			return
		try:
			h = self.__convHLS(self.colorH.text(), 360.0)
			l = self.__convHLS(self.colorL.text(), 1.0)
			s = self.__convHLS(self.colorS.text(), 1.0)
		except ValueError as e:
			return
		self.rgb = ColorString.hls_to_rgb(h, l, s)
		self.__update(updateHLSText=False)

	def __handleHLSSlide(self):
		if self.__blockChangeSignals:
			return
		h = float(self.sliderH.value()) / 10.0
		l = float(self.sliderL.value()) / 100.0
		s = float(self.sliderS.value()) / 100.0
		self.rgb = ColorString.hls_to_rgb(h, l, s)
		self.__update(updateHLSSlider=False)

	def __update(self, updateRGBText=True, updateRGBSlider=True,
			   updateHLSText=True, updateHLSSlider=True,
			   updateHex=True,
			   updateStr=True):

		# Draw the capture display.
		image = self.__origCapturePixmap.toImage()
		pen = QPen(QColor("red"))
		pen.setWidth(1)
		painter = QPainter(image)
		painter.setPen(pen)
		painter.drawLine(self.__selectedPixel[0], 0,
				 self.__selectedPixel[0], self.__selectedPixel[1] - 2)
		painter.drawLine(self.__selectedPixel[0], self.__selectedPixel[1] + 2,
				 self.__selectedPixel[0], image.height() - 1)
		painter.drawLine(0, self.__selectedPixel[1],
				 self.__selectedPixel[0] - 2, self.__selectedPixel[1])
		painter.drawLine(self.__selectedPixel[0] + 2, self.__selectedPixel[1],
				 image.width() - 1, self.__selectedPixel[1])
		painter.drawRect(self.__selectedPixel[0] - 2, self.__selectedPixel[1] - 2,
				 4, 4)
		painter.end()
		self.captureDisplay.setPixmap(QPixmap.fromImage(image))

		# Draw the color display.
		image = QImage(64, 64, QImage.Format.Format_RGB32)
		image.fill(QColor(*self.rgb))
		self.colorDisplay.setPixmap(QPixmap.fromImage(image))

		self.__blockChangeSignals += 1
		try:
			colorString = ColorString.fromRGB(*self.rgb)
			if updateRGBText:
				self.colorR.setText(str(self.rgb[0]))
				self.colorG.setText(str(self.rgb[1]))
				self.colorB.setText(str(self.rgb[2]))
			if updateRGBSlider:
				self.sliderR.setValue(self.rgb[0])
				self.sliderG.setValue(self.rgb[1])
				self.sliderB.setValue(self.rgb[2])
			if updateHLSText:
				self.colorH.setText("%.1f" % colorString.h)
				self.colorL.setText("%.3f" % colorString.l)
				self.colorS.setText("%.3f" % colorString.s)
			if updateHLSSlider:
				self.sliderH.setValue(round(colorString.h * 10))
				self.sliderL.setValue(round(colorString.l * 100))
				self.sliderS.setValue(round(colorString.s * 100))
			if updateHex:
				self.hex.setText("#%02X%02X%02X" % (
						 self.rgb[0], self.rgb[1], self.rgb[2]))
			if updateStr:
				colorName = colorString.string
				self.colorStr.setText(colorName)
				self.colorDisplay.setToolTip(colorName)
		finally:
			self.__blockChangeSignals -= 1

class MainWindow(QMainWindow):
	def __init__(self):
		QMainWindow.__init__(self)
		self.setCentralWidget(MainWidget(self))
		self.setWindowTitle("piccol - PICk COLors")

		self.setMenuBar(QMenuBar(self))
		self.setStatusBar(QStatusBar(self))

		menu = QMenu("&File", self)
		menu.addAction("&Open picture file...", self.openFile)
		menu.addSeparator()
		menu.addAction("&Exit...", self.close)
		self.menuBar().addMenu(menu)

		menu = QMenu("&Help", self)
		menu.addAction("Piccol &homepage...", self.homepage)
		menu.addSeparator()
		menu.addAction("&About...", self.about)
		self.menuBar().addMenu(menu)

	def homepage(self):
		QDesktopServices.openUrl(QUrl(PICCOL_HOME_URL, QUrl.ParsingMode.StrictMode))

	def about(self):
		QMessageBox.about(self, "About piccol",
			"piccol - Color picker and translator - version %s\n"
			"\n"
			"%s\n"
			"\n"
			"Copyright Michael Büsch <m@bues.ch>\n"
			"\n"
			"\n"
			"This program is free software; you can redistribute it and/or modify "
			"it under the terms of the GNU General Public License as published by "
			"the Free Software Foundation; either version 2 of the License, or "
			"(at your option) any later version.\n"
			"\n"
			"This program is distributed in the hope that it will be useful, "
			"but WITHOUT ANY WARRANTY; without even the implied warranty of "
			"MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the "
			"GNU General Public License for more details.\n"
			"\n"
			"You should have received a copy of the GNU General Public License along "
			"with this program; if not, write to the Free Software Foundation, Inc., "
			"51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA." % (
			PICCOL_VERSION, PICCOL_HOME_URL))

	@staticmethod
	def makeCapturePixmap():
		cursorPos = QCursor.pos()
		x, y = cursorPos.x(), cursorPos.y()

		# Make screen shot.
		mouseScreen = QGuiApplication.screenAt(cursorPos)
		pixmap = mouseScreen.grabWindow(0)

		# Translate X/Y to screen corrdinates.
		screenGeo = mouseScreen.geometry()
		x -= screenGeo.x()
		y -= screenGeo.y()

		# Calculate the section pixmap position.
		pixSize = (200, 200)
		padding = (pixSize[0] // 2, pixSize[1] // 2)
		pixPos = [x - padding[0], y - padding[1]]

		# Calculate the pixmap hot spot position.
		hotSpot = list(padding)
		for i in (0, 1):
			if pixPos[i] < 0:
				hotSpot[i] += pixPos[i]
				pixPos[i] = 0
			s = screenGeo.width() if i == 0 else screenGeo.height()
			over = s - (pixPos[i] + pixSize[i])
			if over < 0:
				pixPos[i] = s - pixSize[i] - 1
				hotSpot[i] -= over

		# Get the section pixmap.
		pixmap = pixmap.copy(pixPos[0],
				     pixPos[1],
				     pixSize[0],
				     pixSize[1])
		return pixmap, hotSpot

	def loadFile(self, filename):
		pixmap = QPixmap(str(filename))
		if not pixmap or pixmap.isNull():
			QMessageBox.critical(self,
				"Failed to load file",
				"Failed to load file:\n%s" % filename)
			return
		self.centralWidget().setCapturePixmap(pixmap)

	def openFile(self):
		fn, fil = QFileDialog.getOpenFileName(self,
			"Open picture", "",
			"All files (*)")
		if not fn:
			return
		self.loadFile(fn)

	def pick(self):
		pixmap, hotSpot = self.makeCapturePixmap()
		self.centralWidget().setCapturePixmap(pixmap, hotSpot)

		self.show()

def main():
	qapp = QApplication(sys.argv)
	mainwnd = MainWindow()
	mainwnd.pick()
	if hasattr(qapp, "exec"):
		return qapp.exec()
	else:
		return qapp.exec_()

if __name__ == "__main__":
	sys.exit(main())
