diff options
author | Toon Nolten <toonn@toonn.io> | 2018-09-09 18:38:54 +0200 |
---|---|---|
committer | Toon Nolten <toonn@toonn.io> | 2018-09-09 18:38:54 +0200 |
commit | ae1ef141b5dedf1296a8c163e5a1f9f5ec6f4f9e (patch) | |
tree | 0a78dbf70d4a33c8b32e406ba28d485b892e41f1 /ranger | |
parent | d2b3c17578b70b1ab0bdbc7eb70046fa2e17a434 (diff) | |
parent | 2485dd705e84fb64e3f04f5b4f6956968a5b9708 (diff) | |
download | ranger-ae1ef141b5dedf1296a8c163e5a1f9f5ec6f4f9e.tar.gz |
Merge branch 'master' into stable
Preparing for release 1.9.2
Diffstat (limited to 'ranger')
31 files changed, 1168 insertions, 228 deletions
diff --git a/ranger/__init__.py b/ranger/__init__.py index 0d7c8fdc..ae1ecc48 100644 --- a/ranger/__init__.py +++ b/ranger/__init__.py @@ -12,9 +12,28 @@ from __future__ import (absolute_import, division, print_function) import os + +# Version helper +def version_helper(): + if __release__: + version_string = 'ranger {0}'.format(__version__) + else: + import subprocess + version_string = 'ranger-master {0}' + try: + git_describe = subprocess.check_output(['git', 'describe'], + universal_newlines=True, + stderr=subprocess.PIPE) + version_string = version_string.format(git_describe.strip('\n')) + except (OSError, subprocess.CalledProcessError): + version_string = version_string.format(__version__) + return version_string + + # Information __license__ = 'GPL3' __version__ = '1.9.1' +__release__ = False __author__ = __maintainer__ = 'Roman Zimbelmann' __email__ = 'hut@hut.pm' @@ -27,7 +46,7 @@ MACRO_DELIMITER = '%' MACRO_DELIMITER_ESC = '%%' DEFAULT_PAGER = 'less' USAGE = '%prog [options] [path]' -VERSION = 'ranger-master {0}'.format(__version__) +VERSION = version_helper() # These variables are ignored if the corresponding # XDG environment variable is non-empty and absolute diff --git a/ranger/colorschemes/default.py b/ranger/colorschemes/default.py index c88cdc7c..a9e01e0c 100644 --- a/ranger/colorschemes/default.py +++ b/ranger/colorschemes/default.py @@ -6,7 +6,7 @@ from __future__ import (absolute_import, division, print_function) from ranger.gui.colorscheme import ColorScheme from ranger.gui.color import ( black, blue, cyan, green, magenta, red, white, yellow, default, - normal, bold, reverse, + normal, bold, reverse, dim, BRIGHT, default_colors, ) @@ -39,18 +39,22 @@ class Default(ColorScheme): if context.directory: attr |= bold fg = blue + fg += BRIGHT elif context.executable and not \ any((context.media, context.container, context.fifo, context.socket)): attr |= bold fg = green + fg += BRIGHT if context.socket: - fg = magenta attr |= bold + fg = magenta + fg += BRIGHT if context.fifo or context.device: fg = yellow if context.device: attr |= bold + fg += BRIGHT if context.link: fg = cyan if context.good else magenta if context.tag_marker and not context.selected: @@ -59,10 +63,19 @@ class Default(ColorScheme): fg = white else: fg = red + fg += BRIGHT if not context.selected and (context.cut or context.copied): - fg = black attr |= bold + fg = black + fg += BRIGHT + # If the terminal doesn't support bright colors, use dim white + # instead of black. + if BRIGHT == 0: + attr |= dim + fg = white if context.main_column: + # Doubling up with BRIGHT here causes issues because it's + # additive not idempotent. if context.selected: attr |= bold if context.marked: @@ -78,7 +91,6 @@ class Default(ColorScheme): fg = cyan elif context.in_titlebar: - attr |= bold if context.hostname: fg = red if context.bad else green elif context.directory: @@ -88,6 +100,8 @@ class Default(ColorScheme): bg = green elif context.link: fg = cyan + attr |= bold + fg += BRIGHT elif context.in_statusbar: if context.permissions: @@ -98,13 +112,16 @@ class Default(ColorScheme): if context.marked: attr |= bold | reverse fg = yellow + fg += BRIGHT if context.frozen: attr |= bold | reverse fg = cyan + fg += BRIGHT if context.message: if context.bad: attr |= bold fg = red + fg += BRIGHT if context.loaded: bg = self.progress_bar_color if context.vcsinfo: @@ -138,6 +155,8 @@ class Default(ColorScheme): attr &= ~bold if context.vcsconflict: fg = magenta + elif context.vcsuntracked: + fg = cyan elif context.vcschanged: fg = red elif context.vcsunknown: diff --git a/ranger/colorschemes/snow.py b/ranger/colorschemes/snow.py index 8e9686a8..c5f23c1c 100644 --- a/ranger/colorschemes/snow.py +++ b/ranger/colorschemes/snow.py @@ -4,7 +4,7 @@ from __future__ import (absolute_import, division, print_function) from ranger.gui.colorscheme import ColorScheme -from ranger.gui.color import default_colors, reverse, bold +from ranger.gui.color import default_colors, reverse, bold, BRIGHT class Snow(ColorScheme): @@ -20,6 +20,7 @@ class Snow(ColorScheme): attr = reverse if context.directory: attr |= bold + fg += BRIGHT elif context.highlight: attr |= reverse @@ -36,6 +37,7 @@ class Snow(ColorScheme): elif context.in_taskview: if context.selected: attr |= bold + fg += BRIGHT if context.loaded: attr |= reverse diff --git a/ranger/config/__init__.py b/ranger/config/__init__.py index 71df3cb3..0facbdf8 100644 --- a/ranger/config/__init__.py +++ b/ranger/config/__init__.py @@ -1 +1 @@ -"""Default options and configration files""" +"""Default options and configuration files""" diff --git a/ranger/config/commands.py b/ranger/config/commands.py index a3837d8e..d177203a 100755 --- a/ranger/config/commands.py +++ b/ranger/config/commands.py @@ -3,8 +3,9 @@ # This configuration file is licensed under the same terms as ranger. # =================================================================== # -# NOTE: If you copied this file to ~/.config/ranger/commands_full.py, -# then it will NOT be loaded by ranger, and only serve as a reference. +# NOTE: If you copied this file to /etc/ranger/commands_full.py or +# ~/.config/ranger/commands_full.py, then it will NOT be loaded by ranger, +# and only serve as a reference. # # =================================================================== # This file contains ranger's commands. @@ -13,9 +14,14 @@ # Note that additional commands are automatically generated from the methods # of the class ranger.core.actions.Actions. # -# You can customize commands in the file ~/.config/ranger/commands.py. -# It has the same syntax as this file. In fact, you can just copy this -# file there with `ranger --copy-config=commands' and make your modifications. +# You can customize commands in the files /etc/ranger/commands.py (system-wide) +# and ~/.config/ranger/commands.py (per user). +# They have the same syntax as this file. In fact, you can just copy this +# file to ~/.config/ranger/commands_full.py with +# `ranger --copy-config=commands_full' and make your modifications, don't +# forget to rename it to commands.py. You can also use +# `ranger --copy-config=commands' to copy a short sample commands.py that +# has everything you need to get started. # But make sure you update your configs when you update ranger. # # =================================================================== @@ -120,9 +126,10 @@ class echo(Command): class cd(Command): - """:cd [-r] <dirname> + """:cd [-r] <path> The cd command changes the directory. + If the path is a file, selects that file. The command 'cd -' is equivalent to typing ``. Using the option "-r" will get you to the real path. """ @@ -1393,6 +1400,8 @@ class scout(Command): self.fm.cd(pattern) else: self.fm.move(right=1) + if self.quickly_executed: + self.fm.block_input(0.5) if self.KEEP_OPEN in flags and thisdir != self.fm.thisdir: # reopen the console: @@ -1536,6 +1545,61 @@ class filter_inode_type(Command): self.fm.thisdir.refilter() +class filter_stack(Command): + """ + :filter_stack ... + + Manages the filter stack. + + filter_stack add FILTER_TYPE ARGS... + filter_stack pop + filter_stack decompose + filter_stack rotate [N=1] + filter_stack clear + filter_stack show + """ + def execute(self): + from ranger.core.filter_stack import SIMPLE_FILTERS, FILTER_COMBINATORS + + subcommand = self.arg(1) + + if subcommand == "add": + try: + self.fm.thisdir.filter_stack.append( + SIMPLE_FILTERS[self.arg(2)](self.rest(3)) + ) + except KeyError: + FILTER_COMBINATORS[self.arg(2)](self.fm.thisdir.filter_stack) + elif subcommand == "pop": + self.fm.thisdir.filter_stack.pop() + elif subcommand == "decompose": + inner_filters = self.fm.thisdir.filter_stack.pop().decompose() + if inner_filters: + self.fm.thisdir.filter_stack.extend(inner_filters) + elif subcommand == "clear": + self.fm.thisdir.filter_stack = [] + elif subcommand == "rotate": + rotate_by = int(self.arg(2) or 1) + self.fm.thisdir.filter_stack = ( + self.fm.thisdir.filter_stack[-rotate_by:] + + self.fm.thisdir.filter_stack[:-rotate_by] + ) + elif subcommand == "show": + stack = list(map(str, self.fm.thisdir.filter_stack)) + pager = self.fm.ui.open_pager() + pager.set_source(["Filter stack: "] + stack) + pager.move(to=100, percentage=True) + return + else: + self.fm.notify( + "Unknown subcommand: {}".format(subcommand), + bad=True + ) + return + + self.fm.thisdir.refilter() + + class grep(Command): """:grep <string> @@ -1718,6 +1782,7 @@ class yank(Command): modes = { '': 'basename', + 'name_without_extension': 'basename_without_extension', 'name': 'basename', 'dir': 'dirname', 'path': 'path', @@ -1750,7 +1815,9 @@ class yank(Command): clipboard_commands = clipboards() - selection = self.get_selection_attr(self.modes[self.arg(1)]) + mode = self.modes[self.arg(1)] + selection = self.get_selection_attr(mode) + new_clipboard_contents = "\n".join(selection) for command in clipboard_commands: process = subprocess.Popen(command, universal_newlines=True, diff --git a/ranger/config/rc.conf b/ranger/config/rc.conf index 676090fb..a32ab494 100644 --- a/ranger/config/rc.conf +++ b/ranger/config/rc.conf @@ -1,7 +1,8 @@ # =================================================================== # This file contains the default startup commands for ranger. -# To change them, it is recommended to create the file -# ~/.config/ranger/rc.conf and add your custom commands there. +# To change them, it is recommended to create either /etc/ranger/rc.conf +# (system-wide) or ~/.config/ranger/rc.conf (per user) and add your custom +# commands there. # # If you copy this whole file there, you may want to set the environment # variable RANGER_LOAD_DEFAULT_RC to FALSE to avoid loading it twice. @@ -85,6 +86,10 @@ set preview_images false # width of 8 and height of 11 are used. To use other values, set the options # iterm2_font_width and iterm2_font_height to the desired values. # +# * terminology: +# Previews images in full color in the terminology terminal emulator. +# Supports a wide variety of formats, even vector graphics like svg. +# # * urxvt: # Preview images in full color using urxvt image backgrounds. This # requires using urxvt compiled with pixbuf support. @@ -92,8 +97,21 @@ set preview_images false # * urxvt-full: # The same as urxvt but utilizing not only the preview pane but the # whole terminal window. +# +# * kitty: +# Preview images in full color using kitty image protocol. +# Requires python PIL or pillow library. +# If ranger does not share the local filesystem with kitty +# the transfer method is changed to encode the whole image; +# while slower, this allows remote previews, +# for example during an ssh session. +# Tmux is unsupported. set preview_images_method w3m +# Delay in seconds before displaying an image with the w3m method. +# Increase it in case of experiencing display corruption. +set w3m_delay 0.02 + # Default iTerm2 font size (see: preview_images_method: iterm2) set iterm2_font_width 8 set iterm2_font_height 11 @@ -101,6 +119,10 @@ set iterm2_font_height 11 # Use a unicode "..." character to mark cut-off filenames? set unicode_ellipsis false +# BIDI support - try to properly display file names in RTL languages (Hebrew, Arabic). +# Requires the python-bidi pip package +set bidi_support false + # Show dotfiles in the bookmark preview box? set show_hidden_bookmarks true @@ -124,8 +146,11 @@ set status_bar_on_top false # currently running tasks which support progress bars? set draw_progress_bar_in_status_bar true -# Draw borders around columns? -set draw_borders false +# Draw borders around columns? (separators, outline, both, or none) +# Separators are vertical lines between columns. +# Outline draws a box around all the columns. +# Both combines the two. +set draw_borders none # Display the directory name in tabs? set dirname_in_tabs false @@ -137,6 +162,9 @@ set mouse_enabled true set display_size_in_main_column true set display_size_in_status_bar true +# Display the free disk space in the status bar? +set display_free_space_in_status_bar true + # Display files tags in all columns or only in main column? set display_tags_in_all_columns true @@ -144,7 +172,7 @@ set display_tags_in_all_columns true set update_title false # Set the title to "ranger" in the tmux program? -set update_tmux_title false +set update_tmux_title true # Shorten the title if it gets long? The number defines how many # directories are displayed at once, 0 turns off this feature. @@ -216,6 +244,10 @@ set cd_tab_fuzzy false # disable this feature. set preview_max_size 0 +# The key hint lists up to this size have their sublists expanded. +# Otherwise the submaps are replaced with "...". +set hint_collapse_threshold 10 + # Add the highlighted file to the path in the titlebar set show_selection_in_titlebar true @@ -232,9 +264,14 @@ set metadata_deep_search false # Clear all existing filters when leaving a directory set clear_filters_on_dir_change false -# Disable displaying line numbers in main column +# Disable displaying line numbers in main column. +# Possible values: false, absolute, relative. set line_numbers false +# When line_numbers=relative show the absolute line number in the +# current line. +set relative_current_zero false + # Start line numbers from 1 instead of 0 set one_indexed false @@ -249,6 +286,10 @@ set wrap_scroll false # directories, files and symlinks respectively. set global_inode_type_filter +# This setting allows to freeze the list of files to save I/O bandwidth. It +# should be 'false' during start-up, but you can toggle it by pressing F. +set freeze_files false + # =================================================================== # == Local Options # =================================================================== @@ -270,8 +311,8 @@ alias qall quitall alias qall! quitall! alias setl setlocal -alias filter scout -prt -alias find scout -aeit +alias filter scout -prts +alias find scout -aets alias mark scout -mr alias unmark scout -Mr alias search scout -rs @@ -311,6 +352,8 @@ map r chain draw_possible_programs; console open_with%%space map f console find%space map cd console cd%space +map <C-p> chain console; eval fm.ui.console.history_move(-1) + # Change the line mode map Mf linemode filename map Mi linemode fileinfo @@ -374,6 +417,7 @@ map L history_go 1 map ] move_parent 1 map [ move_parent -1 map } traverse +map { traverse_backwards map ) jump_non map gh cd ~ @@ -385,6 +429,7 @@ map gL cd -r %f map go cd /opt map gv cd /var map gm cd /media +map gi eval fm.cd('/run/media/' + os.getenv('USER')) map gM cd /mnt map gs cd /srv map gp cd /tmp @@ -400,6 +445,7 @@ map dU shell -p du --max-depth=1 -h --apparent-size | sort -rh map yp yank path map yd yank dir map yn yank name +map y. yank name_without_extension # Filesystem Operations map = chmod @@ -474,6 +520,8 @@ map <a-6> tab_open 6 map <a-7> tab_open 7 map <a-8> tab_open 8 map <a-9> tab_open 9 +map <a-r> tab_shift 1 +map <a-l> tab_shift -1 # Sorting map or set sort_reverse! @@ -503,6 +551,8 @@ map zc set collapse_preview! map zd set sort_directories_first! map zh set show_hidden! map <C-h> set show_hidden! +copymap <C-h> <backspace> +copymap <backspace> <backspace2> map zI set flushinput! map zi set preview_images! map zm set mouse_enabled! @@ -514,6 +564,21 @@ map zv set use_preview_script! map zf console filter%space copymap zf zz +# Filter stack +map .n console filter_stack add name%space +map .m console filter_stack add mime%space +map .d filter_stack add type d +map .f filter_stack add type f +map .l filter_stack add type l +map .| filter_stack add or +map .& filter_stack add and +map .! filter_stack add not +map .r console filter_stack rotate +map .c filter_stack clear +map .* filter_stack decompose +map .p filter_stack pop +map .. filter_stack show + # Bookmarks map `<any> enter_bookmark %any map '<any> enter_bookmark %any @@ -558,8 +623,11 @@ cmap <left> eval fm.ui.console.move(left=1) cmap <right> eval fm.ui.console.move(right=1) cmap <home> eval fm.ui.console.move(right=0, absolute=True) cmap <end> eval fm.ui.console.move(right=-1, absolute=True) -cmap <a-left> eval fm.ui.console.move_word(left=1) -cmap <a-right> eval fm.ui.console.move_word(right=1) +cmap <a-b> eval fm.ui.console.move_word(left=1) +cmap <a-f> eval fm.ui.console.move_word(right=1) + +copycmap <a-b> <a-left> +copycmap <a-f> <a-right> # Line Editing cmap <backspace> eval fm.ui.console.delete(-1) @@ -571,6 +639,7 @@ cmap <C-u> eval fm.ui.console.delete_rest(-1) cmap <C-y> eval fm.ui.console.paste() # And of course the emacs way +copycmap <ESC> <C-g> copycmap <up> <C-p> copycmap <down> <C-n> copycmap <left> <C-b> diff --git a/ranger/config/rifle.conf b/ranger/config/rifle.conf index e2653a76..babdcda7 100644 --- a/ranger/config/rifle.conf +++ b/ranger/config/rifle.conf @@ -151,7 +151,7 @@ ext pdf, has atril, X, flag f = atril -- "$@" ext pdf, has okular, X, flag f = okular -- "$@" ext pdf, has epdfview, X, flag f = epdfview -- "$@" ext pdf, has qpdfview, X, flag f = qpdfview "$@" -ext pdf, has open, X, flat f = open "$@" +ext pdf, has open, X, flag f = open "$@" ext docx?, has catdoc, terminal = catdoc -- "$@" | "$PAGER" @@ -164,6 +164,7 @@ ext pptx?|od[dfgpst]|docx?|sxc|xlsx?|xlt|xlw|gnm|gnumeric, has ooffice, X, f ext djvu, has zathura,X, flag f = zathura -- "$@" ext djvu, has evince, X, flag f = evince -- "$@" ext djvu, has atril, X, flag f = atril -- "$@" +ext djvu, has djview, X, flag f = djview -- "$@" ext epub, has ebook-viewer, X, flag f = ebook-viewer -- "$@" ext mobi, has ebook-viewer, X, flag f = ebook-viewer -- "$@" @@ -183,6 +184,7 @@ mime ^image, has eog, X, flag f = eog -- "$@" mime ^image, has eom, X, flag f = eom -- "$@" mime ^image, has nomacs, X, flag f = nomacs -- "$@" mime ^image, has geeqie, X, flag f = geeqie -- "$@" +mime ^image, has gwenview, X, flag f = gwenview -- "$@" mime ^image, has gimp, X, flag f = gimp -- "$@" ext xcf, X, flag f = gimp -- "$@" @@ -210,6 +212,34 @@ ext rar, has unrar = unrar l "$1" | less ext rar, has unrar = for file in "$@"; do unrar x "$file"; done #------------------------------------------- +# Flag t fallback terminals +#------------------------------------------- +# Rarely installed terminal emulators get higher priority; It is assumed that +# if you install a rare terminal emulator, you probably use it. +# gnome-terminal/konsole/xterm on the other hand are often installed as part of +# a desktop environment or as fallback terminal emulators. +mime ^ranger/x-terminal-emulator, has terminology = terminology -e "$@" +mime ^ranger/x-terminal-emulator, has kitty = kitty -- "$@" +mime ^ranger/x-terminal-emulator, has alacritty = alacritty -e "$@" +mime ^ranger/x-terminal-emulator, has sakura = sakura -e "$@" +mime ^ranger/x-terminal-emulator, has lilyterm = lilyterm -e "$@" +#mime ^ranger/x-terminal-emulator, has cool-retro-term = cool-retro-term -e "$@" +mime ^ranger/x-terminal-emulator, has termite = termite -x '"$@"' +#mime ^ranger/x-terminal-emulator, has yakuake = yakuake -e "$@" +mime ^ranger/x-terminal-emulator, has guake = guake -ne "$@" +mime ^ranger/x-terminal-emulator, has tilda = tilda -c "$@" +mime ^ranger/x-terminal-emulator, has st = st -e "$@" +mime ^ranger/x-terminal-emulator, has terminator = terminator -x "$@" +mime ^ranger/x-terminal-emulator, has urxvt = urxvt -e "$@" +mime ^ranger/x-terminal-emulator, has pantheon-terminal = pantheon-terminal -e "$@" +mime ^ranger/x-terminal-emulator, has lxterminal = lxterminal -e "$@" +mime ^ranger/x-terminal-emulator, has mate-terminal = mate-terminal -x "$@" +mime ^ranger/x-terminal-emulator, has xfce4-terminal = xfce4-terminal -x "$@" +mime ^ranger/x-terminal-emulator, has konsole = konsole -e "$@" +mime ^ranger/x-terminal-emulator, has gnome-terminal = gnome-terminal -- "$@" +mime ^ranger/x-terminal-emulator, has xterm = xterm -e "$@" + +#------------------------------------------- # Misc #------------------------------------------- label wallpaper, number 11, mime ^image, has feh, X = feh --bg-scale "$1" diff --git a/ranger/container/directory.py b/ranger/container/directory.py index 18b1687c..81dabb24 100644 --- a/ranger/container/directory.py +++ b/ranger/container/directory.py @@ -155,6 +155,8 @@ class Directory( # pylint: disable=too-many-instance-attributes,too-many-public self.marked_items = [] + self.filter_stack = [] + self._signal_functions = [] func = self.signal_function_factory(self.sort) self._signal_functions += [func] @@ -297,6 +299,7 @@ class Directory( # pylint: disable=too-many-instance-attributes,too-many-public if self.temporary_filter: temporary_filter_search = self.temporary_filter.search filters.append(lambda fobj: temporary_filter_search(fobj.basename)) + filters.extend(self.filter_stack) self.files = [f for f in self.files_all if accept_file(f, filters)] @@ -338,8 +341,9 @@ class Directory( # pylint: disable=too-many-instance-attributes,too-many-public dirlist = [ os.path.join("/", dirpath, d) for d in dirnames - if self.flat == -1 or - (dirpath.count(os.path.sep) - mypath.count(os.path.sep)) <= self.flat + if self.flat == -1 + or (dirpath.count(os.path.sep) + - mypath.count(os.path.sep)) <= self.flat ] filelist += dirlist filelist += [os.path.join("/", dirpath, f) for f in filenames] diff --git a/ranger/container/fsobject.py b/ranger/container/fsobject.py index 0c9f70f6..37151ecf 100644 --- a/ranger/container/fsobject.py +++ b/ranger/container/fsobject.py @@ -6,7 +6,7 @@ from __future__ import (absolute_import, division, print_function) import re from grp import getgrgid from os import lstat, stat -from os.path import abspath, basename, dirname, realpath, relpath +from os.path import abspath, basename, dirname, realpath, relpath, splitext from pwd import getpwuid from time import time @@ -171,6 +171,10 @@ class FileSystemObject( # pylint: disable=too-many-instance-attributes,too-many return basename_list @lazy_property + def basename_without_extension(self): + return splitext(self.basename)[0] + + @lazy_property def safe_basename(self): return self.basename.translate(_SAFE_STRING_TABLE) diff --git a/ranger/container/settings.py b/ranger/container/settings.py index d0b094d0..70a12b51 100644 --- a/ranger/container/settings.py +++ b/ranger/container/settings.py @@ -27,6 +27,7 @@ ALLOWED_SETTINGS = { 'automatically_count_files': bool, 'autosave_bookmarks': bool, 'autoupdate_cumulative_size': bool, + 'bidi_support': bool, 'cd_bookmarks': bool, 'cd_tab_case': str, 'cd_tab_fuzzy': bool, @@ -38,13 +39,15 @@ ALLOWED_SETTINGS = { 'dirname_in_tabs': bool, 'display_size_in_main_column': bool, 'display_size_in_status_bar': bool, + "display_free_space_in_status_bar": bool, 'display_tags_in_all_columns': bool, - 'draw_borders': bool, + 'draw_borders': str, 'draw_progress_bar_in_status_bar': bool, 'flushinput': bool, 'freeze_files': bool, 'global_inode_type_filter': str, 'hidden_filter': str, + 'hint_collapse_threshold': int, 'hostname_in_titlebar': bool, 'idle_delay': int, 'iterm2_font_width': int, @@ -63,6 +66,7 @@ ALLOWED_SETTINGS = { 'preview_images_method': str, 'preview_max_size': int, 'preview_script': (str, type(None)), + 'relative_current_zero': bool, 'save_backtick_bookmark': bool, 'save_console_history': bool, 'save_tabs_on_exit': bool, @@ -89,6 +93,7 @@ ALLOWED_SETTINGS = { 'vcs_backend_hg': str, 'vcs_backend_svn': str, 'viewmode': str, + 'w3m_delay': float, 'wrap_scroll': bool, 'xterm_alt_key': bool, } @@ -96,9 +101,11 @@ ALLOWED_SETTINGS = { ALLOWED_VALUES = { 'cd_tab_case': ['sensitive', 'insensitive', 'smart'], 'confirm_on_delete': ['multiple', 'always', 'never'], + 'draw_borders': ['none', 'both', 'outline', 'separators'], 'line_numbers': ['false', 'absolute', 'relative'], 'one_indexed': [False, True], - 'preview_images_method': ['w3m', 'iterm2', 'urxvt', 'urxvt-full'], + 'preview_images_method': ['w3m', 'iterm2', 'terminology', + 'urxvt', 'urxvt-full', 'kitty'], 'vcs_backend_bzr': ['disabled', 'local', 'enabled'], 'vcs_backend_git': ['enabled', 'disabled', 'local'], 'vcs_backend_hg': ['disabled', 'local', 'enabled'], @@ -111,6 +118,7 @@ DEFAULT_VALUES = { type(None): None, str: "", int: 0, + float: 0.0, list: [], tuple: tuple([]), } diff --git a/ranger/core/actions.py b/ranger/core/actions.py index 31fa9518..40fd52a3 100644 --- a/ranger/core/actions.py +++ b/ranger/core/actions.py @@ -7,7 +7,7 @@ from __future__ import (absolute_import, division, print_function) import codecs import os -from os import link, symlink, getcwd, listdir, stat +from os import link, symlink, listdir, stat from os.path import join, isdir, realpath, exists import re import shlex @@ -110,7 +110,8 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m self.settings.set(option_name, self._parse_option_value(option_name, value), localpath, tags) - def _parse_option_value(self, name, value): + def _parse_option_value( # pylint: disable=too-many-return-statements + self, name, value): types = self.fm.settings.types_of(name) if bool in types: if value.lower() in ('false', 'off', '0'): @@ -124,6 +125,11 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m return int(value) except ValueError: pass + if float in types: + try: + return float(value) + except ValueError: + pass if str in types: return value if list in types: @@ -234,10 +240,25 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m cmd = cmd_class(string, quantifier=quantifier) if cmd.resolve_macros and _MacroTemplate.delimiter in cmd.line: - macros = dict(('any%d' % i, key_to_string(char)) - for i, char in enumerate(wildcards if wildcards is not None else [])) + def any_macro(i, char): + return ('any{:d}'.format(i), key_to_string(char)) + + def anypath_macro(i, char): + try: + val = self.fm.bookmarks[key_to_string(char)] + except KeyError: + self.notify('No bookmark defined for `{}`'.format( + key_to_string(char)), bad=True) + val = MACRO_FAIL + return ('any_path{:d}'.format(i), val) + + macros = dict(f(i, char) for f in (any_macro, anypath_macro) + for i, char in enumerate(wildcards if wildcards + is not None else [])) if 'any0' in macros: macros['any'] = macros['any0'] + if 'any_path0' in macros: + macros['any_path'] = macros['any_path0'] try: line = self.substitute_macros(cmd.line, additional=macros, escape=cmd.escape_macros_for_shell) @@ -401,7 +422,7 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m are multiple choices label: a string to select an opening method by its label flags: a string specifying additional options, see `man rifle` - mimetyle: pass the mimetype to rifle, overriding its own guess + mimetype: pass the mimetype to rifle, overriding its own guess """ mode = kw['mode'] if 'mode' in kw else 0 @@ -409,7 +430,8 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m # ranger can act as a file chooser when running with --choosefile=... if mode == 0 and 'label' not in kw: if ranger.args.choosefile: - open(ranger.args.choosefile, 'w').write(self.fm.thisfile.path) + with open(ranger.args.choosefile, 'w') as fobj: + fobj.write(self.fm.thisfile.path) if ranger.args.choosefiles: paths = [] @@ -482,8 +504,13 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m if narg is not None: mode = narg tfile = self.thisfile - selection = self.thistab.get_selection() - if not self.thistab.enter_dir(tfile) and selection: + if kw.get('selection', True): + selection = self.thistab.get_selection() + else: + selection = [tfile] + if tfile.is_directory: + self.thistab.enter_dir(tfile) + elif selection: result = self.execute_file(selection, mode=mode) if result in (False, ASK_COMMAND): self.open_console('open_with ') @@ -605,6 +632,23 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m self.move(down=1) self.traverse() + def traverse_backwards(self): + self.change_mode('normal') + if self.thisdir.pointer == 0: + self.move(left=1) + if self.thisdir.pointer != 0: + self.traverse_backwards() + else: + self.move(up=1) + while True: + if self.thisfile is not None and self.thisfile.is_directory: + self.enter_dir(self.thisfile.path) + self.move(to=100, percentage=True) + elif self.thisdir.pointer == 0: + break + else: + self.move(up=1) + # -------------------------- # -- Shortcuts / Wrappers # -------------------------- @@ -978,6 +1022,7 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m if not self.settings.preview_script or not self.settings.use_preview_script: try: + # XXX: properly determine file's encoding return codecs.open(path, 'r', errors='ignore') # IOError for Python2, OSError for Python3 except (IOError, OSError): @@ -1063,14 +1108,11 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m data[(-1, -1)] = None data['foundpreview'] = False elif rcode == 2: - fobj = codecs.open(path, 'r', errors='ignore') - try: - data[(-1, -1)] = fobj.read(1024 * 32) - except UnicodeDecodeError: - fobj.close() - fobj = codecs.open(path, 'r', encoding='latin-1', errors='ignore') - data[(-1, -1)] = fobj.read(1024 * 32) - fobj.close() + text = self.read_text_file(path, 1024 * 32) + if not isinstance(text, str): + # Convert 'unicode' to 'str' in Python 2 + text = text.encode('utf-8') + data[(-1, -1)] = text else: data[(-1, -1)] = None @@ -1111,6 +1153,35 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m return None + @staticmethod + def read_text_file(path, count=None): + """Encoding-aware reading of a text file.""" + try: + import chardet + except ImportError: + # Guess encoding ourselves. These should be the most frequently used ones. + encodings = ('utf-8', 'utf-16') + for encoding in encodings: + try: + with codecs.open(path, 'r', encoding=encoding) as fobj: + text = fobj.read(count) + except UnicodeDecodeError: + pass + else: + LOG.debug("guessed encoding of '%s' as %r", path, encoding) + return text + else: + with open(path, 'rb') as fobj: + data = fobj.read(count) + result = chardet.detect(data) + LOG.debug("chardet guess for '%s': %s", path, result) + guessed_encoding = result['encoding'] + return codecs.decode(data, guessed_encoding, 'replace') + + # latin-1 as the last resort + with codecs.open(path, 'r', encoding='latin-1', errors='replace') as fobj: + return fobj.read(count) + # -------------------------- # -- Tabs # -------------------------- @@ -1186,9 +1257,56 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m def tab_new(self, path=None, narg=None): if narg: return self.tab_open(narg, path) - for i in range(1, 10): - if i not in self.tabs: - return self.tab_open(i, path) + i = 1 + while i in self.tabs: + i += 1 + return self.tab_open(i, path) + + def tab_shift(self, offset=0, to=None): # pylint: disable=invalid-name + """Shift the tab left/right + + Shift the current tab to the left or right by either: + offset - changes the tab number by offset + to - shifts the tab to the specified tab number + """ + + oldtab_index = self.current_tab + if to is None: + assert isinstance(offset, int) + # enumerated index (1 to 9) + newtab_index = oldtab_index + offset + else: + assert isinstance(to, int) + newtab_index = to + # shift tabs without enumerating, preserve tab numbers when can + if newtab_index != oldtab_index: + # the other tabs shift in the opposite direction + if (newtab_index - oldtab_index) > 0: + direction = -1 + else: + direction = 1 + + def tabshiftreorder(source_index): + # shift the tabs to make source_index empty + if source_index in self.tabs: + target_index = source_index + direction + # make the target_index empty recursively + tabshiftreorder(target_index) + # shift the source to target + source_tab = self.tabs[source_index] + self.tabs[target_index] = source_tab + del self.tabs[source_index] + + # first remove the current tab from the dict + oldtab = self.tabs[oldtab_index] + del self.tabs[oldtab_index] + # make newtab_index empty by shifting + tabshiftreorder(newtab_index) + self.tabs[newtab_index] = oldtab + self.current_tab = newtab_index + self.thistab = oldtab + self.ui.titlebar.request_redraw() + self.signal_emit('tab.layoutchange') return None def tab_switch(self, path, create_directory=False): @@ -1237,7 +1355,18 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m def get_tab_list(self): assert self.tabs, "There must be at least 1 tab at all times" - return sorted(self.tabs) + + class NaturalOrder(object): # pylint: disable=too-few-public-methods + def __init__(self, obj): + self.obj = obj + + def __lt__(self, other): + try: + return self.obj < other.obj + except TypeError: + return str(self.obj) < str(other.obj) + + return sorted(self.tabs, key=NaturalOrder) # -------------------------- # -- Overview of internals @@ -1373,9 +1502,9 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m self.notify(new_name) try: if relative: - relative_symlink(fobj.path, join(getcwd(), new_name)) + relative_symlink(fobj.path, join(self.fm.thisdir.path, new_name)) else: - symlink(fobj.path, join(getcwd(), new_name)) + symlink(fobj.path, join(self.fm.thisdir.path, new_name)) except OSError as ex: self.notify('Failed to paste symlink: View log for more info', bad=True, exception=ex) @@ -1384,7 +1513,7 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m for fobj in self.copy_buffer: new_name = next_available_filename(fobj.basename) try: - link(fobj.path, join(getcwd(), new_name)) + link(fobj.path, join(self.fm.thisdir.path, new_name)) except OSError as ex: self.notify('Failed to paste hardlink: View log for more info', bad=True, exception=ex) @@ -1392,7 +1521,7 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m def paste_hardlinked_subtree(self): for fobj in self.copy_buffer: try: - target_path = join(getcwd(), fobj.basename) + target_path = join(self.fm.thisdir.path, fobj.basename) self._recurse_hardlinked_tree(fobj.path, target_path) except OSError as ex: self.notify('Failed to paste hardlinked subtree: View log for more info', diff --git a/ranger/core/filter_stack.py b/ranger/core/filter_stack.py new file mode 100644 index 00000000..2ca2b1c5 --- /dev/null +++ b/ranger/core/filter_stack.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +# This file is part of ranger, the console file manager. +# License: GNU GPL version 3, see the file "AUTHORS" for details. +# Author: Wojciech Siewierski <wojciech.siewierski@onet.pl>, 2018 + +from __future__ import (absolute_import, division, print_function) + +import re +import mimetypes + +from ranger.container.directory import accept_file, InodeFilterConstants + +# pylint: disable=too-few-public-methods + + +class BaseFilter(object): + def decompose(self): + return [self] + + +SIMPLE_FILTERS = {} +FILTER_COMBINATORS = {} + + +def stack_filter(filter_name): + def decorator(cls): + SIMPLE_FILTERS[filter_name] = cls + return cls + return decorator + + +def filter_combinator(combinator_name): + def decorator(cls): + FILTER_COMBINATORS[combinator_name] = cls + return cls + return decorator + + +@stack_filter("name") +class NameFilter(BaseFilter): + def __init__(self, pattern): + self.pattern = pattern + self.regex = re.compile(pattern) + + def __call__(self, fobj): + return self.regex.search(fobj.relative_path) + + def __str__(self): + return "<Filter: name =~ /{}/>".format(self.pattern) + + +@stack_filter("mime") +class MimeFilter(BaseFilter): + def __init__(self, pattern): + self.pattern = pattern + self.regex = re.compile(pattern) + + def __call__(self, fobj): + mimetype, _ = mimetypes.guess_type(fobj.relative_path) + if mimetype is None: + return False + return self.regex.search(mimetype) + + def __str__(self): + return "<Filter: mimetype =~ /{}/>".format(self.pattern) + + +@stack_filter("type") +class TypeFilter(BaseFilter): + type_to_function = { + InodeFilterConstants.DIRS: + (lambda fobj: fobj.is_directory), + InodeFilterConstants.FILES: + (lambda fobj: fobj.is_file and not fobj.is_link), + InodeFilterConstants.LINKS: + (lambda fobj: fobj.is_link), + } + + def __init__(self, filetype): + if filetype not in self.type_to_function: + raise KeyError(filetype) + self.filetype = filetype + + def __call__(self, fobj): + return self.type_to_function[self.filetype](fobj) + + def __str__(self): + return "<Filter: type == '{}'>".format(self.filetype) + + +@filter_combinator("or") +class OrFilter(BaseFilter): + def __init__(self, stack): + self.subfilters = [stack[-2], stack[-1]] + + stack.pop() + stack.pop() + + stack.append(self) + + def __call__(self, fobj): + # Turn logical AND (accept_file()) into a logical OR with the + # De Morgan's laws. + return not accept_file( + fobj, + ((lambda x, f=filt: not f(x)) + for filt + in self.subfilters), + ) + + def __str__(self): + return "<Filter: {}>".format(" or ".join(map(str, self.subfilters))) + + def decompose(self): + return self.subfilters + + +@filter_combinator("and") +class AndFilter(BaseFilter): + def __init__(self, stack): + self.subfilters = [stack[-2], stack[-1]] + + stack.pop() + stack.pop() + + stack.append(self) + + def __call__(self, fobj): + return accept_file(fobj, self.subfilters) + + def __str__(self): + return "<Filter: {}>".format(" and ".join(map(str, self.subfilters))) + + def decompose(self): + return self.subfilters + + +@filter_combinator("not") +class NotFilter(BaseFilter): + def __init__(self, stack): + self.subfilter = stack.pop() + stack.append(self) + + def __call__(self, fobj): + return not self.subfilter(fobj) + + def __str__(self): + return "<Filter: not {}>".format(str(self.subfilter)) + + def decompose(self): + return [self.subfilter] diff --git a/ranger/core/fm.py b/ranger/core/fm.py index c55a3922..61b3cb11 100644 --- a/ranger/core/fm.py +++ b/ranger/core/fm.py @@ -24,7 +24,9 @@ from ranger.container.bookmarks import Bookmarks from ranger.core.runner import Runner from ranger.ext.img_display import (W3MImageDisplayer, ITerm2ImageDisplayer, TerminologyImageDisplayer, - URXVTImageDisplayer, URXVTImageFSDisplayer, ImageDisplayer) + URXVTImageDisplayer, URXVTImageFSDisplayer, + KittyImageDisplayer, + ImageDisplayer) from ranger.core.metadata import MetadataManager from ranger.ext.rifle import Rifle from ranger.container.directory import Directory @@ -223,7 +225,7 @@ class FM(Actions, # pylint: disable=too-many-instance-attributes for line in entry.splitlines(): yield line - def _get_image_displayer(self): + def _get_image_displayer(self): # pylint: disable=too-many-return-statements if self.settings.preview_images_method == "w3m": return W3MImageDisplayer() elif self.settings.preview_images_method == "iterm2": @@ -234,6 +236,8 @@ class FM(Actions, # pylint: disable=too-many-instance-attributes return URXVTImageDisplayer() elif self.settings.preview_images_method == "urxvt-full": return URXVTImageFSDisplayer() + elif self.settings.preview_images_method == "kitty": + return KittyImageDisplayer() return ImageDisplayer() def _get_thisfile(self): @@ -424,5 +428,5 @@ class FM(Actions, # pylint: disable=too-many-instance-attributes if not ranger.args.clean and self.settings.save_tabs_on_exit and len(self.tabs) > 1: with open(self.datapath('tabs'), 'a') as fobj: # Don't save active tab since launching ranger changes the active tab - fobj.write('\0'.join(v.path for t, v in self.tabs.items() - if t != self.current_tab) + '\0\0') + fobj.write('\0'.join(v.path for t, v in self.tabs.items()) + + '\0\0') diff --git a/ranger/core/main.py b/ranger/core/main.py index 4adea918..598ce243 100644 --- a/ranger/core/main.py +++ b/ranger/core/main.py @@ -89,14 +89,12 @@ def main( SettingsAware.settings_set(Settings()) + # TODO: deprecate --selectfile if args.selectfile: args.selectfile = os.path.abspath(args.selectfile) args.paths.insert(0, os.path.dirname(args.selectfile)) - if args.paths: - paths = [p[7:] if p.startswith('file:///') else p for p in args.paths] - else: - paths = [os.environ.get('PWD', os.getcwd())] + paths = get_paths(args) paths_inaccessible = [] for path in paths: try: @@ -182,6 +180,7 @@ def main( fm.select_file(args.selectfile) if args.cmd: + fm.enter_dir(fm.thistab.path) for command in args.cmd: fm.execute_console(command) @@ -235,6 +234,24 @@ https://github.com/ranger/ranger/issues return exit_code # pylint: disable=lost-exception +def get_paths(args): + if args.paths: + prefix = 'file:///' + prefix_length = len(prefix) + paths = [path[prefix_length:] if path.startswith(prefix) else path for path in args.paths] + else: + start_directory = os.environ.get('PWD') + is_valid_start_directory = start_directory and os.path.exists(start_directory) + if not is_valid_start_directory: + start_directory = __get_home_directory() + paths = [start_directory] + return paths + + +def __get_home_directory(): + return os.path.expanduser('~') + + def xdg_path(env_var): path = os.environ.get(env_var) if path and os.path.isabs(path): @@ -339,23 +356,50 @@ def load_settings( # pylint: disable=too-many-locals,too-many-branches,too-many fm.commands.load_commands_from_module(commands_default) if not clean: + system_confdir = os.path.join(os.sep, 'etc', 'ranger') + if os.path.exists(system_confdir): + sys.path.append(system_confdir) allow_access_to_confdir(ranger.args.confdir, True) # Load custom commands - custom_comm_path = fm.confpath('commands.py') - if os.path.exists(custom_comm_path): + def import_file(name, path): # From https://stackoverflow.com/a/67692 + # pragma pylint: disable=no-name-in-module,import-error,no-member, deprecated-method + if sys.version_info >= (3, 5): + import importlib.util as util + spec = util.spec_from_file_location(name, path) + module = util.module_from_spec(spec) + spec.loader.exec_module(module) + elif (3, 3) <= sys.version_info < (3, 5): + from importlib.machinery import SourceFileLoader + module = SourceFileLoader(name, path).load_module() + else: + import imp + module = imp.load_source(name, path) + # pragma pylint: enable=no-name-in-module,import-error,no-member + return module + + def load_custom_commands(*paths): old_bytecode_setting = sys.dont_write_bytecode sys.dont_write_bytecode = True - try: - import commands as commands_custom - fm.commands.load_commands_from_module(commands_custom) - except ImportError as ex: - LOG.debug("Failed to import custom commands from '%s'", custom_comm_path) - LOG.exception(ex) - else: - LOG.debug("Loaded custom commands from '%s'", custom_comm_path) + for custom_comm_path in paths: + if os.path.exists(custom_comm_path): + try: + commands_custom = import_file('commands', + custom_comm_path) + fm.commands.load_commands_from_module(commands_custom) + except ImportError as ex: + LOG.debug("Failed to import custom commands from '%s'", + custom_comm_path) + LOG.exception(ex) + else: + LOG.debug("Loaded custom commands from '%s'", + custom_comm_path) sys.dont_write_bytecode = old_bytecode_setting + system_comm_path = os.path.join(system_confdir, 'commands.py') + custom_comm_path = fm.confpath('commands.py') + load_custom_commands(system_comm_path, custom_comm_path) + # XXX Load plugins (experimental) plugindir = fm.confpath('plugins') try: @@ -394,12 +438,16 @@ def load_settings( # pylint: disable=too-many-locals,too-many-branches,too-many allow_access_to_confdir(ranger.args.confdir, False) # Load rc.conf custom_conf = fm.confpath('rc.conf') + system_conf = os.path.join(system_confdir, 'rc.conf') default_conf = fm.relpath('config', 'rc.conf') custom_conf_is_readable = os.access(custom_conf, os.R_OK) - if (os.environ.get('RANGER_LOAD_DEFAULT_RC', 'TRUE').upper() != 'FALSE' or - not custom_conf_is_readable): + system_conf_is_readable = os.access(system_conf, os.R_OK) + if (os.environ.get('RANGER_LOAD_DEFAULT_RC', 'TRUE').upper() != 'FALSE' + or not (custom_conf_is_readable or system_conf_is_readable)): fm.source(default_conf) + if system_conf_is_readable: + fm.source(system_conf) if custom_conf_is_readable: fm.source(custom_conf) diff --git a/ranger/core/runner.py b/ranger/core/runner.py index bb4e512a..f38b026a 100644 --- a/ranger/core/runner.py +++ b/ranger/core/runner.py @@ -235,7 +235,7 @@ class Runner(object): # pylint: disable=too-few-public-methods self.fm.signal_emit('runner.execute.before', popen_kws=popen_kws, context=context) try: - if 'f' in context.flags: + if 'f' in context.flags and 'r' not in context.flags: # This can fail and return False if os.fork() is not # supported, but we assume it is, since curses is used. Popen_forked(**popen_kws) diff --git a/ranger/data/scope.sh b/ranger/data/scope.sh index 540a910e..13a25b45 100755 --- a/ranger/data/scope.sh +++ b/ranger/data/scope.sh @@ -60,7 +60,8 @@ handle_extension() { # PDF pdf) # Preview as text conversion - pdftotext -l 10 -nopgbrk -q -- "${FILE_PATH}" - && exit 5 + pdftotext -l 10 -nopgbrk -q -- "${FILE_PATH}" - | fmt -w ${PV_WIDTH} && exit 5 + mutool draw -F txt -i -- "${FILE_PATH}" 1-10 | fmt -w ${PV_WIDTH} && exit 5 exiftool "${FILE_PATH}" && exit 5 exit 1;; @@ -122,6 +123,43 @@ handle_image() { # -jpeg -tiffcompression jpeg \ # -- "${FILE_PATH}" "${IMAGE_CACHE_PATH%.*}" \ # && exit 6 || exit 1;; + + # Preview archives using the first image inside. + # (Very useful for comic book collections for example.) + # application/zip|application/x-rar|application/x-7z-compressed|\ + # application/x-xz|application/x-bzip2|application/x-gzip|application/x-tar) + # local fn=""; local fe="" + # local zip=""; local rar=""; local tar=""; local bsd="" + # case "${mimetype}" in + # application/zip) zip=1 ;; + # application/x-rar) rar=1 ;; + # application/x-7z-compressed) ;; + # *) tar=1 ;; + # esac + # { [ "$tar" ] && fn=$(tar --list --file "${FILE_PATH}"); } || \ + # { fn=$(bsdtar --list --file "${FILE_PATH}") && bsd=1 && tar=""; } || \ + # { [ "$rar" ] && fn=$(unrar lb -p- -- "${FILE_PATH}"); } || \ + # { [ "$zip" ] && fn=$(zipinfo -1 -- "${FILE_PATH}"); } || return + # + # fn=$(echo "$fn" | python -c "import sys; import mimetypes as m; \ + # [ print(l, end='') for l in sys.stdin if \ + # (m.guess_type(l[:-1])[0] or '').startswith('image/') ]" |\ + # sort -V | head -n 1) + # [ "$fn" = "" ] && return + # [ "$bsd" ] && fn=$(printf '%b' "$fn") + # + # [ "$tar" ] && tar --extract --to-stdout \ + # --file "${FILE_PATH}" -- "$fn" > "${IMAGE_CACHE_PATH}" && exit 6 + # fe=$(echo -n "$fn" | sed 's/[][*?\]/\\\0/g') + # [ "$bsd" ] && bsdtar --extract --to-stdout \ + # --file "${FILE_PATH}" -- "$fe" > "${IMAGE_CACHE_PATH}" && exit 6 + # [ "$bsd" ] || [ "$tar" ] && rm -- "${IMAGE_CACHE_PATH}" + # [ "$rar" ] && unrar p -p- -inul -- "${FILE_PATH}" "$fn" > \ + # "${IMAGE_CACHE_PATH}" && exit 6 + # [ "$zip" ] && unzip -pP "" -- "${FILE_PATH}" "$fe" > \ + # "${IMAGE_CACHE_PATH}" && exit 6 + # [ "$rar" ] || [ "$zip" ] && rm -- "${IMAGE_CACHE_PATH}" + # ;; esac } diff --git a/ranger/ext/direction.py b/ranger/ext/direction.py index 7df45556..33ebb604 100644 --- a/ranger/ext/direction.py +++ b/ranger/ext/direction.py @@ -97,8 +97,8 @@ class Direction(dict): return self.get('cycle') in (True, 'true', 'on', 'yes') def one_indexed(self): - return ('one_indexed' in self and - self.get('one_indexed') in (True, 'true', 'on', 'yes')) + return ('one_indexed' in self + and self.get('one_indexed') in (True, 'true', 'on', 'yes')) def multiply(self, n): for key in ('up', 'right', 'down', 'left'): diff --git a/ranger/ext/human_readable.py b/ranger/ext/human_readable.py index df74eabf..f365e594 100644 --- a/ranger/ext/human_readable.py +++ b/ranger/ext/human_readable.py @@ -15,6 +15,10 @@ def human_readable(byte, separator=' '): # pylint: disable=too-many-return-stat '1023 M' """ + # handle automatically_count_files false + if byte is None: + return '' + # I know this can be written much shorter, but this long version # performs much better than what I had before. If you attempt to # shorten this code, take performance into consideration. diff --git a/ranger/ext/img_display.py b/ranger/ext/img_display.py index 67941e27..f78e170b 100644 --- a/ranger/ext/img_display.py +++ b/ranger/ext/img_display.py @@ -19,9 +19,13 @@ import imghdr import os import struct import sys +import warnings from subprocess import Popen, PIPE import termios +from contextlib import contextmanager +import codecs +from tempfile import NamedTemporaryFile from ranger.core.shared import FileManagerAware @@ -35,6 +39,28 @@ W3MIMGDISPLAY_PATHS = [ '/usr/local/libexec/w3m/w3mimgdisplay', ] +# Helper functions shared between the previewers (make them static methods of the base class?) + + +@contextmanager +def temporarily_moved_cursor(to_y, to_x): + """Common boilerplate code to move the cursor to a drawing area. Use it as: + with temporarily_moved_cursor(dest_y, dest_x): + your_func_here()""" + curses.putp(curses.tigetstr("sc")) + move_cur(to_y, to_x) + yield + curses.putp(curses.tigetstr("rc")) + sys.stdout.flush() + + +# this is excised since Terminology needs to move the cursor multiple times +def move_cur(to_y, to_x): + tparm = curses.tparm(curses.tigetstr("cup"), to_y, to_x) + # on python2 stdout is already in binary mode, in python3 is accessed via buffer + bin_stdout = getattr(sys.stdout, 'buffer', sys.stdout) + bin_stdout.write(tparm) + class ImageDisplayError(Exception): pass @@ -60,7 +86,7 @@ class ImageDisplayer(object): pass -class W3MImageDisplayer(ImageDisplayer): +class W3MImageDisplayer(ImageDisplayer, FileManagerAware): """Implementation of ImageDisplayer using w3mimgdisplay, an utilitary program from w3m (a text-based web browser). w3mimgdisplay can display images either in virtual tty (using linux framebuffer) or in a Xorg session. @@ -119,6 +145,14 @@ class W3MImageDisplayer(ImageDisplayer): input_gen = self._generate_w3m_input(path, start_x, start_y, width, height) except ImageDisplayError: raise + + # Mitigate the issue with the horizontal black bars when + # selecting some images on some systems. 2 milliseconds seems + # enough. Adjust as necessary. + if self.fm.settings.w3m_delay > 0: + from time import sleep + sleep(self.fm.settings.w3m_delay) + self.process.stdin.write(input_gen) self.process.stdin.flush() self.process.stdout.readline() @@ -210,15 +244,8 @@ class ITerm2ImageDisplayer(ImageDisplayer, FileManagerAware): """ def draw(self, path, start_x, start_y, width, height): - curses.putp(curses.tigetstr("sc")) - tparm = curses.tparm(curses.tigetstr("cup"), start_y, start_x) - if sys.version_info[0] < 3: - sys.stdout.write(tparm) - else: - sys.stdout.buffer.write(tparm) # pylint: disable=no-member - sys.stdout.write(self._generate_iterm2_input(path, width, height)) - curses.putp(curses.tigetstr("rc")) - sys.stdout.flush() + with temporarily_moved_cursor(start_y, start_x): + sys.stdout.write(self._generate_iterm2_input(path, width, height)) def clear(self, start_x, start_y, width, height): self.fm.ui.win.redrawwin() @@ -327,44 +354,23 @@ class TerminologyImageDisplayer(ImageDisplayer, FileManagerAware): self.close_protocol = "\000" def draw(self, path, start_x, start_y, width, height): - # Save cursor - curses.putp(curses.tigetstr("sc")) - - y = start_y - # Move to drawing zone - self._move_to(start_x, y) - - # Write intent - sys.stdout.write("%s}ic#%d;%d;%s%s" % ( - self.display_protocol, - width, height, - path, - self.close_protocol)) - - # Write Replacement commands ('#') - for _ in range(0, height): - sys.stdout.write("%s}ib%s%s%s}ie%s" % ( - self.display_protocol, - self.close_protocol, - "#" * width, + with temporarily_moved_cursor(start_y, start_x): + # Write intent + sys.stdout.write("%s}ic#%d;%d;%s%s" % ( self.display_protocol, + width, height, + path, self.close_protocol)) - y = y + 1 - self._move_to(start_x, y) - - # Restore cursor - curses.putp(curses.tigetstr("rc")) - - sys.stdout.flush() - @staticmethod - def _move_to(x, y): - # curses.move(y, x) - tparm = curses.tparm(curses.tigetstr("cup"), y, x) - if sys.version_info[0] < 3: - sys.stdout.write(tparm) - else: - sys.stdout.buffer.write(tparm) # pylint: disable=no-member + # Write Replacement commands ('#') + for y in range(0, height): + move_cur(start_y + y, start_x) + sys.stdout.write("%s}ib%s%s%s}ie%s\n" % ( # needs a newline to work + self.display_protocol, + self.close_protocol, + "#" * width, + self.display_protocol, + self.close_protocol)) def clear(self, start_x, start_y, width, height): self.fm.ui.win.redrawwin() @@ -434,20 +440,20 @@ class URXVTImageDisplayer(ImageDisplayer, FileManagerAware): pct_width, pct_height = self._get_sizes() sys.stdout.write( - self.display_protocol + - path + - ";{pct_width}x{pct_height}+{pct_x}+{pct_y}:op=keep-aspect".format( + self.display_protocol + + path + + ";{pct_width}x{pct_height}+{pct_x}+{pct_y}:op=keep-aspect".format( pct_width=pct_width, pct_height=pct_height, pct_x=pct_x, pct_y=pct_y - ) + - self.close_protocol + ) + + self.close_protocol ) sys.stdout.flush() def clear(self, start_x, start_y, width, height): sys.stdout.write( - self.display_protocol + - ";100x100+1000+1000" + - self.close_protocol + self.display_protocol + + ";100x100+1000+1000" + + self.close_protocol ) sys.stdout.flush() @@ -465,3 +471,196 @@ class URXVTImageFSDisplayer(URXVTImageDisplayer): def _get_offsets(self): """Center the image.""" return self._get_centered_offsets() + + +class KittyImageDisplayer(ImageDisplayer): + """Implementation of ImageDisplayer for kitty (https://github.com/kovidgoyal/kitty/) + terminal. It uses the built APC to send commands and data to kitty, + which in turn renders the image. The APC takes the form + '\033_Gk=v,k=v...;bbbbbbbbbbbbbb\033\\' + | ---------- -------------- | + escape code | | escape code + | base64 encoded payload + key: value pairs as parameters + For more info please head over to : + https://github.com/kovidgoyal/kitty/blob/master/graphics-protocol.asciidoc""" + protocol_start = b'\x1b_G' + protocol_end = b'\x1b\\' + # we are going to use stdio in binary mode a lot, so due to py2 -> py3 + # differnces is worth to do this: + stdbout = getattr(sys.stdout, 'buffer', sys.stdout) + stdbin = getattr(sys.stdin, 'buffer', sys.stdin) + # counter for image ids on kitty's end + image_id = 0 + # we need to find out the encoding for a path string, ascii won't cut it + try: + fsenc = sys.getfilesystemencoding() # returns None if standard utf-8 is used + # throws LookupError if can't find the codec, TypeError if fsenc is None + codecs.lookup(fsenc) + except (LookupError, TypeError): + fsenc = 'utf-8' + + def __init__(self): + # the rest of the initializations that require reading stdio or raising exceptions + # are delayed to the first draw call, since curses + # and ranger exception handler are not online at __init__() time + self.needs_late_init = True + # to init in _late_init() + self.backend = None + self.stream = None + self.pix_row, self.pix_col = (0, 0) + + def _late_init(self): + # tmux + if 'kitty' not in os.environ['TERM']: + # this doesn't seem to work, ranger freezes... + # commenting out the response check does nothing + # self.protocol_start = b'\033Ptmux;\033' + self.protocol_start + # self.protocol_end += b'\033\\' + raise ImgDisplayUnsupportedException( + 'kitty previews only work in' + + ' kitty and outside tmux. ' + + 'Make sure your TERM contains the string "kitty"') + + # automatic check if we share the filesystem using a dummy file + with NamedTemporaryFile() as tmpf: + tmpf.write(bytearray([0xFF] * 3)) + tmpf.flush() + for cmd in self._format_cmd_str( + {'a': 'q', 'i': 1, 'f': 24, 't': 'f', 's': 1, 'v': 1, 'S': 3}, + payload=base64.standard_b64encode(tmpf.name.encode(self.fsenc))): + self.stdbout.write(cmd) + sys.stdout.flush() + resp = b'' + while resp[-2:] != self.protocol_end: + resp += self.stdbin.read(1) + # set the transfer method based on the response + # if resp.find(b'OK') != -1: + if b'OK' in resp: + self.stream = False + elif b'EBADF' in resp: + self.stream = True + else: + raise ImgDisplayUnsupportedException( + 'kitty replied an unexpected response: {}'.format(resp)) + + # get the image manipulation backend + try: + # pillow is the default since we are not going + # to spawn other processes, so it _should_ be faster + import PIL.Image + self.backend = PIL.Image + except ImportError: + raise ImageDisplayError("Image previews in kitty require PIL (pillow)") + # TODO: implement a wrapper class for Imagemagick process to + # replicate the functionality we use from im + + # get dimensions of a cell in pixels + ret = fcntl.ioctl(sys.stdout, termios.TIOCGWINSZ, + struct.pack('HHHH', 0, 0, 0, 0)) + n_cols, n_rows, x_px_tot, y_px_tot = struct.unpack('HHHH', ret) + self.pix_row, self.pix_col = x_px_tot // n_rows, y_px_tot // n_cols + self.needs_late_init = False + + def draw(self, path, start_x, start_y, width, height): + self.image_id += 1 + # dictionary to store the command arguments for kitty + # a is the display command, with T going for immediate output + # i is the id entifier for the image + cmds = {'a': 'T', 'i': self.image_id} + # sys.stderr.write('{}-{}@{}x{}\t'.format(start_x, start_y, width, height)) + + # finish initialization if it is the first call + if self.needs_late_init: + self._late_init() + + with warnings.catch_warnings(record=True): # as warn: + warnings.simplefilter('ignore', self.backend.DecompressionBombWarning) + image = self.backend.open(path) + # TODO: find a way to send a message to the user that + # doesn't stop the image from displaying + # if warn: + # raise ImageDisplayError(str(warn[-1].message)) + box = (width * self.pix_row, height * self.pix_col) + + if image.width > box[0] or image.height > box[1]: + scale = min(box[0] / image.width, box[1] / image.height) + image = image.resize((int(scale * image.width), int(scale * image.height)), + self.backend.LANCZOS) + + # start_x += ((box[0] - image.width) // 2) // self.pix_row + # start_y += ((box[1] - image.height) // 2) // self.pix_col + if self.stream: + # encode the whole image as base64 + # TODO: implement z compression + # to possibly increase resolution in sent image + if image.mode != 'RGB' and image.mode != 'RGBA': + image = image.convert('RGB') + # t: transmissium medium, 'd' for embedded + # f: size of a pixel fragment (8bytes per color) + # s, v: size of the image to recompose the flattened data + # c, r: size in cells of the viewbox + cmds.update({'t': 'd', 'f': len(image.getbands()) * 8, + 's': image.width, 'v': image.height, }) + payload = base64.standard_b64encode( + bytearray().join(map(bytes, image.getdata()))) + else: + # put the image in a temporary png file + # t: transmissium medium, 't' for temporary file (kitty will delete it for us) + # f: size of a pixel fragment (100 just mean that the file is png encoded, + # the only format except raw RGB(A) bitmap that kitty understand) + # c, r: size in cells of the viewbox + cmds.update({'t': 't', 'f': 100, }) + with NamedTemporaryFile(prefix='ranger_thumb_', suffix='.png', delete=False) as tmpf: + image.save(tmpf, format='png', compress_level=0) + payload = base64.standard_b64encode(tmpf.name.encode(self.fsenc)) + + with temporarily_moved_cursor(int(start_y), int(start_x)): + for cmd_str in self._format_cmd_str(cmds, payload=payload): + self.stdbout.write(cmd_str) + # catch kitty answer before the escape codes corrupt the console + resp = b'' + while resp[-2:] != self.protocol_end: + resp += self.stdbin.read(1) + if b'OK' in resp: + return + else: + raise ImageDisplayError('kitty replied "{}"'.format(resp)) + + def clear(self, start_x, start_y, width, height): + # let's assume that every time ranger call this + # it actually wants just to remove the previous image + # TODO: implement this using the actual x, y, since the protocol supports it + cmds = {'a': 'd', 'i': self.image_id} + for cmd_str in self._format_cmd_str(cmds): + self.stdbout.write(cmd_str) + self.stdbout.flush() + # kitty doesn't seem to reply on deletes, checking like we do in draw() + # will slows down scrolling with timeouts from select + self.image_id -= 1 + + def _format_cmd_str(self, cmd, payload=None, max_slice_len=2048): + central_blk = ','.join(["{}={}".format(k, v) for k, v in cmd.items()]).encode('ascii') + if payload is not None: + # we add the m key to signal a multiframe communication + # appending the end (m=0) key to a single message has no effect + while len(payload) > max_slice_len: + payload_blk, payload = payload[:max_slice_len], payload[max_slice_len:] + yield self.protocol_start + \ + central_blk + b',m=1;' + payload_blk + \ + self.protocol_end + yield self.protocol_start + \ + central_blk + b',m=0;' + payload + \ + self.protocol_end + else: + yield self.protocol_start + central_blk + b';' + self.protocol_end + + def quit(self): + # clear all remaining images, then check if all files went through or are orphaned + while self.image_id >= 1: + self.clear(0, 0, 0, 0) + # for k in self.temp_paths: + # try: + # os.remove(self.temp_paths[k]) + # except (OSError, IOError): + # continue diff --git a/ranger/ext/rifle.py b/ranger/ext/rifle.py index 70215039..6832e2cf 100755 --- a/ranger/ext/rifle.py +++ b/ranger/ext/rifle.py @@ -261,6 +261,14 @@ class Rifle(object): # pylint: disable=too-many-instance-attributes process = Popen(["file", "--mime-type", "-Lb", fname], stdout=PIPE, stderr=PIPE) mimetype, _ = process.communicate() self._mimetype = mimetype.decode(ENCODING).strip() + if self._mimetype == 'application/octet-stream': + try: + process = Popen(["mimetype", "--output-format", "%m", fname], + stdout=PIPE, stderr=PIPE) + mimetype, _ = process.communicate() + self._mimetype = mimetype.decode(ENCODING).strip() + except OSError: + pass return self._mimetype def _build_command(self, files, action, flags): @@ -349,29 +357,80 @@ class Rifle(object): # pylint: disable=too-many-instance-attributes self.hook_before_executing(command, self._mimetype, self._app_flags) try: if 'r' in flags: - prefix = ['sudo', '-E', 'su', '-mc'] + prefix = ['sudo', '-E', 'su', 'root', '-mc'] else: prefix = ['/bin/sh', '-c'] cmd = prefix + [command] if 't' in flags: - if 'TERMCMD' not in os.environ: - term = os.environ['TERM'] - if term.startswith('rxvt-unicode'): + term = os.environ.get('TERMCMD', os.environ['TERM']) + + # Handle aliases of xterm and urxvt, rxvt and st and + # termite + # Match 'xterm', 'xterm-256color' + if term in ['xterm', 'xterm-256color']: + term = 'xterm' + if term in ['xterm-kitty']: + term = 'kitty' + if term in ['xterm-termite']: + term = 'termite' + if term in ['st', 'st-256color']: + term = 'st' + if term in ['urxvt', 'rxvt-unicode', + 'rxvt-unicode-256color']: + term = 'urxvt' + if term in ['rxvt', 'rxvt-256color']: + if 'rxvt' in get_executables(): + term = 'rxvt' + else: term = 'urxvt' - elif term.startswith('rxvt-'): - # Sometimes urxvt calls itself "rxvt-256color" - if 'rxvt' in get_executables(): - term = 'rxvt' - else: - term = 'urxvt' - if term not in get_executables(): - self.hook_logger("Can not determine terminal command. " - "Please set $TERMCMD manually.") - # A fallback terminal that is likely installed: - term = 'xterm' - os.environ['TERMCMD'] = term - cmd = [os.environ['TERMCMD'], '-e'] + cmd + + if term not in get_executables(): + self.hook_logger("Can not determine terminal command, " + "using rifle to determine fallback. " + "Please set $TERMCMD manually or " + "change fallbacks in rifle.conf.") + self._mimetype = 'ranger/x-terminal-emulator' + self.execute( + files=[command.split(';')[1].split('--')[0].strip()] + + files, flags='f', + mimetype='ranger/x-terminal-emulator') + return None + + # Choose correct cmdflag accordingly + if term in ['xfce4-terminal', 'mate-terminal', + 'terminator']: + cmdflag = '-x' + elif term in ['xterm', 'urxvt', 'rxvt', 'lxterminal', + 'konsole', 'lilyterm', 'cool-retro-term', + 'terminology', 'pantheon-terminal', 'termite', + 'st', 'stterm']: + cmdflag = '-e' + elif term in ['gnome-terminal', 'kitty']: + cmdflag = '--' + elif term in ['tilda', ]: + cmdflag = '-c' + else: + cmdflag = '-e' + + os.environ['TERMCMD'] = term + + # These terms don't work with the '/bin/sh set --' scheme. + # A temporary fix. + if term in ['tilda', 'pantheon-terminal', 'terminology', + 'termite']: + + target = command.split(';')[0].split('--')[1].strip() + app = command.split(';')[1].split('--')[0].strip() + cmd = [os.environ['TERMCMD'], cmdflag, '%s %s' + % (app, target)] + elif term in ['guake']: + cmd = [os.environ['TERMCMD'], '-n', '${PWD}', cmdflag] + cmd + else: + cmd = [os.environ['TERMCMD'], cmdflag] + cmd + + # self.hook_logger('cmd: %s' %cmd) + if 'f' in flags or 't' in flags: Popen_forked(cmd, env=self.hook_environment(os.environ)) else: diff --git a/ranger/gui/color.py b/ranger/gui/color.py index 45f983e8..8f6439c7 100644 --- a/ranger/gui/color.py +++ b/ranger/gui/color.py @@ -66,6 +66,11 @@ blink = curses.A_BLINK reverse = curses.A_REVERSE underline = curses.A_UNDERLINE invisible = curses.A_INVIS +dim = curses.A_DIM default_colors = (default, default, normal) # pylint: enable=invalid-name,bad-whitespace + +curses.setupterm() +# Adding BRIGHT to a color achieves what `bold` was used for. +BRIGHT = 8 if curses.tigetnum('colors') >= 16 else 0 diff --git a/ranger/gui/context.py b/ranger/gui/context.py index d8d1957c..96849686 100644 --- a/ranger/gui/context.py +++ b/ranger/gui/context.py @@ -23,7 +23,7 @@ CONTEXT_KEYS = [ 'keybuffer', 'infostring', 'vcsfile', 'vcsremote', 'vcsinfo', 'vcscommit', 'vcsdate', - 'vcsconflict', 'vcschanged', 'vcsunknown', 'vcsignored', + 'vcsconflict', 'vcschanged', 'vcsunknown', 'vcsignored', 'vcsuntracked', 'vcsstaged', 'vcssync', 'vcsnone', 'vcsbehind', 'vcsahead', 'vcsdiverged' ] diff --git a/ranger/gui/curses_shortcuts.py b/ranger/gui/curses_shortcuts.py index ac067d01..14f1e0e4 100644 --- a/ranger/gui/curses_shortcuts.py +++ b/ranger/gui/curses_shortcuts.py @@ -35,7 +35,9 @@ class CursesShortcuts(SettingsAware): try: self.win.addstr(*args) - except (curses.error, TypeError): + except (curses.error, TypeError, ValueError): + # a TypeError changed to ValueError from version 3.5 onwards + # https://bugs.python.org/issue22215 if len(args) > 1: self.win.move(y, x) diff --git a/ranger/gui/ui.py b/ranger/gui/ui.py index 990db0ad..441e9032 100644 --- a/ranger/gui/ui.py +++ b/ranger/gui/ui.py @@ -113,7 +113,7 @@ class UI( # pylint: disable=too-many-instance-attributes,too-many-public-method self._draw_title = curses.tigetflag('hs') # has_status_line # Save tmux setting `automatic-rename` - if self.settings.update_tmux_title: + if self.settings.update_tmux_title and 'TMUX' in os.environ: try: self._tmux_automatic_rename = check_output( ['tmux', 'show-window-options', '-v', 'automatic-rename']).strip() @@ -123,7 +123,7 @@ class UI( # pylint: disable=too-many-instance-attributes,too-many-public-method self.update_size() self.is_on = True - if self.settings.update_tmux_title: + if self.settings.update_tmux_title and 'TMUX' in os.environ: sys.stdout.write("\033kranger\033\\") sys.stdout.flush() @@ -172,7 +172,7 @@ class UI( # pylint: disable=too-many-instance-attributes,too-many-public-method DisplayableContainer.destroy(self) # Restore tmux setting `automatic-rename` - if self.settings.update_tmux_title: + if self.settings.update_tmux_title and 'TMUX' in os.environ: if self._tmux_automatic_rename: try: check_output(['tmux', 'set-window-option', @@ -365,7 +365,9 @@ class UI( # pylint: disable=too-many-instance-attributes,too-many-public-method DisplayableContainer.draw(self) if self._draw_title and self.settings.update_title: cwd = self.fm.thisdir.path - if cwd.startswith(self.fm.home_path): + if self.settings.tilde_in_titlebar \ + and (cwd == self.fm.home_path + or cwd.startswith(self.fm.home_path + "/")): cwd = '~' + cwd[len(self.fm.home_path):] if self.settings.shorten_title: split = cwd.rsplit(os.sep, self.settings.shorten_title) diff --git a/ranger/gui/widgets/__init__.py b/ranger/gui/widgets/__init__.py index 36292103..c8f1262b 100644 --- a/ranger/gui/widgets/__init__.py +++ b/ranger/gui/widgets/__init__.py @@ -12,11 +12,11 @@ class Widget(Displayable): 'conflict': ( 'X', ['vcsconflict']), 'untracked': ( - '+', ['vcschanged']), + '?', ['vcsuntracked']), 'deleted': ( '-', ['vcschanged']), 'changed': ( - '*', ['vcschanged']), + '+', ['vcschanged']), 'staged': ( '*', ['vcsstaged']), 'ignored': ( @@ -26,7 +26,7 @@ class Widget(Displayable): 'none': ( ' ', []), 'unknown': ( - '?', ['vcsunknown']), + '!', ['vcsunknown']), } vcsremotestatus_symb = { @@ -41,7 +41,7 @@ class Widget(Displayable): 'none': ( '⌂', ['vcsnone']), 'unknown': ( - '?', ['vcsunknown']), + '!', ['vcsunknown']), } ellipsis = {False: '~', True: '…'} diff --git a/ranger/gui/widgets/browsercolumn.py b/ranger/gui/widgets/browsercolumn.py index b3272cbc..bc6f7b1b 100644 --- a/ranger/gui/widgets/browsercolumn.py +++ b/ranger/gui/widgets/browsercolumn.py @@ -10,6 +10,12 @@ import stat from time import time from os.path import splitext +try: + from bidi.algorithm import get_display # pylint: disable=import-error + HAVE_BIDI = True +except ImportError: + HAVE_BIDI = False + from ranger.ext.widestring import WideString from ranger.core import linemode @@ -183,12 +189,12 @@ class BrowserColumn(Pager): # pylint: disable=too-many-instance-attributes def _draw_file(self): """Draw a preview of the file, if the settings allow it""" self.win.move(0, 0) - if not self.target.accessible: - self.addnstr("not accessible", self.wid) + if self.target is None or not self.target.has_preview(): Pager.close(self) return - if self.target is None or not self.target.has_preview(): + if not self.target.accessible: + self.addnstr("not accessible", self.wid) Pager.close(self) return @@ -206,7 +212,7 @@ class BrowserColumn(Pager): # pylint: disable=too-many-instance-attributes line_number = i if self.settings.line_numbers == 'relative': line_number = abs(selected_i - i) - if line_number == 0: + if not self.settings.relative_current_zero and line_number == 0: if self.settings.one_indexed: line_number = selected_i + 1 else: @@ -318,8 +324,8 @@ class BrowserColumn(Pager): # pylint: disable=too-many-instance-attributes text = current_linemode.filetitle(drawn, metadata) - if drawn.marked and (self.main_column or - self.settings.display_tags_in_all_columns): + if drawn.marked and (self.main_column + or self.settings.display_tags_in_all_columns): text = " " + text # Computing predisplay data. predisplay contains a list of lists @@ -410,9 +416,15 @@ class BrowserColumn(Pager): # pylint: disable=too-many-instance-attributes def _total_len(predisplay): return sum([len(WideString(s)) for s, _ in predisplay]) + def _bidi_transpose(self, text): + if self.settings.bidi_support and HAVE_BIDI: + return get_display(text) + return text + def _draw_text_display(self, text, space): - wtext = WideString(text) - wext = WideString(splitext(text)[1]) + bidi_text = self._bidi_transpose(text) + wtext = WideString(bidi_text) + wext = WideString(splitext(bidi_text)[1]) wellip = WideString(self.ellipsis[self.settings.unicode_ellipsis]) if len(wtext) > space: wtext = wtext[:max(1, space - len(wext) - len(wellip))] + wellip + wext diff --git a/ranger/gui/widgets/pager.py b/ranger/gui/widgets/pager.py index 42adf1e9..d64d4ac1 100644 --- a/ranger/gui/widgets/pager.py +++ b/ranger/gui/widgets/pager.py @@ -109,8 +109,9 @@ class Pager(Widget): # pylint: disable=too-many-instance-attributes try: self.fm.image_displayer.draw(self.image, self.x, self.y, self.wid, self.hei) - except ImgDisplayUnsupportedException: + except ImgDisplayUnsupportedException as ex: self.fm.settings.preview_images = False + self.fm.notify(ex, bad=True) except Exception as ex: # pylint: disable=broad-except self.fm.notify(ex, bad=True) else: @@ -233,7 +234,7 @@ class Pager(Widget): # pylint: disable=too-many-instance-attributes def _generate_lines(self, starty, startx): i = starty if not self.source: - raise StopIteration + return while True: try: line = self._get_line(i).expandtabs(4) @@ -243,5 +244,5 @@ class Pager(Widget): # pylint: disable=too-many-instance-attributes line = line[startx:self.wid + startx] yield line.rstrip().replace('\r\n', '\n') except IndexError: - raise StopIteration + return i += 1 diff --git a/ranger/gui/widgets/statusbar.py b/ranger/gui/widgets/statusbar.py index 266d48ca..3457955e 100644 --- a/ranger/gui/widgets/statusbar.py +++ b/ranger/gui/widgets/statusbar.py @@ -275,13 +275,14 @@ class StatusBar(Widget): # pylint: disable=too-many-instance-attributes right.add("/" + str(len(target.marked_items))) else: right.add(human_readable(target.disk_usage, separator='') + " sum") - try: - free = get_free_space(target.mount_path) - except OSError: - pass - else: - right.add(", ", "space") - right.add(human_readable(free, separator='') + " free") + if self.settings.display_free_space_in_status_bar: + try: + free = get_free_space(target.mount_path) + except OSError: + pass + else: + right.add(", ", "space") + right.add(human_readable(free, separator='') + " free") right.add(" ", "space") if target.marked_items: diff --git a/ranger/gui/widgets/titlebar.py b/ranger/gui/widgets/titlebar.py index 042b4b04..765c1248 100644 --- a/ranger/gui/widgets/titlebar.py +++ b/ranger/gui/widgets/titlebar.py @@ -102,8 +102,9 @@ class TitleBar(Widget): bar.add(' ', 'hostname', clr, fixed=True) pathway = self.fm.thistab.pathway - if self.settings.tilde_in_titlebar and \ - self.fm.thisdir.path.startswith(self.fm.home_path): + if self.settings.tilde_in_titlebar \ + and (self.fm.thisdir.path.startswith(self.fm.home_path + "/") + or self.fm.thisdir.path == self.fm.home_path): pathway = pathway[self.fm.home_path.count('/') + 1:] bar.add('~/', 'directory', fixed=True) diff --git a/ranger/gui/widgets/view_base.py b/ranger/gui/widgets/view_base.py index cb205d92..4493443e 100644 --- a/ranger/gui/widgets/view_base.py +++ b/ranger/gui/widgets/view_base.py @@ -72,8 +72,8 @@ class ViewBase(Widget, DisplayableContainer): # pylint: disable=too-many-instan sorted_bookmarks = sorted( ( item for item in self.fm.bookmarks - if self.fm.settings.show_hidden_bookmarks or - '/.' not in item[1].path + if self.fm.settings.show_hidden_bookmarks + or '/.' not in item[1].path ), key=lambda t: t[0].lower(), ) @@ -112,16 +112,62 @@ class ViewBase(Widget, DisplayableContainer): # pylint: disable=too-many-instan self.color_reset() self.need_clear = True hints = [] - for key, value in self.fm.ui.keybuffer.pointer.items(): - key = key_to_string(key) - if isinstance(value, dict): - text = '...' - else: - text = value - if text.startswith('hint') or text.startswith('chain hint'): - continue - hints.append((key, text)) - hints.sort(key=lambda t: t[1]) + + def populate_hints(keymap, prefix=""): + for key, value in keymap.items(): + key = prefix + key_to_string(key) + if isinstance(value, dict): + populate_hints(value, key) + else: + text = value + if text.startswith('hint') or text.startswith('chain hint'): + continue + hints.append((key, text)) + populate_hints(self.fm.ui.keybuffer.pointer) + + def sort_hints(hints): + """Sort the hints by the action string but first group them by the + first key. + + """ + from itertools import groupby + + # groupby needs the list to be sorted. + hints.sort(key=lambda t: t[0]) + + def group_hints(hints): + def first_key(hint): + return hint[0][0] + + def action_string(hint): + return hint[1] + + return (sorted(group, key=action_string) + for _, group + in groupby( + hints, + key=first_key)) + + grouped_hints = group_hints(hints) + + # If there are too many hints, collapse the sublists. + if len(hints) > self.fm.settings.hint_collapse_threshold: + def first_key_in_group(group): + return group[0][0][0] + grouped_hints = ( + [(first_key_in_group(hint_group), "...")] + if len(hint_group) > 1 + else hint_group + for hint_group in grouped_hints + ) + + # Sort by the first action in group. + grouped_hints = sorted(grouped_hints, key=lambda g: g[0][1]) + + def flatten(nested_list): + return [item for inner_list in nested_list for item in inner_list] + return flatten(grouped_hints) + hints = sort_hints(hints) hei = min(self.hei - 1, len(hints)) ystart = self.hei - hei diff --git a/ranger/gui/widgets/view_miller.py b/ranger/gui/widgets/view_miller.py index e138ee4b..55d401a0 100644 --- a/ranger/gui/widgets/view_miller.py +++ b/ranger/gui/widgets/view_miller.py @@ -99,7 +99,12 @@ class ViewMiller(ViewBase): # pylint: disable=too-many-ancestors,too-many-insta directory.use() DisplayableContainer.draw(self) if self.settings.draw_borders: - self._draw_borders() + draw_borders = self.settings.draw_borders.lower() + if draw_borders in ['both', 'true']: # 'true' for backwards compat. + border_types = ['separators', 'outline'] + else: + border_types = [draw_borders] + self._draw_borders(border_types) if self.draw_bookmarks: self._draw_bookmarks() elif self.draw_hints: @@ -107,8 +112,9 @@ class ViewMiller(ViewBase): # pylint: disable=too-many-ancestors,too-many-insta elif self.draw_info: self._draw_info(self.draw_info) - def _draw_borders(self): + def _draw_borders(self, border_types): # pylint: disable=too-many-branches win = self.win + self.color('in_browser', 'border') left_start = 0 @@ -129,49 +135,57 @@ class ViewMiller(ViewBase): # pylint: disable=too-many-ancestors,too-many-insta right_end = self.wid - 1 # Draw horizontal lines and the leftmost vertical line - try: - # pylint: disable=no-member - win.hline(0, left_start, curses.ACS_HLINE, right_end - left_start) - win.hline(self.hei - 1, left_start, curses.ACS_HLINE, right_end - left_start) - win.vline(1, left_start, curses.ACS_VLINE, self.hei - 2) - # pylint: enable=no-member - except curses.error: - pass + if 'outline' in border_types: + try: + # pylint: disable=no-member + win.hline(0, left_start, curses.ACS_HLINE, right_end - left_start) + win.hline(self.hei - 1, left_start, curses.ACS_HLINE, right_end - left_start) + win.vline(1, left_start, curses.ACS_VLINE, self.hei - 2) + # pylint: enable=no-member + except curses.error: + pass # Draw the vertical lines in the middle - for child in self.columns[:-1]: - if not child.has_preview(): - continue - if child.main_column and self.pager.visible: - # If we "zoom in" with the pager, we have to - # skip the between main_column and pager. - break - x = child.x + child.wid - y = self.hei - 1 + if 'separators' in border_types: + for child in self.columns[:-1]: + if not child.has_preview(): + continue + if child.main_column and self.pager.visible: + # If we "zoom in" with the pager, we have to + # skip the between main_column and pager. + break + x = child.x + child.wid + y = self.hei - 1 + try: + # pylint: disable=no-member + win.vline(1, x, curses.ACS_VLINE, y - 1) + if 'outline' in border_types: + self.addch(0, x, curses.ACS_TTEE, 0) + self.addch(y, x, curses.ACS_BTEE, 0) + else: + self.addch(0, x, curses.ACS_VLINE, 0) + self.addch(y, x, curses.ACS_VLINE, 0) + # pylint: enable=no-member + except curses.error: + # in case it's off the boundaries + pass + + if 'outline' in border_types: + # Draw the last vertical line try: # pylint: disable=no-member - win.vline(1, x, curses.ACS_VLINE, y - 1) - self.addch(0, x, curses.ACS_TTEE, 0) - self.addch(y, x, curses.ACS_BTEE, 0) + win.vline(1, right_end, curses.ACS_VLINE, self.hei - 2) # pylint: enable=no-member except curses.error: - # in case it's off the boundaries pass - # Draw the last vertical line - try: + if 'outline' in border_types: # pylint: disable=no-member - win.vline(1, right_end, curses.ACS_VLINE, self.hei - 2) + self.addch(0, left_start, curses.ACS_ULCORNER) + self.addch(self.hei - 1, left_start, curses.ACS_LLCORNER) + self.addch(0, right_end, curses.ACS_URCORNER) + self.addch(self.hei - 1, right_end, curses.ACS_LRCORNER) # pylint: enable=no-member - except curses.error: - pass - - # pylint: disable=no-member - self.addch(0, left_start, curses.ACS_ULCORNER) - self.addch(self.hei - 1, left_start, curses.ACS_LLCORNER) - self.addch(0, right_end, curses.ACS_URCORNER) - self.addch(self.hei - 1, right_end, curses.ACS_LRCORNER) - # pylint: enable=no-member def _collapse(self): # Should the last column be cut off? (Because there is no preview) @@ -195,10 +209,12 @@ class ViewMiller(ViewBase): # pylint: disable=too-many-ancestors,too-many-insta """Resize all the columns according to the given ratio""" ViewBase.resize(self, y, x, hei, wid) - borders = self.settings.draw_borders - pad = 1 if borders else 0 + border_type = self.settings.draw_borders.lower() + if border_type in ['outline', 'both', 'true']: + pad = 1 + else: + pad = 0 left = pad - self.is_collapsed = self._collapse() if self.is_collapsed: generator = enumerate(self.stretch_ratios) |