├── LICENSE-MIT ├── README.md ├── pim └── pim.desktop /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) James Campos 2 | Copyright (c) Lex Black 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### pim, a Python image viewer with vim-like keybindings 2 | 3 | All options / bindings can be found at the top of the file in the `__init__` function. 4 | 5 | Pim is released under the MIT license. 6 | 7 | Dependencies: **pygobject** (Python bindings for GObject) for python3 above version **3.22** 8 | -------------------------------------------------------------------------------- /pim: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Thanks 4 | # alterecco, for making [visible](http://drop.dotright.net/visible) (dead), 5 | # and inspiring me (James Campos) to make this program. 6 | 7 | # Pim 8 | # Python image viewer with vim-like keybindings 9 | # v0.10.1 10 | 11 | import argparse 12 | from random import shuffle 13 | import mimetypes 14 | from gi import require_version 15 | require_version('Gtk', '3.0') 16 | from gi.repository import GLib, Gtk, Gdk, GdkPixbuf 17 | import os 18 | import shutil 19 | import time 20 | import uuid 21 | 22 | # dict like, easy to use class, to avoid the hassle with global vars 23 | g = argparse.Namespace( 24 | drag = False, 25 | fullscreen=False, 26 | geometry='800x600', 27 | hide_delay=1, 28 | index=0, 29 | rotation=0, 30 | rotation_lock=False, 31 | shuffle=False, 32 | slideshow=False, 33 | slideshow_delay=5, 34 | sbar=False, 35 | zoom_lock=False, 36 | 37 | paths=[], 38 | marked=[], 39 | 40 | # Definition of buttons 41 | buttons = { 42 | '1': 'drag', 43 | '8': 'prev', 44 | '9': 'next' 45 | }, 46 | 47 | # Definition of keybindings 48 | keybinds = { 49 | 'S-space': 'prev', 50 | 'space': 'next', 51 | 'p': 'prev', 52 | 'n': 'next', 53 | 54 | 'H': 'pageLeft', 55 | 'J': 'pageDown', 56 | 'K': 'pageUp', 57 | 'L': 'pageRight', 58 | 59 | 'h': 'left', 60 | 'j': 'down', 61 | 'k': 'up', 62 | 'l': 'right', 63 | 64 | 'Left': 'left', 65 | 'Down': 'down', 66 | 'Up': 'up', 67 | 'Right': 'right', 68 | 69 | 'G': 'scrollEnd', 70 | 'g': 'scrollStart', 71 | 72 | 'm': 'mark_picture', 73 | 74 | 'C-r': 'rotation_lock', 75 | 'R': 'rotateL', 76 | 'r': 'rotateR', 77 | 't': 'slower', 78 | 'T': 'faster', 79 | 80 | 'b': 'status', 81 | 's': 'slideshow', 82 | 'f': 'fullscreen', 83 | 'q': 'quit', 84 | 'Q': 'saveq', 85 | 'x': 'delete', 86 | 'X': 'deleteb', 87 | 'w': 'zoom_fit', 88 | 'plus': 'zoom_in', 89 | 'minus': 'zoom_out', 90 | '1': 'zoom_100', 91 | '2': 'zoom_200', 92 | '3': 'zoom_300', 93 | 'e': 'zoom_w', 94 | 'E': 'zoom_h', 95 | 'z': 'zoom_lock' 96 | } 97 | ) 98 | 99 | BUTTONS = { 100 | Gdk.EventType.BUTTON_PRESS: 1, 101 | Gdk.EventType._2BUTTON_PRESS: 2, 102 | Gdk.EventType._3BUTTON_PRESS: 3 103 | } 104 | 105 | HOME = os.getenv('HOME') 106 | XDG_DATA_HOME = os.getenv('XDG_DATA_HOME') or HOME + '/.local/share' 107 | TRASH = XDG_DATA_HOME + '/Trash' 108 | 109 | 110 | def cursor_hide(): 111 | g.win.get_window().set_cursor(g.cursor) 112 | g.cursor_id = None 113 | 114 | 115 | def delete(delta=0): 116 | # https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html 117 | if not os.path.isdir(TRASH): 118 | os.mkdir(TRASH) 119 | os.mkdir(TRASH + '/files') 120 | os.mkdir(TRASH + '/info') 121 | 122 | path = g.paths.pop(g.index) 123 | # Check if physical existing 124 | if os.path.exists(path): 125 | name = uuid.uuid4().hex 126 | info = ( 127 | '[Trash Info]\n' 128 | 'Path={}\n' 129 | 'DeletionDate={}' 130 | ).format(path, time.strftime('%Y-%m-%dT%H:%M:%S')) 131 | 132 | f = open(TRASH + '/info/' + name + '.trashinfo', 'w') 133 | f.write(info) 134 | f.close() 135 | shutil.move(path, TRASH + '/files/' + name) 136 | print(':: trashed:', path) 137 | else: 138 | print(':: removed', path, 'from the selection') 139 | 140 | if not len(g.paths): 141 | print(':: quitting, as no files remain') 142 | quit() 143 | else: 144 | if g.index == len(g.paths): 145 | g.index -= 1 146 | 147 | move_index(delta) 148 | 149 | 150 | def drag(): 151 | g.drag = True 152 | 153 | 154 | def get_zoom_percent(zWidth=False, zHeight=False): 155 | pboWidth = g.pixbufOriginal.get_width() 156 | pboHeight = g.pixbufOriginal.get_height() 157 | pboScale = pboWidth / pboHeight 158 | 159 | if g.fullscreen: 160 | winSize = (g.mon_size.width, g.mon_size.height) 161 | wScale = g.mon_size.width / g.mon_size.height 162 | else: 163 | winSize = g.win_size 164 | wScale = g.win_size[0] / g.win_size[1] 165 | 166 | stickout = zWidth | zHeight 167 | if pboWidth < winSize[0] and pboHeight < winSize[1] and not stickout: 168 | return 1 169 | elif (pboScale < wScale and not stickout) or zHeight: 170 | return winSize[1] / pboHeight 171 | else: 172 | return winSize[0] / pboWidth 173 | 174 | 175 | def handle_button_press(widget, event): 176 | clicks = BUTTONS[event.type] 177 | button = str(event.button) 178 | name = g.buttons.get(button) 179 | 180 | if clicks == 1 and name: 181 | action = Actions[name] 182 | action[0](*action[1:]) 183 | elif clicks == 2 and button == '1': 184 | toggle_fullscreen() 185 | 186 | return True # XXX without this, single clicks fire twice 187 | 188 | 189 | def handle_button_release(win, event): 190 | g.drag = False 191 | return True 192 | 193 | 194 | def handle_motion(win, event): 195 | win.get_window().set_cursor(None) 196 | if g.cursor_id: 197 | GLib.source_remove(g.cursor_id) 198 | g.cursor_id = GLib.timeout_add_seconds(g.hide_delay, cursor_hide) 199 | 200 | if (g.drag): 201 | xscale = g.hadj.props.upper / g.hadj.props.page_size 202 | yscale = g.vadj.props.upper / g.vadj.props.page_size 203 | g.hadj.set_value(event.x * xscale) 204 | g.vadj.set_value(event.y * yscale) 205 | 206 | 207 | def handle_key(win, event): 208 | key = Gdk.keyval_name(event.keyval) 209 | 210 | # XXX only support one modifier key, so we don't have to support 211 | # modifiers in arbitrary order 212 | if event.state & Gdk.ModifierType.MOD1_MASK: 213 | key = 'A-' + key 214 | elif event.state & Gdk.ModifierType.CONTROL_MASK: 215 | key = 'C-' + key 216 | elif event.state & Gdk.ModifierType.SHIFT_MASK and len(key) > 1: 217 | key = 'S-' + key 218 | 219 | name = g.keybinds.get(key) 220 | 221 | if name: 222 | action = Actions[name] 223 | action[0](*action[1:]) 224 | 225 | 226 | def mark(): 227 | if g.paths[g.index] in g.marked: 228 | g.marked.remove(g.paths[g.index]) 229 | else: 230 | g.marked.append(g.paths[g.index]) 231 | update_info() 232 | 233 | 234 | def move_index(delta, slide=False): 235 | # Manual interaction stops slideshow 236 | if g.slideshow and not slide: 237 | toggle_slideshow() 238 | 239 | g.index = (g.index + delta) % len(g.paths) 240 | 241 | # reshuffle on wrap-around 242 | if g.shuffle and g.index == 0 and delta > 0: 243 | shuffle(g.paths) 244 | 245 | path = g.paths[g.index] 246 | try: 247 | if not os.path.exists(path): 248 | print(":: Error: Couldn't open", path) 249 | delete() 250 | return 251 | else: 252 | g.pixbufOriginal = GdkPixbuf.PixbufAnimation.new_from_file(path) 253 | if g.pixbufOriginal.is_static_image(): 254 | g.pixbufOriginal = g.pixbufOriginal.get_static_image() 255 | if g.rotation_lock: 256 | g.pixbufOriginal = g.pixbufOriginal.rotate_simple(g.rotation) 257 | else: 258 | g.rotation = 0 259 | if not g.zoom_lock: 260 | if not g.fullscreen: 261 | g.win_size = g.win.get_size() 262 | g.zoom_percent = get_zoom_percent() 263 | else: 264 | g.zoom_percent = 1 265 | update_image() 266 | 267 | scroll(Gtk.ScrollType.START, False) 268 | scroll(Gtk.ScrollType.START, True) 269 | 270 | except GLib.Error as err: 271 | print(":: couldn't read", path, 'because:', err) 272 | move_index(1) 273 | 274 | return True # for the slideshow 275 | 276 | 277 | def parse_args(): 278 | # ToDo: As the vars are contained in a argparse dict -> Look at combining them 279 | usage = '%(prog)s [options] path1 [path2 path3 ...]' 280 | parser = argparse.ArgumentParser(usage=usage) 281 | parser.add_argument('-b', '--bar', action='store_true', dest='sbar', 282 | help='display statusbar', default=g.sbar) 283 | parser.add_argument('-f', '--fullscreen', action='store_true', 284 | dest='fullscreen', help='start in fullscreen', 285 | default=g.fullscreen) 286 | parser.add_argument('-g', '--geometry', dest='geometry', 287 | help='set window size', default=g.geometry) 288 | parser.add_argument('-s', '--shuffle', action='store_true', 289 | dest='shuffle', help='shuffle filelist', 290 | default=g.shuffle) 291 | parser.add_argument('-S', '--no-shuffle', action='store_false', 292 | dest='shuffle', help="don't shuffle the filelist") 293 | parser.add_argument('--slideshow-delay', type=int, 294 | help='set the slideshow delay', 295 | default=g.slideshow_delay) 296 | parser.add_argument('path', nargs='+') 297 | parser.parse_args(namespace=g) 298 | 299 | if not populate(g.path): 300 | parser.error('no loadable images detected') 301 | 302 | 303 | def parse_geometry(): 304 | # Not nice, but adding type=int to argparse won't help because of the x 305 | # ToDo: Look for a better solution 306 | if g.geometry.find('x') >= 0: 307 | g.geometry = g.geometry.split('x') 308 | for ele in range(0, len(g.geometry)): 309 | if len(g.geometry[ele]) > 0: 310 | g.geometry[ele] = int(g.geometry[ele]) 311 | else: 312 | print(':: Warning: Missing geometry parameter.' 313 | ' Replacing with default') 314 | g.geometry[ele] = 200*(4-ele) 315 | else: 316 | print(':: Warning: The geometry should be like that: 800x600' 317 | '\n::Falling back to default') 318 | g.geometry = '800x600' 319 | parse_geometry() 320 | 321 | 322 | def populate(args): 323 | """ Generate a list of paths from the given arguments """ 324 | 325 | # get supported mimetypes 326 | types = [] 327 | for pixbuf_format in GdkPixbuf.Pixbuf.get_formats(): 328 | types.extend(pixbuf_format.get_mime_types()) 329 | 330 | args = g.path 331 | # If only one path is passed do special stuff 332 | single = None 333 | if len(args) == 1: 334 | arg = args[0] 335 | if os.path.isfile(arg): 336 | # use parent directory 337 | single = os.path.abspath(arg) 338 | directory = os.path.dirname(single) 339 | args = (directory,) 340 | elif os.path.isdir(arg) and os.path.isfile('pim-position'): 341 | f = open('pim-position') 342 | single = f.read() 343 | print(':: Found position file. Starting with: '+single) 344 | 345 | # add everything 346 | for arg in args: 347 | path = os.path.abspath(arg) 348 | if os.path.isfile(path): 349 | g.paths.append(path) 350 | elif os.path.isdir(path): 351 | paths = [os.path.join(path, x) for x in os.listdir(path)] 352 | paths.sort() 353 | g.paths.extend(paths) 354 | else: 355 | print(':: Error: {} is not a valid path!'.format(arg)) 356 | 357 | # remove unsupported files 358 | g.paths = [path for path in g.paths if mimetypes.guess_type(path)[ 359 | 0] in types] 360 | 361 | # shuffle 362 | if g.shuffle: 363 | shuffle(g.paths) 364 | 365 | # complete special stuff for single arg 366 | if single and single in g.paths: 367 | g.index = g.paths.index(single) 368 | else: 369 | g.index = 0 370 | 371 | return len(g.paths) 372 | 373 | 374 | def quit(remember_position=False): 375 | if remember_position and len(g.paths): 376 | try: 377 | f = open('pim-position', 'w') 378 | f.writelines(g.paths[g.index]) 379 | f.close() 380 | except IOError as e: 381 | print(e) 382 | for pos in g.marked: 383 | print(pos) 384 | 385 | Gtk.main_quit() 386 | 387 | 388 | def rotate(delta): 389 | try: 390 | g.pixbufOriginal = g.pixbufOriginal.rotate_simple(delta % 360) 391 | g.rotation = (g.rotation + delta) % 360 392 | 393 | if not g.zoom_lock: 394 | g.zoom_percent = get_zoom_percent() 395 | update_image() 396 | except: 397 | print(':: Warning: Animation object cannot be rotated') 398 | 399 | 400 | def scroll(scrolltype, horizontal): 401 | g.scrolled_win.emit('scroll-child', scrolltype, horizontal) 402 | 403 | 404 | def set_default_window_size(): 405 | parse_geometry() 406 | winWidth = g.geometry[0] if g.mon_size.width >= 800 else g.mon_size.width 407 | winHeight = g.geometry[1] if g.mon_size.height >= 600 else g.mon_size.height 408 | 409 | g.win.resize(winWidth, winHeight) 410 | if g.fullscreen: 411 | g.win.fullscreen() 412 | 413 | 414 | def change_delay(delta): 415 | if g.slideshow_delay == 1 and delta == -1: 416 | return 417 | else: 418 | g.slideshow_delay += delta 419 | if g.slideshow: 420 | toggle_slideshow() 421 | toggle_slideshow() 422 | 423 | 424 | def toggle_fullscreen(): 425 | g.fullscreen = not g.fullscreen 426 | 427 | if g.fullscreen: 428 | g.win.fullscreen() 429 | # Save previous window size. Possible since get_size gets old value 430 | # And this is also the cause for some problems 431 | # (zoomfactor for fullscreen on non fullscreen window) 432 | g.win_size = g.win.get_size() 433 | else: 434 | g.win.unfullscreen() 435 | if not g.zoom_lock: 436 | g.zoom_percent = get_zoom_percent() 437 | 438 | update_image() 439 | 440 | 441 | def toggle_rotation_lock(): 442 | g.rotation_lock = not g.rotation_lock 443 | 444 | 445 | def toggle_slideshow(): 446 | g.slideshow = not g.slideshow 447 | if g.slideshow: 448 | g.timer_id = GLib.timeout_add_seconds(g.slideshow_delay, 449 | move_index, 1, True) 450 | else: 451 | GLib.source_remove(g.timer_id) 452 | update_info() 453 | 454 | 455 | def toggle_statusbar(): 456 | if not g.sbar: 457 | Gtk.Widget.hide(g.statusbar) 458 | else: 459 | Gtk.Widget.show(g.statusbar) 460 | g.sbar = not g.sbar 461 | 462 | 463 | def toggle_zoom_lock(): 464 | g.zoom_lock = not g.zoom_lock 465 | 466 | 467 | def update_image(): 468 | """ Show the final image """ 469 | 470 | pboWidth = g.pixbufOriginal.get_width() 471 | pboHeight = g.pixbufOriginal.get_height() 472 | 473 | try: 474 | pbfWidth = int(pboWidth * g.zoom_percent) 475 | pbfHeight = int(pboHeight * g.zoom_percent) 476 | pixbufFinal = g.pixbufOriginal.scale_simple( 477 | pbfWidth, pbfHeight, GdkPixbuf.InterpType.BILINEAR) 478 | g.image.set_from_pixbuf(pixbufFinal) 479 | except: 480 | g.image.set_from_animation(g.pixbufOriginal) 481 | 482 | update_info() 483 | 484 | 485 | def update_info(): 486 | message = '[{0}/{1}] [ {3:3.0f}% ] {2: <50} {5: <3} {4: <11}'.format( 487 | g.index+1, len(g.paths), g.paths[g.index], 488 | g.zoom_percent * 489 | 100, '[slideshow ({0}s)]'.format( 490 | g.slideshow_delay) if g.slideshow else '', 491 | '[*]' if g.paths[g.index] in g.marked else '') 492 | g.win.set_title('pim '+message) 493 | g.statusbar.push(1, message) 494 | 495 | 496 | def zoom_delta(delta): 497 | try: 498 | g.zoom_percent = g.zoom_percent + delta 499 | if g.zoom_percent <= 0: 500 | g.zoom_percent = 1/100 501 | update_image() 502 | except: 503 | print(':: Warning: Animation object cannot be zoomed') 504 | 505 | 506 | def zoom_to(percent, zWidth=False, zHeight=False): 507 | try: 508 | if not g.fullscreen: 509 | g.win_size = g.win.get_size() 510 | g.zoom_percent = percent if percent else get_zoom_percent(zWidth, zHeight) 511 | update_image() 512 | except: 513 | print(':: Warning: Animation object cannot be zoomed') 514 | 515 | 516 | def main(): 517 | parse_args() 518 | 519 | dis = g.dis = Gdk.Display().get_default() 520 | 521 | g.cursor = Gdk.Cursor.new_from_name(Gdk.Display.get_default(), 'none') 522 | g.cursor_id = GLib.timeout_add_seconds(g.hide_delay, cursor_hide) 523 | 524 | win = g.win = Gtk.Window() 525 | win.add_events(Gdk.EventMask.KEY_PRESS_MASK | 526 | Gdk.EventMask.POINTER_MOTION_MASK) 527 | win.connect('destroy', Gtk.main_quit) 528 | win.connect('button_press_event', handle_button_press) 529 | win.connect('button-release-event', handle_button_release) 530 | win.connect('key_press_event', handle_key) 531 | win.connect('motion-notify-event', handle_motion) 532 | win.set_icon_name('image-x-generic') 533 | 534 | vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 535 | win.add(vbox) 536 | 537 | g.scrolled_win = Gtk.ScrolledWindow() 538 | vbox.pack_start(g.scrolled_win, True, True, 0) 539 | 540 | viewport = Gtk.Viewport() 541 | viewport.set_shadow_type(Gtk.ShadowType.NONE) 542 | g.scrolled_win.add(viewport) 543 | g.hadj = g.scrolled_win.get_hadjustment() 544 | g.vadj = g.scrolled_win.get_vadjustment() 545 | 546 | # Not nice in regard of hardcoded monitor 547 | monitor = Gdk.Display.get_monitor(dis, 0) 548 | g.mon_size = Gdk.Monitor.get_geometry(monitor) 549 | 550 | set_default_window_size() 551 | g.win_size = win.get_size() 552 | 553 | g.image = Gtk.Image() 554 | viewport.add(g.image) 555 | 556 | g.statusbar = Gtk.Statusbar() 557 | vbox.pack_end(g.statusbar, False, False, 0) 558 | 559 | move_index(0) 560 | win.show_all() 561 | if g.fullscreen: 562 | g.win.fullscreen() 563 | toggle_statusbar() 564 | Gtk.main() 565 | 566 | 567 | Actions = { 568 | # if True, scroll in the horizontal direction. 569 | 'scrollStart': (scroll, Gtk.ScrollType.START, False), 570 | 'scrollEnd': (scroll, Gtk.ScrollType.END, False), 571 | 'pageLeft': (scroll, Gtk.ScrollType.PAGE_BACKWARD, True), 572 | 'pageDown': (scroll, Gtk.ScrollType.PAGE_FORWARD, False), 573 | 'pageUp': (scroll, Gtk.ScrollType.PAGE_BACKWARD, False), 574 | 'pageRight': (scroll, Gtk.ScrollType.PAGE_FORWARD, True), 575 | 'left': (scroll, Gtk.ScrollType.STEP_BACKWARD, True), 576 | 'down': (scroll, Gtk.ScrollType.STEP_FORWARD, False), 577 | 'up': (scroll, Gtk.ScrollType.STEP_BACKWARD, False), 578 | 'right': (scroll, Gtk.ScrollType.STEP_FORWARD, True), 579 | 580 | 'delete': (delete, 0), 581 | 'deleteb': (delete, -1), 582 | 'drag': (drag,), 583 | 'fullscreen': (toggle_fullscreen,), 584 | 'next': (move_index, 1), 585 | 'prev': (move_index, -1), 586 | 'quit': (quit,), 587 | 'saveq': (quit, True), 588 | 'mark_picture': (mark,), 589 | 'rotation_lock': (toggle_rotation_lock,), 590 | 'rotateR': (rotate, -90), 591 | 'rotateL': (rotate, 90), 592 | 'slideshow': (toggle_slideshow,), 593 | 'slower': (change_delay, 1), 594 | 'faster': (change_delay, -1), 595 | 'status': (toggle_statusbar,), 596 | 'zoom_fit': (zoom_to, 0), 597 | 'zoom_in': (zoom_delta, +.25), 598 | 'zoom_out': (zoom_delta, -.25), 599 | 'zoom_100': (zoom_to, 1), 600 | 'zoom_200': (zoom_to, 2), 601 | 'zoom_300': (zoom_to, 3), 602 | 'zoom_h': (zoom_to, 0, False, True), 603 | 'zoom_w': (zoom_to, 0, True, False), 604 | 'zoom_lock': (toggle_zoom_lock,) 605 | } 606 | 607 | 608 | if __name__ == '__main__': 609 | 610 | main() 611 | -------------------------------------------------------------------------------- /pim.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Name=pim 4 | GenericName=Image Viewer 5 | Comment=PyGobject based image viewer with vim like keybindings 6 | Icon=image-x-generic 7 | Exec=pim %F 8 | MimeType=image/bmp;image/gif;image/jpeg;image/jp2;image/jpeg2000;image/jpx;image/png;image/svg;image/tiff; 9 | --------------------------------------------------------------------------------