Guest User

Untitled

a guest
Jun 17th, 2024
81
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 43.84 KB | None | 0 0
  1. #!/usr/bin/python3
  2.  
  3. # Copyright (c) 2024 William Kappler
  4. #
  5. # Permission to use, copy, modify, and distribute this software for any
  6. # purpose with or without fee is hereby granted, provided that the above
  7. # copyright notice and this permission notice appear in all copies.
  8. #
  9. # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
  10. # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
  11. # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
  12. # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
  13. # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
  14. # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
  15. # PERFORMANCE OF THIS SOFTWARE.
  16.  
  17. # Note: This script may be launched directly, but is intended to be launched by
  18. # the "kiyoi" shell script, because launching this directly will undesirably
  19. # block the console that ends up running the master instance. The launcher
  20. # makes sure the script releases the console.
  21. # However, direct launching is necessary for development/debugging.
  22.  
  23. # TODO
  24. # * Final check of translations
  25. # * General cleanup
  26. # * Release the console if a master instance was started
  27. #   - script wrapping for Linux and idgaf about Windows
  28.  
  29. import argparse
  30. import atexit
  31. from enum import Enum
  32. import errno
  33. from functools import cmp_to_key, partial
  34. import json
  35. import locale
  36. import math
  37. import os
  38. import pyperclip
  39. import random
  40. import sys
  41. import uuid
  42. import tempfile
  43. from time import sleep
  44.  
  45. # These must come BEFORE PIL.
  46. from PySide6.QtCore import Qt, QRect, QSize, QPoint, QSettings, QTimer, QStandardPaths
  47. from PySide6.QtGui import QPixmap, QAction, QActionGroup, QIcon
  48. from PySide6.QtWidgets import QApplication, QLabel, QMainWindow, QRubberBand, QMenu, QDialog, QVBoxLayout, QDialogButtonBox, QFileDialog
  49.  
  50. from PIL import UnidentifiedImageError as PillowUnidentifiedImageError
  51. from PIL import Image as PillowImage
  52. from PIL import ImageQt as PillowImageQt
  53.  
  54. SCRIPT_PATH = os.path.abspath(os.path.dirname(os.path.realpath(__file__)))
  55.  
  56. LANGUAGE_RECORD = False
  57.  
  58. PROGRAM_NAME = "Kiyoi"
  59. PROGRAM_FULL_NAME = PROGRAM_NAME + " Image Viewer"
  60.  
  61. PER_IMAGE_SCALE_MEMORY_LIMIT_MB = 1024 # 1 gb, only applies to upscaling (native and downscaling is always allowed)
  62.  
  63. #INTERPROCESS_COMMUNICATION_DIRECTORY = PROGRAM_NAME.lower() + "_com"
  64. INTERPROCESS_COMMUNICATION_DIRECTORY = PROGRAM_NAME.lower() + "_TESTcom"
  65. MAIN_INSTANCE_LOCK_FILE = PROGRAM_NAME.lower() +  ".lock"
  66. MAIN_INSTANCE_REQUEST_FILE_PREFIX = PROGRAM_NAME.lower() +  "_"
  67. MAIN_INSTANCE_REQUEST_FILE_SUFFIX = ".req"
  68. MAIN_INSTANCE_REQUEST_TIMEOUT_MIN_MSEC = 500.0
  69. MAIN_INSTANCE_REQUEST_TIMEOUT_RANDOM_MSEC = 1500.0 # Used to try to prevent stepping on new processes
  70. INTERPROCESS_INTERVAL_MSEC = 50
  71.  
  72. SETTINGS_FILE = "settings.ini"
  73. LANGUAGE_DIRECTORY = os.path.join(SCRIPT_PATH, "locales")
  74. LANGUAGE_FILE_SUFFIX = ".lang"
  75. LANGUAGE_NAME_STRING = "LANGUAGE_FULL_NAME"
  76.  
  77. RESOURCE_DIRECTORY = os.path.join(SCRIPT_PATH, "resources")
  78.  
  79. SUPPORTED_EXTENSIONS_NO_DOT = ('blp','bmp','dds','dib','gif','icns','ico','jpeg','jpg','jpe','jp2','j2k','jpf','jpm','jpc','jpx','mj2','png','apng','tga','icb','vda','vst','tiff','tif','webp')
  80. SUPPORTED_EXTENSIONS = ['.'+s for s in SUPPORTED_EXTENSIONS_NO_DOT]
  81. BROWSER_FILTER = "Supported Images ({})".format(' '.join(['*.'+s for s in SUPPORTED_EXTENSIONS_NO_DOT]))
  82.  
  83. SCALE_LIST_POWERS_OF_TWO = [2**x for x in range(-4,5)]
  84. SCALE_LIST_FINER = [2**(0.5*x) for x in range(-8,9)]
  85. SCALE_LIST_MUCH_FINER = [2**(0.25*x) for x in range(-16,17)]
  86. SCALE_FILTERS = {
  87.     PillowImage.Resampling.NEAREST.name: PillowImage.Resampling.NEAREST,
  88.     PillowImage.Resampling.BOX.name: PillowImage.Resampling.BOX,
  89.     PillowImage.Resampling.BILINEAR.name: PillowImage.Resampling.BILINEAR,
  90.     PillowImage.Resampling.HAMMING.name: PillowImage.Resampling.HAMMING,
  91.     PillowImage.Resampling.BICUBIC.name: PillowImage.Resampling.BICUBIC,
  92.     PillowImage.Resampling.LANCZOS.name: PillowImage.Resampling.LANCZOS
  93. }
  94. SCALE_LEVEL_LISTS = {
  95.     "POWERS_OF_TWO": SCALE_LIST_POWERS_OF_TWO,
  96.     "FINER": SCALE_LIST_FINER,
  97.     "MUCH_FINER": SCALE_LIST_MUCH_FINER
  98. }
  99.  
  100. WHEEL_DELTA_PER_STEP = 120 # Per Qt docs
  101.  
  102. MINIMUM_CROPPED_SIZE = 5
  103.  
  104. def replaceText(language, string, record=None):
  105.     if record is not None:
  106.         record[string] = ""
  107.  
  108.     if language and string in language:
  109.         return language[string]
  110.     else:
  111.         return string
  112.  
  113. def getInterprocessCommunicationDir():
  114.     return os.path.join(tempfile.gettempdir(), INTERPROCESS_COMMUNICATION_DIRECTORY, os.getlogin(), '')
  115.  
  116. def getLockFilePath():
  117.     return os.path.join(getInterprocessCommunicationDir(), MAIN_INSTANCE_LOCK_FILE)
  118.  
  119.  
  120. class FileSortType(Enum):
  121.     ALPHABETICAL_ASC = 1
  122.     ALPHABETICAL_DSC = 2
  123.     MODIFIED_ASC = 3
  124.     MODIFIED_DSC = 4
  125.     CREATED_ASC = 5
  126.     CREATED_DSC = 6
  127.  
  128.  
  129. class ListRounding(Enum):
  130.     EITHER = 1 # Round to closest value
  131.     PREFER_UP = 2 # Round up to closest value, or down if none exists
  132.     PREFER_DOWN = 3 # Round down to closest value, or up if none exists
  133.  
  134.  
  135. def closestInList(items, value, rounding):
  136.     if rounding is ListRounding.EITHER:
  137.         return min(enumerate(items), key=lambda x: abs(x[1]-value))[0]
  138.     else:
  139.         uplist = [(i,(x-value)) for i,x in enumerate(items) if (x-value) >= 0]
  140.         downlist = [(i,(value-x)) for i,x in enumerate(items) if (value-x) >= 0]
  141.  
  142.         if not uplist and not downlist:
  143.             raise ValueError("Invalid list")
  144.  
  145.         if rounding is ListRounding.PREFER_UP or not downlist:
  146.             searchlist = uplist
  147.         elif rounding is ListRounding.PREFER_DOWN or not uplist:
  148.             searchlist = downlist
  149.  
  150.         return min(searchlist, key=lambda x: x[1])[0]
  151.  
  152.  
  153. class BasicDialog(QDialog):
  154.     def __init__(self, parent, title, message):
  155.         super().__init__(parent)
  156.  
  157.         self.setWindowTitle(title)
  158.  
  159.         self.buttonBox = QDialogButtonBox(QDialogButtonBox.Ok)
  160.         self.buttonBox.accepted.connect(self.accept)
  161.  
  162.         label = QLabel(message)
  163.  
  164.         self.layout = QVBoxLayout()
  165.         self.layout.addWidget(label)
  166.         self.layout.addWidget(self.buttonBox)
  167.  
  168.         self.setLayout(self.layout)
  169.  
  170.  
  171. # This is used as the base class for image processing layers used
  172. # to convert a pillow image into a seemlessly animated image.
  173. # * FileImageLayer (extraction of frames and storing original frames)
  174. # * ScaleImageLayer (scaling images to a given ratio)
  175. # * CropImageLayer (cropping frames)
  176. # * QtImageLayer (convert pillow images to Qt images)
  177. class ImageLayer:
  178.     def __init__(self):
  179.         if type(self) == ImageLayer:
  180.             raise NotImplementedError("ImageLayer cannot be instantiated.")
  181.  
  182.     # Scaled/cropped width (depending on layer)
  183.     def getImageWidth(self):
  184.         return self.getPillowImage(0).width
  185.  
  186.     # Scaled/cropped height (depending on layer)
  187.     def getImageHeight(self):
  188.         return self.getPillowImage(0).height
  189.  
  190.     def hasAnimation(self):
  191.         return self.getFrameCount() > 1
  192.  
  193.     def __iter__(self):
  194.         class ImageLayerIterator:
  195.             def __init__ (self, lst):
  196.                 self.lst = lst
  197.                 self.current = 0
  198.  
  199.             def __iter__ (self):
  200.                 return self
  201.  
  202.             def __next__ (self):
  203.                 try:
  204.                     item = self.lst[self.current]
  205.                 except IndexError:
  206.                     raise StopIteration()
  207.                 self.current += 1
  208.                 return item
  209.  
  210.         return ImageLayerIterator(self.getLayerFrameList())
  211.  
  212.     def getLayerFrameList(self):
  213.         if hasattr(self, "layer_frames") and self.layer_frames:
  214.             return self.layer_frames
  215.         else:
  216.             return self.sublayer.getLayerFrameList()
  217.  
  218.     def setBaseImage(self, pillow_image):
  219.         self.sublayer.setBaseImage(pillow_image)
  220.         self.updateLayer()
  221.  
  222.     # "bounds" is the distances from their respective edges,
  223.     # given as (left, top, right, bottom).
  224.     # Note: Pillow's coordinate system is different!
  225.     def applyCrop(self, bounds, cumulative):
  226.         self.sublayer.applyCrop(bounds, cumulative)
  227.         self.updateLayer()
  228.  
  229.     def isCropped(self):
  230.         return self.sublayer.isCropped()
  231.  
  232.     def getCropBounds(self):
  233.         return self.sublayer.getCropBounds()
  234.  
  235.     # accumulate_scale, if true, applies the scale ratio on top of the
  236.     # existing scale ratio. Otherwise, the given ratio replaces the old one.
  237.     def applyScale(self, ratio, pillow_filter, accumulate_scale=False):
  238.         self.sublayer.applyScale(ratio, pillow_filter, accumulate_scale)
  239.         self.updateLayer()
  240.  
  241.     def getScaleRatio(self):
  242.         return self.sublayer.getScaleRatio()
  243.  
  244.     def getScalingFilter(self):
  245.         return self.sublayer.getScalingFilter()
  246.  
  247.     def getFrameCount(self):
  248.         return self.sublayer.getFrameCount()
  249.  
  250.     def getFrameDuration(self, frame_number):
  251.         return self.sublayer.getFrameDuration(frame_number)
  252.  
  253.     def getPillowImage(self, frame_number):
  254.         return self.getLayerFrameList()[frame_number]
  255.  
  256.     def getQtImage(self, frame_number):
  257.         raise NotImplementedError
  258.  
  259.  
  260. class FileImageLayer(ImageLayer):
  261.     def setBaseImage(self, pillow_image):
  262.         self.layer_frames = []
  263.         self.frame_lengths = []
  264.         if hasattr(pillow_image, "n_frames") and pillow_image.n_frames != 1:
  265.             for i in range(pillow_image.n_frames):
  266.                 pillow_image.seek(i)
  267.                 self.layer_frames.append(pillow_image.copy())
  268.                 self.frame_lengths.append(pillow_image.info['duration'])
  269.         else:
  270.             self.layer_frames = [pillow_image]
  271.             self.frame_lengths = None
  272.  
  273.     def getFrameDuration(self, frame_number):
  274.         if self.frame_lengths is not None:
  275.             return self.frame_lengths[frame_number]
  276.         else:
  277.             return None
  278.    
  279.     def getFrameCount(self):
  280.         if self.frame_lengths is not None:
  281.             return len(self.frame_lengths)
  282.         else:
  283.             return 1
  284.  
  285.  
  286. class ScaleImageLayer(ImageLayer):
  287.     def __init__(self, sublayer):
  288.         self.sublayer = sublayer
  289.  
  290.         self.scale_ratio = 1
  291.         self.scaling_filter = PillowImage.Resampling.BILINEAR
  292.  
  293.     def checkMemoryUsage(self, ratio):
  294.         # This is approximate, but that's enough.
  295.         memory_req = self.getFrameCount() * self.getImageWidth() * self.getImageHeight() * ratio * ratio * 4 / 1024 / 1024
  296.         if memory_req > PER_IMAGE_SCALE_MEMORY_LIMIT_MB and ratio > 1:
  297.             # TODO: Do this in some UI-friendly way.
  298.             print("Cannot set size: memory usage would exceed per-image maximum.")
  299.             return False
  300.         else:
  301.             return True
  302.  
  303.     def updateLayer(self):
  304.         if self.scale_ratio is not None and self.scale_ratio != 1:
  305.             # Recheck the scale ratio in case the image has been swapped.
  306.             if not self.checkMemoryUsage(self.scale_ratio):
  307.                 self.scale_ratio = None
  308.                 self.layer_frames = None
  309.                 return
  310.            
  311.             # Similar as with cropping, we have to clear the existing
  312.             # layer frames or the below operations will be incorrect.
  313.             self.layer_frames = []
  314.  
  315.             orig_width = self.sublayer.getImageWidth()
  316.             orig_height = self.sublayer.getImageHeight()
  317.  
  318.             size = (int(self.scale_ratio * orig_width), int(self.scale_ratio * orig_height))
  319.            
  320.             for frame in self.sublayer:
  321.                 self.layer_frames.append(frame.resize(size, resample=self.scaling_filter))
  322.         else:
  323.             self.layer_frames = None
  324.  
  325.     def applyScale(self, ratio, pillow_filter, accumulate_scale=False):
  326.         if ratio is not None:
  327.             if accumulate_scale:
  328.                 ratio = self.scale_ratio * ratio
  329.            
  330.             if self.checkMemoryUsage(ratio):
  331.                 self.scale_ratio = ratio
  332.        
  333.         if pillow_filter is not None:
  334.             self.scaling_filter = pillow_filter
  335.  
  336.         self.updateLayer()
  337.  
  338.     def getScaleRatio(self):
  339.         return self.scale_ratio
  340.  
  341.     def getScalingFilter(self):
  342.         return self.scaling_filter
  343.  
  344.  
  345. class CropImageLayer(ImageLayer):
  346.     def __init__(self, sublayer):
  347.         self.sublayer = sublayer
  348.  
  349.         self.crop_bounds = None
  350.  
  351.     def updateLayer(self):
  352.         if self.crop_bounds is not None and self.crop_bounds != (0, 0, 0, 0):
  353.             if len(self.crop_bounds) != 4:
  354.                 raise ValueError
  355.  
  356.             # The existing cropped frames must be cleared here,
  357.             # because the below is always cumulative otherwise.
  358.             # getImageWidth()/getImageHeight() will give the
  359.             # size of this layer if layer_frames is populated.
  360.             self.layer_frames = []
  361.  
  362.             orig_width = self.sublayer.getImageWidth()
  363.             orig_height = self.sublayer.getImageHeight()
  364.  
  365.             pil_crop = (self.crop_bounds[0],
  366.                         self.crop_bounds[1],
  367.                         orig_width - self.crop_bounds[2],
  368.                         orig_height - self.crop_bounds[3])
  369.  
  370.             for frame in self.sublayer:
  371.                 self.layer_frames.append(frame.crop(pil_crop))
  372.         else:
  373.             self.layer_frames = None
  374.  
  375.     def applyCrop(self, bounds, cumulative):
  376.         if bounds is not None and len(bounds) != 4:
  377.             raise ValueError
  378.        
  379.         if self.crop_bounds is not None and cumulative:
  380.             # We can't just iterate because Python tuples are constant,
  381.             # so we do this instead.
  382.             self.crop_bounds = tuple(map(sum, zip(self.crop_bounds, bounds)))
  383.         else:
  384.             self.crop_bounds = bounds
  385.         self.updateLayer()
  386.  
  387.     def isCropped(self):
  388.         return self.crop_bounds is not None and self.crop_bounds != (0, 0, 0, 0)
  389.    
  390.     def getCropBounds(self):
  391.         if self.crop_bounds is not None:
  392.             return self.crop_bounds
  393.         else:
  394.             return (0, 0, 0, 0)
  395.  
  396.     # Crop needs to implement this function to make sure crop bounds
  397.     # scale with the image.
  398.     def applyScale(self, ratio, pillow_filter, accumulate_scale=False):
  399.         if ratio is not None and self.isCropped():
  400.             old_ratio = self.sublayer.getScaleRatio()
  401.             self.sublayer.applyScale(ratio, pillow_filter, accumulate_scale)
  402.            
  403.             rel_ratio = ratio / old_ratio
  404.             self.crop_bounds = tuple(int(x * rel_ratio) for x in self.crop_bounds)
  405.         else:
  406.             self.sublayer.applyScale(ratio, pillow_filter, accumulate_scale)
  407.  
  408.         if self.isCropped():
  409.             self.updateLayer()
  410.  
  411.  
  412. class QtImageLayer(ImageLayer):
  413.     def __init__(self, sublayer):
  414.         self.sublayer = sublayer
  415.  
  416.     def updateLayer(self):
  417.         self.qt_images = []
  418.         for frame in self.sublayer:
  419.             self.qt_images.append(PillowImageQt.ImageQt(frame))
  420.  
  421.     def getQtImage(self, frame_number):
  422.         return self.qt_images[frame_number]
  423.  
  424.  
  425. def makeImageLayers():
  426.     fileLayer = FileImageLayer()
  427.     scaleLayer = ScaleImageLayer(fileLayer)
  428.     cropLayer = CropImageLayer(scaleLayer)
  429.     qtLayer = QtImageLayer(cropLayer)
  430.     return qtLayer
  431.  
  432.  
  433. class Viewer(QMainWindow):
  434.     def __init__(self, app, filepath):
  435.         super().__init__(None)
  436.         self.setWindowFlag(Qt.FramelessWindowHint)
  437. #        self.setWindowFlag(Qt.Popup) # Bypass KDE movement and snapping if present
  438.  
  439.         self.main_menu = None
  440.         self.accum_wheel_delta = 0
  441.         self.drawing_crop = False
  442.         self.rubber_band = None
  443.         self.drag_down_window_offset = None
  444.         self.file_list = None
  445.  
  446.         self.current_filepath = filepath
  447.         self.app = app
  448.  
  449.         self.image_layers = makeImageLayers()
  450.  
  451.         self.current_frame = 0
  452.         self.play_animation = False
  453.  
  454.         # This isn't started until an animated image is loaded.
  455.         self.animation_timer = QTimer(self)
  456.         self.animation_timer.timeout.connect(self.animate)
  457.         self.animation_timer.setSingleShot(True)
  458.  
  459.         self.label = QLabel(self)
  460.         self.setCentralWidget(self.label)
  461.  
  462.         self.setImageFromPath(filepath)
  463.         self.regenerate()
  464.         self.initMenu()
  465.  
  466.     def onCopyImagePath(self):
  467.         try:
  468.             pyperclip.copy(self.current_filepath)
  469.         except pyperclip.PyperclipException as e:
  470.             raise pyperclip.PyperclipException('pyperclip.copy() failed. On Linux/BSD, this may be due to you missing the program "xsel".') from e
  471.  
  472.     def showAbout(self):
  473.         BasicDialog(self, _("About"), _("ABOUT_CONTENT")).exec()
  474.  
  475.     def showHelp(self):
  476.         BasicDialog(self, _("Help"), _("HELP_CONTENT")).exec()
  477.  
  478.     def makeLanguageMenu(self):
  479.         ag_languages = QActionGroup(self)
  480.         ag_languages.setExclusive(True)
  481.  
  482.         for lang_code, lang_name in self.app.getLocales().items():
  483.             action = QAction(lang_code, self, checkable=True)
  484.             action.triggered.connect(partial(self.app.changeLocale, lang_code))
  485.             action.setText("{} ({})".format(lang_name, lang_code))
  486.  
  487.             if lang_code == self.app.getCurrentLocale():
  488.                 action.setChecked(True)
  489.  
  490.             ag_languages.addAction(action)
  491.  
  492.         menu = QMenu(_("Language"))
  493.         menu.addActions(ag_languages.actions())
  494.         return menu
  495.  
  496.     def initMenu(self):
  497.         if self.main_menu:
  498.             self.main_menu.deleteLater()
  499.  
  500.         act_next_image = QAction(_("Next image"), self)
  501.         act_next_image.triggered.connect(partial(self.changeImage, 1))
  502.  
  503.         act_prev_image = QAction(_("Previous image"), self)
  504.         act_prev_image.triggered.connect(partial(self.changeImage, -1))
  505.  
  506.         act_copy_path = QAction(_("Copy image path"), self)
  507.         act_copy_path.triggered.connect(self.onCopyImagePath)
  508.  
  509.         ag_scale_filter = QActionGroup(self)
  510.         ag_scale_filter.setExclusive(True)
  511.  
  512.         for name, filter in SCALE_FILTERS.items():
  513.             action = QAction(_(name), self, checkable=True)
  514.             action.triggered.connect(partial(self.app.setSetting, "ScalingAlgorithm", name))
  515.  
  516.             # Make tooltips optional if the language lacks them.
  517.             if _(name + "_TOOLTIP") != "":
  518.                 action.setToolTip(_(name + "_TOOLTIP"))
  519.  
  520.             if filter is self.getScalingFilter():
  521.                 action.setChecked(True)
  522.  
  523.             ag_scale_filter.addAction(action)
  524.  
  525.         ag_scale_level_list = QActionGroup(self)
  526.         ag_scale_level_list.setExclusive(True)
  527.  
  528.         for name, list in SCALE_LEVEL_LISTS.items():
  529.             action = QAction(_(name), self, checkable=True)
  530.             action.setToolTip(", ".join(['%.2f' % x for x in list]))
  531.             action.triggered.connect(partial(self.app.setSetting, "ScalingList", name))
  532.  
  533.             if list is self.getScaleLevelList():
  534.                 action.setChecked(True)
  535.  
  536.             ag_scale_level_list.addAction(action)
  537.  
  538.         ag_file_sort = QActionGroup(self)
  539.         ag_file_sort.setExclusive(True)
  540.  
  541.         for sort_type in FileSortType:
  542.             action = QAction(_(sort_type.name), self, checkable=True)
  543.             action.triggered.connect(partial(self.app.setSetting, "FileSorting", sort_type.name))
  544.  
  545.             if sort_type is self.getFileSortType():
  546.                 action.setChecked(True)
  547.  
  548.             ag_file_sort.addAction(action)
  549.  
  550.         act_about = QAction(_("About"), self)
  551.         act_about.triggered.connect(self.showAbout)
  552.  
  553.         act_help = QAction(_("Help"), self)
  554.         act_help.triggered.connect(self.showHelp)
  555.  
  556.         act_minimize = QAction(_("Minimize"), self)
  557.         act_minimize.triggered.connect(self.showMinimized)
  558.  
  559.         act_close = QAction(_("Close"), self)
  560.         act_close.triggered.connect(self.close)
  561.  
  562.         # Make the menu.
  563.         self.main_menu = QMenu(self)
  564.         self.main_menu.setToolTipsVisible(True)
  565.  
  566.         self.main_menu.addAction(act_next_image)
  567.         self.main_menu.addAction(act_prev_image)
  568.         self.main_menu.addAction(act_copy_path)
  569.  
  570.         self.main_menu.addSection(_("Scaling Filter"))
  571.         self.main_menu.addActions(ag_scale_filter.actions())
  572.  
  573.         self.main_menu.addSection(_("Scaling Levels"))
  574.         self.main_menu.addActions(ag_scale_level_list.actions())
  575.  
  576.         self.main_menu.addSection(_("File Sorting"))
  577.         self.main_menu.addActions(ag_file_sort.actions())
  578.  
  579.         self.main_menu.addSection(_("Window"))
  580.         self.main_menu.addAction(act_minimize)
  581.         self.main_menu.addAction(act_close)
  582.  
  583.         self.main_menu.addSection(_("Miscellaneous"))
  584.         self.main_menu.addMenu(self.makeLanguageMenu())
  585.         self.main_menu.addAction(act_about)
  586.         self.main_menu.addAction(act_help)
  587.  
  588.     def getScalingFilter(self):
  589.         return SCALE_FILTERS[self.app.getSetting("ScalingAlgorithm", "BILINEAR")]
  590.  
  591.     def getScaleLevelList(self):
  592.         return SCALE_LEVEL_LISTS[self.app.getSetting("ScalingList", "FINER")]
  593.  
  594.     def getFileSortType(self):
  595.         return FileSortType[self.app.getSetting("FileSorting", "MODIFIED_DSC")]
  596.  
  597.     # Update image scaling (if the scale level list was changed, syncs to the new list),
  598.     # regenerates the menus, and reshows the image.
  599.     def regenerate(self):
  600.         new_scale_ratio = None
  601.         new_scaling_filter = None
  602.        
  603.         if self.image_layers.getScaleRatio() not in self.getScaleLevelList():
  604.             new_scale_ratio = closestInList(new_scale_level_list, self.image_layers.getScaleRatio(), ListRounding.EITHER)
  605.        
  606.         if self.getScalingFilter() != self.image_layers.getScalingFilter():
  607.             new_scaling_filter = self.getScalingFilter()
  608.        
  609.         if new_scale_ratio is not None or new_scaling_filter is not None:
  610.             self.image_layers.applyScale(new_scale_ratio, new_scaling_filter)
  611.  
  612.         self.initMenu()
  613.         self.updateQtImage()
  614.  
  615.     def notifySettingChanged(self, key):
  616.         if key == "FileSorting":
  617.             self.updateFileListSort()
  618.         elif key == "Language":
  619.             self.initMenu()
  620.         else:
  621.             self.regenerate()
  622.  
  623.     def updateFileListSort(self):
  624.         sort = self.getFileSortType()
  625.  
  626.         if not self.getFileList():
  627.             return
  628.  
  629.         if sort in (FileSortType.ALPHABETICAL_ASC, FileSortType.ALPHABETICAL_DSC):
  630.             self.file_list.sort(key=cmp_to_key(locale.strcoll), reverse=(sort is FileSortType.ALPHABETICAL_DSC))
  631.         elif sort in (FileSortType.MODIFIED_ASC, FileSortType.MODIFIED_DSC):
  632.             self.file_list.sort(key=os.path.getmtime, reverse=(sort is FileSortType.MODIFIED_DSC))
  633.         elif sort in (FileSortType.CREATED_ASC, FileSortType.CREATED_DSC):
  634.             self.file_list.sort(key=os.path.getctime, reverse=(sort is FileSortType.CREATED_DSC))
  635.         else:
  636.             raise ValueError("Unhandled sorting")
  637.  
  638.     def getFileList(self):
  639.         if not self.file_list:
  640.             self.file_list = []
  641.             dir = os.path.dirname(self.current_filepath)
  642.             for file in os.listdir(path=dir):
  643.                 if os.path.splitext(file)[1].lower() in SUPPORTED_EXTENSIONS:
  644.                     self.file_list.append(os.path.join(dir, file))
  645.  
  646.             self.updateFileListSort()
  647.  
  648.         return self.file_list
  649.  
  650.     def setImageFromPath(self, filepath):
  651.         self.image_layers.setBaseImage(PillowImage.open(filepath))
  652.         self.current_filepath = filepath
  653.         self.setWindowTitle(os.path.basename(filepath))
  654.  
  655.         self.current_frame = 0
  656.  
  657.         if self.image_layers.hasAnimation():
  658.             self.play_animation = True
  659.             self.setFrameTimer()
  660.         else: self.play_animation = False
  661.  
  662.         self.updateQtImage()
  663.  
  664.     def changeImage(self, offset):
  665.         if offset == 0:
  666.             return
  667.        
  668.         file_list = self.getFileList()
  669.  
  670.         # If image loading fails, we don't want to get stuck.
  671.         # Keep iterating until we get somewhere.
  672.         while True:
  673.             try:
  674.                 cur_i = file_list.index(self.current_filepath)
  675.                 self.setImageFromPath(file_list[(cur_i + offset) % len(file_list)])
  676.                 return
  677.             except (PillowUnidentifiedImageError, FileNotFoundError) as e:
  678.                 offset += int(math.copysign(1, offset))
  679.  
  680.     def prevImage(self):
  681.         self.changeImage(-1)
  682.  
  683.     def nextImage(self):
  684.         self.changeImage(1)
  685.  
  686.     def updateQtImage(self):
  687.         pixmap = QPixmap.fromImage(self.image_layers.getQtImage(self.current_frame))
  688.  
  689.         first_update = self.label.pixmap is None
  690.  
  691.         self.label.setPixmap(pixmap)
  692.  
  693.         previous_size = self.size()
  694.  
  695.         # Needed to keep the image in its current size from blocking shrinking the window.
  696.         self.setMinimumSize(0, 0)
  697.  
  698.         # Undo the max size that would be set below from a previous update.
  699.         self.setMaximumSize(self.maximumWidth(), self.maximumHeight())
  700.  
  701.         self.resize(pixmap.width(), pixmap.height())
  702.  
  703.         # Prevent maximize or other window size changes.
  704.         self.setMinimumSize(pixmap.width(), pixmap.height())
  705.         self.setMaximumSize(pixmap.width(), pixmap.height())
  706.  
  707.         new_size = self.size()
  708.  
  709.         if not first_update:
  710.             resize_offset = (previous_size - new_size) / 2
  711.             self.move(self.pos() + QPoint(resize_offset.width(), resize_offset.height()))
  712.  
  713.     def animate(self, frame_increment=1):
  714.         total_frames = self.image_layers.getFrameCount()
  715.         self.current_frame = (self.current_frame + frame_increment) % total_frames
  716.         self.updateQtImage()
  717.  
  718.         if self.play_animation:
  719.             self.setFrameTimer()
  720.  
  721.     def setFrameTimer(self):
  722.         # The timer was already set up a single-shot.
  723.         # Gifs allow each frame to have a different speed,
  724.         # so we use single-shot to time the next frame.
  725.         self.animation_timer.setInterval(self.image_layers.getFrameDuration(self.current_frame))
  726.         self.animation_timer.start()
  727.    
  728.     # Amount can be negative; the current scale ratio need not be in the list,
  729.     # but if it isn't, one 'amount' will be used to snap to the nearest
  730.     # item in the scale list.
  731.     def incrementImageScale(self, amount):
  732.         if amount == 0:
  733.             return
  734.        
  735.         scale_level_list = self.getScaleLevelList()
  736.         current_scale = self.image_layers.getScaleRatio()
  737.  
  738.         if amount > 0:
  739.             current_in_list = scale_level_list[closestInList(scale_level_list, current_scale, ListRounding.PREFER_UP)]
  740.         else:
  741.             current_in_list = scale_level_list[closestInList(scale_level_list, current_scale, ListRounding.PREFER_DOWN)]
  742.        
  743.         if abs(current_in_list - current_scale) > 0.01:
  744.             amount - math.copysign(1, amount) # Basically, move 1 towards 0 as docs say.
  745.        
  746.         current_scale = current_in_list # We overwrite it either way just to make sure "in" works.
  747.        
  748.         index = scale_level_list.index(current_scale)
  749.         index = min(max(index + amount, 0), len(scale_level_list) - 1)
  750.  
  751.         self.image_layers.applyScale(scale_level_list[index], None)
  752.         self.updateQtImage()
  753.  
  754.     def resetImageScale(self):
  755.         self.image_layers.applyScale(1, None)
  756.         self.updateQtImage()
  757.  
  758.     # Fits the image to the screen. If fit_larger is true, the image will be fit on its
  759.     # proportionally (to the screen) smaller side; if it is false, it will be fit on the
  760.     # proportionally larger side. Meaning, fit_larger=true will (probably) make the image
  761.     # exceed the size of the screen on one side, fit_larger=false will (probably) make
  762.     # it smaller than the screen on one side. If the aspect ratio of the image is the same
  763.     # as the (usable) screen, the fit_larger=true and fit_larger=false will be the same.
  764.     def setScreenFill(self, fit_larger):
  765.         screenSize = self.screen().availableSize()
  766.  
  767.         width = self.image_layers.getImageWidth()
  768.         height = self.image_layers.getImageHeight()
  769.  
  770.         ratios = (screenSize.width() / width, screenSize.height() / height)
  771.  
  772.         if fit_larger:
  773.             factor = max(ratios)
  774.         else:
  775.             factor = min(ratios)
  776.  
  777.         # Must use use accumulate_scale because the ratio is
  778.         # based on the current scale (indirectly).
  779.         self.image_layers.applyScale(factor, None, True)
  780.         self.updateQtImage()
  781.  
  782.     # Qt handler.
  783.     def wheelEvent(self, event):
  784.         if Qt.AltModifier in event.modifiers():
  785.             # Note: I wanted to make alt control the
  786.             # screen fit, but it seems holding alt causes
  787.             # the angle delta to always be 0.
  788.             self.resetImageScale()
  789.         elif Qt.ControlModifier in event.modifiers():
  790.             if event.angleDelta().y() > 0:
  791.                 self.setScreenFill(True)
  792.             else:
  793.                 self.setScreenFill(False)
  794.         else:
  795.             self.accum_wheel_delta = self.accum_wheel_delta + event.angleDelta().y()
  796.             steps = self.accum_wheel_delta // WHEEL_DELTA_PER_STEP
  797.             self.accum_wheel_delta -= steps * WHEEL_DELTA_PER_STEP
  798.             if steps != 0:
  799.                 self.incrementImageScale(steps)
  800.  
  801.     # Qt handler.
  802.     def mousePressEvent(self, event):
  803.         if event.button() is Qt.MouseButton.LeftButton and Qt.ControlModifier in event.modifiers():
  804.             origin = event.position().toPoint()
  805.  
  806.             if not self.rubber_band:
  807.                 self.rubber_band = QRubberBand(QRubberBand.Rectangle, self)
  808.  
  809.             self.drawing_crop = True
  810.             self.drawing_crop_origin = origin
  811.  
  812.             self.rubber_band.setGeometry(QRect(origin, QSize()))
  813.             self.rubber_band.show()
  814.         elif event.button() is Qt.MouseButton.LeftButton:
  815.             self.drag_down_window_offset = self.pos() - event.globalPosition().toPoint()
  816.  
  817.     # Qt handler.
  818.     def mouseMoveEvent(self, event):
  819.         if self.drawing_crop:
  820.             pos = event.position().toPoint()
  821.             self.rubber_band.setGeometry(QRect(self.drawing_crop_origin, pos).normalized())
  822.         elif self.drag_down_window_offset:
  823.             self.move(self.drag_down_window_offset + event.globalPosition().toPoint())
  824.  
  825.     # Qt handler.
  826.     def mouseReleaseEvent(self, event):
  827.         if event.button() is Qt.MouseButton.MiddleButton:
  828.             self.close()
  829.         elif event.button() is Qt.MouseButton.LeftButton and self.drawing_crop:
  830.             final_geometry = self.rubber_band.geometry()
  831.  
  832.             self.rubber_band.hide()
  833.             self.drawing_crop = False
  834.  
  835.             # Qt allows the geometry to be negative if dragged outside the
  836.             # window, so we need to limit them to the image size.
  837.             image_width = self.image_layers.getImageWidth()
  838.             image_height = self.image_layers.getImageHeight()
  839.  
  840.             top = max(0, final_geometry.y())
  841.             bottom = image_height - min(final_geometry.y() + final_geometry.height(), image_height)
  842.             left = max(0, final_geometry.x())
  843.             right = image_width - min(final_geometry.x() + final_geometry.width(), image_width)
  844.  
  845.             rel_bottom = image_height - bottom
  846.             rel_right = image_width - right
  847.  
  848.             # If the selection is tiny, cancel the crop entirely.
  849.             if (rel_right - left) < MINIMUM_CROPPED_SIZE and (rel_bottom - top) < MINIMUM_CROPPED_SIZE:
  850.                 old_crop = self.image_layers.getCropBounds()
  851.                 new_pos = self.pos() - QPoint(old_crop[0], old_crop[1])
  852.  
  853.                 self.image_layers.applyCrop((0, 0, 0, 0), False)
  854.                 self.updateQtImage()
  855.                 self.move(new_pos)
  856.  
  857.                 return
  858.  
  859.             self.image_layers.applyCrop((left, top, right, bottom), True)
  860.  
  861.             # Calculate the position the window belongs in now. However, it
  862.             # isn't moved until later, because updateQtImage() will resize
  863.             # the window, which will mess with positioning.
  864.             new_pos = QPoint(left, top) + self.pos()
  865.  
  866.             self.updateQtImage()
  867.             self.move(new_pos)
  868.         elif event.button() is Qt.MouseButton.LeftButton and Qt.AltModifier in event.modifiers():
  869.             self.resetImageScale()
  870.         elif event.button() is Qt.MouseButton.LeftButton and self.drag_down_window_offset:
  871.             self.drag_down_window_offset = None
  872.  
  873.     # Qt handler.
  874.     def keyPressEvent(self, event):
  875.         if event.key() in [Qt.Key_Left, Qt.Key_Up, Qt.Key_A, Qt.Key_W]:
  876.             self.prevImage()
  877.         elif event.key() in [Qt.Key_Right, Qt.Key_Down, Qt.Key_D, Qt.Key_S]:
  878.             self.nextImage()
  879.         elif event.key() in [Qt.Key_Plus, Qt.Key_Equal]:
  880.             self.changeScale(1)
  881.         elif event.key() in [Qt.Key_Minus, Qt.Key_Underscore]:
  882.             self.changeScale(-1)
  883.         elif event.key() in [Qt.Key_Period]:
  884.             self.changeScale(SpecialScale.FIT_IMAGE)
  885.         elif event.key() in [Qt.Key_Comma]:
  886.             self.setImageCrop(None)
  887.         elif event.key() in [Qt.Key_Space]:
  888.             if self.play_animation:
  889.                 self.play_animation = False
  890.                 self.animation_timer.stop()
  891.             elif self.image_layers.hasAnimation():
  892.                 self.play_animation = True
  893.                 self.setFrameTimer()
  894.         elif event.key() in [Qt.Key_BracketLeft]:
  895.             self.play_animation = False
  896.             self.animation_timer.stop()
  897.             self.animate(-1)
  898.         elif event.key() in [Qt.Key_BracketRight]:
  899.             self.play_animation = False
  900.             self.animation_timer.stop()
  901.             self.animate(1)
  902.         elif event.key() in [Qt.Key_Escape]:
  903.             self.close()
  904.  
  905.     # Qt handler.
  906.     def contextMenuEvent(self, event):
  907.         self.main_menu.exec(event.globalPos())
  908.  
  909.     # Qt handler.
  910.     def closeEvent(self, event):
  911.         self.app.windowClosed(self)
  912.  
  913.     # Qt handler.
  914.     def moveEvent(self, event):
  915.         event.accept()
  916.  
  917.  
  918. class ViewerApplication(QApplication):
  919.     def __init__(self, args):
  920.         super().__init__(args)
  921.  
  922.         # TODO: Neither of these work?
  923.         self.setApplicationDisplayName(PROGRAM_FULL_NAME)
  924.         self.setApplicationName(PROGRAM_FULL_NAME)
  925.  
  926.         self.windows = []
  927.  
  928.         self.settings = QSettings(os.path.join((QStandardPaths.writableLocation(QStandardPaths.ConfigLocation)), PROGRAM_NAME.lower(), SETTINGS_FILE), QSettings.IniFormat)
  929.  
  930.         self.language_record = None
  931.         if LANGUAGE_RECORD:
  932.             self.language_record = {}
  933.  
  934.             def saveLanguageTemplate():
  935.                 record_json = json.dumps(self.language_record, sort_keys=True, indent=4)
  936.                 record_file = open(os.path.join(LANGUAGE_DIRECTORY, "lang.template"), 'x', encoding="utf8")
  937.                 record_file.write(record_json)
  938.                 record_file.close()
  939.  
  940.             atexit.register(saveLanguageTemplate)
  941.  
  942.         self.languages = {}
  943.         self.syncLanguages()
  944.         self.changeLocale(self.settings.value("Language", locale.getlocale()[0]), True)
  945.  
  946.         self.interprocess_timer = QTimer(self)
  947.         self.interprocess_timer.timeout.connect(self.checkForNewFiles)
  948.         self.interprocess_timer.setInterval(INTERPROCESS_INTERVAL_MSEC)
  949.         self.interprocess_timer.start()
  950.  
  951.         self.setupApplicationIcon()
  952.  
  953.     def setupApplicationIcon(self):
  954.         self.application_icon = QIcon()
  955.  
  956.         self.application_icon.addFile(os.path.join(RESOURCE_DIRECTORY, "kiyoi8.png"), size=QSize(8, 8))
  957.         self.application_icon.addFile(os.path.join(RESOURCE_DIRECTORY, "kiyoi16.png"), size=QSize(16, 16))
  958.         self.application_icon.addFile(os.path.join(RESOURCE_DIRECTORY, "kiyoi32.png"), size=QSize(32, 32))
  959.         self.application_icon.addFile(os.path.join(RESOURCE_DIRECTORY, "kiyoi48.png"), size=QSize(48, 48))
  960.         self.application_icon.addFile(os.path.join(RESOURCE_DIRECTORY, "kiyoi64.png"), size=QSize(64, 64))
  961.         self.application_icon.addFile(os.path.join(RESOURCE_DIRECTORY, "kiyoi128.png"), size=QSize(128, 128))
  962.         self.application_icon.addFile(os.path.join(RESOURCE_DIRECTORY, "kiyoi256.png"), size=QSize(256, 256))
  963.  
  964.     def syncLanguages(self):
  965.         self.languages = {}
  966.         for file_name in os.listdir(path=LANGUAGE_DIRECTORY):
  967.             if os.path.splitext(file_name)[1] == LANGUAGE_FILE_SUFFIX:
  968.                 lang_code = os.path.splitext(file_name)[0].lower()
  969.                 if not lang_code in self.languages:
  970.                     path = os.path.join(LANGUAGE_DIRECTORY, file_name)
  971.                     file = open(path, encoding="utf8")
  972.                     self.languages[lang_code] = json.load(file)
  973.                     file.close()
  974.  
  975.     def changeLocale(self, locale_code, dontsave=False):
  976.         # The locale code is in the format XX[_YY].
  977.         # If XX_YY is not found, XX is tried.
  978.         # If XX does not exist, the "key" strings are
  979.         # printed when strings are requested, which will
  980.         # likely be sort-of English.
  981.         # Sometimes that might happen due to the "C" locale.
  982.         # XX/YY need not be two characters, but RFC 1766 ones are.
  983.         # Codes are handled case-insensitively.
  984.         language = None
  985.         locale_code = locale_code.lower()
  986.        
  987.         if locale_code in self.languages:
  988.             language = self.languages[locale_code]
  989.         elif "_" in locale_code and locale_code.split("_")[0] in self.languages:
  990.             locale_code = locale_code.split("_")[0]
  991.             language = self.languages[locale_code]
  992.        
  993.         global _
  994.         _ = partial(replaceText, language, record=self.language_record)
  995.  
  996.         self.locale_code = locale_code
  997.  
  998.         if dontsave is False:
  999.             self.setSetting("Language", locale_code)
  1000.  
  1001.     def getCurrentLocale(self):
  1002.         return self.locale_code
  1003.  
  1004.     def getLocales(self):
  1005.         locales = {}
  1006.         for code, language in self.languages.items():
  1007.             locales[code] = language[LANGUAGE_NAME_STRING]
  1008.         return locales
  1009.  
  1010.     def getSetting(self, key, default=None):
  1011.         return self.settings.value(key, default)
  1012.  
  1013.     def setSetting(self, key, value):
  1014.         self.settings.setValue(key, value)
  1015.         self.settings.sync()
  1016.  
  1017.         # This is for non-regenerating settings, like the file list sort.
  1018.         for window in self.windows:
  1019.             window.notifySettingChanged(key)
  1020.  
  1021.     def windowClosed(self, window):
  1022.         self.windows.remove(window)
  1023.  
  1024.     def openImageFile(self, path):
  1025.         viewer = Viewer(self, path)
  1026.  
  1027.         viewer.setWindowIcon(self.application_icon)
  1028.  
  1029.         viewer.show()
  1030.  
  1031.         # Sidestep the window getting "stuck" to the screen edges if too large.
  1032.         # See: https://stackoverflow.com/a/68477844
  1033.         viewer.updateQtImage()
  1034.         viewer.activateWindow()
  1035.  
  1036.         self.windows.append(viewer)
  1037.  
  1038.     def checkForNewFiles(self):
  1039.         # Get all the request files.
  1040.         request_file_list = []
  1041.         dir = os.path.dirname(getInterprocessCommunicationDir())
  1042.         for file in os.listdir(path=dir):
  1043.             if os.path.splitext(file)[1] == MAIN_INSTANCE_REQUEST_FILE_SUFFIX:
  1044.                 request_file_list.append(os.path.join(dir, file))
  1045.  
  1046.         # Parse the request files into actual image requests.aboutToQuit
  1047.         #
  1048.         # This is broken up so that waiting on an image to open doesn't
  1049.         # cause new master instances to be incorrectly started.
  1050.         request_image_list = []
  1051.         for request_file in request_file_list:
  1052.             with open(request_file, 'r', encoding="utf8") as file:
  1053.                 for line in file:
  1054.                     if line != "":
  1055.                         request_image_list.append(line)
  1056.             os.remove(request_file)
  1057.  
  1058.         # Actually attempt to open the image requests.
  1059.         for request_image in request_image_list:
  1060.             self.openImageFile(request_image)
  1061.        
  1062.         # If there are no more windows open, quit.
  1063.         if len(self.windows) == 0:
  1064.             self.quit()
  1065.  
  1066.  
  1067. def createMainInstance(path):
  1068.     try:
  1069.         os.makedirs(getInterprocessCommunicationDir())
  1070.     except:
  1071.         pass
  1072.  
  1073.     try:
  1074.         lockfile = open(getLockFilePath(), 'x', encoding="utf8")
  1075.     except OSError as e:
  1076.         # The file either exists or it couldn't be created.
  1077.         # Those are separate issues, so check again if it
  1078.         # exists.
  1079.         if os.path.exists(getLockFilePath()):
  1080.             # This process was stepped on by another starting process.
  1081.             # Fail, let the caller determine what to do (probably notify
  1082.             # the process that exists).
  1083.             return False
  1084.         else:
  1085.             # Something else is wrong. Reraise.
  1086.             raise OSError('Cannot create main instance lock file.') from e
  1087.  
  1088.     lockfile.write("Main instance PID: {}".format(os.getpid()))
  1089.     lockfile.close()
  1090.  
  1091.     try:
  1092.         app = ViewerApplication(sys.argv)
  1093.         app.openImageFile(path)
  1094.         app.exec()
  1095.     finally:
  1096.         os.remove(getLockFilePath())
  1097.    
  1098.     return True
  1099.  
  1100. def notifyMainInstance(path):
  1101.     request_file = MAIN_INSTANCE_REQUEST_FILE_PREFIX + str(uuid.uuid4()) + MAIN_INSTANCE_REQUEST_FILE_SUFFIX
  1102.     request_path = os.path.join(getInterprocessCommunicationDir(), request_file)
  1103.     requestfile = open(request_path, 'x', encoding="utf8")
  1104.     requestfile.write(path)
  1105.     requestfile.close()
  1106.  
  1107.     # We use a partially random interval to try to avoid restarting the
  1108.     # server multiple times at once, if multiple instances were all
  1109.     # opened while the main instance was dead. This isn't foolproof,
  1110.     # but it's something.
  1111.     random_sleep_msec = MAIN_INSTANCE_REQUEST_TIMEOUT_RANDOM_MSEC * random.random()
  1112.     sleep((MAIN_INSTANCE_REQUEST_TIMEOUT_MIN_MSEC + random_sleep_msec) / 1000)
  1113.  
  1114.     if os.path.exists(request_path):
  1115.         # The request was never received... main instance may be dead, try to
  1116.         # restart it.
  1117.         os.remove(request_path)
  1118.         os.remove(getLockFilePath())
  1119.         return False
  1120.  
  1121.     return True
  1122.  
  1123. def createOrNotifyMainInstance(path):
  1124.     if os.path.exists(getLockFilePath()):
  1125.         # No main instance, attempt to start one.
  1126.         if notifyMainInstance(path):
  1127.             return
  1128.         else:
  1129.             # Couldn't notify instance, it may have died.
  1130.             # Try again.
  1131.             createOrNotifyMainInstance(path)
  1132.     else:
  1133.         # Main instance exists (or existed), attempt to notify it.
  1134.         if createMainInstance(path):
  1135.             return
  1136.         else:
  1137.             # Creating the main instance failed, but there wasn't a critical
  1138.             # error, so it must already exist. Try to notify it.
  1139.             createOrNotifyMainInstance(path)
  1140.  
  1141. def kiyoi_run():
  1142.     parser = argparse.ArgumentParser(description=PROGRAM_FULL_NAME)
  1143.     parser.add_argument('path', nargs='?', help="Path to image. If not given, a file browser will be opened.")
  1144.     args = parser.parse_args()
  1145.  
  1146.     path = args.path
  1147.  
  1148.     if path is None:
  1149.         # We need to start an application to be able to show the file browser,
  1150.         # but we also don't keep it around because we may not want it.
  1151.         app = QApplication()
  1152.         path = QFileDialog.getOpenFileName(filter=BROWSER_FILTER)[0]
  1153.         app.shutdown()
  1154.    
  1155.     if path:
  1156.         # Because of the business with having a single master instance,
  1157.         # relative paths need resolved NOW or they may be mishandled.
  1158.         # Additionally, if the path is bad (and we can detect that),
  1159.         # we want to fail now, in the instance the user actually started,
  1160.         # rather than have the master instance spit out an unhelpful error.
  1161.         path = os.path.realpath(path)
  1162.  
  1163.         # Fail early if possible. We can't reasonably detect invalid images,
  1164.         # but we can detect ones that don't exist or we don't support.
  1165.         if not os.path.isfile(path):
  1166.             raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), path)
  1167.  
  1168.         if not os.path.splitext(path)[1].lower() in SUPPORTED_EXTENSIONS:
  1169.             raise ValueError("Invalid file type")
  1170.  
  1171.         createOrNotifyMainInstance(path)
  1172.  
  1173. if __name__ == "__main__":
  1174.     kiyoi_run()
  1175.  
Advertisement
Add Comment
Please, Sign In to add comment