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()
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()
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 Pageselector(Gtk.Dialog): """ The Pageselector takes care of the popup page selector """ __slots__ = ('__window', '__image_handler', '__number_of_pages', '__selector_adjustment', '__image_preview', '__thumbnail_page', '__threadpool') def __init__(self, window: MainWindow): self.__window = window self.__events = Events() self.__events.add_event(EventType.PAGE_AVAILABLE, self._page_available) self.__image_handler = ImageHandler() super().__init__(title='Go to page...', modal=True, destroy_with_parent=True) self.set_modal(True) self.set_transient_for(window) self.set_size_request(560, 820) self.add_buttons( '_Go', Gtk.ResponseType.OK, '_Cancel', Gtk.ResponseType.CANCEL, ) self.set_default_response(Gtk.ResponseType.OK) self.connect('response', self._response) self.set_resizable(True) self.__number_of_pages = self.__image_handler.get_number_of_pages() self.__selector_adjustment = Gtk.Adjustment( value=self.__image_handler.get_current_page(), lower=1, upper=self.__number_of_pages, step_increment=1, page_increment=1) page_selector = Gtk.Scale.new(Gtk.Orientation.VERTICAL, self.__selector_adjustment) page_selector.set_draw_value(False) page_selector.set_digits(0) page_spinner = Gtk.SpinButton.new(self.__selector_adjustment, 0.0, 0) page_spinner.connect('changed', self._page_text_changed) page_spinner.set_activates_default(True) page_spinner.set_numeric(True) pages_label = Gtk.Label(label=f' of {self.__number_of_pages}') pages_label.set_xalign(0.0) pages_label.set_yalign(0.5) self.__image_preview = Gtk.Image() # Group preview image and page selector next to each other preview_box = Gtk.HBox() preview_box.set_border_width(5) preview_box.set_spacing(5) preview_box.pack_start(self.__image_preview, True, True, 0) preview_box.pack_end(page_selector, False, True, 0) # Below them, group selection spinner and current page label selection_box = Gtk.HBox() selection_box.set_border_width(5) selection_box.pack_start(page_spinner, True, True, 0) selection_box.pack_end(pages_label, False, True, 0) self.get_content_area().pack_start(preview_box, True, True, 0) self.get_content_area().pack_end(selection_box, False, True, 0) self.show_all() self.__selector_adjustment.connect('value-changed', self._cb_value_changed) # Set focus on the input box. page_spinner.select_region(0, -1) page_spinner.grab_focus() # Currently displayed thumbnail page. self.__thumbnail_page = 0 self.__threadpool = GlobalThreadPool.threadpool self._update_thumbnail(int(self.__selector_adjustment.props.value)) def _cb_value_changed(self, *args): """ Called whenever the spinbox value changes. Updates the preview thumbnail """ page = int(self.__selector_adjustment.props.value) if page != self.__thumbnail_page: self._update_thumbnail(page) def _page_text_changed(self, control, *args): """ Called when the page selector has been changed. Used to instantly update the preview thumbnail when entering page numbers by hand """ if control.get_text().isdigit(): page = int(control.get_text()) if 0 < page <= self.__number_of_pages: control.set_value(page) def _response(self, widget, event, *args): if event == Gtk.ResponseType.OK: self.__window.set_page(int(self.__selector_adjustment.props.value)) self.__events.remove_event(EventType.PAGE_AVAILABLE, self._page_available) self.__threadpool.renew() self.destroy() def _update_thumbnail(self, page: int): """ Trigger a thumbnail update """ width = self.__image_preview.get_allocation().width height = self.__image_preview.get_allocation().height self.__thumbnail_page = page self.__threadpool.apply_async(self._generate_thumbnail, args=(page, width, height), callback=self._generate_thumbnail_cb) def _generate_thumbnail(self, page: int, width: int, height: int): """ Generate the preview thumbnail for the page selector. A transparent image will be used if the page is not yet available """ return page, self.__image_handler.get_thumbnail(page=page, size=(width, height)) def _generate_thumbnail_cb(self, params): page, pixbuf = params return self._thumbnail_finished(page, pixbuf) def _thumbnail_finished(self, page: int, pixbuf): # Don't bother if we changed page in the meantime. if page == self.__thumbnail_page: self.__image_preview.set_from_pixbuf(pixbuf) def _page_available(self, page: int): if page == int(self.__selector_adjustment.props.value): self._update_thumbnail(page)
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()
class MagnifyingLens: """ The MagnifyingLens creates cursors from the raw pixbufs containing the unscaled data for the currently displayed images. It does this by looking at the cursor position and calculating what image data to put in the "lens" cursor. Note: The mapping is highly dependent on the exact layout of the main window images, thus this module isn't really independent from the main module as it uses implementation details not in the interface """ __slots__ = ('__window', '__image_handler', '__enabled', '__point', '__last_lens_rect') def __init__(self, window: MainWindow): super().__init__() self.__window = window self.__image_handler = ImageHandler() #: Stores lens state self.__enabled = False #: Stores a tuple of the last mouse coordinates self.__point = None #: Stores the last rectangle that was used to render the lens self.__last_lens_rect = None @property def enabled(self): return self.__enabled @enabled.setter def enabled(self, enabled): if self.__image_handler.get_number_of_pages() == 0: return self.__enabled = enabled if self.__enabled: self.__window.cursor_handler.set_cursor_hidden() if self.__point: self._draw_lens(*self.__point) else: self.__window.cursor_handler.set_cursor_normal() self._clear_lens() self.__last_lens_rect = None def _draw_lens(self, x: int, y: int): """ Calculate what image data to put in the lens and update the cursor with it; <x> and <y> are the positions of the cursor within the main window layout area """ rectangle = self._calculate_lens_rect(x, y, config['LENS_SIZE'], config['LENS_SIZE']) pixbuf = self._get_lens_pixbuf(x, y) draw_region = cairo.Region(rectangle=rectangle) window = self.__window.main_layout.get_window() frame = window.begin_draw_frame(draw_region) self._clear_lens(rectangle) cr = Gdk.DrawingContext.get_cairo_context(frame) surface = Gdk.cairo_surface_create_from_pixbuf(pixbuf, 0, window) cr.set_source_surface(surface, rectangle.x, rectangle.y) cr.paint() window.end_paint() window.end_draw_frame(frame) self.__last_lens_rect = rectangle def _calculate_lens_rect(self, x: int, y: int, width: int, height: int): """ Calculates the area where the lens will be drawn on screen. This method takes screen space into calculation and moves the rectangle accordingly when the the rectangle would otherwise flow over the allocated area """ lens_x = max(x - width // 2, 0) lens_y = max(y - height // 2, 0) max_width, max_height = self.__window.get_visible_area_size() max_width += int(self.__window.hadjust.get_value()) max_height += int(self.__window.vadjust.get_value()) lens_x = min(lens_x, max_width - width) lens_y = min(lens_y, max_height - height) # Don't forget 1 pixel border... return cairo.RectangleInt(lens_x, lens_y, width + 2, height + 2) def _clear_lens(self, current_lens_region=None): """ Invalidates the area that was damaged by the last call to draw_lens """ if not self.__last_lens_rect: return window = self.__window.main_layout.get_window() lrect = Gdk.Rectangle() lrect.x = self.__last_lens_rect.x lrect.y = self.__last_lens_rect.y lrect.width = self.__last_lens_rect.width lrect.height = self.__last_lens_rect.height if not current_lens_region: window.invalidate_rect(lrect, True) return crect = Gdk.Rectangle() crect.x = current_lens_region.x crect.y = current_lens_region.y crect.width = current_lens_region.width crect.height = current_lens_region.height rwidth = crect.width rheigt = crect.height intersect_v = Gdk.Rectangle() if crect.x - lrect.x > 0: # movement to the right intersect_v.x = lrect.x intersect_v.y = lrect.y intersect_v.width = crect.x - lrect.x intersect_v.height = rheigt else: # movement to the left intersect_v.x = crect.x + rwidth intersect_v.y = crect.y intersect_v.width = lrect.x - crect.x intersect_v.height = rheigt window.invalidate_rect(intersect_v, True) intersect_h = Gdk.Rectangle() if crect.y - lrect.y > 0: # movement down intersect_h.x = lrect.x intersect_h.y = lrect.y intersect_h.width = rwidth intersect_h.height = crect.y - lrect.y else: # movement up intersect_h.x = lrect.x intersect_h.y = rheigt + crect.y intersect_h.width = rwidth intersect_h.height = lrect.y - crect.y window.invalidate_rect(intersect_h, True) self.__last_lens_rect = None def toggle(self, value: bool): """ Toggle on or off the lens depending on the state of <action> """ self.enabled = value def motion_event(self, widget, event): """ Called whenever the mouse moves over the image area """ self.__point = (int(event.x), int(event.y)) if self.enabled: self._draw_lens(*self.__point) def _get_lens_pixbuf(self, x: int, y: int): """ Get a pixbuf containing the appropiate image data for the lens where <x> and <y> are the positions of the cursor """ canvas = GdkPixbuf.Pixbuf.new(colorspace=GdkPixbuf.Colorspace.RGB, has_alpha=True, bits_per_sample=8, width=config['LENS_SIZE'], height=config['LENS_SIZE']) canvas.fill(0x000000FF) # black cb = self.__window.layout.get_content_boxes() source_pixbufs = self.__image_handler.get_pixbufs(len(cb)) for idx, item in enumerate(cb): if ImageTools.is_animation(source_pixbufs[idx]): continue cpos = cb[idx].get_position() self._add_subpixbuf(canvas, x - cpos[0], y - cpos[1], cb[idx].get_size(), source_pixbufs[idx]) return ImageTools.add_border(canvas) def _add_subpixbuf(self, canvas, x: int, y: int, image_size: tuple, source_pixbuf): """ Copy a subpixbuf from <source_pixbuf> to <canvas> as it should be in the lens if the coordinates <x>, <y> are the mouse pointer position on the main window layout area. The displayed image (scaled from the <source_pixbuf>) must have size <image_size> """ # Prevent division by zero exceptions further down if not image_size[0]: return # FIXME This merely prevents Errors being raised if source_pixbuf is an # animation. The result might be broken, though, since animation, # rotation etc. might not match or will be ignored: source_pixbuf = ImageTools.static_image(source_pixbuf) rotation = config['ROTATION'] if config['AUTO_ROTATE_FROM_EXIF']: rotation += ImageTools.get_implied_rotation(source_pixbuf) rotation %= 360 if rotation in (90, 270): scale = source_pixbuf.get_height() / image_size[0] else: scale = source_pixbuf.get_width() / image_size[0] x *= scale y *= scale source_mag = config['LENS_MAGNIFICATION'] / scale width = height = config['LENS_SIZE'] / source_mag paste_left = x > width / 2 paste_top = y > height / 2 dest_x = max(0, int(math.ceil((width / 2 - x) * source_mag))) dest_y = max(0, int(math.ceil((height / 2 - y) * source_mag))) match rotation: case 90: x, y = y, source_pixbuf.get_height() - x case 180: x = source_pixbuf.get_width() - x y = source_pixbuf.get_height() - y case 270: x, y = source_pixbuf.get_width() - y, x src_x = x - width / 2 src_y = y - height / 2 if src_x < 0: width += src_x src_x = 0 if src_y < 0: height += src_y src_y = 0 width = max(0, min(source_pixbuf.get_width() - src_x, width)) height = max(0, min(source_pixbuf.get_height() - src_y, height)) if width < 1 or height < 1: return subpixbuf = source_pixbuf.new_subpixbuf(int(src_x), int(src_y), int(width), int(height)) subpixbuf = subpixbuf.scale_simple( int(math.ceil(source_mag * subpixbuf.get_width())), int(math.ceil(source_mag * subpixbuf.get_height())), config['GDK_SCALING_FILTER']) subpixbuf = ImageTools.rotate_pixbuf(subpixbuf, rotation) subpixbuf = ImageTools.enhance(subpixbuf) if paste_left: dest_x = 0 else: dest_x = min(canvas.get_width() - subpixbuf.get_width(), dest_x) if paste_top: dest_y = 0 else: dest_y = min(canvas.get_height() - subpixbuf.get_height(), dest_y) if subpixbuf.get_has_alpha(): subpixbuf = ImageTools.add_alpha_background(subpixbuf, subpixbuf.get_width(), subpixbuf.get_height()) subpixbuf.copy_area(0, 0, subpixbuf.get_width(), subpixbuf.get_height(), canvas, dest_x, dest_y)
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()