def __init__(self, window: MainWindow):
        super().__init__()

        self.__window = window

        self.__file_handler = FileHandler(None)
        self.__image_handler = ImageHandler()
示例#2
0
    def __init__(self, window: MainWindow):
        super().__init__(title='Open')

        self.__window = window

        self.__file_handler = FileHandler(None)

        self.__action = Gtk.FileChooserAction.OPEN
        self.__last_activated_file = None

        self.set_modal(True)
        self.set_transient_for(self.__window)
        self.add_buttons(*(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
                           Gtk.STOCK_OPEN, Gtk.ResponseType.OK))
        self.set_default_response(Gtk.ResponseType.OK)

        self.__filechooser = Gtk.FileChooserWidget(action=self.__action)
        self.__filechooser.set_select_multiple(True)
        self.__filechooser.set_size_request(1280, 720)
        self.vbox.pack_start(self.__filechooser, True, True, 0)
        self.set_border_width(4)
        self.__filechooser.set_border_width(6)
        self.connect('response', self._response)
        self.__filechooser.connect('file_activated', self._response,
                                   Gtk.ResponseType.OK)

        self.add_filter('All files', [])
        self.add_archive_filters()
        self.add_image_filters()

        filters = self.__filechooser.list_filters()
        self.__filechooser.set_filter(
            filters[config['FILECHOOSER_LAST_FILTER']])

        current_file = self.__file_handler.get_base_path()
        try:
            if current_file is not None:
                # If a file is currently open, use its path
                if current_file.is_file():
                    current_file = current_file.parent

                self.__filechooser.set_current_folder(str(current_file))

            elif self.__last_activated_file:
                # If no file is open, use the last stored file
                self.__filechooser.set_filename(self.__last_activated_file)
            else:
                # If no file was stored yet, fall back to preferences
                self.__filechooser.set_current_folder(
                    config['FILECHOOSER_LAST_BROWSED_PATH'])
        except TypeError:
            self.__filechooser.set_current_folder(str(Path.home()))

        self.show_all()
    def __init__(self, window: MainWindow):
        super().__init__()

        self.__window = window

        self.__events = Events()

        self.__file_handler = FileHandler(None)
        self.__image_handler = ImageHandler()

        self.__bookmark_path = ConfigFiles.BOOKMARK.value
        self.__bookmark_state_dirty = False

        #: List of bookmarks
        self.__bookmarks = self.load_bookmarks_file()
        #: Modification date of bookmarks file
        self.__bookmarks_size = self.get_bookmarks_file_size()
示例#4
0
    def __init__(self, window: MainWindow):
        super().__init__(title='Preferences')

        self.__window = window

        self.__file_handler = FileHandler(None)
        self.__image_handler = ImageHandler()

        self.set_modal(True)
        self.set_transient_for(window)

        self.__reset_button = self.add_button('Clear _dialog choices', Gtk.ResponseType.REJECT)
        self.__reset_button.set_sensitive(len(config['STORED_DIALOG_CHOICES']) > 0)
        self.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)

        self.set_resizable(False)
        self.set_default_response(Gtk.ResponseType.CLOSE)

        self.connect('response', self._response)

        notebook = Gtk.Notebook()
        self.vbox.pack_start(notebook, True, True, 0)
        self.set_border_width(2)
        notebook.set_border_width(2)

        notebook.append_page(self._init_appearance_tab(),
                             Gtk.Label(label='Appearance'))

        notebook.append_page(self._init_behaviour_tab(),
                             Gtk.Label(label='Behaviour'))

        notebook.append_page(self._init_display_tab(),
                             Gtk.Label(label='Display'))

        notebook.append_page(self._init_animation_tab(),
                             Gtk.Label(label='Animation'))

        notebook.append_page(self._init_advanced_tab(),
                             Gtk.Label(label='Advanced'))

        self.show_all()
    def __init__(self, window: MainWindow):
        super().__init__(title='Properties')

        self.set_transient_for(window)
        self.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
                         Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT)

        self.__window = window

        events = Events()
        events.add_event(EventType.FILE_OPENED, self._on_book_change)
        events.add_event(EventType.FILE_CLOSED, self._on_book_change)
        events.add_event(EventType.PAGE_AVAILABLE, self._on_page_available)
        events.add_event(EventType.PAGE_CHANGED, self._on_page_change)

        self.__file_handler = FileHandler(None)
        self.__image_handler = ImageHandler()

        self.resize(870, 560)
        self.set_border_width(2)
        self.set_resizable(True)
        self.set_default_response(Gtk.ResponseType.CLOSE)

        self.__notebook = Gtk.Notebook()
        self.vbox.pack_start(self.__notebook, True, True, 0)

        self.__notebook.set_border_width(2)

        self.__archive_page = PropertiesPage()
        self.__image_page = PropertiesPage()

        self.__notebook.append_page(self.__archive_page,
                                    Gtk.Label(label='Archive'))
        self.__notebook.append_page(self.__image_page,
                                    Gtk.Label(label='Image'))

        self._update_archive_page()

        self.show_all()
示例#6
0
    def __init__(self, window: MainWindow):
        super().__init__()

        self.__window = window
        self.__keybindings = None
        self.__keybindings_map = None

        self.__file_handler = FileHandler(None)

        self.__was_fullscreen = False
        self.__previous_size = (None, None)

        # Dispatch keyboard input handling
        # Some keys require modifiers that are irrelevant to the hotkey. Find out and ignore them.
        self.__all_accels_mask = (Gdk.ModifierType.CONTROL_MASK |
                                  Gdk.ModifierType.SHIFT_MASK |
                                  Gdk.ModifierType.MOD1_MASK)

        self.__keymap = Gdk.Keymap.get_for_display(Gdk.Display.get_default())

        self.__last_pointer_pos_x = 0
        self.__last_pointer_pos_y = 0
示例#7
0
class FileChooser(Gtk.Dialog):
    """
    We roll our own FileChooserDialog because the one in GTK seems
    buggy with the preview widget. The <action> argument dictates what type
    of filechooser dialog we want (i.e. it is Gtk.FileChooserAction.OPEN
    or Gtk.FileChooserAction.SAVE).
    """

    __slots__ = ('__window', '__action', '__last_activated_file',
                 '__filechooser')

    def __init__(self, window: MainWindow):
        super().__init__(title='Open')

        self.__window = window

        self.__file_handler = FileHandler(None)

        self.__action = Gtk.FileChooserAction.OPEN
        self.__last_activated_file = None

        self.set_modal(True)
        self.set_transient_for(self.__window)
        self.add_buttons(*(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
                           Gtk.STOCK_OPEN, Gtk.ResponseType.OK))
        self.set_default_response(Gtk.ResponseType.OK)

        self.__filechooser = Gtk.FileChooserWidget(action=self.__action)
        self.__filechooser.set_select_multiple(True)
        self.__filechooser.set_size_request(1280, 720)
        self.vbox.pack_start(self.__filechooser, True, True, 0)
        self.set_border_width(4)
        self.__filechooser.set_border_width(6)
        self.connect('response', self._response)
        self.__filechooser.connect('file_activated', self._response,
                                   Gtk.ResponseType.OK)

        self.add_filter('All files', [])
        self.add_archive_filters()
        self.add_image_filters()

        filters = self.__filechooser.list_filters()
        self.__filechooser.set_filter(
            filters[config['FILECHOOSER_LAST_FILTER']])

        current_file = self.__file_handler.get_base_path()
        try:
            if current_file is not None:
                # If a file is currently open, use its path
                if current_file.is_file():
                    current_file = current_file.parent

                self.__filechooser.set_current_folder(str(current_file))

            elif self.__last_activated_file:
                # If no file is open, use the last stored file
                self.__filechooser.set_filename(self.__last_activated_file)
            else:
                # If no file was stored yet, fall back to preferences
                self.__filechooser.set_current_folder(
                    config['FILECHOOSER_LAST_BROWSED_PATH'])
        except TypeError:
            self.__filechooser.set_current_folder(str(Path.home()))

        self.show_all()

    def add_filter(self, name, patterns=None):
        """
        Add a filter, called <name>, for each mime type in <mimes> and
        each pattern in <patterns> to the filechooser
        """

        if patterns is None:
            patterns = []

        ffilter = Gtk.FileFilter()
        ffilter.add_custom(
            Gtk.FileFilterFlags.FILENAME | Gtk.FileFilterFlags.MIME_TYPE,
            self._filter, patterns)

        ffilter.set_name(name)
        self.__filechooser.add_filter(ffilter)
        return ffilter

    def add_archive_filters(self):
        """
        Add archive filters to the filechooser
        """

        ffilter = Gtk.FileFilter()
        ffilter.set_name('All archives')
        self.__filechooser.add_filter(ffilter)

        for ext in ArchiveSupported.EXTS.value:
            ffilter.add_pattern(f'*{ext}')

    def add_image_filters(self):
        """
        Add images filters to the filechooser
        """

        ffilter = Gtk.FileFilter()
        ffilter.set_name('All images')
        self.__filechooser.add_filter(ffilter)

        for ext in ImageSupported.EXTS.value:
            ffilter.add_pattern(f'*{ext}')

    def files_chosen(self, paths: list):
        if paths:
            filter_index = self.__filechooser.list_filters().index(
                self.__filechooser.get_filter())
            config['FILECHOOSER_LAST_FILTER'] = filter_index

            self.__file_handler.open_file_init(paths)

        self.destroy()

    def _filter(self, filter_info, pattern):
        """
        Callback function used to determine if a file
        should be filtered or not. Returns True
        if the file passed in C{filter_info} should be displayed
        """

        return bool(
            filter(
                lambda match_pattern: fnmatch.fnmatch(filter_info.filename,
                                                      match_pattern), pattern))

    def _response(self, widget, response):
        """
        Return a list of the paths of the chosen files, or None if the
        event only changed the current directory
        """

        if not response == Gtk.ResponseType.OK:
            self.files_chosen([])
            return

        paths = [Path(path) for path in self.__filechooser.get_filenames()]

        self.__last_activated_file = paths[0]
        self.files_chosen(paths)

        config[
            'FILECHOOSER_LAST_BROWSED_PATH'] = self.__filechooser.get_current_folder(
            )
示例#8
0
class PreferencesDialog(Gtk.Dialog):
    """
    The preferences dialog where most (but not all) settings that are
    saved between sessions are presented to the user
    """

    __slots__ = ('__window', '__file_handler', '__image_handler', '__reset_button')

    def __init__(self, window: MainWindow):
        super().__init__(title='Preferences')

        self.__window = window

        self.__file_handler = FileHandler(None)
        self.__image_handler = ImageHandler()

        self.set_modal(True)
        self.set_transient_for(window)

        self.__reset_button = self.add_button('Clear _dialog choices', Gtk.ResponseType.REJECT)
        self.__reset_button.set_sensitive(len(config['STORED_DIALOG_CHOICES']) > 0)
        self.add_button(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE)

        self.set_resizable(False)
        self.set_default_response(Gtk.ResponseType.CLOSE)

        self.connect('response', self._response)

        notebook = Gtk.Notebook()
        self.vbox.pack_start(notebook, True, True, 0)
        self.set_border_width(2)
        notebook.set_border_width(2)

        notebook.append_page(self._init_appearance_tab(),
                             Gtk.Label(label='Appearance'))

        notebook.append_page(self._init_behaviour_tab(),
                             Gtk.Label(label='Behaviour'))

        notebook.append_page(self._init_display_tab(),
                             Gtk.Label(label='Display'))

        notebook.append_page(self._init_animation_tab(),
                             Gtk.Label(label='Animation'))

        notebook.append_page(self._init_advanced_tab(),
                             Gtk.Label(label='Advanced'))

        self.show_all()

    def _init_appearance_tab(self):
        # ----------------------------------------------------------------
        # The "Appearance" tab.
        # ----------------------------------------------------------------
        page = PreferencePage()

        page.new_section('User Interface')

        page.add_row(self._create_pref_check_button(
            'Escape key closes program',
            'ESCAPE_QUITS'))

        page.new_section('Thumbnails')

        page.add_row(self._create_pref_check_button(
            'Show page numbers on thumbnails',
            'SHOW_PAGE_NUMBERS_ON_THUMBNAILS'))

        page.add_row(Gtk.Label(label='Thumbnail size (pixels):'),
                     self._create_pref_spinner(
                         'THUMBNAIL_SIZE',
                         1, 20, 500, 1, 10, 0))

        page.new_section('Transparency')

        page.add_row(self._create_pref_check_button(
            'Use a checkered background for transparent images',
            'CHECKERED_BG_FOR_TRANSPARENT_IMAGES'))

        page.add_row(Gtk.Label(label='Checkered background size:'),
                     self._create_combobox_checkered_bg_size())

        return page

    def _init_behaviour_tab(self):
        # ----------------------------------------------------------------
        # The "Behaviour" tab.
        # ----------------------------------------------------------------
        page = PreferencePage()

        page.new_section('Scroll')

        page.add_row(self._create_pref_check_button(
            'Flip pages with mouse wheel',
            'FLIP_WITH_WHEEL'))

        page.add_row(Gtk.Label(label='Number of pixels to scroll a page per arrow key press:'),
                     self._create_pref_spinner(
                         'PIXELS_TO_SCROLL_PER_KEY_EVENT',
                         1, 1, 500, 1, 3, 0))

        page.add_row(Gtk.Label(label='Number of pixels to scroll a page per mouse wheel turn:'),
                     self._create_pref_spinner(
                         'PIXELS_TO_SCROLL_PER_MOUSE_WHEEL_EVENT',
                         1, 1, 500, 1, 3, 0))

        page.new_section('Manga Mode')

        page.add_row(self._create_pref_check_button(
            'Default manga mode',
            'DEFAULT_MANGA_MODE'))

        page.new_section('Double Page Mode')

        page.add_row(self._create_pref_check_button(
            'Default double page mode',
            'DEFAULT_DOUBLE_PAGE'))

        page.add_row(self._create_pref_check_button(
            'Flip two pages in double page mode',
            'DOUBLE_STEP_IN_DOUBLE_PAGE_MODE'))

        page.add_row(Gtk.Label(label='Show only one page where appropriate:'),
                     self._create_combobox_doublepage_as_one())

        page.new_section('Page Selection')

        page.add_row(self._create_pref_check_button(
            'Start at the first page when opening a previous archive',
            'OPEN_FIRST_PAGE'))

        page.add_row(Gtk.Label(label='Total pages to change when using ff:'),
                     self._create_pref_spinner(
                         'PAGE_FF_STEP',
                         1, 1, 100, 1, 3, 0))

        return page

    def _init_display_tab(self):
        # ----------------------------------------------------------------
        # The "Display" tab.
        # ----------------------------------------------------------------
        page = PreferencePage()

        page.new_section('Window')

        page.add_row(self._create_pref_check_button(
            'Save main window size',
            'WINDOW_SAVE'))

        page.new_section('Fullscreen')

        page.add_row(self._create_pref_check_button(
            'Use fullscreen by default',
            'DEFAULT_FULLSCREEN'))

        page.add_row(self._create_pref_check_button(
            'Hide the statusbar in fullscreen',
            'FULLSCREEN_HIDE_STATUSBAR'))

        page.add_row(self._create_pref_check_button(
            'Hide the menubar in fullscreen',
            'FULLSCREEN_HIDE_MENUBAR'))

        page.new_section('Fit To Size Mode')

        page.add_row(Gtk.Label(label='Page zoom mode:'),
                     self._create_combobox_zoom_mode())

        page.add_row(Gtk.Label(label='Fit to width or height:'),
                     self._create_combobox_fitmode())

        page.add_row(self._create_pref_check_button(
            'Stretch small images',
            'STRETCH'))

        page.add_row(Gtk.Label(label='Fixed size for this mode:'),
                     self._create_pref_spinner(
                         'FIT_TO_SIZE_PX',
                         1, 10, 10000, 10, 50, 0))

        page.new_section('Rotation')

        page.add_row(self._create_pref_check_button(
            'Keep manual rotation on page change',
            'KEEP_TRANSFORMATION'))

        page.add_row(self._create_pref_check_button(
            'Rotate images according to their metadata',
            'AUTO_ROTATE_FROM_EXIF'))

        page.new_section('Image Scaling Quality')

        page.add_row(Gtk.Label(label='GDK image scaling'),
                     self._create_combobox_scaling_quality())

        page.add_row(self._create_pref_check_button(
            'Enable PIL image scaling',
            'ENABLE_PIL_SCALING'))

        page.add_row(Gtk.Label(label='PIL image scaling'),
                     self._create_combobox_pil_scaling_filter())

        page.new_section('Statusbar')

        page.add_row(self._create_pref_check_button(
            'Show full path of current file in statusbar',
            'STATUSBAR_FULLPATH'))

        page.add_row(self._create_pref_check_button(
            'Show page scaling percent',
            'STATUSBAR_SHOW_SCALE'))

        page.new_section('Bookmarks')

        page.add_row(self._create_pref_check_button(
            'Show bookmark path in bookmark menu',
            'BOOKMARK_SHOW_PATH'))

        return page

    def _init_animation_tab(self):
        # ----------------------------------------------------------------
        # The "Animation" tab.
        # ----------------------------------------------------------------
        page = PreferencePage()

        page.new_section('Animated Images')

        page.add_row(Gtk.Label(label='Animation Mode'),
                     self._create_combobox_animation_mode())

        page.add_row(self._create_pref_check_button(
            'Use animation background (otherwise uses Appearance -> Background)',
            'ANIMATION_BACKGROUND'))

        page.add_row(self._create_pref_check_button(
            'Enable scale, rotate, flip and enhance operation on animation',
            'ANIMATION_TRANSFORM'))

        return page

    def _init_advanced_tab(self):
        # ----------------------------------------------------------------
        # The "Advanced" tab.
        # ----------------------------------------------------------------
        page = PreferencePage()

        page.new_section('File Order')

        page.add_row(Gtk.Label(label='Sort files and directories by:'),
                     self._create_combobox_sort_by())

        page.add_row(Gtk.Label(label='Sort archives by:'),
                     self._create_combobox_archive_sort_by())

        page.new_section('Moving Files')

        page.add_row(Gtk.Label(label='Move file location (must be relative)'),
                     self._create_pref_text_box('MOVE_FILE'))

        page.new_section('Page Cache')

        page.add_row(Gtk.Label(label='Pages to cache ahead of current page:'),
                     self._create_pref_spinner(
                         'PAGE_CACHE_FORWARD',
                         1, 1, 50, 1, 3, 0))

        page.add_row(Gtk.Label(label='Pages to cache behind the current page:'),
                     self._create_pref_spinner(
                         'PAGE_CACHE_BEHIND',
                         1, 1, 10, 1, 3, 0))

        page.new_section('Magnifying Lens')

        page.add_row(Gtk.Label(label='Magnifying lens size (in pixels):'),
                     self._create_pref_spinner(
                         'LENS_SIZE',
                         1, 50, 400, 1, 10, 0))

        page.add_row(Gtk.Label(label='Magnification lens factor:'),
                     self._create_pref_spinner(
                         'LENS_MAGNIFICATION',
                         1, 1.1, 10.0, 0.1, 1.0, 1))

        page.new_section('Enhance')

        page.add_row(self._create_pref_check_button(
            'Show extra info on enhance dialog',
            'ENHANCE_EXTRA'))

        page.new_section('Unit Size')

        page.add_row(self._create_pref_check_button(
            'Show filesize in SI unit size 10^3 instead of 2^10',
            'SI_UNITS'))

        page.new_section('Threads')

        page.add_row(Gtk.Label(label='Maximum number of threads:'),
                     self._create_pref_spinner(
                         'MAX_THREADS',
                         1, 1, 128, 1, 4, 0))

        return page

    def _response(self, dialog, response):
        match response:
            case Gtk.ResponseType.REJECT:
                # Reset stored choices
                config['STORED_DIALOG_CHOICES'] = {}
                self.__reset_button.set_sensitive(False)

            case _:
                self.destroy()

    def _create_combobox_checkered_bg_size(self):
        """
        Creates combo box to set box size for alpha images
        """

        items = (
            ('2', 2),
            ('4', 4),
            ('8', 8),
            ('16', 16),
            ('32', 32),
            ('64', 64),
            ('128', 128),
            ('256', 256),
            ('512', 512),
        )

        return self._create_combobox(items, 'CHECKERED_BG_SIZE')

    def _create_combobox_zoom_mode(self):
        """
        Creates combo box to set box size for alpha images
        """

        items = (
            ('Best fit', ZoomModes.BEST.value),
            ('Fit to width', ZoomModes.WIDTH.value),
            ('Fit to height', ZoomModes.HEIGHT.value),
            ('Fit to size', ZoomModes.SIZE.value),
            ('Manual fit', ZoomModes.MANUAL.value),
        )

        return self._create_combobox(items, 'ZOOM_MODE')

    def _create_combobox_doublepage_as_one(self):
        """
        Creates the ComboBox control for selecting virtual double page options
        """

        items = (
            ('Never', DoublePage.NEVER.value),
            ('Only for title pages', DoublePage.AS_ONE_TITLE.value),
            ('Only for wide images', DoublePage.AS_ONE_WIDE.value),
            ('Always', DoublePage.ALWAYS.value))

        return self._create_combobox(items, 'VIRTUAL_DOUBLE_PAGE_FOR_FITTING_IMAGES')

    def _create_combobox_fitmode(self):
        """Combobox for fit to size mode"""
        items = (
            ('Fit to width', ZoomModes.WIDTH.value),
            ('Fit to height', ZoomModes.HEIGHT.value))

        return self._create_combobox(items, 'FIT_TO_SIZE_MODE')

    def _create_combobox_sort_by(self):
        """
        Creates the ComboBox control for selecting file sort by options
        """

        sortkey_items = (
            ('No sorting', FileSortType.NONE.value),
            ('File name', FileSortType.NAME.value),
            ('File size', FileSortType.SIZE.value),
            ('Last modified', FileSortType.LAST_MODIFIED.value))

        sortkey_box = self._create_combobox(sortkey_items, 'SORT_BY')

        sortorder_items = (
            ('Ascending', FileSortDirection.ASCENDING.value),
            ('Descending', FileSortDirection.DESCENDING.value))

        sortorder_box = self._create_combobox(sortorder_items, 'SORT_ORDER')

        box = Gtk.HBox()
        box.pack_start(sortkey_box, True, True, 0)
        box.pack_start(sortorder_box, True, True, 0)

        return box

    def _create_combobox_archive_sort_by(self):
        """
        Creates the ComboBox control for selecting archive sort by options
        """

        sortkey_items = (
            ('No sorting', FileSortType.NONE.value),
            ('Natural order', FileSortType.NAME.value),
            ('Literal order', FileSortType.NAME_LITERAL.value))

        sortkey_box = self._create_combobox(sortkey_items, 'SORT_ARCHIVE_BY')

        sortorder_items = (
            ('Ascending', FileSortDirection.ASCENDING.value),
            ('Descending', FileSortDirection.DESCENDING.value))

        sortorder_box = self._create_combobox(sortorder_items, 'SORT_ARCHIVE_ORDER')

        box = Gtk.HBox()
        box.pack_start(sortkey_box, True, True, 0)
        box.pack_start(sortorder_box, True, True, 0)

        return box

    def _create_combobox_scaling_quality(self):
        """
        Creates combo box for image scaling quality
        """

        items = (
            ('Nearest', ScalingGDK.Nearest.value),
            ('Tiles', ScalingGDK.Tiles.value),
            ('Bilinear', ScalingGDK.Bilinear.value),
        )

        return self._create_combobox(items, 'GDK_SCALING_FILTER')

    def _create_combobox_pil_scaling_filter(self):
        """
        Creates combo box for PIL filter to scale with in main view
        """

        items = (
            ('Nearest', ScalingPIL.Nearest.value),
            ('Lanczos', ScalingPIL.Lanczos.value),
            ('Bilinear', ScalingPIL.Bilinear.value),
            ('Bicubic', ScalingPIL.Bicubic.value),
            ('Box', ScalingPIL.Box.value),
            ('Hamming', ScalingPIL.Hamming.value),
        )

        return self._create_combobox(items, 'PIL_SCALING_FILTER')

    def _create_combobox_animation_mode(self):
        """
        Creates combo box for animation mode
        """

        items = (
            ('Never', Animation.DISABLED.value),
            ('Normal', Animation.NORMAL.value),
            ('Once', Animation.ONCE.value),
            ('Infinity', Animation.INF.value),
        )

        return self._create_combobox(items, 'ANIMATION_MODE')

    def _changed_cb(self, combobox, preference: str):
        """
        Called whenever cb box has been changed
        """

        _iter = combobox.get_active_iter()
        if not combobox.get_model().iter_is_valid(_iter):
            return

        value = combobox.get_model().get_value(_iter, 1)
        last_value = config[preference]
        if value == last_value:
            return

        config[preference] = value

        match preference:
            case ('ANIMATION_MODE' | 'SORT_ARCHIVE_ORDER' | 'SORT_ARCHIVE_BY' | 'SORT_ORDER' | 'SORT_BY'):
                self.__file_handler.refresh_file()
            case ('PIL_SCALING_FILTER' | 'GDK_SCALING_FILTER'):
                self.__window.statusbar.update_image_scaling()
                self.__window.draw_image()
            case ('VIRTUAL_DOUBLE_PAGE_FOR_FITTING_IMAGES' | 'CHECKERED_BG_SIZE'):
                self.__window.draw_image()
            case ('FIT_TO_SIZE_MODE' | 'ZOOM_MODE'):
                self.__window.change_zoom_mode()

    def _create_combobox(self, options: tuple, preference: str):
        """
        Creates a new dropdown combobox and populates it with the items passed in C{options}.

        :param options: List of tuples: (Option display text, option value)
        :param preference: One of the values passed in C{options} that will
            be pre-selected when the control is created.
        :returns: Gtk.ComboBox
        """

        # Use the first list item to determine typing of model fields.
        # First field is textual description, second field is value.
        model = Gtk.ListStore(GObject.TYPE_STRING, type(options[0][1]))
        for text, value in options:
            model.append((text, value))

        box = Gtk.ComboBox(model=model)
        renderer = Gtk.CellRendererText()
        box.pack_start(renderer, True)
        box.add_attribute(renderer, 'text', 0)

        # Set active box option
        _iter = model.get_iter_first()
        while _iter:
            if model.get_value(_iter, 1) == config[preference]:
                box.set_active_iter(_iter)
                break
            else:
                _iter = model.iter_next(_iter)

        box.connect('changed', self._changed_cb, preference)

        return box

    def _create_pref_check_button(self, label: str, prefkey: str):
        button = Gtk.CheckButton(label=label)
        button.set_active(config[prefkey])
        button.connect('toggled', self._check_button_cb, prefkey)
        return button

    def _check_button_cb(self, button, preference: str):
        """
        Callback for all checkbutton-type preferences
        """

        config[preference] = button.get_active()

        match preference:
            case ('CHECKERED_BG_FOR_TRANSPARENT_IMAGES' | 'AUTO_ROTATE_FROM_EXIF'):
                self.__window.draw_image()
            case ('ANIMATION_BACKGROUND' | 'ANIMATION_TRANSFORM'):
                self.__window.thumbnailsidebar.toggle_page_numbers_visible()
                self.__file_handler.refresh_file()
            case ('OPEN_FIRST_PAGE'):
                self.__file_handler.update_opening_behavior()

    def _create_pref_spinner(self, prefkey: str, scale: float, lower: float, upper: float,
                             step_incr: float, page_incr: float, digits: float):
        value = config[prefkey] / scale
        adjustment = Gtk.Adjustment(value=value, lower=lower, upper=upper, step_increment=step_incr,
                                    page_increment=page_incr)
        spinner = Gtk.SpinButton.new(adjustment, 0.0, digits)
        spinner.set_size_request(80, -1)
        spinner.connect('value_changed', self._spinner_cb, prefkey)
        return spinner

    def _spinner_cb(self, spinbutton, preference: str):
        """
        Callback for spinner-type preferences
        """

        value = spinbutton.get_value()

        if preference not in ('LENS_MAGNIFICATION',):
            config[preference] = int(value)

        match preference:
            case ('THUMBNAIL_SIZE'):
                self.__window.thumbnailsidebar.resize()
                self.__window.draw_image()
            case ('PAGE_CACHE_FORWARD' | 'PAGE_CACHE_BEHIND'):
                self.__image_handler.do_caching()
            case ('FIT_TO_SIZE_PX'):
                self.__window.change_zoom_mode()

    def _create_pref_text_box(self, preference: str):
        def save_pref_text_box(text):
            config[preference] = text.get_text()

        box = Gtk.Entry()
        box.set_text(config[preference])
        box.connect('changed', save_pref_text_box)
        return box
示例#9
0
class MainWindow(Gtk.ApplicationWindow):
    """
    The main window, is created at start and terminates the program when closed
    """
    def __init__(self, open_path: list = None):
        super().__init__(type=Gtk.WindowType.TOPLEVEL)

        # ----------------------------------------------------------------
        # Attributes
        # ----------------------------------------------------------------

        # Load configuration.
        self.__preference_manager = PreferenceManager()
        self.__preference_manager.load_config_file()

        self.bookmark_backend = BookmarkBackend()

        # Used to detect window fullscreen state transitions.
        self.was_fullscreen = False
        self.is_manga_mode = config['DEFAULT_MANGA_MODE']
        self.__page_orientation = self.page_orientation()
        self.previous_size = (None, None)
        # Remember last scroll destination.
        self.__last_scroll_destination = Constants.SCROLL_TO['START']

        self.__dummy_layout = FiniteLayout([(1, 1)], (1, 1), [1, 1], 0, 0, 0)
        self.__layout = self.__dummy_layout
        self.__spacing = 2
        self.__waiting_for_redraw = False

        self.__main_layout = Gtk.Layout()
        self.__main_scrolled_window = Gtk.ScrolledWindow()

        self.__main_scrolled_window.add(self.__main_layout)

        self.event_handler = EventHandler(self)
        self.__vadjust = self.__main_scrolled_window.get_vadjustment()
        self.__hadjust = self.__main_scrolled_window.get_hadjustment()

        self.icons = Icons()

        self.filehandler = FileHandler(self)
        self.filehandler.file_closed += self._on_file_closed
        self.filehandler.file_opened += self._on_file_opened
        self.imagehandler = ImageHandler(self)
        self.imagehandler.page_available += self._page_available
        self.thumbnailsidebar = ThumbnailSidebar(self)

        self.statusbar = Statusbar()
        self.cursor_handler = CursorHandler(self)
        self.enhancer = ImageEnhancer(self)
        self.lens = MagnifyingLens(self)
        self.zoom = ZoomModel()

        self.menubar = Menubar(self)

        self.keybindings_map = KeyBindingsMap(self).BINDINGS
        self.keybindings = KeybindingManager(self)

        self.images = [Gtk.Image(),
                       Gtk.Image()]  # XXX limited to at most 2 pages

        # ----------------------------------------------------------------
        # Setup
        # ----------------------------------------------------------------
        self.set_title(Constants.APPNAME)
        self.restore_window_geometry()

        # Hook up keyboard shortcuts
        self.event_handler.event_handler_init()
        self.event_handler.register_key_events()

        for img in self.images:
            self.__main_layout.put(img, 0, 0)

        grid = Gtk.Grid()
        grid.attach(self.menubar, 0, 0, 2, 1)
        grid.attach(self.thumbnailsidebar, 0, 1, 1, 1)
        grid.attach_next_to(self.__main_scrolled_window, self.thumbnailsidebar,
                            Gtk.PositionType.RIGHT, 1, 1)
        self.__main_scrolled_window.set_hexpand(True)
        self.__main_scrolled_window.set_vexpand(True)
        grid.attach(self.statusbar, 0, 2, 2, 1)
        self.add(grid)

        self.change_zoom_mode()

        if not config['KEEP_TRANSFORMATION']:
            config['ROTATION'] = 0
            config['VERTICAL_FLIP'] = False
            config['HORIZONTAL_FLIP'] = False

        # Each widget "eats" part of the main layout visible area.
        self.__toggle_axis = {
            self.thumbnailsidebar: Constants.AXIS['WIDTH'],
            self.statusbar: Constants.AXIS['HEIGHT'],
            self.menubar: Constants.AXIS['HEIGHT'],
        }

        self.__main_layout.set_events(Gdk.EventMask.BUTTON1_MOTION_MASK
                                      | Gdk.EventMask.BUTTON2_MOTION_MASK
                                      | Gdk.EventMask.BUTTON_PRESS_MASK
                                      | Gdk.EventMask.BUTTON_RELEASE_MASK
                                      | Gdk.EventMask.POINTER_MOTION_MASK)

        self.__main_layout.drag_dest_set(
            Gtk.DestDefaults.ALL, [Gtk.TargetEntry.new('text/uri-list', 0, 0)],
            Gdk.DragAction.COPY | Gdk.DragAction.MOVE)

        self.connect('delete_event', self.terminate_program)
        self.connect('key_press_event', self.event_handler.key_press_event)
        self.connect('configure_event', self.event_handler.resize_event)
        self.connect('window-state-event',
                     self.event_handler.window_state_event)

        self.__main_layout.connect('button_release_event',
                                   self.event_handler.mouse_release_event)
        self.__main_layout.connect('scroll_event',
                                   self.event_handler.scroll_wheel_event)
        self.__main_layout.connect('button_press_event',
                                   self.event_handler.mouse_press_event)
        self.__main_layout.connect('motion_notify_event',
                                   self.event_handler.mouse_move_event)
        self.__main_layout.connect('drag_data_received',
                                   self.event_handler.drag_n_drop_event)

        self.show_all()

        if open_path:
            self.filehandler.initialize_fileprovider(path=open_path)
            self.filehandler.open_file(Path(open_path[0]))

        if config['HIDE_CURSOR']:
            self.cursor_handler.auto_hide_on()

            # Make sure we receive *all* mouse motion events,
            # even if a modal dialog is being shown.
            def _on_event(event):
                if Gdk.EventType.MOTION_NOTIFY == event.type:
                    self.cursor_handler.refresh()
                Gtk.main_do_event(event)

            Gdk.event_handler_set(_on_event)

    def get_layout(self):
        return self.__layout

    def get_main_layout(self):
        return self.__main_layout

    def get_hadjust(self):
        return self.__hadjust

    def get_vadjust(self):
        return self.__vadjust

    def get_event_handler(self):
        return self.event_handler

    def page_orientation(self):
        if self.is_manga_mode:
            return Constants.ORIENTATION['MANGA']
        else:
            return Constants.ORIENTATION['WESTERN']

    def draw_image(self, scroll_to=None):
        """
        Draw the current pages and update the titlebar and statusbar
        """

        # FIXME: what if scroll_to is different?
        if not self.__waiting_for_redraw:  # Don't stack up redraws.
            self.__waiting_for_redraw = True
            GLib.idle_add(self._draw_image,
                          scroll_to,
                          priority=GLib.PRIORITY_HIGH_IDLE)

    def _draw_image(self, scroll_to: int):
        if not self.filehandler.get_file_loaded():
            self.thumbnailsidebar.hide()
            self._clear_main_area()
            self.__waiting_for_redraw = False
            return

        if not self.imagehandler.page_is_available():
            # Save scroll destination for when the page becomes available.
            self.__last_scroll_destination = scroll_to
            # If the pixbuf for the current page(s) isn't available clear old pixbufs.
            self._clear_main_area()
            self.__waiting_for_redraw = False
            return

        distribution_axis = Constants.AXIS['DISTRIBUTION']
        alignment_axis = Constants.AXIS['ALIGNMENT']
        pixbuf_count = 2 if self.displayed_double(
        ) else 1  # XXX limited to at most 2 pages
        pixbuf_list = list(self.imagehandler.get_pixbufs(pixbuf_count))
        do_not_transform = [
            ImageTools.disable_transform(x) for x in pixbuf_list
        ]
        size_list = [[pixbuf.get_width(),
                      pixbuf.get_height()] for pixbuf in pixbuf_list]

        orientation = self.__page_orientation

        # Rotation handling:
        # - apply Exif rotation on individual images
        # - apply automatic rotation (size based) on whole page
        # - apply manual rotation on whole page
        if config['AUTO_ROTATE_FROM_EXIF']:
            rotation_list = [
                ImageTools.get_implied_rotation(pixbuf)
                for pixbuf in pixbuf_list
            ]
        else:
            rotation_list = [0] * len(pixbuf_list)

        virtual_size = [0, 0]
        for i in range(pixbuf_count):
            if rotation_list[i] in (90, 270):
                size_list[i].reverse()
            size = size_list[i]
            virtual_size[distribution_axis] += size[distribution_axis]
            virtual_size[alignment_axis] = max(virtual_size[alignment_axis],
                                               size[alignment_axis])
        rotation = (self._get_size_rotation(*virtual_size) +
                    config['ROTATION']) % 360

        if rotation in (90, 270):
            distribution_axis, alignment_axis = alignment_axis, distribution_axis
            orientation.reverse()
            for i in range(pixbuf_count):
                size_list[i].reverse()
        elif rotation in (180, 270):
            orientation.reverse()

        for i in range(pixbuf_count):
            rotation_list[i] = (rotation_list[i] + rotation) % 360

        if config['VERTICAL_FLIP']:
            orientation.reverse()
        if config['HORIZONTAL_FLIP']:
            orientation.reverse()

        viewport_size = ()  # dummy
        scaled_sizes = [(0, 0)]
        union_scaled_size = (0, 0)
        # Visible area size is recomputed depending on scrollbar visibility
        while True:
            new_viewport_size = self.get_visible_area_size()
            if new_viewport_size == viewport_size:
                break
            viewport_size = new_viewport_size
            zoom_dummy_size = list(viewport_size)
            dasize = zoom_dummy_size[distribution_axis] - self.__spacing * (
                pixbuf_count - 1)
            if dasize <= 0:
                dasize = 1
            zoom_dummy_size[distribution_axis] = dasize
            scaled_sizes = self.zoom.get_zoomed_size(size_list,
                                                     zoom_dummy_size,
                                                     distribution_axis,
                                                     do_not_transform)

            self.__layout = FiniteLayout(scaled_sizes, viewport_size,
                                         orientation, self.__spacing,
                                         distribution_axis, alignment_axis)

            union_scaled_size = self.__layout.get_union_box().get_size()

        for i in range(pixbuf_count):
            pixbuf_list[i] = ImageTools.fit_pixbuf_to_rectangle(
                pixbuf_list[i], scaled_sizes[i], rotation_list[i])

        for i in range(pixbuf_count):
            pixbuf_list[i] = ImageTools.trans_pixbuf(
                pixbuf_list[i],
                flip=config['VERTICAL_FLIP'],
                flop=config['HORIZONTAL_FLIP'])
            pixbuf_list[i] = self.enhancer.enhance(pixbuf_list[i])

        for i in range(pixbuf_count):
            ImageTools.set_from_pixbuf(self.images[i], pixbuf_list[i])

        resolutions = [(*size, scaled_size[0] / size[0])
                       for scaled_size, size in zip(scaled_sizes, size_list)]

        if self.is_manga_mode:
            resolutions.reverse()

        self.statusbar.set_resolution(resolutions)
        self.statusbar.update()

        self.__main_layout.get_bin_window().freeze_updates()

        self.__main_layout.set_size(*union_scaled_size)
        content_boxes = self.__layout.get_content_boxes()
        for i in range(pixbuf_count):
            self.__main_layout.move(self.images[i],
                                    *content_boxes[i].get_position())

        for i in range(pixbuf_count):
            self.images[i].show()
        for i in range(pixbuf_count, len(self.images)):
            self.images[i].hide()

        # Reset orientation so scrolling behaviour is sane.
        self.__layout.set_orientation(self.__page_orientation)

        if scroll_to is not None:
            destination = (scroll_to, ) * 2
            self.scroll_to_predefined(destination)

        self.__main_layout.get_bin_window().thaw_updates()

        self.__waiting_for_redraw = False

    def _update_page_information(self):
        """Updates the window with information that can be gathered
        even when the page pixbuf(s) aren't ready yet"""
        page_number = self.imagehandler.get_current_page()
        if not page_number:
            return
        if self.displayed_double():
            number_of_pages = 2
            filenames = self.imagehandler.get_page_data(
                double=True, manga=self.is_manga_mode, filename=True)
            filesizes = self.imagehandler.get_page_data(
                double=True, manga=self.is_manga_mode, filesize=True)
            filename = ', '.join(filenames)
            filesize = ', '.join(filesizes)
        else:
            number_of_pages = 1
            filename = self.imagehandler.get_page_data(filename=True)
            filesize = self.imagehandler.get_page_data(filesize=True)
        self.statusbar.set_page_number(page_number,
                                       self.imagehandler.get_number_of_pages(),
                                       number_of_pages)
        self.statusbar.set_filename(filename)
        if config['STATUSBAR_FULLPATH']:
            self.statusbar.set_root(self.filehandler.get_path_to_base())
        else:
            self.statusbar.set_root(self.filehandler.get_base_filename())
        self.statusbar.set_mode()
        self.statusbar.set_filesize(filesize)
        self.statusbar.set_filesize_archive(
            self.filehandler.get_path_to_base())
        self.statusbar.update()
        self.update_title()

    @staticmethod
    def _get_size_rotation(width: int, height: int):
        """
        Determines the rotation to be applied. Returns the degree of rotation (0, 90, 180, 270)
        """

        if (height > width and config['AUTO_ROTATE_DEPENDING_ON_SIZE']
                in (Constants.AUTOROTATE['HEIGHT_90'],
                    Constants.AUTOROTATE['HEIGHT_270'])):
            if config['AUTO_ROTATE_DEPENDING_ON_SIZE'] == Constants.AUTOROTATE[
                    'HEIGHT_90']:
                return 90
            else:
                return 270
        elif (width > height and config['AUTO_ROTATE_DEPENDING_ON_SIZE']
              in (Constants.AUTOROTATE['WIDTH_90'],
                  Constants.AUTOROTATE['WIDTH_270'])):
            if config['AUTO_ROTATE_DEPENDING_ON_SIZE'] == Constants.AUTOROTATE[
                    'WIDTH_90']:
                return 90
            else:
                return 270

        return 0

    def _get_virtual_double_page(self, page: int = None):
        """
        Return True if the current state warrants use of virtual
        double page mode (i.e. if double page mode is on, the corresponding
        preference is set, and one of the two images that should normally
        be displayed has a width that exceeds its height), or if currently
        on the first page

        :returns: True if the current state warrants use of virtual double page mode
        """

        if page is None:
            page = self.imagehandler.get_current_page()

        if (page == 1 and config['VIRTUAL_DOUBLE_PAGE_FOR_FITTING_IMAGES']
                & Constants.DOUBLE_PAGE['AS_ONE_TITLE']
                and self.filehandler.get_archive_type() is not None):
            return True

        if (not config['DEFAULT_DOUBLE_PAGE']
                or not config['VIRTUAL_DOUBLE_PAGE_FOR_FITTING_IMAGES']
                & Constants.DOUBLE_PAGE['AS_ONE_WIDE']
                or page == self.imagehandler.get_number_of_pages()):
            return False

        for page in (page, page + 1):
            if not self.imagehandler.page_is_available(page):
                return False
            pixbuf = self.imagehandler.get_pixbuf(page - 1)
            width, height = pixbuf.get_width(), pixbuf.get_height()
            if config['AUTO_ROTATE_FROM_EXIF']:
                rotation = ImageTools.get_implied_rotation(pixbuf)

                # if rotation not in (0, 90, 180, 270):
                #     return

                if rotation in (90, 270):
                    width, height = height, width
            if width > height:
                return True

        return False

    def _page_available(self, page: int):
        """
        Called whenever a new page is ready for displaying
        """

        # Refresh display when currently opened page becomes available.
        current_page = self.imagehandler.get_current_page()
        nb_pages = 2 if self.displayed_double() else 1
        if current_page <= page < (current_page + nb_pages):
            self.draw_image(scroll_to=self.__last_scroll_destination)
            self._update_page_information()

    def _on_file_opened(self):
        self.thumbnailsidebar.show()
        number, count = self.filehandler.get_file_number()
        self.statusbar.set_file_number(number, count)
        self.statusbar.update()

    def _on_file_closed(self):
        self.clear()
        self.thumbnailsidebar.hide()
        self.thumbnailsidebar.clear()
        self.set_icon_list(self.icons.mcomix_icons)

    def new_page(self, at_bottom: bool = False):
        """
        Draw a *new* page correctly (as opposed to redrawing the same image with a new size or whatever)
        """

        if not config['KEEP_TRANSFORMATION']:
            config['ROTATION'] = 0
            config['HORIZONTAL_FLIP'] = False
            config['VERTICAL_FLIP'] = False

        if at_bottom:
            scroll_to = Constants.SCROLL_TO['END']
        else:
            scroll_to = Constants.SCROLL_TO['START']

        self.draw_image(scroll_to=scroll_to)

    @Callback
    def page_changed(self):
        """
        Called on page change
        """

        self.thumbnailsidebar.load_thumbnails()
        self._update_page_information()

    def set_page(self, num: int, at_bottom: bool = False):
        if num == self.imagehandler.get_current_page():
            return
        self.imagehandler.set_page(num)
        self.page_changed()
        self.new_page(at_bottom=at_bottom)

    def flip_page(self, number_of_pages: int, single_step: bool = False):
        if not self.filehandler.get_file_loaded():
            return

        current_page = self.imagehandler.get_current_page()
        current_number_of_pages = self.imagehandler.get_number_of_pages()

        new_page = current_page + number_of_pages
        if (abs(number_of_pages) == 1 and not single_step
                and config['DEFAULT_DOUBLE_PAGE']
                and config['DOUBLE_STEP_IN_DOUBLE_PAGE_MODE']):
            if number_of_pages == +1 and not self._get_virtual_double_page():
                new_page += +1
            elif number_of_pages == -1 and not self._get_virtual_double_page(
                    new_page - 1):
                new_page -= 1

        if new_page <= 0:
            # Only switch to previous page when flipping one page before the
            # first one. (Note: check for (page number <= 1) to handle empty
            # archive case).
            if number_of_pages == -1 and current_page <= +1:
                return self.filehandler.open_archive_direction(forward=False)
            # Handle empty archive case.
            new_page = min(+1, current_number_of_pages)
        elif new_page > current_number_of_pages:
            if number_of_pages == +1:
                return self.filehandler.open_archive_direction(forward=True)
            new_page = current_number_of_pages

        if new_page != current_page:
            self.set_page(new_page, at_bottom=(-1 == number_of_pages))

    def first_page(self):
        if self.imagehandler.get_number_of_pages():
            self.set_page(1)

    def last_page(self):
        number_of_pages = self.imagehandler.get_number_of_pages()
        if number_of_pages:
            self.set_page(number_of_pages)

    def page_select(self, *args):
        Pageselector(self)

    def rotate_90(self, *args):
        self.rotate_x(rotation=90)

    def rotate_180(self, *args):
        self.rotate_x(rotation=180)

    def rotate_270(self, *args):
        self.rotate_x(rotation=270)

    def rotate_x(self, rotation: int, *args):
        config['ROTATION'] = (config['ROTATION'] + rotation) % 360
        self.draw_image()

    def flip_horizontally(self, *args):
        config['HORIZONTAL_FLIP'] = not config['HORIZONTAL_FLIP']
        self.draw_image()

    def flip_vertically(self, *args):
        config['VERTICAL_FLIP'] = not config['VERTICAL_FLIP']
        self.draw_image()

    def change_double_page(self, *args):
        config['DEFAULT_DOUBLE_PAGE'] = not config['DEFAULT_DOUBLE_PAGE']
        self._update_page_information()
        self.draw_image()

    def change_manga_mode(self, *args):
        config['DEFAULT_MANGA_MODE'] = not config['DEFAULT_MANGA_MODE']
        self.is_manga_mode = config['DEFAULT_MANGA_MODE']
        self.__page_orientation = self.page_orientation()
        self._update_page_information()
        self.draw_image()

    def is_fullscreen(self):
        window_state = self.get_window().get_state()
        return (window_state & Gdk.WindowState.FULLSCREEN) != 0

    def change_fullscreen(self, *args):
        # Disable action until transition if complete.

        if self.is_fullscreen():
            self.unfullscreen()
        else:
            self.save_window_geometry()
            self.fullscreen()

        # No need to call draw_image explicitely,
        # as we'll be receiving a window state
        # change or resize event.

    def change_zoom_mode(self, value=None, *args):
        if value:
            config['ZOOM_MODE'] = value
        self.zoom.set_fit_mode(config['ZOOM_MODE'])
        self.zoom.set_scale_up(config['STRETCH'])
        self.zoom.reset_user_zoom()
        self.draw_image()

    def change_autorotation(self, value=None, *args):
        """
        Switches between automatic rotation modes, depending on which
        radiobutton is currently activated
        """

        if value:
            config['AUTO_ROTATE_DEPENDING_ON_SIZE'] = value
        self.draw_image()

    def toggle_image_scaling_pil(self):
        config['ENABLE_PIL_SCALING'] = not config['ENABLE_PIL_SCALING']
        self.draw_image()
        self.statusbar.update_image_scaling()
        self.statusbar.update()

    def change_image_scaling_gdk(self, step: int):
        self._loop_img_scaling(config_key='SCALING_QUALITY',
                               algos=Constants.SCALING_GDK,
                               step=step)
        self.draw_image()
        self.statusbar.update_image_scaling()
        self.statusbar.update()

    def change_image_scaling_pil(self, step: int):
        if not config['ENABLE_PIL_SCALING']:
            # disable changing if not active
            return

        self._loop_img_scaling(config_key='PIL_SCALING_FILTER',
                               algos=Constants.SCALING_PIL,
                               step=step)
        self.draw_image()
        self.statusbar.update_image_scaling()
        self.statusbar.update()

    @staticmethod
    def _loop_img_scaling(config_key: str, algos: tuple, step: int):
        try:
            scale = algos[config[config_key] + step].value
        except IndexError:
            if step == +1:
                # overflow goto beginning
                scale = algos[0].value
            elif step == -1:
                # underflow goto end
                scale = algos[-1].value
            else:
                raise ValueError

        config[config_key] = scale

    def change_stretch(self, *args):
        """
        Toggles stretching small images
        """

        config['STRETCH'] = not config['STRETCH']
        self.zoom.set_scale_up(config['STRETCH'])
        self.draw_image()

    def open_dialog_file_chooser(self, *args):
        DialogFileChooser().open_dialog(self)

    def open_dialog_properties(self, *args):
        DialogProperties().open_dialog(self)

    def open_dialog_preference(self, *args):
        DialogPreference().open_dialog(self)

    def open_dialog_enhance(self, *args):
        DialogEnhance().open_dialog(self)

    def open_dialog_about(self, *args):
        DialogAbout().open_dialog(self)

    @staticmethod
    def change_keep_transformation(*args):
        config['KEEP_TRANSFORMATION'] = not config['KEEP_TRANSFORMATION']

    def manual_zoom_in(self, *args):
        self.zoom.zoom_in()
        self.draw_image()

    def manual_zoom_out(self, *args):
        self.zoom.zoom_out()
        self.draw_image()

    def manual_zoom_original(self, *args):
        self.zoom.reset_user_zoom()
        self.draw_image()

    def scroll(self, x: int, y: int):
        """
        Scroll <x> px horizontally and <y> px vertically. If <bound> is
        'first' or 'second', we will not scroll out of the first or second
        page respectively (dependent on manga mode). The <bound> argument
        only makes sense in double page mode.

        :returns: True if call resulted in new adjustment values, False otherwise
        """

        old_hadjust = self.__hadjust.get_value()
        old_vadjust = self.__vadjust.get_value()

        visible_width, visible_height = self.get_visible_area_size()

        hadjust_upper = max(0, self.__hadjust.get_upper() - visible_width)
        vadjust_upper = max(0, self.__vadjust.get_upper() - visible_height)
        hadjust_lower = 0

        new_hadjust = old_hadjust + x
        new_vadjust = old_vadjust + y

        new_hadjust = max(hadjust_lower, new_hadjust)
        new_vadjust = max(0, new_vadjust)

        new_hadjust = min(hadjust_upper, new_hadjust)
        new_vadjust = min(vadjust_upper, new_vadjust)

        self.__vadjust.set_value(new_vadjust)
        self.__hadjust.set_value(new_hadjust)

        return old_vadjust != new_vadjust or old_hadjust != new_hadjust

    def scroll_to_predefined(self, destination: tuple):
        self.__layout.scroll_to_predefined(destination)
        viewport_position = self.__layout.get_viewport_box().get_position()
        self.__hadjust.set_value(viewport_position[0])  # 2D only
        self.__vadjust.set_value(viewport_position[1])  # 2D only

    def clear(self):
        """
        Clear the currently displayed data (i.e. "close" the file)
        """

        self.set_title(Constants.APPNAME)
        self.statusbar.set_message('')
        self.draw_image()

    def _clear_main_area(self):
        for i in self.images:
            i.hide()
            i.clear()

        self.__layout = self.__dummy_layout
        self.__main_layout.set_size(*self.__layout.get_union_box().get_size())

    def displayed_double(self):
        """
        :returns: True if two pages are currently displayed
        """

        return (self.imagehandler.get_current_page()
                and config['DEFAULT_DOUBLE_PAGE']
                and not self._get_virtual_double_page()
                and self.imagehandler.get_current_page() !=
                self.imagehandler.get_number_of_pages())

    def get_visible_area_size(self):
        """
        :returns: a 2-tuple with the width and height of the visible part of the main layout area
        """

        dimensions = list(self.get_size())
        size = 0

        for widget, axis in self.__toggle_axis.items():
            minimum_size, natural_size = widget.get_preferred_size()
            if Constants.AXIS['WIDTH'] == axis:
                size = natural_size.width
            elif Constants.AXIS['HEIGHT'] == axis:
                size = natural_size.height
            dimensions[axis] -= size

        return tuple(dimensions)

    def set_cursor(self, mode):
        """
        Set the cursor on the main layout area to <mode>. You should
        probably use the cursor_handler instead of using this method directly
        """

        self.__main_layout.get_bin_window().set_cursor(mode)

    def update_title(self):
        """
        Set the title acording to current state
        """

        self.set_title(f'[{self.statusbar.get_page_number()}] '
                       f'{self.imagehandler.get_current_filename()} '
                       f'[{self.statusbar.get_mode()}]')

    def extract_page(self, *args):
        """
        Derive some sensible filename (archive name + _ + filename should do) and offer
        the user the choice to save the current page with the selected name
        """

        page = self.imagehandler.get_current_page()

        if self.displayed_double():
            # asks for left or right page if in double page mode
            # and not showing a single page

            response_left = 70
            response_right = 80

            dialog = MessageDialog(parent=self,
                                   flags=Gtk.DialogFlags.MODAL,
                                   message_type=Gtk.MessageType.QUESTION,
                                   buttons=Gtk.ButtonsType.NONE)
            dialog.add_buttons('Left', response_left, 'Right', response_right,
                               Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
            dialog.set_default_response(Gtk.ResponseType.CANCEL)
            dialog.set_text(f'Save Left or Right page?')
            result = dialog.run()

            if result not in (response_left, response_right):
                return None

            if result == response_left:
                if self.is_manga_mode:
                    page += 1
            elif result == response_right:
                if not self.is_manga_mode:
                    page += 1

            if page > self.imagehandler.get_number_of_pages():
                page = self.imagehandler.get_number_of_pages()

        page_name = self.imagehandler.get_page_data(page=page, filename=True)
        page_path = self.imagehandler.get_path_to_page(page=page)

        save_dialog = Gtk.FileChooserDialog(title='Save page as',
                                            action=Gtk.FileChooserAction.SAVE)
        save_dialog.add_buttons(Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT,
                                Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT)
        save_dialog.set_modal(True)
        save_dialog.set_transient_for(self)
        save_dialog.set_do_overwrite_confirmation(True)
        save_dialog.set_current_name(page_name)

        if save_dialog.run(
        ) == Gtk.ResponseType.ACCEPT and save_dialog.get_filename():
            shutil.copy(page_path, save_dialog.get_filename())

        save_dialog.destroy()

    def move_file(self, *args):
        self._move_file(move_else_delete=True)

    def trash_file(self, *args):
        self._move_file(move_else_delete=False)

    def _move_file(self, move_else_delete: bool = True, *args):
        """
        The currently opened file/archive will be moved to prefs['MOVE_FILE']
        or
        The currently opened file/archive will be trashed after showing a confirmation dialog
        """

        current_file = self.imagehandler.get_real_path()

        def file_action(move: bool = True):
            if move:
                Path.rename(current_file, target_file)
            else:
                send2trash(bytes(current_file))

        if move_else_delete:
            target_dir = Path() / current_file.parent / config['MOVE_FILE']
            target_file = Path() / target_dir / current_file.name

            if not Path.exists(target_dir):
                target_dir.mkdir()

        else:
            dialog = MessageDialog(parent=self,
                                   flags=Gtk.DialogFlags.MODAL,
                                   message_type=Gtk.MessageType.QUESTION,
                                   buttons=Gtk.ButtonsType.NONE)
            dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
                               Gtk.STOCK_DELETE, Gtk.ResponseType.OK)
            dialog.set_default_response(Gtk.ResponseType.OK)
            dialog.set_should_remember_choice('delete-opend-file',
                                              (Gtk.ResponseType.OK, ))
            dialog.set_text(
                f'Trash Selected File: "{escape(current_file.name)}"?')
            result = dialog.run()
            if result != Gtk.ResponseType.OK:
                return None

        if self.filehandler.get_archive_type() is not None:
            next_opened = self.filehandler.open_archive_direction(forward=True)
            if not next_opened:
                next_opened = self.filehandler.open_archive_direction(
                    forward=False)
            if not next_opened:
                self.filehandler.close_file()

            if Path.is_file(current_file):
                file_action(move_else_delete)
        else:
            if self.imagehandler.get_number_of_pages() > 1:
                # Open the next/previous file
                if self.imagehandler.get_current_page(
                ) >= self.imagehandler.get_number_of_pages():
                    self.flip_page(number_of_pages=-1)
                else:
                    self.flip_page(number_of_pages=+1)
                # Move the desired file
                if Path.is_file(current_file):
                    file_action(move_else_delete)

                # Refresh the directory
                self.filehandler.refresh_file()
            else:
                self.filehandler.close_file()
                if Path.is_file(current_file):
                    file_action(move_else_delete)

    def minimize(self, *args):
        """
        Minimizes the MComix window
        """

        self.iconify()

    def get_window_geometry(self):
        return self.get_position() + self.get_size()

    def save_window_geometry(self):
        if config['WINDOW_SAVE']:
            (
                config['WINDOW_X'],
                config['WINDOW_Y'],
                config['WINDOW_WIDTH'],
                config['WINDOW_HEIGHT'],
            ) = self.get_window_geometry()

    def restore_window_geometry(self):
        if self.get_window_geometry() == (config['WINDOW_X'],
                                          config['WINDOW_Y'],
                                          config['WINDOW_WIDTH'],
                                          config['WINDOW_HEIGHT']):
            return False
        self.resize(config['WINDOW_WIDTH'], config['WINDOW_HEIGHT'])
        self.move(config['WINDOW_X'], config['WINDOW_Y'])
        return True

    def terminate_program(self, *args):
        """
        Run clean-up tasks and exit the program
        """

        if not self.is_fullscreen():
            self.save_window_geometry()

        self.hide()

        if Gtk.main_level() > 0:
            Gtk.main_quit()

        # write config file
        self.__preference_manager.write_config_file()
        self.keybindings.write_keybindings_file()
        self.bookmark_backend.write_bookmarks_file()

        self.filehandler.close_file()
示例#10
0
    def __init__(self, open_path: list = None):
        super().__init__(type=Gtk.WindowType.TOPLEVEL)

        # ----------------------------------------------------------------
        # Attributes
        # ----------------------------------------------------------------

        # Load configuration.
        self.__preference_manager = PreferenceManager()
        self.__preference_manager.load_config_file()

        self.bookmark_backend = BookmarkBackend()

        # Used to detect window fullscreen state transitions.
        self.was_fullscreen = False
        self.is_manga_mode = config['DEFAULT_MANGA_MODE']
        self.__page_orientation = self.page_orientation()
        self.previous_size = (None, None)
        # Remember last scroll destination.
        self.__last_scroll_destination = Constants.SCROLL_TO['START']

        self.__dummy_layout = FiniteLayout([(1, 1)], (1, 1), [1, 1], 0, 0, 0)
        self.__layout = self.__dummy_layout
        self.__spacing = 2
        self.__waiting_for_redraw = False

        self.__main_layout = Gtk.Layout()
        self.__main_scrolled_window = Gtk.ScrolledWindow()

        self.__main_scrolled_window.add(self.__main_layout)

        self.event_handler = EventHandler(self)
        self.__vadjust = self.__main_scrolled_window.get_vadjustment()
        self.__hadjust = self.__main_scrolled_window.get_hadjustment()

        self.icons = Icons()

        self.filehandler = FileHandler(self)
        self.filehandler.file_closed += self._on_file_closed
        self.filehandler.file_opened += self._on_file_opened
        self.imagehandler = ImageHandler(self)
        self.imagehandler.page_available += self._page_available
        self.thumbnailsidebar = ThumbnailSidebar(self)

        self.statusbar = Statusbar()
        self.cursor_handler = CursorHandler(self)
        self.enhancer = ImageEnhancer(self)
        self.lens = MagnifyingLens(self)
        self.zoom = ZoomModel()

        self.menubar = Menubar(self)

        self.keybindings_map = KeyBindingsMap(self).BINDINGS
        self.keybindings = KeybindingManager(self)

        self.images = [Gtk.Image(),
                       Gtk.Image()]  # XXX limited to at most 2 pages

        # ----------------------------------------------------------------
        # Setup
        # ----------------------------------------------------------------
        self.set_title(Constants.APPNAME)
        self.restore_window_geometry()

        # Hook up keyboard shortcuts
        self.event_handler.event_handler_init()
        self.event_handler.register_key_events()

        for img in self.images:
            self.__main_layout.put(img, 0, 0)

        grid = Gtk.Grid()
        grid.attach(self.menubar, 0, 0, 2, 1)
        grid.attach(self.thumbnailsidebar, 0, 1, 1, 1)
        grid.attach_next_to(self.__main_scrolled_window, self.thumbnailsidebar,
                            Gtk.PositionType.RIGHT, 1, 1)
        self.__main_scrolled_window.set_hexpand(True)
        self.__main_scrolled_window.set_vexpand(True)
        grid.attach(self.statusbar, 0, 2, 2, 1)
        self.add(grid)

        self.change_zoom_mode()

        if not config['KEEP_TRANSFORMATION']:
            config['ROTATION'] = 0
            config['VERTICAL_FLIP'] = False
            config['HORIZONTAL_FLIP'] = False

        # Each widget "eats" part of the main layout visible area.
        self.__toggle_axis = {
            self.thumbnailsidebar: Constants.AXIS['WIDTH'],
            self.statusbar: Constants.AXIS['HEIGHT'],
            self.menubar: Constants.AXIS['HEIGHT'],
        }

        self.__main_layout.set_events(Gdk.EventMask.BUTTON1_MOTION_MASK
                                      | Gdk.EventMask.BUTTON2_MOTION_MASK
                                      | Gdk.EventMask.BUTTON_PRESS_MASK
                                      | Gdk.EventMask.BUTTON_RELEASE_MASK
                                      | Gdk.EventMask.POINTER_MOTION_MASK)

        self.__main_layout.drag_dest_set(
            Gtk.DestDefaults.ALL, [Gtk.TargetEntry.new('text/uri-list', 0, 0)],
            Gdk.DragAction.COPY | Gdk.DragAction.MOVE)

        self.connect('delete_event', self.terminate_program)
        self.connect('key_press_event', self.event_handler.key_press_event)
        self.connect('configure_event', self.event_handler.resize_event)
        self.connect('window-state-event',
                     self.event_handler.window_state_event)

        self.__main_layout.connect('button_release_event',
                                   self.event_handler.mouse_release_event)
        self.__main_layout.connect('scroll_event',
                                   self.event_handler.scroll_wheel_event)
        self.__main_layout.connect('button_press_event',
                                   self.event_handler.mouse_press_event)
        self.__main_layout.connect('motion_notify_event',
                                   self.event_handler.mouse_move_event)
        self.__main_layout.connect('drag_data_received',
                                   self.event_handler.drag_n_drop_event)

        self.show_all()

        if open_path:
            self.filehandler.initialize_fileprovider(path=open_path)
            self.filehandler.open_file(Path(open_path[0]))

        if config['HIDE_CURSOR']:
            self.cursor_handler.auto_hide_on()

            # Make sure we receive *all* mouse motion events,
            # even if a modal dialog is being shown.
            def _on_event(event):
                if Gdk.EventType.MOTION_NOTIFY == event.type:
                    self.cursor_handler.refresh()
                Gtk.main_do_event(event)

            Gdk.event_handler_set(_on_event)
示例#11
0
class MainWindow(Gtk.ApplicationWindow):
    """
    The main window, is created at start and terminates the program when closed
    """

    def __init__(self, open_path: list = None):
        super().__init__(type=Gtk.WindowType.TOPLEVEL)

        # Load configuration.
        self.__preference_manager = PreferenceManager()
        self.__preference_manager.load_config_file()

        self.__events = Events()
        self.__events.add_event(EventType.FILE_OPENED, self._on_file_opened)
        self.__events.add_event(EventType.FILE_CLOSED, self._on_file_closed)
        self.__events.add_event(EventType.PAGE_AVAILABLE, self._page_available)
        self.__events.add_event(EventType.PAGE_CHANGED, self._page_changed)

        # Remember last scroll destination.
        self.__last_scroll_destination = Scroll.START.value

        self.__dummy_layout = FiniteLayout([(1, 1)], (1, 1), [1, 1], 0, 0)
        self.__layout = self.__dummy_layout
        self.__waiting_for_redraw = False
        self.__page_orientation = self._page_orientation()

        self.__file_handler = FileHandler(self)
        self.__filesystem_actions = FileSystemActions(self)
        self.__image_handler = ImageHandler()
        self.__bookmark_backend = BookmarkBackend(self)

        self.__thumbnailsidebar = ThumbnailSidebar(self)
        self.__thumbnailsidebar.hide()

        self.__statusbar = Statusbar()

        self.__zoom = ZoomModel()
        self.__zoom.set_fit_mode(config['ZOOM_MODE'])
        self.__zoom.set_scale_up(config['STRETCH'])
        self.__zoom.reset_user_zoom()

        self.__menubar = Menubar(self)

        self.__event_handler = EventHandler(self)

        self.__keybindings_map = KeyBindingsMap(self).BINDINGS
        self.__keybindings = KeybindingManager(self)

        # Hook up keyboard shortcuts
        self.__event_handler.event_handler_init()
        self.__event_handler.register_key_events()

        self.__cursor_handler = CursorHandler(self)
        self.__lens = MagnifyingLens(self)

        self.__main_layout = Gtk.Layout()
        self.__main_scrolled_window = Gtk.ScrolledWindow()
        self.__main_scrolled_window.add(self.__main_layout)
        self.__main_scrolled_window.set_hexpand(True)
        self.__main_scrolled_window.set_vexpand(True)
        self.__vadjust = self.__main_scrolled_window.get_vadjustment()
        self.__hadjust = self.__main_scrolled_window.get_hadjustment()

        grid = Gtk.Grid()
        grid.attach(self.__menubar, 0, 0, 2, 1)
        grid.attach(self.__thumbnailsidebar, 0, 1, 1, 1)
        grid.attach_next_to(self.__main_scrolled_window, self.__thumbnailsidebar, Gtk.PositionType.RIGHT, 1, 1)
        grid.attach(self.__statusbar, 0, 2, 2, 1)
        self.add(grid)

        # XXX limited to at most 2 pages
        self.__images = [Gtk.Image(), Gtk.Image()]
        for img in self.__images:
            self.__main_layout.put(img, 0, 0)

        # Each widget "eats" part of the main layout visible area.
        self.__toggle_axis = {
            self.__thumbnailsidebar: ZoomAxis.WIDTH.value,
            self.__statusbar: ZoomAxis.HEIGHT.value,
            self.__menubar: ZoomAxis.HEIGHT.value,
        }

        self.__main_layout.set_events(Gdk.EventMask.BUTTON1_MOTION_MASK |
                                      Gdk.EventMask.BUTTON2_MOTION_MASK |
                                      Gdk.EventMask.BUTTON_PRESS_MASK |
                                      Gdk.EventMask.BUTTON_RELEASE_MASK |
                                      Gdk.EventMask.POINTER_MOTION_MASK)

        self.__main_layout.drag_dest_set(Gtk.DestDefaults.ALL,
                                         [Gtk.TargetEntry.new('text/uri-list', 0, 0)],
                                         Gdk.DragAction.COPY |
                                         Gdk.DragAction.MOVE)

        self.connect('delete_event', self.terminate_program)
        self.connect('key_press_event', self.__event_handler.key_press_event)
        self.connect('configure_event', self.__event_handler.resize_event)
        self.connect('window-state-event', self.__event_handler.window_state_event)

        self.__main_layout.connect('button_release_event', self.__event_handler.mouse_release_event)
        self.__main_layout.connect('scroll_event', self.__event_handler.scroll_wheel_event)
        self.__main_layout.connect('button_press_event', self.__event_handler.mouse_press_event)
        self.__main_layout.connect('motion_notify_event', self.__event_handler.mouse_move_event)
        self.__main_layout.connect('drag_data_received', self.__event_handler.drag_n_drop_event)
        self.__main_layout.connect('motion-notify-event', self.__lens.motion_event)
        self.__main_layout.connect('motion-notify-event', self.__cursor_handler.refresh)

        self.set_title(Mcomix.APP_NAME.value)
        self.restore_window_geometry()

        if config['DEFAULT_FULLSCREEN']:
            self.change_fullscreen()

        self.show_all()

        self.__file_handler.open_file_init(open_path)

    @property
    def bookmark_backend(self):
        """
        Interface for BookmarkBackend
        """

        return self.__bookmark_backend

    @property
    def thumbnailsidebar(self):
        """
        Interface for ThumbnailSidebar
        """

        return self.__thumbnailsidebar

    @property
    def statusbar(self):
        """
        Interface for Statusbar
        """

        return self.__statusbar

    @property
    def event_handler(self):
        """
        Interface for EventHandler
        """

        return self.__event_handler

    @property
    def keybindings_map(self):
        """
        Interface for KeyBindingsMap
        """

        return self.__keybindings_map

    @property
    def keybindings(self):
        """
        Interface for KeybindingManager
        """

        return self.__keybindings

    @property
    def cursor_handler(self):
        """
        Interface for CursorHandler
        """

        return self.__cursor_handler

    @property
    def lens(self):
        """
        Interface for MagnifyingLens
        """

        return self.__lens

    @property
    def layout(self):
        return self.__layout

    @property
    def main_layout(self):
        return self.__main_layout

    @property
    def hadjust(self):
        return self.__hadjust

    @property
    def vadjust(self):
        return self.__vadjust

    def _page_orientation(self):
        if ViewState.is_manga_mode:
            return PageOrientation.MANGA.value
        else:
            return PageOrientation.WESTERN.value

    def draw_image(self, scroll_to=None):
        """
        Draw the current pages and update the titlebar and statusbar
        """

        if self.__waiting_for_redraw:
            # Don't stack up redraws.
            return

        self.__waiting_for_redraw = True
        GLib.idle_add(self._draw_image, scroll_to, priority=GLib.PRIORITY_HIGH_IDLE)

    def _draw_image(self, scroll_to: int):
        # hides old images before showing new ones
        # also if in double page mode and only a single
        # image is going to be shown, prevents a ghost second image
        for i in self.__images:
            i.clear()

        if not self.__file_handler.get_file_loaded():
            self.__thumbnailsidebar.hide()
            self.__waiting_for_redraw = False
            return

        self.__thumbnailsidebar.show()

        if not self.__image_handler.page_is_available():
            # Save scroll destination for when the page becomes available.
            self.__last_scroll_destination = scroll_to
            self.__waiting_for_redraw = False
            return

        distribution_axis = ZoomAxis.DISTRIBUTION.value
        alignment_axis = ZoomAxis.ALIGNMENT.value
        # XXX limited to at most 2 pages
        pixbuf_count = 2 if ViewState.is_displaying_double else 1
        pixbuf_count_iter = range(pixbuf_count)
        pixbuf_list = list(self.__image_handler.get_pixbufs(pixbuf_count))
        do_not_transform = [ImageTools.disable_transform(x) for x in pixbuf_list]
        size_list = [[pixbuf.get_width(), pixbuf.get_height()] for pixbuf in pixbuf_list]

        # Rotation handling:
        # - apply Exif rotation on individual images
        # - apply manual rotation on whole page
        orientation = self.__page_orientation
        if config['AUTO_ROTATE_FROM_EXIF']:
            rotation_list = [ImageTools.get_implied_rotation(pixbuf) for pixbuf in pixbuf_list]
            for i in pixbuf_count_iter:
                if rotation_list[i] in (90, 270):
                    size_list[i].reverse()
        else:
            # no auto rotation
            rotation_list = [0] * len(pixbuf_list)

        rotation = config['ROTATION'] % 360
        match rotation:
            case (90 | 270):
                distribution_axis, alignment_axis = alignment_axis, distribution_axis
                orientation.reverse()
                for i in pixbuf_count_iter:
                    size_list[i].reverse()
            case 180:
                orientation.reverse()

        # Recompute the visible area size
        viewport_size = self.get_visible_area_size()
        zoom_dummy_size = list(viewport_size)
        scaled_sizes = self.__zoom.get_zoomed_size(size_list, zoom_dummy_size, distribution_axis, do_not_transform)

        self.__layout = FiniteLayout(scaled_sizes, viewport_size, orientation, distribution_axis, alignment_axis)

        self.__main_layout.set_size(*self.__layout.get_union_box().get_size())

        content_boxes = self.__layout.get_content_boxes()

        for i in pixbuf_count_iter:
            rotation_list[i] = (rotation_list[i] + rotation) % 360

            pixbuf_list[i] = ImageTools.fit_pixbuf_to_rectangle(pixbuf_list[i], scaled_sizes[i], rotation_list[i])
            pixbuf_list[i] = ImageTools.enhance(pixbuf_list[i])

            ImageTools.set_from_pixbuf(self.__images[i], pixbuf_list[i])

            self.__main_layout.move(self.__images[i], *content_boxes[i].get_position())
            self.__images[i].show()

        # Reset orientation so scrolling behaviour is sane.
        self.__layout.set_orientation(self.__page_orientation)

        if scroll_to is not None:
            destination = (scroll_to,) * 2
            self.scroll_to_predefined(destination)

        # update statusbar
        resolutions = [(*size, scaled_size[0] / size[0]) for scaled_size, size in zip(scaled_sizes, size_list, strict=True)]
        if ViewState.is_manga_mode:
            resolutions.reverse()
        self.__statusbar.set_resolution(resolutions)
        self.__statusbar.update()

        self.__waiting_for_redraw = False

    def _update_page_information(self):
        """
        Updates the window with information that can be gathered
        even when the page pixbuf(s) aren't ready yet
        """

        page = self.__image_handler.get_current_page()
        if not page:
            return

        filenames = self.__image_handler.get_page_filename(page=page)
        filesizes = self.__image_handler.get_page_filesize(page=page)

        filename = ', '.join(filenames)
        filesize = ', '.join(filesizes)

        self.__statusbar.set_page_number(page, self.__image_handler.get_number_of_pages())
        self.__statusbar.set_filename(filename)
        self.__statusbar.set_filesize(filesize)

        self.__statusbar.update()

    def _get_virtual_double_page(self, page: int = None):
        """
        Return True if the current state warrants use of virtual
        double page mode (i.e. if double page mode is on, the corresponding
        preference is set, and one of the two images that should normally
        be displayed has a width that exceeds its height), or if currently
        on the first page

        :returns: True if the current state warrants use of virtual double page mode
        """

        if page is None:
            page = self.__image_handler.get_current_page()

        if (page == 1 and
                config['VIRTUAL_DOUBLE_PAGE_FOR_FITTING_IMAGES'] & DoublePage.AS_ONE_TITLE.value and
                self.__file_handler.is_archive()):
            return True

        if (not config['DEFAULT_DOUBLE_PAGE'] or
                not config['VIRTUAL_DOUBLE_PAGE_FOR_FITTING_IMAGES'] & DoublePage.AS_ONE_WIDE.value or
                self.__image_handler.is_last_page(page)):
            return False

        for page in (page, page + 1):
            if not self.__image_handler.page_is_available(page):
                return False
            pixbuf = self.__image_handler.get_pixbuf(page)
            width, height = pixbuf.get_width(), pixbuf.get_height()
            if config['AUTO_ROTATE_FROM_EXIF']:
                rotation = ImageTools.get_implied_rotation(pixbuf)

                # if rotation not in (0, 90, 180, 270):
                #     return

                if rotation in (90, 270):
                    width, height = height, width
            if width > height:
                return True

        return False

    def _page_available(self, page: int):
        """
        Called whenever a new page is ready for displaying
        """

        # Refresh display when currently opened page becomes available.
        current_page = self.__image_handler.get_current_page()
        nb_pages = 2 if ViewState.is_displaying_double else 1
        if current_page <= page < (current_page + nb_pages):
            self._displayed_double()
            self.draw_image(scroll_to=self.__last_scroll_destination)
            self._update_page_information()

    def _on_file_opened(self):
        self._displayed_double()
        self.__thumbnailsidebar.show()

        if config['STATUSBAR_FULLPATH']:
            self.__statusbar.set_archive_filename(self.__file_handler.get_base_path())
        else:
            self.__statusbar.set_archive_filename(self.__file_handler.get_base_path().name)
        self.__statusbar.set_view_mode()
        self.__statusbar.set_filesize_archive(self.__file_handler.get_base_path())
        self.__statusbar.set_file_number(*self.__file_handler.get_file_number())
        self.__statusbar.update()

        self._update_title()

    def _on_file_closed(self):
        self.clear()
        self.__thumbnailsidebar.hide()
        self.__thumbnailsidebar.clear()

    def _new_page(self, at_bottom: bool = False):
        """
        Draw a *new* page correctly (as opposed to redrawing the same image with a new size or whatever)
        """

        if not config['KEEP_TRANSFORMATION']:
            config['ROTATION'] = 0

        if at_bottom:
            scroll_to = Scroll.END.value
        else:
            scroll_to = Scroll.START.value

        self.draw_image(scroll_to=scroll_to)

    def page_changed(self):
        """
        Called on page change
        """

        self.__events.run_events(EventType.PAGE_CHANGED)

    def _page_changed(self):
        self._displayed_double()
        self.__thumbnailsidebar.hide()
        self.__thumbnailsidebar.load_thumbnails()
        self._update_page_information()

    def set_page(self, num: int, at_bottom: bool = False):
        """
        Draws a *new* page (as opposed to redrawing the same image with a new size or whatever)
        """

        if num == self.__image_handler.get_current_page():
            return

        self.__image_handler.set_page(num)
        self.page_changed()
        self._new_page(at_bottom=at_bottom)

    def flip_page(self, number_of_pages: int, single_step: bool = False):
        if not self.__file_handler.get_file_loaded():
            return

        current_page = self.__image_handler.get_current_page()
        current_number_of_pages = self.__image_handler.get_number_of_pages()

        new_page = current_page + number_of_pages
        if (abs(number_of_pages) == 1 and
                not single_step and
                config['DEFAULT_DOUBLE_PAGE'] and
                config['DOUBLE_STEP_IN_DOUBLE_PAGE_MODE']):
            if number_of_pages == 1 and not self._get_virtual_double_page():
                new_page += 1
            elif number_of_pages == -1 and not self._get_virtual_double_page(new_page - 1):
                new_page -= 1

        if new_page <= 0:
            # Only switch to previous page when flipping one page before the
            # first one. (Note: check for (page number <= 1) to handle empty
            # archive case).
            if number_of_pages == -1 and current_page <= 1:
                return self.__file_handler.open_archive_direction(forward=False)
            # Handle empty archive case.
            new_page = min(1, current_number_of_pages)
        elif new_page > current_number_of_pages:
            if number_of_pages == 1:
                return self.__file_handler.open_archive_direction(forward=True)
            new_page = current_number_of_pages

        if new_page != current_page:
            self.set_page(new_page, at_bottom=(-1 == number_of_pages))

    def first_page(self):
        if self.__image_handler.get_number_of_pages():
            self.set_page(1)

    def last_page(self):
        number_of_pages = self.__image_handler.get_number_of_pages()
        if number_of_pages:
            self.set_page(number_of_pages)

    def page_select(self, *args):
        Pageselector(self)

    def rotate_90(self, *args):
        self.rotate_x(rotation=90)

    def rotate_180(self, *args):
        self.rotate_x(rotation=180)

    def rotate_270(self, *args):
        self.rotate_x(rotation=270)

    def rotate_x(self, rotation: int, *args):
        config['ROTATION'] = (config['ROTATION'] + rotation) % 360
        self.draw_image()

    def change_double_page(self, *args):
        config['DEFAULT_DOUBLE_PAGE'] = not config['DEFAULT_DOUBLE_PAGE']
        self._displayed_double()
        self._update_page_information()
        self.draw_image()

    def change_manga_mode(self, *args):
        config['DEFAULT_MANGA_MODE'] = not config['DEFAULT_MANGA_MODE']
        ViewState.is_manga_mode = config['DEFAULT_MANGA_MODE']
        self.__page_orientation = self._page_orientation()
        self.__statusbar.set_view_mode()
        self._update_page_information()
        self.draw_image()

    def is_fullscreen(self):
        window_state = self.get_window().get_state()
        return (window_state & Gdk.WindowState.FULLSCREEN) != 0

    def change_fullscreen(self, *args):
        # Disable action until transition if complete.

        if self.is_fullscreen():
            self.unfullscreen()

            self.__cursor_handler.auto_hide_off()

            # menu/status can only be hidden in fullscreen
            # if not hidden using .show() is the same as a NOOP
            self.__statusbar.show()
            self.__menubar.show()
        else:
            self.save_window_geometry()
            self.fullscreen()

            self.__cursor_handler.auto_hide_on()

            if config['FULLSCREEN_HIDE_STATUSBAR']:
                self.__statusbar.hide()
            if config['FULLSCREEN_HIDE_MENUBAR']:
                self.__menubar.hide()

        # No need to call draw_image explicitely,
        # as we'll be receiving a window state
        # change or resize event.

    def change_fit_mode_best(self, *args):
        self.change_zoom_mode(ZoomModes.BEST.value)

    def change_fit_mode_width(self, *args):
        self.change_zoom_mode(ZoomModes.WIDTH.value)

    def change_fit_mode_height(self, *args):
        self.change_zoom_mode(ZoomModes.HEIGHT.value)

    def change_fit_mode_size(self, *args):
        self.change_zoom_mode(ZoomModes.SIZE.value)

    def change_fit_mode_manual(self, *args):
        self.change_zoom_mode(ZoomModes.MANUAL.value)

    def change_zoom_mode(self, value: int = None):
        if value is not None:
            config['ZOOM_MODE'] = value
        self.__zoom.set_fit_mode(config['ZOOM_MODE'])
        self.__zoom.set_scale_up(config['STRETCH'])
        self.__zoom.reset_user_zoom()
        self.draw_image()

    def toggle_image_scaling(self):
        config['ENABLE_PIL_SCALING'] = not config['ENABLE_PIL_SCALING']

        self.__statusbar.update_image_scaling()
        self.draw_image()

    def change_image_scaling(self, step: int):
        if config['ENABLE_PIL_SCALING']:
            config_key = 'PIL_SCALING_FILTER'
            algos = ScalingPIL
        else:
            config_key = 'GDK_SCALING_FILTER'
            algos = ScalingGDK

        # inc/dec active algo, modulus loops algos to start on overflow
        # and end on underflow
        config[config_key] = algos((config[config_key] + step) % len(algos)).value

        self.__statusbar.update_image_scaling()
        self.draw_image()

    def change_stretch(self, *args):
        """
        Toggles stretching small images
        """

        config['STRETCH'] = not config['STRETCH']
        self.__zoom.set_scale_up(config['STRETCH'])
        self.draw_image()

    def open_dialog_about(self, *args):
        dialog = DialogChooser(DialogChoice.ABOUT)
        dialog.open_dialog(self)

    def open_dialog_enhance(self, *args):
        dialog = DialogChooser(DialogChoice.ENHANCE)
        dialog.open_dialog(self)

    def open_dialog_file_chooser(self, *args):
        dialog = DialogChooser(DialogChoice.FILECHOOSER)
        dialog.open_dialog(self)

    def open_dialog_keybindings(self, *args):
        dialog = DialogChooser(DialogChoice.KEYBINDINGS)
        dialog.open_dialog(self)

    def open_dialog_preference(self, *args):
        dialog = DialogChooser(DialogChoice.PREFERENCES)
        dialog.open_dialog(self)

    def open_dialog_properties(self, *args):
        dialog = DialogChooser(DialogChoice.PROPERTIES)
        dialog.open_dialog(self)

    def change_keep_transformation(self, *args):
        config['KEEP_TRANSFORMATION'] = not config['KEEP_TRANSFORMATION']

    def manual_zoom_in(self, *args):
        self.__zoom.zoom_in()
        self.draw_image()

    def manual_zoom_out(self, *args):
        self.__zoom.zoom_out()
        self.draw_image()

    def manual_zoom_original(self, *args):
        self.__zoom.reset_user_zoom()
        self.draw_image()

    def scroll(self, x: int, y: int):
        """
        Scroll <x> px horizontally and <y> px vertically. If <bound> is
        'first' or 'second', we will not scroll out of the first or second
        page respectively (dependent on manga mode). The <bound> argument
        only makes sense in double page mode.

        :returns: True if call resulted in new adjustment values, False otherwise
        """

        old_hadjust = self.__hadjust.get_value()
        old_vadjust = self.__vadjust.get_value()

        visible_width, visible_height = self.get_visible_area_size()

        hadjust_upper = max(0, self.__hadjust.get_upper() - visible_width)
        vadjust_upper = max(0, self.__vadjust.get_upper() - visible_height)
        hadjust_lower = 0

        new_hadjust = old_hadjust + x
        new_vadjust = old_vadjust + y

        new_hadjust = max(hadjust_lower, new_hadjust)
        new_vadjust = max(0, new_vadjust)

        new_hadjust = min(hadjust_upper, new_hadjust)
        new_vadjust = min(vadjust_upper, new_vadjust)

        self.__vadjust.set_value(new_vadjust)
        self.__hadjust.set_value(new_hadjust)

        return old_vadjust != new_vadjust or old_hadjust != new_hadjust

    def scroll_to_predefined(self, destination: tuple):
        self.__layout.scroll_to_predefined(destination)
        viewport_position = self.__layout.get_viewport_box().get_position()
        self.__hadjust.set_value(viewport_position[0])  # 2D only
        self.__vadjust.set_value(viewport_position[1])  # 2D only

    def clear(self):
        """
        Clear the currently displayed data (i.e. "close" the file)
        """

        self.set_title(Mcomix.APP_NAME.value)
        self.__statusbar.set_message('')
        self.draw_image()

    def _displayed_double(self):
        """
        sets True if two pages are currently displayed
        """

        ViewState.is_displaying_double = (
            self.__image_handler.get_current_page() and
            config['DEFAULT_DOUBLE_PAGE'] and
            not self._get_virtual_double_page() and
            not self.__image_handler.is_last_page()
        )

    def get_visible_area_size(self):
        """
        :returns: a 2-tuple with the width and height of the visible part of the main layout area
        """

        dimensions = list(self.get_size())
        size = 0

        for widget, axis in self.__toggle_axis.items():
            size = widget.get_preferred_size()
            match axis:
                case ZoomAxis.WIDTH.value:
                    size = size.natural_size.width
                case ZoomAxis.HEIGHT.value:
                    size = size.natural_size.height
            dimensions[axis] -= size

        return tuple(dimensions)

    def _update_title(self):
        """
        Set the title acording to current state
        """

        self.set_title(f'{Mcomix.APP_NAME.value} [{self.__file_handler.get_real_path()}]')

    def extract_page(self, *args):
        self.__filesystem_actions.extract_page()

    def move_file(self, *args):
        self.__filesystem_actions.move_file()

    def trash_file(self, *args):
        self.__filesystem_actions.trash_file()

    def minimize(self, *args):
        """
        Minimizes the MComix window
        """

        self.iconify()

    def get_window_geometry(self):
        return self.get_position() + self.get_size()

    def save_window_geometry(self):
        if config['WINDOW_SAVE']:
            (
                config['WINDOW_X'],
                config['WINDOW_Y'],
                config['WINDOW_WIDTH'],
                config['WINDOW_HEIGHT'],
            ) = self.get_window_geometry()

    def restore_window_geometry(self):
        if self.get_window_geometry() == (config['WINDOW_X'],
                                          config['WINDOW_Y'],
                                          config['WINDOW_WIDTH'],
                                          config['WINDOW_HEIGHT']):
            return False
        self.resize(config['WINDOW_WIDTH'], config['WINDOW_HEIGHT'])
        self.move(config['WINDOW_X'], config['WINDOW_Y'])
        return True

    def terminate_program(self, *args):
        """
        Run clean-up tasks and exit the program
        """

        if not self.is_fullscreen():
            self.save_window_geometry()

        self.hide()

        if Gtk.main_level() > 0:
            Gtk.main_quit()

        # write config file
        self.__preference_manager.write_config_file()
        self.__keybindings.write_keybindings_file()
        self.__bookmark_backend.write_bookmarks_file()

        self.__file_handler.close_file()
示例#12
0
    def __init__(self, open_path: list = None):
        super().__init__(type=Gtk.WindowType.TOPLEVEL)

        # Load configuration.
        self.__preference_manager = PreferenceManager()
        self.__preference_manager.load_config_file()

        self.__events = Events()
        self.__events.add_event(EventType.FILE_OPENED, self._on_file_opened)
        self.__events.add_event(EventType.FILE_CLOSED, self._on_file_closed)
        self.__events.add_event(EventType.PAGE_AVAILABLE, self._page_available)
        self.__events.add_event(EventType.PAGE_CHANGED, self._page_changed)

        # Remember last scroll destination.
        self.__last_scroll_destination = Scroll.START.value

        self.__dummy_layout = FiniteLayout([(1, 1)], (1, 1), [1, 1], 0, 0)
        self.__layout = self.__dummy_layout
        self.__waiting_for_redraw = False
        self.__page_orientation = self._page_orientation()

        self.__file_handler = FileHandler(self)
        self.__filesystem_actions = FileSystemActions(self)
        self.__image_handler = ImageHandler()
        self.__bookmark_backend = BookmarkBackend(self)

        self.__thumbnailsidebar = ThumbnailSidebar(self)
        self.__thumbnailsidebar.hide()

        self.__statusbar = Statusbar()

        self.__zoom = ZoomModel()
        self.__zoom.set_fit_mode(config['ZOOM_MODE'])
        self.__zoom.set_scale_up(config['STRETCH'])
        self.__zoom.reset_user_zoom()

        self.__menubar = Menubar(self)

        self.__event_handler = EventHandler(self)

        self.__keybindings_map = KeyBindingsMap(self).BINDINGS
        self.__keybindings = KeybindingManager(self)

        # Hook up keyboard shortcuts
        self.__event_handler.event_handler_init()
        self.__event_handler.register_key_events()

        self.__cursor_handler = CursorHandler(self)
        self.__lens = MagnifyingLens(self)

        self.__main_layout = Gtk.Layout()
        self.__main_scrolled_window = Gtk.ScrolledWindow()
        self.__main_scrolled_window.add(self.__main_layout)
        self.__main_scrolled_window.set_hexpand(True)
        self.__main_scrolled_window.set_vexpand(True)
        self.__vadjust = self.__main_scrolled_window.get_vadjustment()
        self.__hadjust = self.__main_scrolled_window.get_hadjustment()

        grid = Gtk.Grid()
        grid.attach(self.__menubar, 0, 0, 2, 1)
        grid.attach(self.__thumbnailsidebar, 0, 1, 1, 1)
        grid.attach_next_to(self.__main_scrolled_window, self.__thumbnailsidebar, Gtk.PositionType.RIGHT, 1, 1)
        grid.attach(self.__statusbar, 0, 2, 2, 1)
        self.add(grid)

        # XXX limited to at most 2 pages
        self.__images = [Gtk.Image(), Gtk.Image()]
        for img in self.__images:
            self.__main_layout.put(img, 0, 0)

        # Each widget "eats" part of the main layout visible area.
        self.__toggle_axis = {
            self.__thumbnailsidebar: ZoomAxis.WIDTH.value,
            self.__statusbar: ZoomAxis.HEIGHT.value,
            self.__menubar: ZoomAxis.HEIGHT.value,
        }

        self.__main_layout.set_events(Gdk.EventMask.BUTTON1_MOTION_MASK |
                                      Gdk.EventMask.BUTTON2_MOTION_MASK |
                                      Gdk.EventMask.BUTTON_PRESS_MASK |
                                      Gdk.EventMask.BUTTON_RELEASE_MASK |
                                      Gdk.EventMask.POINTER_MOTION_MASK)

        self.__main_layout.drag_dest_set(Gtk.DestDefaults.ALL,
                                         [Gtk.TargetEntry.new('text/uri-list', 0, 0)],
                                         Gdk.DragAction.COPY |
                                         Gdk.DragAction.MOVE)

        self.connect('delete_event', self.terminate_program)
        self.connect('key_press_event', self.__event_handler.key_press_event)
        self.connect('configure_event', self.__event_handler.resize_event)
        self.connect('window-state-event', self.__event_handler.window_state_event)

        self.__main_layout.connect('button_release_event', self.__event_handler.mouse_release_event)
        self.__main_layout.connect('scroll_event', self.__event_handler.scroll_wheel_event)
        self.__main_layout.connect('button_press_event', self.__event_handler.mouse_press_event)
        self.__main_layout.connect('motion_notify_event', self.__event_handler.mouse_move_event)
        self.__main_layout.connect('drag_data_received', self.__event_handler.drag_n_drop_event)
        self.__main_layout.connect('motion-notify-event', self.__lens.motion_event)
        self.__main_layout.connect('motion-notify-event', self.__cursor_handler.refresh)

        self.set_title(Mcomix.APP_NAME.value)
        self.restore_window_geometry()

        if config['DEFAULT_FULLSCREEN']:
            self.change_fullscreen()

        self.show_all()

        self.__file_handler.open_file_init(open_path)
示例#13
0
class BookmarkBackend:
    """
    The _BookmarkBackend is a backend for both the bookmarks menu and dialog.
    Changes in the _BookmarkBackend are mirrored in both
    """
    def __init__(self, window: MainWindow):
        super().__init__()

        self.__window = window

        self.__events = Events()

        self.__file_handler = FileHandler(None)
        self.__image_handler = ImageHandler()

        self.__bookmark_path = ConfigFiles.BOOKMARK.value
        self.__bookmark_state_dirty = False

        #: List of bookmarks
        self.__bookmarks = self.load_bookmarks_file()
        #: Modification date of bookmarks file
        self.__bookmarks_size = self.get_bookmarks_file_size()

    def add_bookmark(self, bookmark):
        """
        Add the <bookmark> to the list
        """

        self.__bookmarks.append(bookmark)

        self.__bookmark_state_dirty = True
        self.write_bookmarks_file()
        self.__bookmark_state_dirty = False

        self.__events.run_events(EventType.BOOKMARK_ADD)

    def remove_bookmark(self, bookmark):
        """
        Remove the <bookmark> from the list
        """

        self.__bookmarks.remove(bookmark)

        self.__bookmark_state_dirty = True
        self.write_bookmarks_file()
        self.__bookmark_state_dirty = False

        self.__events.run_events(EventType.BOOKMARK_REMOVE)

    def add_current_to_bookmarks(self):
        """
        Add the currently viewed page to the list
        """

        path = self.__file_handler.get_real_path()
        current_page = self.__image_handler.get_current_page()
        total_pages = self.__image_handler.get_number_of_pages()
        date_added = datetime.today().timestamp()

        same_file_bookmarks = []

        for bookmark in self.__bookmarks:
            if Path(bookmark.bookmark_path) == path:
                if bookmark.bookmark_current_page == current_page:
                    logger.info(
                        f'Bookmark already exists for file \'{path}\' on page \'{current_page}\''
                    )
                    return

                same_file_bookmarks.append(bookmark)

        # If the same file was already bookmarked, ask to replace
        # the existing bookmarks before deleting them.
        if len(same_file_bookmarks) > 0:
            response = self.show_replace_bookmark_dialog(current_page)

            # Delete old bookmarks
            if response == Gtk.ResponseType.YES:
                for bookmark in same_file_bookmarks:
                    self.remove_bookmark(bookmark)
            # Perform no action
            elif response not in (Gtk.ResponseType.YES, Gtk.ResponseType.NO):
                return

        bookmark = Bookmark(self.__window, path, current_page, total_pages,
                            date_added)
        self.add_bookmark(bookmark)

    def open_bookmark(self, path: Path, current_page: int):
        """
        Open the file and page the bookmark represents
        """

        if not path.is_file():
            dialog = MessageDialogInfo()
            dialog.set_text(primary='Bookmarked file does not exist',
                            secondary=f'{path}')
            dialog.run()
            return

        if self.__file_handler.get_real_path() != path:
            self.__file_handler.open_file_init(paths=[path],
                                               start_page=current_page)
        else:
            self.__window.set_page(current_page)

    def get_bookmarks(self):
        """
        Return all the bookmarks in the list
        """

        if not self.file_was_modified():
            return self.__bookmarks

        self.__bookmarks = self.load_bookmarks_file()
        return self.__bookmarks

    def get_bookmarks_file_size(self):
        if not self.__bookmark_path.is_file():
            return 0

        return self.__bookmark_path.stat().st_size

    def load_bookmarks_file(self):
        """
        Loads persisted bookmarks from a local file.

        :return: Tuple of (bookmarks, file mtime)
        """

        bookmarks = []

        if not Path.is_file(self.__bookmark_path):
            return bookmarks

        try:
            with Path.open(self.__bookmark_path, mode='rt',
                           encoding='utf8') as fd:
                for bookmark in yaml.safe_load(fd):
                    for item in bookmark:
                        path = Path(bookmark[item]['path'], item)
                        current_page = bookmark[item]['current_page']
                        total_pages = bookmark[item]['total_pages']
                        date_added = bookmark[item]['created']

                        # if not path.is_file():
                        #     logger.warning(f'Missing bookmark: {path}')

                        bookmarks.append(
                            Bookmark(self.__window,
                                     path=path,
                                     current_page=current_page,
                                     total_pages=total_pages,
                                     date_added=date_added))
        except Exception as ex:
            logger.error(
                f'Could not parse bookmarks file: \'{self.__bookmark_path}\'')
            logger.error(f'Exception: {ex}')

        return bookmarks

    def bookmark_pack(self, bookmark):
        """
        Returns a dict. The bookmark can be fully
        re-created using the values in the dict
        """

        return {
            bookmark.bookmark_name: {
                # YAML does not work with Pathlike objects
                # when using CSafeDumper
                'path': str(Path(bookmark.bookmark_path).parent),
                'current_page': bookmark.bookmark_current_page,
                'total_pages': bookmark.bookmark_total_pages,
                # archive_type is deprecated, to be removed before next release
                'archive_type': 0,
                'created': bookmark.bookmark_date_added
            }
        }

    def file_was_modified(self):
        """
        Checks the bookmark store's mtime to see if it has been modified
        since it was last read
        """

        if not self.__bookmark_path.is_file() or \
                (self.get_bookmarks_file_size() != self.__bookmarks_size):
            return True

        return False

    def write_bookmarks_file(self):
        """
        Store relevant bookmark info in the mcomix directory
        """

        # Merge changes in case file was modified from within other instances
        if not self.__bookmark_state_dirty:
            logger.info('No changes to write for bookmarks')
            return
        logger.info('Writing changes to bookmarks')

        if self.file_was_modified():
            new_bookmarks = self.load_bookmarks_file()
            self.__bookmarks = list(set(self.__bookmarks + new_bookmarks))

        packs = [self.bookmark_pack(bookmark) for bookmark in self.__bookmarks]
        bookmarks = yaml.dump(packs,
                              Dumper=yaml.CSafeDumper,
                              sort_keys=False,
                              allow_unicode=True,
                              width=2147483647)
        self.__bookmark_path.write_text(bookmarks)
        self.__bookmarks_size = self.get_bookmarks_file_size()

    def show_replace_bookmark_dialog(self, new_page):
        """
        Present a confirmation dialog to replace old bookmarks.

        :returns: RESPONSE_YES to create replace bookmarks,
        RESPONSE_NO to create a new bookmark, RESPONSE_CANCEL to abort creating
        a new bookmark
        """

        dialog = MessageDialogRemember()
        dialog.add_buttons(Gtk.STOCK_YES, Gtk.ResponseType.YES, Gtk.STOCK_NO,
                           Gtk.ResponseType.NO, Gtk.STOCK_CANCEL,
                           Gtk.ResponseType.CANCEL)
        dialog.set_default_response(Gtk.ResponseType.YES)
        dialog.set_should_remember_choice(
            'replace-existing-bookmark',
            (Gtk.ResponseType.YES, Gtk.ResponseType.NO))
        dialog.set_text(
            primary='The current book already contains marked pages.',
            secondary=
            f'Do you want to replace them with a new bookmark on page {new_page}?'
            'Selecting "No" will create a new bookmark.')

        return dialog.run()
class PropertiesDialog(Gtk.Dialog):
    __slots__ = ('__window', '__image_handler', '__notebook', '__archive_page',
                 '__image_page')

    def __init__(self, window: MainWindow):
        super().__init__(title='Properties')

        self.set_transient_for(window)
        self.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
                         Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT)

        self.__window = window

        events = Events()
        events.add_event(EventType.FILE_OPENED, self._on_book_change)
        events.add_event(EventType.FILE_CLOSED, self._on_book_change)
        events.add_event(EventType.PAGE_AVAILABLE, self._on_page_available)
        events.add_event(EventType.PAGE_CHANGED, self._on_page_change)

        self.__file_handler = FileHandler(None)
        self.__image_handler = ImageHandler()

        self.resize(870, 560)
        self.set_border_width(2)
        self.set_resizable(True)
        self.set_default_response(Gtk.ResponseType.CLOSE)

        self.__notebook = Gtk.Notebook()
        self.vbox.pack_start(self.__notebook, True, True, 0)

        self.__notebook.set_border_width(2)

        self.__archive_page = PropertiesPage()
        self.__image_page = PropertiesPage()

        self.__notebook.append_page(self.__archive_page,
                                    Gtk.Label(label='Archive'))
        self.__notebook.append_page(self.__image_page,
                                    Gtk.Label(label='Image'))

        self._update_archive_page()

        self.show_all()

    def _on_page_change(self):
        self._update_image_page()

    def _on_book_change(self):
        self._update_archive_page()

    def _on_page_available(self, page_number: int):
        if page_number == 1:
            self._update_page_image(self.__archive_page, 1)
        current_page_number = self.__image_handler.get_current_page()
        if current_page_number == page_number:
            self._update_image_page()

    def _update_archive_page(self):
        page = self.__archive_page
        page.reset()
        if not self.__file_handler.is_archive():
            self._update_image_page()
            if self.__notebook.get_n_pages() == 2:
                self.__notebook.detach_tab(page)
            return
        if self.__notebook.get_n_pages() == 1:
            self.__notebook.insert_page(page, Gtk.Label(label='Archive'), 0)
        self._update_page_image(page, 1)
        page.set_filename(self.__file_handler.get_real_path().name)
        path = self.__file_handler.get_base_path()
        main_info = (f'{self.__image_handler.get_number_of_pages()} pages',
                     'Archive File'
                     if self.__file_handler.is_archive else 'Image File')
        page.set_main_info(main_info)
        self._update_page_secondary_info(page, path)
        page.show_all()
        self._update_image_page()

    def _update_image_page(self):
        page = self.__image_page
        page.reset()
        if not self.__image_handler.page_is_available():
            return
        self._update_page_image(page)
        path = self.__image_handler.get_path_to_page()
        page.set_filename(path.name)
        width, height = self.__image_handler.get_size()
        main_info = (
            f'{width}x{height} px',
            self.__image_handler.get_mime_name(),
        )
        page.set_main_info(main_info)
        self._update_page_secondary_info(page, path)
        page.show_all()

    def _update_page_image(self, page, page_number: int = None):
        if not self.__image_handler.page_is_available(page_number):
            return
        thumb = self.__image_handler.get_thumbnail(page=page_number,
                                                   size=(256, 256))
        page.set_thumbnail(thumb)

    def _update_page_secondary_info(self, page, path: Path):
        stats = Path.stat(path)
        secondary_info = (('Location', Path.resolve(path).parent),
                          ('Size', FileSize(path)),
                          ('Modified', datetime.fromtimestamp(
                              stats.st_mtime).strftime('%Y-%m-%d %H:%M:%S')),
                          ('Accessed', datetime.fromtimestamp(
                              stats.st_atime).strftime('%Y-%m-%d %H:%M:%S')),
                          ('Permissions', oct(stat.S_IMODE(stats.st_mode))),
                          ('Owner', path.owner()), ('Group', path.group()))
        page.set_secondary_info(secondary_info)
示例#15
0
    def __init__(self, window: MainWindow):
        super().__init__()

        file_handler = FileHandler(None)

        group_nav = 'Navigation'
        group_scroll = 'Scrolling'
        group_zoom = 'Zoom'
        group_trans = 'Transformation'
        group_view = 'View mode'
        group_pagefit = 'Page fit mode'
        group_ui = 'User interface'
        group_info = 'Info'
        group_file = 'File'
        group_scale = 'Image Scaling'

        @dataclass(frozen=True)
        class INFO:
            __slots__ = ('group', 'title')
            group: str
            title: str

        @dataclass(frozen=True)
        class KEYBINDINGS:
            __slots__ = ('keybindings')
            keybindings: list

        @dataclass(frozen=True)
        class KEY_EVENT:
            __slots__ = ('callback', 'callback_kwargs')
            callback: Callable
            callback_kwargs: dict

        @dataclass(frozen=True)
        class MAP:
            __slots__ = ('info', 'keybindings', 'key_event')
            info: INFO
            keybindings: KEYBINDINGS
            key_event: KEY_EVENT

        self.BINDINGS = {
            # Navigation
            'previous_page':
            MAP(
                INFO(group_nav, 'Previous page'),
                KEYBINDINGS(['Page_Up', 'KP_Page_Up', 'BackSpace']),
                KEY_EVENT(
                    window.flip_page,
                    {'number_of_pages': -1},
                ),
            ),
            'next_page':
            MAP(
                INFO(group_nav, 'Next page'),
                KEYBINDINGS(['Page_Down', 'KP_Page_Down']),
                KEY_EVENT(
                    window.flip_page,
                    {'number_of_pages': 1},
                ),
            ),
            'previous_page_singlestep':
            MAP(
                INFO(group_nav, 'Previous page (always one page)'),
                KEYBINDINGS(
                    ['<Primary>Up', '<Primary>Page_Up',
                     '<Primary>KP_Page_Up']),
                KEY_EVENT(
                    window.flip_page,
                    {
                        'number_of_pages': -1,
                        'single_step': True
                    },
                ),
            ),
            'next_page_singlestep':
            MAP(
                INFO(group_nav, 'Next page (always one page)'),
                KEYBINDINGS([
                    '<Primary>Down', '<Primary>Page_Down',
                    '<Primary>KP_Page_Down'
                ]),
                KEY_EVENT(
                    window.flip_page,
                    {
                        'number_of_pages': 1,
                        'single_step': True
                    },
                ),
            ),
            'previous_page_ff':
            MAP(
                INFO(group_nav, 'Rewind by X pages'),
                KEYBINDINGS([
                    '<Shift>Page_Up', '<Shift>KP_Page_Up', '<Shift>BackSpace',
                    '<Shift><Mod1>Left'
                ]),
                KEY_EVENT(
                    window.flip_page,
                    {'number_of_pages': -config['PAGE_FF_STEP']},
                ),
            ),
            'next_page_ff':
            MAP(
                INFO(group_nav, 'Forward by X pages'),
                KEYBINDINGS([
                    '<Shift>Page_Down', '<Shift>KP_Page_Down',
                    '<Shift><Mod1>Right'
                ]),
                KEY_EVENT(
                    window.flip_page,
                    {'number_of_pages': config['PAGE_FF_STEP']},
                ),
            ),
            'first_page':
            MAP(
                INFO(group_nav, 'First page'),
                KEYBINDINGS(['Home', 'KP_Home']),
                KEY_EVENT(
                    window.first_page,
                    None,
                ),
            ),
            'last_page':
            MAP(
                INFO(group_nav, 'Last page'),
                KEYBINDINGS(['End', 'KP_End']),
                KEY_EVENT(
                    window.last_page,
                    None,
                ),
            ),
            'go_to':
            MAP(
                INFO(group_nav, 'Go to page'),
                KEYBINDINGS(['G']),
                KEY_EVENT(
                    window.page_select,
                    None,
                ),
            ),
            'next_archive':
            MAP(
                INFO(group_nav, 'Next archive'),
                KEYBINDINGS(['<Primary>Right']),
                KEY_EVENT(
                    file_handler.open_archive_direction,
                    {'forward': True},
                ),
            ),
            'previous_archive':
            MAP(
                INFO(group_nav, 'Previous archive'),
                KEYBINDINGS(['<Primary>Left']),
                KEY_EVENT(
                    file_handler.open_archive_direction,
                    {'forward': False},
                ),
            ),

            # Scrolling
            # Arrow keys scroll the image
            'scroll_down':
            MAP(
                INFO(group_scroll, 'Scroll down'),
                KEYBINDINGS(['Down', 'KP_Down']),
                KEY_EVENT(
                    window.event_handler.scroll_with_flipping,
                    {
                        'x': 0,
                        'y': config['PIXELS_TO_SCROLL_PER_KEY_EVENT']
                    },
                ),
            ),
            'scroll_left':
            MAP(
                INFO(group_scroll, 'Scroll left'),
                KEYBINDINGS(['Left', 'KP_Left']),
                KEY_EVENT(
                    window.event_handler.scroll_with_flipping,
                    {
                        'x': -config['PIXELS_TO_SCROLL_PER_KEY_EVENT'],
                        'y': 0
                    },
                ),
            ),
            'scroll_right':
            MAP(
                INFO(group_scroll, 'Scroll right'),
                KEYBINDINGS(['Right', 'KP_Right']),
                KEY_EVENT(
                    window.event_handler.scroll_with_flipping,
                    {
                        'x': config['PIXELS_TO_SCROLL_PER_KEY_EVENT'],
                        'y': 0
                    },
                ),
            ),
            'scroll_up':
            MAP(
                INFO(group_scroll, 'Scroll up'),
                KEYBINDINGS(['Up', 'KP_Up']),
                KEY_EVENT(
                    window.event_handler.scroll_with_flipping,
                    {
                        'x': 0,
                        'y': -config['PIXELS_TO_SCROLL_PER_KEY_EVENT']
                    },
                ),
            ),

            # View
            'zoom_original':
            MAP(
                INFO(group_zoom, 'Normal size'),
                KEYBINDINGS(['<Control>0', 'KP_0']),
                KEY_EVENT(
                    window.manual_zoom_original,
                    None,
                ),
            ),
            'zoom_in':
            MAP(
                INFO(group_zoom, 'Zoom in'),
                KEYBINDINGS(['plus', 'KP_Add', 'equal']),
                KEY_EVENT(
                    window.manual_zoom_in,
                    None,
                ),
            ),
            'zoom_out':
            MAP(
                INFO(group_zoom, 'Zoom out'),
                KEYBINDINGS(['minus', 'KP_Subtract']),
                KEY_EVENT(
                    window.manual_zoom_out,
                    None,
                ),
            ),

            # Zoom out is already defined as GTK menu hotkey
            'keep_transformation':
            MAP(
                INFO(group_trans, 'Keep transformation'),
                KEYBINDINGS(['k']),
                KEY_EVENT(
                    window.change_keep_transformation,
                    None,
                ),
            ),
            'rotate_90':
            MAP(
                INFO(group_trans, 'Rotate 90°'),
                KEYBINDINGS(['r']),
                KEY_EVENT(
                    window.rotate_x,
                    {'rotation': 90},
                ),
            ),
            'rotate_180':
            MAP(
                INFO(group_trans, 'Rotate 180°'),
                KEYBINDINGS([]),
                KEY_EVENT(
                    window.rotate_x,
                    {'rotation': 180},
                ),
            ),
            'rotate_270':
            MAP(
                INFO(group_trans, 'Rotate 270°'),
                KEYBINDINGS(['<Shift>r']),
                KEY_EVENT(
                    window.rotate_x,
                    {'rotation': 270},
                ),
            ),

            # View mode
            'double_page':
            MAP(
                INFO(group_view, 'Double page mode'),
                KEYBINDINGS(['d']),
                KEY_EVENT(
                    window.change_double_page,
                    None,
                ),
            ),
            'manga_mode':
            MAP(
                INFO(group_view, 'Manga mode'),
                KEYBINDINGS(['m']),
                KEY_EVENT(
                    window.change_manga_mode,
                    None,
                ),
            ),

            # Fit mode
            'stretch':
            MAP(
                INFO(group_pagefit, 'Stretch small images'),
                KEYBINDINGS(['y']),
                KEY_EVENT(
                    window.change_stretch,
                    None,
                ),
            ),
            'best_fit_mode':
            MAP(
                INFO(group_pagefit, 'Best fit mode'),
                KEYBINDINGS(['b']),
                KEY_EVENT(
                    window.change_fit_mode_best,
                    None,
                ),
            ),
            'fit_width_mode':
            MAP(
                INFO(group_pagefit, 'Fit width mode'),
                KEYBINDINGS(['w']),
                KEY_EVENT(
                    window.change_fit_mode_width,
                    None,
                ),
            ),
            'fit_height_mode':
            MAP(
                INFO(group_pagefit, 'Fit height mode'),
                KEYBINDINGS(['h']),
                KEY_EVENT(
                    window.change_fit_mode_height,
                    None,
                ),
            ),
            'fit_size_mode':
            MAP(
                INFO(group_pagefit, 'Fit size mode'),
                KEYBINDINGS(['s']),
                KEY_EVENT(
                    window.change_fit_mode_size,
                    None,
                ),
            ),
            'fit_manual_mode':
            MAP(
                INFO(group_pagefit, 'Manual zoom mode'),
                KEYBINDINGS(['a']),
                KEY_EVENT(
                    window.change_fit_mode_manual,
                    None,
                ),
            ),

            # General UI
            'exit_fullscreen':
            MAP(
                INFO(group_ui, 'Exit from fullscreen'),
                KEYBINDINGS(['Escape']),
                KEY_EVENT(
                    window.event_handler.escape_event,
                    None,
                ),
            ),
            'fullscreen':
            MAP(
                INFO(group_ui, 'Fullscreen'),
                KEYBINDINGS(['f', 'F11']),
                KEY_EVENT(
                    window.change_fullscreen,
                    None,
                ),
            ),
            'minimize':
            MAP(
                INFO(group_ui, 'Minimize'),
                KEYBINDINGS(['n']),
                KEY_EVENT(
                    window.minimize,
                    None,
                ),
            ),

            # Info
            'about':
            MAP(
                INFO(group_info, 'About'),
                KEYBINDINGS(['F1']),
                KEY_EVENT(
                    window.open_dialog_about,
                    None,
                ),
            ),

            # File operations
            'close':
            MAP(
                INFO(group_file, 'Close'),
                KEYBINDINGS(['<Control>W']),
                KEY_EVENT(
                    file_handler.close_file,
                    None,
                ),
            ),
            'delete':
            MAP(
                INFO(group_file, 'Delete'),
                KEYBINDINGS(['Delete']),
                KEY_EVENT(
                    window.trash_file,
                    None,
                ),
            ),
            'enhance_image':
            MAP(
                INFO(group_file, 'Enhance image'),
                KEYBINDINGS(['e']),
                KEY_EVENT(
                    window.open_dialog_enhance,
                    None,
                ),
            ),
            'extract_page':
            MAP(
                INFO(group_file, 'Extract Page'),
                KEYBINDINGS(['<Control><Shift>s']),
                KEY_EVENT(
                    window.extract_page,
                    None,
                ),
            ),
            'move_file':
            MAP(
                INFO(group_file, 'Move to subdirectory'),
                KEYBINDINGS(['Insert', 'grave']),
                KEY_EVENT(
                    window.move_file,
                    None,
                ),
            ),
            'open':
            MAP(
                INFO(group_file, 'Open'),
                KEYBINDINGS(['<Control>O']),
                KEY_EVENT(
                    window.open_dialog_file_chooser,
                    None,
                ),
            ),
            'preferences':
            MAP(
                INFO(group_file, 'Preferences'),
                KEYBINDINGS(['F12']),
                KEY_EVENT(
                    window.open_dialog_preference,
                    None,
                ),
            ),
            'properties':
            MAP(
                INFO(group_file, 'Properties'),
                KEYBINDINGS(['<Alt>Return']),
                KEY_EVENT(
                    window.open_dialog_properties,
                    None,
                ),
            ),
            'quit':
            MAP(
                INFO(group_file, 'Quit'),
                KEYBINDINGS(['<Control>Q']),
                KEY_EVENT(
                    window.terminate_program,
                    None,
                ),
            ),
            'refresh_archive':
            MAP(
                INFO(group_file, 'Refresh'),
                KEYBINDINGS(['<control><shift>R']),
                KEY_EVENT(
                    file_handler.refresh_file,
                    None,
                ),
            ),

            # Image Scaling
            'toggle_scaling_pil':
            MAP(
                INFO(group_scale, 'Toggle GDK/PIL Image scaling'),
                KEYBINDINGS(['c']),
                KEY_EVENT(
                    window.toggle_image_scaling,
                    None,
                ),
            ),
            'scaling_inc':
            MAP(
                INFO(group_scale, 'Cycle GDK/PIL Image scaling forward'),
                KEYBINDINGS(['z']),
                KEY_EVENT(
                    window.change_image_scaling,
                    {'step': 1},
                ),
            ),
            'scaling_dec':
            MAP(
                INFO(group_scale, 'Cycle GDK/PIL Image scaling backwards'),
                KEYBINDINGS(['x']),
                KEY_EVENT(
                    window.change_image_scaling,
                    {'step': -1},
                ),
            ),
        }
class ThumbnailSidebar(Gtk.ScrolledWindow):
    """
    A thumbnail sidebar including scrollbar for the main window
    """

    __slots__ = ('__window', '__image_handler', '__loaded',
                 '__currently_selected_row', '__thumbnail_size',
                 '__width_padding', '__empty_thumbnail',
                 '__thumbnail_liststore', '__treeview',
                 '__thumbnail_page_treeviewcolumn', '__text_cellrenderer',
                 '__thumbnail_image_treeviewcolumn')

    # Thumbnail border width in pixels.
    def __init__(self, window: MainWindow):
        super().__init__()

        self.__window = window

        events = Events()
        events.add_event(EventType.PAGE_AVAILABLE, self._on_page_available)
        events.add_event(EventType.PAGE_CHANGED, self._on_page_change)

        self.__file_handler = FileHandler(None)
        self.__image_handler = ImageHandler()

        #: Thumbnail load status
        self.__loaded = False
        #: Selected row in treeview
        self.__currently_selected_row = 0

        self.__thumbnail_size = config['THUMBNAIL_SIZE'] + 2  # plus border
        self.__width_padding = self.__thumbnail_size + 10
        self.__empty_thumbnail = self._create_empty_thumbnail()

        self.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.ALWAYS)
        # Disable stupid overlay scrollbars...
        self.set_overlay_scrolling(False)

        # models - contains data
        self.__thumbnail_liststore = Gtk.ListStore(int, GdkPixbuf.Pixbuf, bool)

        # view - responsible for laying out the columns
        self.__treeview = ThumbnailTreeView(
            self.__thumbnail_liststore,
            0,  # UID
            1,  # pixbuf
            2,  # status
        )
        self.__treeview.set_headers_visible(False)
        self.__treeview.generate_thumbnail = self._generate_thumbnail
        self.__treeview.set_activate_on_single_click(True)

        self.__treeview.connect_after('drag_begin', self._drag_begin)
        self.__treeview.connect('drag_data_get', self._drag_data_get)
        self.__treeview.connect('row-activated', self._row_activated_event)

        # enable drag and dropping of images from thumbnail bar to some file manager
        self.__treeview.enable_model_drag_source(Gdk.ModifierType.BUTTON1_MASK,
                                                 [('text/uri-list', 0, 0)],
                                                 Gdk.DragAction.COPY)

        # Page column
        self.__thumbnail_page_treeviewcolumn = Gtk.TreeViewColumn(None)
        self.__treeview.append_column(self.__thumbnail_page_treeviewcolumn)
        self.__text_cellrenderer = Gtk.CellRendererText()
        # Right align page numbers.
        self.__text_cellrenderer.set_property('xalign', 1.0)
        self.__thumbnail_page_treeviewcolumn.set_sizing(
            Gtk.TreeViewColumnSizing.FIXED)
        self.__thumbnail_page_treeviewcolumn.pack_start(
            self.__text_cellrenderer, False)
        self.__thumbnail_page_treeviewcolumn.add_attribute(
            self.__text_cellrenderer, 'text', 0)
        self.__thumbnail_page_treeviewcolumn.set_visible(False)

        # Pixbuf column
        self.__thumbnail_image_treeviewcolumn = Gtk.TreeViewColumn(None)
        self.__treeview.append_column(self.__thumbnail_image_treeviewcolumn)
        self.__pixbuf_cellrenderer = Gtk.CellRendererPixbuf()
        self.__thumbnail_image_treeviewcolumn.set_sizing(
            Gtk.TreeViewColumnSizing.FIXED)
        self.__thumbnail_image_treeviewcolumn.set_fixed_width(
            self.__width_padding)
        self.__thumbnail_image_treeviewcolumn.pack_start(
            self.__pixbuf_cellrenderer, True)
        self.__thumbnail_image_treeviewcolumn.add_attribute(
            self.__pixbuf_cellrenderer, 'pixbuf', 1)

        self.__treeview.set_fixed_height_mode(True)
        self.__treeview.set_can_focus(False)

        self.add(self.__treeview)
        self.show_all()

    def toggle_page_numbers_visible(self):
        """
        Enables or disables page numbers on the thumbnail bar
        """

        if config['SHOW_PAGE_NUMBERS_ON_THUMBNAILS']:
            number_of_pages = len(
                str(self.__image_handler.get_number_of_pages()))
            self.__text_cellrenderer.set_property('width-chars',
                                                  number_of_pages + 1)
            w = self.__text_cellrenderer.get_preferred_size(
                self.__treeview)[1].width
            self.__thumbnail_page_treeviewcolumn.set_fixed_width(w)

        self.__thumbnail_page_treeviewcolumn.set_visible(
            config['SHOW_PAGE_NUMBERS_ON_THUMBNAILS'])

    def get_width(self):
        """
        Return the width in pixels of the ThumbnailSidebar
        """

        return self.size_request().width

    def show(self, *args):
        """
        Show the ThumbnailSidebar
        """

        self.load_thumbnails()

        super().show()

    def hide(self):
        """
        Hide the ThumbnailSidebar
        """

        super().hide()

        self.__treeview.stop_update()

    def clear(self):
        """
        Clear the ThumbnailSidebar of any loaded thumbnails
        """

        self.__loaded = False
        self.__treeview.stop_update()
        self.__thumbnail_liststore.clear()

    def resize(self):
        """
        Reload the thumbnails with the size specified by in the preferences
        """

        self.clear()
        self.__thumbnail_size = config['THUMBNAIL_SIZE'] + 2  # plus border
        self.__width_padding = self.__thumbnail_size + 10
        self.__thumbnail_image_treeviewcolumn.set_fixed_width(
            self.__width_padding)
        self.load_thumbnails()

    def load_thumbnails(self):
        """
        Load the thumbnails, if it is appropriate to do so
        """

        if (not self.__file_handler.get_file_loaded()
                or not self.__image_handler.get_number_of_pages()
                or self.__loaded):
            return

        self.toggle_page_numbers_visible()

        # Detach model for performance reasons
        model = self.__treeview.get_model()
        self.__treeview.set_model(None)

        # Create empty preview thumbnails.
        for row in range(self.__image_handler.get_number_of_pages()):
            self.__thumbnail_liststore.append(
                (row + 1, self.__empty_thumbnail, False))

        self.__loaded = True

        # Re-attach model
        self.__treeview.set_model(model)

        # Update current image selection in the thumb bar.
        self._set_selected_row(self.__currently_selected_row)

    def _generate_thumbnail(self, uid: int):
        """
        Generate the pixbuf for C{path} at demand
        """

        size = config['THUMBNAIL_SIZE']
        pixbuf = self.__image_handler.get_thumbnail(page=uid,
                                                    size=(size, size))
        if pixbuf is None:
            return None

        return ImageTools.add_border(pixbuf)

    def _set_selected_row(self, row: int, scroll: bool = True):
        """
        Set currently selected row.
        If <scroll> is True, the tree is automatically
        scrolled to ensure the selected row is visible
        """

        self.__currently_selected_row = row
        self.__treeview.get_selection().select_path(row)
        if self.__loaded and scroll:
            self.__treeview.scroll_to_cell(row, use_align=True, row_align=0.25)

    def _get_selected_row(self):
        """
        :returns: the index of the currently selected row
        """

        try:
            return self.__treeview.get_selection().get_selected_rows()[1][0][0]
        except IndexError:
            logger.warning('failed to get thumbar index')
            return 0

    def _row_activated_event(self, treeview, path, column):
        """
        Handle events due to changed thumbnail selection
        """

        selected_row = self._get_selected_row()
        self._set_selected_row(selected_row, scroll=False)
        self.__window.set_page(selected_row + 1)

    def _drag_data_get(self, treeview, context, selection, *args):
        """
        Put the URI of the selected file into the SelectionData, so that
        the file can be copied (e.g. to a file manager)
        """

        selected = self._get_selected_row()
        path = self.__image_handler.get_path_to_page(selected + 1)
        uri = path.as_uri()
        selection.set_uris([uri])

    def _drag_begin(self, treeview, context):
        """
        We hook up on drag_begin events so that we can set the hotspot
        for the cursor at the top left corner of the thumbnail (so that we
        might actually see where we are dropping!)
        """

        path = treeview.get_cursor()[0]
        surface = treeview.create_row_drag_icon(path)
        pixbuf = Gdk.pixbuf_get_from_surface(surface, 0, 0,
                                             surface.get_width(),
                                             surface.get_height())
        Gtk.drag_set_icon_pixbuf(context, pixbuf, -5, -5)

    def _create_empty_thumbnail(self):
        """
        Create an empty filler pixmap
        """

        pixbuf = GdkPixbuf.Pixbuf.new(colorspace=GdkPixbuf.Colorspace.RGB,
                                      has_alpha=True,
                                      bits_per_sample=8,
                                      width=self.__thumbnail_size,
                                      height=self.__thumbnail_size)

        # Make the pixbuf transparent.
        pixbuf.fill(0)

        return pixbuf

    def _on_page_change(self):
        row = self.__image_handler.get_current_page() - 1
        if row == self.__currently_selected_row:
            return
        self._set_selected_row(row)

    def _on_page_available(self, page):
        """
        Called whenever a new page is ready for display
        """

        if self.get_visible():
            self.__treeview.draw_thumbnails_on_screen()
示例#17
0
class EventHandler:
    __slots__ = (
        '__window', '__file_handler', '__keybindings', '__keybindings_map', '__all_accels_mask',
        '__keymap', '__last_pointer_pos_x', '__last_pointer_pos_y',
        '__was_fullscreen', '__previous_size',
    )

    def __init__(self, window: MainWindow):
        super().__init__()

        self.__window = window
        self.__keybindings = None
        self.__keybindings_map = None

        self.__file_handler = FileHandler(None)

        self.__was_fullscreen = False
        self.__previous_size = (None, None)

        # Dispatch keyboard input handling
        # Some keys require modifiers that are irrelevant to the hotkey. Find out and ignore them.
        self.__all_accels_mask = (Gdk.ModifierType.CONTROL_MASK |
                                  Gdk.ModifierType.SHIFT_MASK |
                                  Gdk.ModifierType.MOD1_MASK)

        self.__keymap = Gdk.Keymap.get_for_display(Gdk.Display.get_default())

        self.__last_pointer_pos_x = 0
        self.__last_pointer_pos_y = 0

    def event_handler_init(self):
        """
        lazy init to avoid circular deps
        """

        self.__keybindings = self.__window.keybindings
        self.__keybindings_map = self.__window.keybindings_map

    def resize_event(self, widget, event):
        """
        Handle events from resizing and moving the main window
        """

        size = (event.width, event.height)
        if size != self.__previous_size:
            self.__previous_size = size
            self.__window.draw_image()

    def window_state_event(self, widget, event):
        is_fullscreen = self.__window.is_fullscreen()
        if self.__was_fullscreen != is_fullscreen:
            # Fullscreen state changed.
            self.__was_fullscreen = is_fullscreen
            # Re-enable control, now that transition is complete.
            if is_fullscreen:
                redraw = True
            else:
                # Only redraw if we don't need to restore geometry.
                redraw = not self.__window.restore_window_geometry()
            if redraw:
                self.__previous_size = self.__window.get_size()
                self.__window.draw_image()

    def register_key_events(self):
        """
        Registers keyboard events and their default binings, and hooks
        them up with their respective callback functions
        """

        for action in self.__keybindings_map.keys():
            self.__keybindings.register(
                name=action,
                callback=self.__keybindings_map[action].key_event.callback,
                callback_kwargs=self.__keybindings_map[action].key_event.callback_kwargs,
            )

    def key_press_event(self, widget, event, *args):
        """
        Handle key press events on the main window
        """

        code = self.__keymap.translate_keyboard_state(event.hardware_keycode,
                                                      event.get_state(), event.group)

        if code[0]:
            keyval = code.keyval
            consumed_modifiers = code.consumed_modifiers

            # If the resulting key is upper case (i.e. SHIFT + key),
            # convert it to lower case and remove SHIFT from the consumed flags
            # to match how keys are registered (<Shift> + lowercase)
            if event.get_state() & Gdk.ModifierType.SHIFT_MASK and keyval != Gdk.keyval_to_lower(keyval):
                keyval = Gdk.keyval_to_lower(keyval)
                consumed_modifiers &= ~Gdk.ModifierType.SHIFT_MASK

            # 'consumed_modifiers' is the modifier that was necessary to type the key
            self.__keybindings.execute((keyval, event.get_state() & ~consumed_modifiers & self.__all_accels_mask))

    def escape_event(self):
        """
        Determines the behavior of the ESC key
        """

        if config['ESCAPE_QUITS']:
            self.__window.terminate_program()
        else:
            self.__window.change_fullscreen()

    def scroll_wheel_event(self, widget, event, *args):
        """
        Handle scroll wheel events on the main layout area. The scroll
        wheel flips pages in best fit mode and scrolls the scrollbars otherwise
        """

        if event.get_state() & Gdk.ModifierType.BUTTON2_MASK:
            return

        deltas = event.get_scroll_deltas()

        if deltas.delta_y < 0:
            # Gdk.ScrollDirection.UP
            if event.get_state() & Gdk.ModifierType.CONTROL_MASK:
                self.__window.manual_zoom_in()
            else:
                self.scroll_with_flipping(0, -config['PIXELS_TO_SCROLL_PER_MOUSE_WHEEL_EVENT'])
        elif deltas.delta_y > 0:
            # Gdk.ScrollDirection.DOWN
            if event.get_state() & Gdk.ModifierType.CONTROL_MASK:
                self.__window.manual_zoom_out()
            else:
                self.scroll_with_flipping(0, config['PIXELS_TO_SCROLL_PER_MOUSE_WHEEL_EVENT'])
        elif not config['FLIP_WITH_WHEEL']:
            return
        elif deltas.delta_x > 0:
            # Gdk.ScrollDirection.RIGHT
            self.__window.flip_page(number_of_pages=-1)
        elif deltas.delta_x < 0:
            # Gdk.ScrollDirection.LEFT
            self.__window.flip_page(number_of_pages=1)

    def mouse_press_event(self, widget, event):
        """
        Handle mouse click events on the main layout area
        """

        match event.button:
            case 1:
                pass
            case 2:
                self.__window.lens.toggle(True)
            case 3:
                pass
            case 4:
                pass

    def mouse_release_event(self, widget, event):
        """
        Handle mouse button release events on the main layout area
        """

        match event.button:
            case 1:
                pass
            case 2:
                self.__window.lens.toggle(False)
            case 3:
                pass
            case 4:
                pass

    def mouse_move_event(self, widget, event):
        """
        Handle mouse pointer movement events
        """

        if 'GDK_BUTTON1_MASK' in event.get_state().value_names:
            self.__window.cursor_handler.set_cursor_grab()
            self.__window.scroll(self.__last_pointer_pos_x - event.x_root,
                                 self.__last_pointer_pos_y - event.y_root)
            self.__last_pointer_pos_x = event.x_root
            self.__last_pointer_pos_y = event.y_root

    def drag_n_drop_event(self, widget, context, x, y, selection, drag_id, eventtime):
        """
        Handle drag-n-drop events on the main layout area
        """

        # The drag source is inside MComix itself, so we ignore.
        if Gtk.drag_get_source_widget(context) is not None:
            return

        uris = selection.get_uris()
        if not uris:
            return

        paths = [Path(url2pathname(urlparse(uri).path)) for uri in uris]
        self.__file_handler.open_file_init(paths)

    def scroll_with_flipping(self, x: int, y: int):
        """
        Handle scrolling with the scroll wheel or the arrow keys, for which
        the pages might be flipped depending on the preferences.  Returns True
        if able to scroll without flipping and False if a new page was flipped to
        """

        if not config['FLIP_WITH_WHEEL']:
            return

        if self.__window.scroll(x, y):
            return True

        if y > 0 or (ViewState.is_manga_mode and x < 0) or \
                (not ViewState.is_manga_mode and x > 0):
            self.__window.flip_page(number_of_pages=1)
        else:
            self.__window.flip_page(number_of_pages=-1)
    def __init__(self, window: MainWindow):
        super().__init__()

        self.__window = window

        events = Events()
        events.add_event(EventType.PAGE_AVAILABLE, self._on_page_available)
        events.add_event(EventType.PAGE_CHANGED, self._on_page_change)

        self.__file_handler = FileHandler(None)
        self.__image_handler = ImageHandler()

        #: Thumbnail load status
        self.__loaded = False
        #: Selected row in treeview
        self.__currently_selected_row = 0

        self.__thumbnail_size = config['THUMBNAIL_SIZE'] + 2  # plus border
        self.__width_padding = self.__thumbnail_size + 10
        self.__empty_thumbnail = self._create_empty_thumbnail()

        self.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.ALWAYS)
        # Disable stupid overlay scrollbars...
        self.set_overlay_scrolling(False)

        # models - contains data
        self.__thumbnail_liststore = Gtk.ListStore(int, GdkPixbuf.Pixbuf, bool)

        # view - responsible for laying out the columns
        self.__treeview = ThumbnailTreeView(
            self.__thumbnail_liststore,
            0,  # UID
            1,  # pixbuf
            2,  # status
        )
        self.__treeview.set_headers_visible(False)
        self.__treeview.generate_thumbnail = self._generate_thumbnail
        self.__treeview.set_activate_on_single_click(True)

        self.__treeview.connect_after('drag_begin', self._drag_begin)
        self.__treeview.connect('drag_data_get', self._drag_data_get)
        self.__treeview.connect('row-activated', self._row_activated_event)

        # enable drag and dropping of images from thumbnail bar to some file manager
        self.__treeview.enable_model_drag_source(Gdk.ModifierType.BUTTON1_MASK,
                                                 [('text/uri-list', 0, 0)],
                                                 Gdk.DragAction.COPY)

        # Page column
        self.__thumbnail_page_treeviewcolumn = Gtk.TreeViewColumn(None)
        self.__treeview.append_column(self.__thumbnail_page_treeviewcolumn)
        self.__text_cellrenderer = Gtk.CellRendererText()
        # Right align page numbers.
        self.__text_cellrenderer.set_property('xalign', 1.0)
        self.__thumbnail_page_treeviewcolumn.set_sizing(
            Gtk.TreeViewColumnSizing.FIXED)
        self.__thumbnail_page_treeviewcolumn.pack_start(
            self.__text_cellrenderer, False)
        self.__thumbnail_page_treeviewcolumn.add_attribute(
            self.__text_cellrenderer, 'text', 0)
        self.__thumbnail_page_treeviewcolumn.set_visible(False)

        # Pixbuf column
        self.__thumbnail_image_treeviewcolumn = Gtk.TreeViewColumn(None)
        self.__treeview.append_column(self.__thumbnail_image_treeviewcolumn)
        self.__pixbuf_cellrenderer = Gtk.CellRendererPixbuf()
        self.__thumbnail_image_treeviewcolumn.set_sizing(
            Gtk.TreeViewColumnSizing.FIXED)
        self.__thumbnail_image_treeviewcolumn.set_fixed_width(
            self.__width_padding)
        self.__thumbnail_image_treeviewcolumn.pack_start(
            self.__pixbuf_cellrenderer, True)
        self.__thumbnail_image_treeviewcolumn.add_attribute(
            self.__pixbuf_cellrenderer, 'pixbuf', 1)

        self.__treeview.set_fixed_height_mode(True)
        self.__treeview.set_can_focus(False)

        self.add(self.__treeview)
        self.show_all()
class FileSystemActions:
    __slots__ = ('__window', '__file_handler', '__image_handler')

    def __init__(self, window: MainWindow):
        super().__init__()

        self.__window = window

        self.__file_handler = FileHandler(None)
        self.__image_handler = ImageHandler()

    def extract_page(self):
        """
        Derive some sensible filename (the filename should do) and offer
        the user the choice to save the current page with the selected name
        """

        page = self.__image_handler.get_current_page()

        if ViewState.is_displaying_double:
            # asks for left or right page if in double page mode
            # and not showing a single page

            response_left = 70
            response_right = 80

            dialog = MessageDialogRemember()
            dialog.add_buttons('Left', response_left, 'Right', response_right,
                               Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
            dialog.set_default_response(Gtk.ResponseType.CANCEL)
            dialog.set_text(primary='Extract Left or Right page?')
            result = dialog.run()

            if result not in (response_left, response_right):
                return None

            if result == response_left:
                if ViewState.is_manga_mode:
                    page += 1
            elif result == response_right:
                if not ViewState.is_manga_mode:
                    page += 1

        page_name = self.__image_handler.get_page_filename(page=page)[0]
        page_path = self.__image_handler.get_path_to_page(page=page)

        save_dialog = Gtk.FileChooserDialog(title='Save page as',
                                            action=Gtk.FileChooserAction.SAVE)
        save_dialog.add_buttons(Gtk.STOCK_OK, Gtk.ResponseType.ACCEPT,
                                Gtk.STOCK_CANCEL, Gtk.ResponseType.REJECT)
        save_dialog.set_modal(True)
        save_dialog.set_transient_for(self.__window)
        save_dialog.set_do_overwrite_confirmation(True)
        save_dialog.set_current_name(page_name)

        if save_dialog.run(
        ) == Gtk.ResponseType.ACCEPT and save_dialog.get_filename():
            shutil.copy(page_path, save_dialog.get_filename())

        save_dialog.destroy()

    def move_file(self):
        """
        The currently opened file/archive will be moved to prefs['MOVE_FILE']
        """

        current_file = self.__file_handler.get_real_path()

        target_dir = Path() / current_file.parent / config['MOVE_FILE']
        target_file = Path() / target_dir / current_file.name

        if not Path.exists(target_dir):
            target_dir.mkdir()

        try:
            self._load_next_file()
        except Exception:
            logger.error('File action failed: move_file()')

        if current_file.is_file():
            Path.rename(current_file, target_file)

        if not target_file.is_file():
            dialog = MessageDialogInfo()
            dialog.set_text(primary='File was not moved',
                            secondary=f'{target_file}')
            dialog.run()

    def trash_file(self):
        """
        The currently opened file/archive will be trashed after showing a confirmation dialog
        """

        current_file = self.__file_handler.get_real_path()

        dialog = MessageDialogRemember()
        dialog.add_buttons(Gtk.STOCK_OK, Gtk.ResponseType.OK, Gtk.STOCK_CANCEL,
                           Gtk.ResponseType.CANCEL, Gtk.STOCK_DELETE,
                           Gtk.ResponseType.OK)
        dialog.set_default_response(Gtk.ResponseType.OK)
        dialog.set_should_remember_choice('delete-opend-file',
                                          (Gtk.ResponseType.OK, ))
        dialog.set_text('Trash Selected File?',
                        secondary=f'{current_file.name}')
        result = dialog.run()
        if result != Gtk.ResponseType.OK:
            return

        try:
            self._load_next_file()
        except Exception:
            logger.error('File action failed: trash_file()')

        if current_file.is_file():
            send2trash(bytes(current_file))

        if current_file.is_file():
            dialog = MessageDialogInfo()
            dialog.set_text(primary='File was not deleted',
                            secondary=f'{current_file}')
            dialog.run()

    def _load_next_file(self):
        """
        Shared logic for move_file() and trash_file()
        """

        if self.__file_handler.is_archive():
            next_opened = self.__file_handler.open_archive_direction(
                forward=True)
            if not next_opened:
                next_opened = self.__file_handler.open_archive_direction(
                    forward=False)
            if not next_opened:
                self.__file_handler.close_file()
        else:
            if self.__image_handler.get_number_of_pages() > 1:
                # Open the next/previous file
                if self.__image_handler.is_last_page():
                    self.__window.flip_page(number_of_pages=-1)
                else:
                    self.__window.flip_page(number_of_pages=1)

                # Refresh the directory
                self.__file_handler.refresh_file()
            else:
                self.__file_handler.close_file()