diff options
Diffstat (limited to 'ranger')
55 files changed, 1428 insertions, 692 deletions
diff --git a/ranger/__init__.py b/ranger/__init__.py index fabaeae9..cc388d60 100644 --- a/ranger/__init__.py +++ b/ranger/__init__.py @@ -11,6 +11,7 @@ program you want to use to open your files with. from __future__ import (absolute_import, division, print_function) import os +from sys import version_info # Version helper @@ -21,21 +22,23 @@ def version_helper(): import subprocess version_string = 'ranger-master {0}' try: - git_describe = subprocess.Popen(['git', 'describe'], - universal_newlines=True, - cwd=RANGERDIR, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - (git_description, _) = git_describe.communicate() + with subprocess.Popen( + ["git", "describe"], + universal_newlines=True, + cwd=RANGERDIR, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) as git_describe: + (git_description, _) = git_describe.communicate() version_string = version_string.format(git_description.strip('\n')) - except (OSError, subprocess.CalledProcessError): + except (OSError, subprocess.CalledProcessError, AttributeError): version_string = version_string.format(__version__) return version_string # Information __license__ = 'GPL3' -__version__ = '1.9.2' +__version__ = '1.9.3' __release__ = False __author__ = __maintainer__ = 'Roman Zimbelmann' __email__ = 'hut@hut.pm' @@ -50,6 +53,7 @@ MACRO_DELIMITER_ESC = '%%' DEFAULT_PAGER = 'less' USAGE = '%prog [options] [path]' VERSION = version_helper() +PY3 = version_info[0] >= 3 # These variables are ignored if the corresponding # XDG environment variable is non-empty and absolute diff --git a/ranger/api/commands.py b/ranger/api/commands.py index 90a79488..7b76203b 100644 --- a/ranger/api/commands.py +++ b/ranger/api/commands.py @@ -287,7 +287,7 @@ class Command(FileManagerAware): if dn.startswith(rel_basename)] except (OSError, StopIteration): # os.walk found nothing - pass + return None else: dirnames.sort() @@ -353,7 +353,7 @@ class Command(FileManagerAware): if name.startswith(rel_basename)]) except (OSError, StopIteration): # os.walk found nothing - pass + return None else: # no results, return None if not names: @@ -395,7 +395,8 @@ def command_function_factory(func): class CommandFunction(Command): __doc__ = func.__doc__ - def execute(self): # pylint: disable=too-many-branches + def execute(self): + # pylint: disable=too-many-branches,too-many-return-statements if not func: return None if len(self.args) == 1: @@ -404,7 +405,7 @@ def command_function_factory(func): except TypeError: return func() - args, kwargs = list(), dict() + args, kwargs = [], {} for arg in self.args[1:]: equal_sign = arg.find("=") value = arg if equal_sign == -1 else arg[equal_sign + 1:] @@ -442,6 +443,8 @@ def command_function_factory(func): self.fm.notify("Bad arguments for %s: %s, %s" % (func.__name__, args, kwargs), bad=True) + return None + CommandFunction.__name__ = func.__name__ return CommandFunction diff --git a/ranger/colorschemes/default.py b/ranger/colorschemes/default.py index 3d50e2e4..86d2fbba 100644 --- a/ranger/colorschemes/default.py +++ b/ranger/colorschemes/default.py @@ -64,6 +64,9 @@ class Default(ColorScheme): else: fg = red fg += BRIGHT + if context.line_number and not context.selected: + fg = default + attr &= ~bold if not context.selected and (context.cut or context.copied): attr |= bold fg = black diff --git a/ranger/colorschemes/jungle.py b/ranger/colorschemes/jungle.py index a1fd768d..c11ce64a 100644 --- a/ranger/colorschemes/jungle.py +++ b/ranger/colorschemes/jungle.py @@ -4,7 +4,7 @@ from __future__ import (absolute_import, division, print_function) from ranger.colorschemes.default import Default -from ranger.gui.color import green, red, blue +from ranger.gui.color import green, red, blue, bold class Scheme(Default): @@ -17,6 +17,10 @@ class Scheme(Default): and not context.inactive_pane: fg = self.progress_bar_color + if context.line_number and not context.selected: + fg = self.progress_bar_color + attr &= ~bold + if context.in_titlebar and context.hostname: fg = red if context.bad else blue diff --git a/ranger/colorschemes/snow.py b/ranger/colorschemes/snow.py index c5f23c1c..354c2a47 100644 --- a/ranger/colorschemes/snow.py +++ b/ranger/colorschemes/snow.py @@ -21,6 +21,9 @@ class Snow(ColorScheme): if context.directory: attr |= bold fg += BRIGHT + if context.line_number and not context.selected: + attr |= bold + fg += BRIGHT elif context.highlight: attr |= reverse diff --git a/ranger/config/.pylintrc b/ranger/config/.pylintrc index 7cac80d1..509c6f31 100644 --- a/ranger/config/.pylintrc +++ b/ranger/config/.pylintrc @@ -5,4 +5,4 @@ class-rgx=[a-z][a-z0-9_]{1,30}$ [FORMAT] max-line-length = 99 max-module-lines=3000 -disable=locally-disabled,locally-enabled,missing-docstring,duplicate-code,fixme +disable=consider-using-f-string,duplicate-code,fixme,import-outside-toplevel,locally-disabled,locally-enabled,missing-docstring,no-else-return,super-with-arguments diff --git a/ranger/config/commands.py b/ranger/config/commands.py index 5defa677..43c4993f 100755 --- a/ranger/config/commands.py +++ b/ranger/config/commands.py @@ -94,7 +94,9 @@ from __future__ import (absolute_import, division, print_function) from collections import deque import os import re +from io import open +from ranger import PY3 from ranger.api.commands import Command @@ -337,7 +339,7 @@ class open_with(Command): def execute(self): app, flags, mode = self._get_app_flags_mode(self.rest(1)) self.fm.execute_file( - files=[f for f in self.fm.thistab.get_selection()], + files=self.fm.thistab.get_selection(), app=app, flags=flags, mode=mode) @@ -480,36 +482,106 @@ class set_(Command): return None -class setlocal(set_): - """:setlocal path=<regular expression> <option name>=<python expression> +class setlocal_(set_): + """Shared class for setinpath and setinregex - Gives an option a new value. + By implementing the _arg abstract propery you can affect what the name of + the pattern/path/regex argument can be, this should be a regular expression + without match groups. + + By implementing the _format_arg abstract method you can affect how the + argument is formatted as a regular expression. """ - PATH_RE_DQUOTED = re.compile(r'^setlocal\s+path="(.*?)"') - PATH_RE_SQUOTED = re.compile(r"^setlocal\s+path='(.*?)'") - PATH_RE_UNQUOTED = re.compile(r'^path=(.*?)$') + from abc import (ABCMeta, abstractmethod, abstractproperty) + + __metaclass__ = ABCMeta + + @property + @abstractmethod + def _arg(self): + """The name of the option for the path/regex""" + raise NotImplementedError + + def __init__(self, *args, **kwargs): + super(setlocal_, self).__init__(*args, **kwargs) + # We require quoting of paths with whitespace so we have to take care + # not to match escaped quotes. + self.path_re_dquoted = re.compile( + r'^set.+?\s+{arg}="(.*?[^\\])"'.format(arg=self._arg) + ) + self.path_re_squoted = re.compile( + r"^set.+?\s+{arg}='(.*?[^\\])'".format(arg=self._arg) + ) + self.path_re_unquoted = re.compile( + r'^{arg}=(.+?)$'.format(arg=self._arg) + ) def _re_shift(self, match): if not match: return None - path = os.path.expanduser(match.group(1)) - for _ in range(len(path.split())): + path = match.group(1) + # Prepend something that behaves like "path=" in case path starts with + # whitespace + for _ in "={0}".format(path).split(): self.shift() - return path + return os.path.expanduser(path) + + @abstractmethod + def _format_arg(self, arg): + """How to format the argument as a regular expression""" + raise NotImplementedError def execute(self): - path = self._re_shift(self.PATH_RE_DQUOTED.match(self.line)) - if path is None: - path = self._re_shift(self.PATH_RE_SQUOTED.match(self.line)) - if path is None: - path = self._re_shift(self.PATH_RE_UNQUOTED.match(self.arg(1))) - if path is None and self.fm.thisdir: - path = self.fm.thisdir.path - if not path: + arg = self._re_shift(self.path_re_dquoted.match(self.line)) + if arg is None: + arg = self._re_shift(self.path_re_squoted.match(self.line)) + if arg is None: + arg = self._re_shift(self.path_re_unquoted.match(self.arg(1))) + if arg is None and self.fm.thisdir: + arg = self.fm.thisdir.path + if arg is None: return + else: + arg = self._format_arg(arg) name, value, _ = self.parse_setting_line() - self.fm.set_option_from_string(name, value, localpath=path) + self.fm.set_option_from_string(name, value, localpath=arg) + + +class setinpath(setlocal_): + """:setinpath path=<path> <option name>=<python expression> + + Sets an option when in a directory that matches <path>, relative paths can + match multiple directories, for example, ``path=build`` would match a build + directory in any directory. If the <path> contains whitespace it needs to + be quoted and nested quotes need to be backslash-escaped. The "path" + argument can also be named "pattern" to allow for easier switching with + ``setinregex``. + """ + _arg = "(?:path|pattern)" + + def _format_arg(self, arg): + return "{0}$".format(re.escape(arg)) + + +class setlocal(setinpath): + """:setlocal is an alias for :setinpath""" + + +class setinregex(setlocal_): + """:setinregex re=<regex> <option name>=<python expression> + + Sets an option when in a specific directory. If the <regex> contains + whitespace it needs to be quoted and nested quotes need to be + backslash-escaped. Special characters need to be escaped if they are + intended to match literally as documented in the ``re`` library + documentation. The "re" argument can also be named "regex" or "pattern," + which allows for easier switching with ``setinpath``. + """ + _arg = "(?:re(?:gex)?|pattern)" + + def _format_arg(self, arg): + return arg class setintag(set_): @@ -697,7 +769,7 @@ class delete(Command): return self._tab_directory_content() def _question_callback(self, files, answer): - if answer == 'y' or answer == 'Y': + if answer.lower() == 'y': self.fm.delete(files) @@ -727,8 +799,11 @@ class trash(Command): return os.path.isdir(path) and not os.path.islink(path) and len(os.listdir(path)) > 0 if self.rest(1): - files = shlex.split(self.rest(1)) - many_files = (len(files) > 1 or is_directory_with_files(files[0])) + file_names = shlex.split(self.rest(1)) + files = self.fm.get_filesystem_objects(file_names) + if files is None: + return + many_files = (len(files) > 1 or is_directory_with_files(files[0].path)) else: cwd = self.fm.thisdir tfile = self.fm.thisfile @@ -736,27 +811,42 @@ class trash(Command): self.fm.notify("Error: no file selected for deletion!", bad=True) return + files = self.fm.thistab.get_selection() # relative_path used for a user-friendly output in the confirmation. - files = [f.relative_path for f in self.fm.thistab.get_selection()] + file_names = [f.relative_path for f in files] many_files = (cwd.marked_items or is_directory_with_files(tfile.path)) confirm = self.fm.settings.confirm_on_delete if confirm != 'never' and (confirm != 'multiple' or many_files): self.fm.ui.console.ask( - "Confirm deletion of: %s (y/N)" % ', '.join(files), + "Confirm deletion of: %s (y/N)" % ', '.join(file_names), partial(self._question_callback, files), ('n', 'N', 'y', 'Y'), ) else: # no need for a confirmation, just delete - self.fm.execute_file(files, label='trash') + self._trash_files_catch_arg_list_error(files) def tab(self, tabnum): return self._tab_directory_content() def _question_callback(self, files, answer): - if answer == 'y' or answer == 'Y': + if answer.lower() == 'y': + self._trash_files_catch_arg_list_error(files) + + def _trash_files_catch_arg_list_error(self, files): + """ + Executes the fm.execute_file method but catches the OSError ("Argument list too long") + that occurs when moving too many files to trash (and would otherwise crash ranger). + """ + try: self.fm.execute_file(files, label='trash') + except OSError as err: + if err.errno == 7: + self.fm.notify("Error: Command too long (try passing less files at once)", + bad=True) + else: + raise class jump_non(Command): @@ -827,21 +917,31 @@ class mark_tag(Command): class console(Command): - """:console <command> + """:console [-p N | -s sep] <command> + Flags: + -p N Set position at N index + -s sep Set position at separator(any char[s] sequence), example '#' Open the console with the given command. """ def execute(self): position = None + command = self.rest(1) if self.arg(1)[0:2] == '-p': + command = self.rest(2) try: position = int(self.arg(1)[2:]) except ValueError: pass - else: - self.shift() - self.fm.open_console(self.rest(1), position=position) + elif self.arg(1)[0:2] == '-s': + command = self.rest(3) + sentinel = self.arg(2) + pos = command.find(sentinel) + if pos != -1: + command = command.replace(sentinel, '', 1) + position = pos + self.fm.open_console(command, position=position) class load_copy_buffer(Command): @@ -852,20 +952,19 @@ class load_copy_buffer(Command): copy_buffer_filename = 'copy_buffer' def execute(self): - import sys - from ranger.container.file import File from os.path import exists + from ranger.container.file import File fname = self.fm.datapath(self.copy_buffer_filename) - unreadable = IOError if sys.version_info[0] < 3 else OSError + unreadable = OSError if PY3 else IOError try: - fobj = open(fname, 'r') + with open(fname, "r", encoding="utf-8") as fobj: + self.fm.copy_buffer = set( + File(g) for g in fobj.read().split("\n") if exists(g) + ) except unreadable: return self.fm.notify( "Cannot open %s" % (fname or self.copy_buffer_filename), bad=True) - self.fm.copy_buffer = set(File(g) - for g in fobj.read().split("\n") if exists(g)) - fobj.close() self.fm.ui.redraw_main_column() return None @@ -878,17 +977,15 @@ class save_copy_buffer(Command): copy_buffer_filename = 'copy_buffer' def execute(self): - import sys fname = None fname = self.fm.datapath(self.copy_buffer_filename) - unwritable = IOError if sys.version_info[0] < 3 else OSError + unwritable = OSError if PY3 else IOError try: - fobj = open(fname, 'w') + with open(fname, "w", encoding="utf-8") as fobj: + fobj.write("\n".join(fobj.path for fobj in self.fm.copy_buffer)) except unwritable: return self.fm.notify("Cannot open %s" % (fname or self.copy_buffer_filename), bad=True) - fobj.write("\n".join(fobj.path for fobj in self.fm.copy_buffer)) - fobj.close() return None @@ -928,11 +1025,16 @@ class touch(Command): """ def execute(self): - from os.path import join, expanduser, lexists + from os.path import join, expanduser, lexists, dirname + from os import makedirs fname = join(self.fm.thisdir.path, expanduser(self.rest(1))) + dirname = dirname(fname) if not lexists(fname): - open(fname, 'a').close() + if not lexists(dirname): + makedirs(dirname) + with open(fname, 'a', encoding="utf-8"): + pass # Just create the file else: self.fm.notify("file/directory exists!", bad=True) @@ -1124,26 +1226,31 @@ class bulkrename(Command): After you close it, it will be executed. """ + def __init__(self, *args, **kwargs): + super(bulkrename, self).__init__(*args, **kwargs) + self.flags, _ = self.parse_flags() + if not self.flags: + self.flags = "w" + def execute(self): # pylint: disable=too-many-locals,too-many-statements,too-many-branches - import sys import tempfile from ranger.container.file import File from ranger.ext.shell_escape import shell_escape as esc - py3 = sys.version_info[0] >= 3 # Create and edit the file list filenames = [f.relative_path for f in self.fm.thistab.get_selection()] with tempfile.NamedTemporaryFile(delete=False) as listfile: listpath = listfile.name - if py3: + if PY3: listfile.write("\n".join(filenames).encode( encoding="utf-8", errors="surrogateescape")) else: listfile.write("\n".join(filenames)) self.fm.execute_file([File(listpath)], app='editor') - with (open(listpath, 'r', encoding="utf-8", errors="surrogateescape") if - py3 else open(listpath, 'r')) as listfile: + with open( + listpath, "r", encoding="utf-8", errors="surrogateescape" + ) as listfile: new_filenames = listfile.read().split("\n") os.unlink(listpath) if all(a == b for a, b in zip(filenames, new_filenames)): @@ -1170,7 +1277,7 @@ class bulkrename(Command): old=esc(old), new=esc(new))) # Make sure not to forget the ending newline script_content = "\n".join(script_lines) + "\n" - if py3: + if PY3: cmdfile.write(script_content.encode(encoding="utf-8", errors="surrogateescape")) else: @@ -1184,7 +1291,7 @@ class bulkrename(Command): script_was_edited = (script_content != cmdfile.read()) # Do the renaming - self.fm.run(['/bin/sh', cmdfile.name], flags='w') + self.fm.run(['/bin/sh', cmdfile.name], flags=self.flags) # Retag the files, but only if the script wasn't changed during review, # because only then we know which are the source and destination files. @@ -1458,22 +1565,21 @@ class scout(Command): Multiple flags can be combined. For example, ":scout -gpt" would create a :filter-like command using globbing. """ - # pylint: disable=bad-whitespace - AUTO_OPEN = 'a' - OPEN_ON_ENTER = 'e' - FILTER = 'f' - SM_GLOB = 'g' - IGNORE_CASE = 'i' - KEEP_OPEN = 'k' - SM_LETTERSKIP = 'l' - MARK = 'm' - UNMARK = 'M' - PERM_FILTER = 'p' - SM_REGEX = 'r' - SMART_CASE = 's' - AS_YOU_TYPE = 't' - INVERT = 'v' - # pylint: enable=bad-whitespace + + AUTO_OPEN = "a" + OPEN_ON_ENTER = "e" + FILTER = "f" + SM_GLOB = "g" + IGNORE_CASE = "i" + KEEP_OPEN = "k" + SM_LETTERSKIP = "l" + MARK = "m" + UNMARK = "M" + PERM_FILTER = "p" + SM_REGEX = "r" + SMART_CASE = "s" + AS_YOU_TYPE = "t" + INVERT = "v" def __init__(self, *args, **kwargs): super(scout, self).__init__(*args, **kwargs) @@ -1704,7 +1810,7 @@ class filter_stack(Command): return else: self.fm.notify( - "Unknown subcommand: {}".format(subcommand), + "Unknown subcommand: {sub}".format(sub=subcommand), bad=True ) return @@ -1720,7 +1826,7 @@ class grep(Command): def execute(self): if self.rest(1): - action = ['grep', '--line-number'] + action = ['grep', '-n'] action.extend(['-e', self.rest(1), '-r']) action.extend(f.path for f in self.fm.thistab.get_selection()) self.fm.execute_command(action, flags='p') @@ -1852,7 +1958,7 @@ class meta(prompt_metadata): def execute(self): key = self.arg(1) - update_dict = dict() + update_dict = {} update_dict[key] = self.rest(2) selection = self.fm.thistab.get_selection() for fobj in selection: @@ -1897,7 +2003,7 @@ class linemode(default_linemode): class yank(Command): - """:yank [name|dir|path] + """:yank [name|dir|path|name_without_extension] Copies the file's name (default), directory or path into both the primary X selection and the clipboard. @@ -1932,7 +2038,7 @@ class yank(Command): ['pbcopy'], ], } - ordered_managers = ['pbcopy', 'wl-copy', 'xclip', 'xsel'] + ordered_managers = ['pbcopy', 'xclip', 'xsel', 'wl-copy'] executables = get_executables() for manager in ordered_managers: if manager in executables: @@ -1946,9 +2052,10 @@ class yank(Command): new_clipboard_contents = "\n".join(selection) for command in clipboard_commands: - process = subprocess.Popen(command, universal_newlines=True, - stdin=subprocess.PIPE) - process.communicate(input=new_clipboard_contents) + with subprocess.Popen( + command, universal_newlines=True, stdin=subprocess.PIPE + ) as process: + process.communicate(input=new_clipboard_contents) def get_selection_attr(self, attr): return [getattr(item, attr) for item in diff --git a/ranger/config/commands_sample.py b/ranger/config/commands_sample.py index 97b79096..20317566 100644 --- a/ranger/config/commands_sample.py +++ b/ranger/config/commands_sample.py @@ -48,7 +48,7 @@ class my_edit(Command): self.fm.notify("The given file does not exist!", bad=True) return - # This executes a function from ranger.core.acitons, a module with a + # This executes a function from ranger.core.actions, a module with a # variety of subroutines that can help you construct commands. # Check out the source, or run "pydoc ranger.core.actions" for a list. self.fm.edit_file(target_filename) diff --git a/ranger/config/rc.conf b/ranger/config/rc.conf index 9d08a6a7..e2dfc29f 100644 --- a/ranger/config/rc.conf +++ b/ranger/config/rc.conf @@ -25,7 +25,6 @@ # multipane: Midnight-commander like multipane view showing all tabs next # to each other set viewmode miller -#set viewmode multipane # How many columns are there, and what are their relative widths? set column_ratios 1,3,4 @@ -334,6 +333,7 @@ alias qall! quitall! alias setl setlocal alias filter scout -prts +alias hide scout -prtsv alias find scout -aets alias mark scout -mr alias unmark scout -Mr @@ -467,8 +467,8 @@ map g? cd /usr/share/doc/ranger # External Programs map E edit -map du shell -p du --max-depth=1 -h --apparent-size -map dU shell -p du --max-depth=1 -h --apparent-size | sort -rh +map du shell -p (du --max-depth=1 --human-readable --apparent-size || du -d 1 -h) 2>/dev/null +map dU shell -p (du --max-depth=1 --human-readable --apparent-size || du -d 1 -h) 2>/dev/null | sort -rh map yp yank path map yd yank dir map yn yank name @@ -620,20 +620,20 @@ map m<any> set_bookmark %any map um<any> unset_bookmark %any map m<bg> draw_bookmarks -copymap m<bg> um<bg> `<bg> '<bg> +copymap m<bg> um<bg> `<bg> '<bg> p`<bg> p'<bg> # Generate all the chmod bindings with some python help: eval for arg in "rwxXst": cmd("map +u{0} shell -f chmod u+{0} %s".format(arg)) eval for arg in "rwxXst": cmd("map +g{0} shell -f chmod g+{0} %s".format(arg)) eval for arg in "rwxXst": cmd("map +o{0} shell -f chmod o+{0} %s".format(arg)) eval for arg in "rwxXst": cmd("map +a{0} shell -f chmod a+{0} %s".format(arg)) -eval for arg in "rwxXst": cmd("map +{0} shell -f chmod u+{0} %s".format(arg)) +eval for arg in "rwxXst": cmd("map +{0} shell -f chmod +{0} %s".format(arg)) eval for arg in "rwxXst": cmd("map -u{0} shell -f chmod u-{0} %s".format(arg)) eval for arg in "rwxXst": cmd("map -g{0} shell -f chmod g-{0} %s".format(arg)) eval for arg in "rwxXst": cmd("map -o{0} shell -f chmod o-{0} %s".format(arg)) eval for arg in "rwxXst": cmd("map -a{0} shell -f chmod a-{0} %s".format(arg)) -eval for arg in "rwxXst": cmd("map -{0} shell -f chmod u-{0} %s".format(arg)) +eval for arg in "rwxXst": cmd("map -{0} shell -f chmod -{0} %s".format(arg)) # =================================================================== # == Define keys for the console @@ -671,6 +671,8 @@ cmap <A-d> eval fm.ui.console.delete_word(backward=False) cmap <C-k> eval fm.ui.console.delete_rest(1) cmap <C-u> eval fm.ui.console.delete_rest(-1) cmap <C-y> eval fm.ui.console.paste() +cmap <C-t> eval fm.ui.console.transpose_chars() +cmap <A-t> eval fm.ui.console.transpose_words() # And of course the emacs way copycmap <ESC> <C-g> diff --git a/ranger/config/rifle.conf b/ranger/config/rifle.conf index 86f53fd1..432b2281 100644 --- a/ranger/config/rifle.conf +++ b/ranger/config/rifle.conf @@ -86,15 +86,15 @@ ext x?html?, has w3m, terminal = w3m "$@" #------------------------------------------- # Define the "editor" for text files as first action mime ^text, label editor = ${VISUAL:-$EDITOR} -- "$@" -mime ^text, label pager = "$PAGER" -- "$@" -!mime ^text, label editor, ext xml|json|csv|tex|py|pl|rb|js|sh|php = ${VISUAL:-$EDITOR} -- "$@" -!mime ^text, label pager, ext xml|json|csv|tex|py|pl|rb|js|sh|php = "$PAGER" -- "$@" +mime ^text, label pager = $PAGER -- "$@" +!mime ^text, label editor, ext xml|json|csv|tex|py|pl|rb|rs|js|sh|php|dart = ${VISUAL:-$EDITOR} -- "$@" +!mime ^text, label pager, ext xml|json|csv|tex|py|pl|rb|rs|js|sh|php|dart = $PAGER -- "$@" ext 1 = man "$1" ext s[wmf]c, has zsnes, X = zsnes "$1" ext s[wmf]c, has snes9x-gtk,X = snes9x-gtk "$1" ext nes, has fceux, X = fceux "$1" -ext exe = wine "$1" +ext exe, has wine = wine "$1" name ^[mM]akefile$ = make #-------------------------------------------- @@ -106,6 +106,7 @@ ext rb = ruby -- "$1" ext js = node -- "$1" ext sh = sh -- "$1" ext php = php -- "$1" +ext dart = dart run -- "$1" #-------------------------------------------- # Audio without X @@ -118,17 +119,19 @@ ext midi?, terminal, has wildmidi = wildmidi -- "$@" #-------------------------------------------- # Video/Audio with a GUI #------------------------------------------- -mime ^video|audio, has gmplayer, X, flag f = gmplayer -- "$@" -mime ^video|audio, has smplayer, X, flag f = smplayer "$@" -mime ^video, has mpv, X, flag f = mpv -- "$@" -mime ^video, has mpv, X, flag f = mpv --fs -- "$@" -mime ^video, has mplayer2, X, flag f = mplayer2 -- "$@" -mime ^video, has mplayer2, X, flag f = mplayer2 -fs -- "$@" -mime ^video, has mplayer, X, flag f = mplayer -- "$@" -mime ^video, has mplayer, X, flag f = mplayer -fs -- "$@" -mime ^video|audio, has vlc, X, flag f = vlc -- "$@" -mime ^video|audio, has totem, X, flag f = totem -- "$@" -mime ^video|audio, has totem, X, flag f = totem --fullscreen -- "$@" +mime ^video|^audio, has gmplayer, X, flag f = gmplayer -- "$@" +mime ^video|^audio, has smplayer, X, flag f = smplayer "$@" +mime ^video, has mpv, X, flag f = mpv -- "$@" +mime ^video, has mpv, X, flag f = mpv --fs -- "$@" +mime ^video, has mplayer2, X, flag f = mplayer2 -- "$@" +mime ^video, has mplayer2, X, flag f = mplayer2 -fs -- "$@" +mime ^video, has mplayer, X, flag f = mplayer -- "$@" +mime ^video, has mplayer, X, flag f = mplayer -fs -- "$@" +mime ^video|^audio, has vlc, X, flag f = vlc -- "$@" +mime ^video|^audio, has totem, X, flag f = totem -- "$@" +mime ^video|^audio, has totem, X, flag f = totem --fullscreen -- "$@" +mime ^audio, has audacity, X, flag f = audacity -- "$@" +ext aup, has audacity, X, flag f = audacity -- "$@" #-------------------------------------------- # Video without X @@ -153,7 +156,8 @@ ext pdf, has epdfview, X, flag f = epdfview -- "$@" ext pdf, has qpdfview, X, flag f = qpdfview "$@" ext pdf, has open, X, flag f = open "$@" -ext docx?, has catdoc, terminal = catdoc -- "$@" | "$PAGER" +ext sc, has sc, = sc -- "$@" +ext docx?, has catdoc, terminal = catdoc -- "$@" | $PAGER ext sxc|xlsx?|xlt|xlw|gnm|gnumeric, has gnumeric, X, flag f = gnumeric -- "$@" ext sxc|xlsx?|xlt|xlw|gnm|gnumeric, has kspread, X, flag f = kspread -- "$@" @@ -171,19 +175,25 @@ ext epub, has zathura, X, flag f = zathura -- "$@" ext epub, has mupdf, X, flag f = mupdf -- "$@" ext mobi, has ebook-viewer, X, flag f = ebook-viewer -- "$@" -ext cbr, has zathura, X, flag f = zathura -- "$@" -ext cbz, has zathura, X, flag f = zathura -- "$@" +ext cb[rz], has qcomicbook, X, flag f = qcomicbook "$@" +ext cb[rz], has mcomix, X, flag f = mcomix -- "$@" +ext cb[rz], has zathura, X, flag f = zathura -- "$@" +ext cb[rz], has atril, X, flag f = atril -- "$@" + +ext sla, has scribus, X, flag f = scribus -- "$@" #------------------------------------------- # Images #------------------------------------------- +mime ^image, has viewnior, X, flag f = viewnior -- "$@" + mime ^image/svg, has inkscape, X, flag f = inkscape -- "$@" mime ^image/svg, has display, X, flag f = display -- "$@" mime ^image, has imv, X, flag f = imv -- "$@" mime ^image, has pqiv, X, flag f = pqiv -- "$@" mime ^image, has sxiv, X, flag f = sxiv -- "$@" -mime ^image, has feh, X, flag f = feh -- "$@" +mime ^image, has feh, X, flag f, !ext gif = feh -- "$@" mime ^image, has mirage, X, flag f = mirage -- "$@" mime ^image, has ristretto, X, flag f = ristretto "$@" mime ^image, has eog, X, flag f = eog -- "$@" @@ -192,7 +202,10 @@ mime ^image, has nomacs, X, flag f = nomacs -- "$@" mime ^image, has geeqie, X, flag f = geeqie -- "$@" mime ^image, has gpicview, X, flag f = gpicview -- "$@" mime ^image, has gwenview, X, flag f = gwenview -- "$@" +mime ^image, has mcomix, X, flag f = mcomix -- "$@" mime ^image, has gimp, X, flag f = gimp -- "$@" +mime ^image, has krita, X, flag f = krita -- "$@" +ext kra, has krita, X, flag f = krita -- "$@" ext xcf, X, flag f = gimp -- "$@" #------------------------------------------- @@ -200,15 +213,15 @@ ext xcf, X, flag f = gimp -- "$@" #------------------------------------------- # avoid password prompt by providing empty password -ext 7z, has 7z = 7z -p l "$@" | "$PAGER" +ext 7z, has 7z = 7z -p l "$@" | $PAGER # This requires atool -ext ace|ar|arc|bz2?|cab|cpio|cpt|deb|dgc|dmg|gz, has atool = atool --list --each -- "$@" | "$PAGER" -ext iso|jar|msi|pkg|rar|shar|tar|tgz|xar|xpi|xz|zip, has atool = atool --list --each -- "$@" | "$PAGER" +ext ace|ar|arc|bz2?|cab|cpio|cpt|deb|dgc|dmg|gz, has atool = atool --list --each -- "$@" | $PAGER +ext iso|jar|msi|pkg|rar|shar|tar|tgz|xar|xpi|xz|zip, has atool = atool --list --each -- "$@" | $PAGER ext 7z|ace|ar|arc|bz2?|cab|cpio|cpt|deb|dgc|dmg|gz, has atool = atool --extract --each -- "$@" ext iso|jar|msi|pkg|rar|shar|tar|tgz|xar|xpi|xz|zip, has atool = atool --extract --each -- "$@" # Listing and extracting archives without atool: -ext tar|gz|bz2|xz, has tar = tar vvtf "$1" | "$PAGER" +ext tar|gz|bz2|xz, has tar = tar vvtf "$1" | $PAGER ext tar|gz|bz2|xz, has tar = for file in "$@"; do tar vvxf "$file"; done ext bz2, has bzip2 = for file in "$@"; do bzip2 -dk "$file"; done ext zip, has unzip = unzip -l "$1" | less @@ -217,6 +230,9 @@ ext ace, has unace = unace l "$1" | less ext ace, has unace = for file in "$@"; do unace e "$file"; done ext rar, has unrar = unrar l "$1" | less ext rar, has unrar = for file in "$@"; do unrar x "$file"; done +ext rar|zip, has qcomicbook, X, flag f = qcomicbook "$@" +ext rar|zip, has mcomix, X, flag f = mcomix -- "$@" +ext rar|zip, has zathura, X, flag f = zathura -- "$@" #------------------------------------------- # Fonts @@ -262,13 +278,13 @@ label wallpaper, number 14, mime ^image, has feh, X = feh --bg-fill "$1" #------------------------------------------- # Generic file openers #------------------------------------------- -label open, has xdg-open = xdg-open -- "$@" +label open, has xdg-open = xdg-open "$@" label open, has open = open -- "$@" # Define the editor for non-text files + pager as last action - !mime ^text, !ext xml|json|csv|tex|py|pl|rb|js|sh|php = ask -label editor, !mime ^text, !ext xml|json|csv|tex|py|pl|rb|js|sh|php = ${VISUAL:-$EDITOR} -- "$@" -label pager, !mime ^text, !ext xml|json|csv|tex|py|pl|rb|js|sh|php = "$PAGER" -- "$@" + !mime ^text, !ext xml|json|csv|tex|py|pl|rb|rs|js|sh|php|dart = ask +label editor, !mime ^text, !ext xml|json|csv|tex|py|pl|rb|rs|js|sh|php|dart = ${VISUAL:-$EDITOR} -- "$@" +label pager, !mime ^text, !ext xml|json|csv|tex|py|pl|rb|rs|js|sh|php|dart = $PAGER -- "$@" ###################################################################### @@ -281,4 +297,4 @@ mime application/x-executable = "$1" # Move the file to trash using trash-cli. label trash, has trash-put = trash-put -- "$@" -label trash = mkdir -p -- ${XDG_DATA_DIR:-$HOME/.ranger}/ranger-trash; mv -- "$@" ${XDG_DATA_DIR:-$HOME/.ranger}/ranger-trash +label trash = mkdir -p -- "${XDG_DATA_HOME:-$HOME/.local/share}/ranger/trash"; mv -- "$@" "${XDG_DATA_HOME:-$HOME/.local/share}/ranger/trash" diff --git a/ranger/container/bookmarks.py b/ranger/container/bookmarks.py index 7b5e56b7..293bb01a 100644 --- a/ranger/container/bookmarks.py +++ b/ranger/container/bookmarks.py @@ -6,7 +6,9 @@ from __future__ import (absolute_import, division, print_function) import string import re import os +from io import open +from ranger import PY3 from ranger.core.shared import FileManagerAware ALLOWED_KEYS = string.ascii_letters + string.digits + "`'" @@ -94,7 +96,7 @@ class Bookmarks(FileManagerAware): else: raise KeyError("Cannot open bookmark: `%s'!" % key) else: - raise KeyError("Nonexistant Bookmark: `%s'!" % key) + raise KeyError("Nonexistent Bookmark: `%s'!" % key) def __setitem__(self, key, value): """Bookmark <value> to the key <key>. @@ -168,22 +170,25 @@ class Bookmarks(FileManagerAware): def save(self): """Save the bookmarks to the bookmarkfile. - This is done automatically after every modification if autosave is True.""" + This is done automatically after every modification if autosave is True. + """ self.update() if self.path is None: return path_new = self.path + '.new' try: - fobj = open(path_new, 'w') + with open(path_new, 'w', encoding="utf-8") as fobj: + for key, value in self.dct.items(): + if key in ALLOWED_KEYS \ + and key not in self.nonpersistent_bookmarks: + key_value = "{0}:{1}\n".format(key, value) + if not PY3 and isinstance(key_value, str): + key_value = key_value.decode("utf-8") + fobj.write(key_value) except OSError as ex: self.fm.notify('Bookmarks error: {0}'.format(str(ex)), bad=True) return - for key, value in self.dct.items(): - if isinstance(key, str) and key in ALLOWED_KEYS \ - and key not in self.nonpersistent_bookmarks: - fobj.write("{0}:{1}\n".format(str(key), str(value))) - fobj.close() try: old_perms = os.stat(self.path) @@ -218,24 +223,24 @@ class Bookmarks(FileManagerAware): if not os.path.exists(self.path): try: - with open(self.path, 'w') as fobj: + with open(self.path, 'w', encoding="utf-8") as fobj: pass except OSError as ex: self.fm.notify('Bookmarks error: {0}'.format(str(ex)), bad=True) return None try: - fobj = open(self.path, 'r') + with open(self.path, 'r', encoding="utf-8") as fobj: + dct = {} + for line in fobj: + if self.load_pattern.match(line): + key, value = line[0], line[2:-1] + if key in ALLOWED_KEYS: + dct[key] = self.bookmarktype(value) except OSError as ex: self.fm.notify('Bookmarks error: {0}'.format(str(ex)), bad=True) return None - dct = {} - for line in fobj: - if self.load_pattern.match(line): - key, value = line[0], line[2:-1] - if key in ALLOWED_KEYS: - dct[key] = self.bookmarktype(value) - fobj.close() + return dct def _set_dict(self, dct, original): diff --git a/ranger/container/directory.py b/ranger/container/directory.py index 81dabb24..bcbfc4e6 100644 --- a/ranger/container/directory.py +++ b/ranger/container/directory.py @@ -74,7 +74,7 @@ def accept_file(fobj, filters): def walklevel(some_dir, level): some_dir = some_dir.rstrip(os.path.sep) - followlinks = True if level > 0 else False + followlinks = level > 0 assert os.path.isdir(some_dir) num_sep = some_dir.count(os.path.sep) for root, dirs, files in os.walk(some_dir, followlinks=followlinks): @@ -254,7 +254,7 @@ class Directory( # pylint: disable=too-many-instance-attributes,too-many-public def refilter(self): if self.files_all is None: - return # propably not loaded yet + return # probably not loaded yet self.last_update_time = time() @@ -508,6 +508,7 @@ class Directory( # pylint: disable=too-many-instance-attributes,too-many-public def sort(self): """Sort the contained files""" + # pylint: disable=comparison-with-callable if self.files_all is None: return diff --git a/ranger/container/file.py b/ranger/container/file.py index 6450cfe6..4cc29887 100644 --- a/ranger/container/file.py +++ b/ranger/container/file.py @@ -4,13 +4,13 @@ from __future__ import (absolute_import, division, print_function) import re -import sys +from ranger import PY3 from ranger.container.fsobject import FileSystemObject N_FIRST_BYTES = 256 CONTROL_CHARACTERS = set(list(range(0, 9)) + list(range(14, 32))) -if sys.version_info[0] < 3: +if not PY3: CONTROL_CHARACTERS = set(chr(n) for n in CONTROL_CHARACTERS) # Don't even try to preview files which match this regular expression: @@ -86,7 +86,7 @@ class File(FileSystemObject): return True if PREVIEW_BLACKLIST.search(self.basename): return False - if self.path == '/dev/core' or self.path == '/proc/kcore': + if self.path in ('/dev/core', '/proc/kcore'): return False if self.is_binary(): return False diff --git a/ranger/container/fsobject.py b/ranger/container/fsobject.py index 7de889bf..51eb5376 100644 --- a/ranger/container/fsobject.py +++ b/ranger/container/fsobject.py @@ -296,7 +296,7 @@ class FileSystemObject( # pylint: disable=too-many-instance-attributes,too-many if self.is_link: new_stat = self.preload[0] self.preload = None - self.exists = True if new_stat else False + self.exists = bool(new_stat) else: try: new_stat = lstat(path) @@ -309,11 +309,11 @@ class FileSystemObject( # pylint: disable=too-many-instance-attributes,too-many # Set some attributes - self.accessible = True if new_stat else False + self.accessible = bool(new_stat) mode = new_stat.st_mode if new_stat else 0 fmt = mode & 0o170000 - if fmt == 0o020000 or fmt == 0o060000: # stat.S_IFCHR/BLK + if fmt in (0o020000, 0o060000): # stat.S_IFCHR/BLK self.is_device = True self.size = 0 self.infostring = 'dev' diff --git a/ranger/container/history.py b/ranger/container/history.py index 9361e373..93a5b1fe 100644 --- a/ranger/container/history.py +++ b/ranger/container/history.py @@ -108,17 +108,11 @@ class History(object): raise HistoryEmptyException def back(self): - self.index -= 1 - if self.index < 0: - self.index = 0 + self.index = max(0, self.index - 1) return self.current() def move(self, n): - self.index += n - if self.index > len(self.history) - 1: - self.index = len(self.history) - 1 - if self.index < 0: - self.index = 0 + self.index = max(0, min(len(self.history) - 1, self.index + n)) return self.current() def search(self, string, n): diff --git a/ranger/container/settings.py b/ranger/container/settings.py index 7549325a..80250d4f 100644 --- a/ranger/container/settings.py +++ b/ranger/container/settings.py @@ -14,13 +14,11 @@ from ranger.gui.colorscheme import _colorscheme_name_to_class # Use these priority constants to trigger events at specific points in time # during processing of the signals "setopt" and "setopt.<some_setting_name>" -# pylint: disable=bad-whitespace -SIGNAL_PRIORITY_RAW = 2.0 # signal.value will be raw -SIGNAL_PRIORITY_SANITIZE = 1.0 # (Internal) post-processing signal.value -SIGNAL_PRIORITY_BETWEEN = 0.6 # sanitized signal.value, old fm.settings.XYZ -SIGNAL_PRIORITY_SYNC = 0.2 # (Internal) updating fm.settings.XYZ +SIGNAL_PRIORITY_RAW = 2.0 # signal.value will be raw +SIGNAL_PRIORITY_SANITIZE = 1.0 # (Internal) post-processing signal.value +SIGNAL_PRIORITY_BETWEEN = 0.6 # sanitized signal.value, old fm.settings.XYZ +SIGNAL_PRIORITY_SYNC = 0.2 # (Internal) updating fm.settings.XYZ SIGNAL_PRIORITY_AFTER_SYNC = 0.1 # after fm.settings.XYZ was updated -# pylint: enable=bad-whitespace ALLOWED_SETTINGS = { @@ -42,6 +40,7 @@ ALLOWED_SETTINGS = { "display_free_space_in_status_bar": bool, 'display_tags_in_all_columns': bool, 'draw_borders': str, + 'draw_borders_multipane': str, 'draw_progress_bar_in_status_bar': bool, 'flushinput': bool, 'freeze_files': bool, @@ -107,6 +106,8 @@ ALLOWED_VALUES = { 'cd_tab_case': ['sensitive', 'insensitive', 'smart'], 'confirm_on_delete': ['multiple', 'always', 'never'], 'draw_borders': ['none', 'both', 'outline', 'separators'], + 'draw_borders_multipane': [None, 'none', 'both', 'outline', + 'separators', 'active-pane'], 'line_numbers': ['false', 'absolute', 'relative'], 'nested_ranger_warning': ['true', 'false', 'error'], 'one_indexed': [False, True], @@ -135,10 +136,10 @@ class Settings(SignalDispatcher, FileManagerAware): def __init__(self): SignalDispatcher.__init__(self) - self.__dict__['_localsettings'] = dict() - self.__dict__['_localregexes'] = dict() - self.__dict__['_tagsettings'] = dict() - self.__dict__['_settings'] = dict() + self.__dict__['_localsettings'] = {} + self.__dict__['_localregexes'] = {} + self.__dict__['_tagsettings'] = {} + self.__dict__['_settings'] = {} for name in ALLOWED_SETTINGS: self.signal_bind('setopt.' + name, self._sanitize, priority=SIGNAL_PRIORITY_SANITIZE) @@ -280,11 +281,11 @@ class Settings(SignalDispatcher, FileManagerAware): if path: if path not in self._localsettings: try: - regex = re.compile(re.escape(path)) + regex = re.compile(path) except re.error: # Bad regular expression return self._localregexes[path] = regex - self._localsettings[path] = dict() + self._localsettings[path] = {} self._localsettings[path][name] = value # make sure name is in _settings, so __iter__ runs through @@ -296,7 +297,7 @@ class Settings(SignalDispatcher, FileManagerAware): elif tags: for tag in tags: if tag not in self._tagsettings: - self._tagsettings[tag] = dict() + self._tagsettings[tag] = {} self._tagsettings[tag][name] = value else: self._settings[name] = value diff --git a/ranger/container/tags.py b/ranger/container/tags.py index dabaf6a8..4d4e6c59 100644 --- a/ranger/container/tags.py +++ b/ranger/container/tags.py @@ -5,39 +5,43 @@ from __future__ import (absolute_import, division, print_function) -from os.path import isdir, exists, dirname, abspath, realpath, expanduser, sep import string -import sys +from io import open +from os.path import exists, abspath, realpath, expanduser, sep + +from ranger.core.shared import FileManagerAware ALLOWED_KEYS = string.ascii_letters + string.digits + string.punctuation -class Tags(object): +class Tags(FileManagerAware): default_tag = '*' def __init__(self, filename): + # COMPAT: The intent is to get abspath/normpath's behavior of + # collapsing `symlink/..`, abspath is retained for historical reasons + # because the documentation states its behavior isn't necessarily in + # line with normpath's. self._filename = realpath(abspath(expanduser(filename))) - if isdir(dirname(self._filename)) and not exists(self._filename): - open(self._filename, 'w') - self.sync() def __contains__(self, item): return item in self.tags def add(self, *items, **others): - if 'tag' in others: - tag = others['tag'] - else: - tag = self.default_tag + if len(items) == 0: + return + tag = others.get('tag', self.default_tag) self.sync() for item in items: self.tags[item] = tag self.dump() def remove(self, *items): + if len(items) == 0: + return self.sync() for item in items: try: @@ -47,10 +51,9 @@ class Tags(object): self.dump() def toggle(self, *items, **others): - if 'tag' in others: - tag = others['tag'] - else: - tag = self.default_tag + if len(items) == 0: + return + tag = others.get('tag', self.default_tag) tag = str(tag) if tag not in ALLOWED_KEYS: return @@ -72,24 +75,22 @@ class Tags(object): def sync(self): try: - if sys.version_info[0] >= 3: - fobj = open(self._filename, 'r', errors='replace') + with open( + self._filename, "r", encoding="utf-8", errors="replace" + ) as fobj: + self.tags = self._parse(fobj) + except (OSError, IOError) as err: + if exists(self._filename): + self.fm.notify(err, bad=True) else: - fobj = open(self._filename, 'r') - except OSError: - pass - else: - self.tags = self._parse(fobj) - fobj.close() + self.tags = {} def dump(self): try: - fobj = open(self._filename, 'w') - except OSError: - pass - else: - self._compile(fobj) - fobj.close() + with open(self._filename, 'w', encoding="utf-8") as fobj: + self._compile(fobj) + except OSError as err: + self.fm.notify(err, bad=True) def _compile(self, fobj): for path, tag in self.tags.items(): @@ -100,7 +101,7 @@ class Tags(object): fobj.write('{0}:{1}\n'.format(tag, path)) def _parse(self, fobj): - result = dict() + result = {} for line in fobj: line = line.rstrip('\n') if len(line) > 2 and line[1] == ':': @@ -122,6 +123,7 @@ class Tags(object): elif path.startswith(path_old + sep): pnew = path_new + path[len(path_old):] if pnew: + # pylint: disable=unnecessary-dict-index-lookup del self.tags[path] self.tags[pnew] = tag changed = True @@ -140,7 +142,7 @@ class TagsDummy(Tags): """ def __init__(self, filename): # pylint: disable=super-init-not-called - self.tags = dict() + self.tags = {} def __contains__(self, item): return False diff --git a/ranger/core/actions.py b/ranger/core/actions.py index c3d7de86..a552e328 100644 --- a/ranger/core/actions.py +++ b/ranger/core/actions.py @@ -7,36 +7,36 @@ from __future__ import (absolute_import, division, print_function) import codecs import os -from os import link, symlink, listdir, stat -from os.path import join, isdir, realpath, exists import re import shlex import shutil import string import tempfile +from hashlib import sha512 from inspect import cleandoc -from stat import S_IEXEC -from hashlib import sha1 -from sys import version_info +from io import open from logging import getLogger +from os import link, symlink, listdir, stat +from os.path import join, isdir, realpath, exists +from stat import S_IEXEC import ranger +from ranger import PY3 +from ranger.container.directory import Directory +from ranger.container.file import File +from ranger.container.settings import ALLOWED_SETTINGS, ALLOWED_VALUES +from ranger.core.loader import CommandLoader, CopyLoader +from ranger.core.shared import FileManagerAware, SettingsAware +from ranger.core.tab import Tab from ranger.ext.direction import Direction -from ranger.ext.relative_symlink import relative_symlink +from ranger.ext.get_executables import get_executables from ranger.ext.keybinding_parser import key_to_string, construct_keybinding -from ranger.ext.safe_path import get_safe_path -from ranger.ext.shell_escape import shell_quote +from ranger.ext.macrodict import MacroDict, MACRO_FAIL, macro_val from ranger.ext.next_available_filename import next_available_filename +from ranger.ext.relative_symlink import relative_symlink from ranger.ext.rifle import squash_flags, ASK_COMMAND -from ranger.core.shared import FileManagerAware, SettingsAware -from ranger.core.tab import Tab -from ranger.container.directory import Directory -from ranger.container.file import File -from ranger.core.loader import CommandLoader, CopyLoader -from ranger.container.settings import ALLOWED_SETTINGS, ALLOWED_VALUES - - -MACRO_FAIL = "<\x01\x01MACRO_HAS_NO_VALUE\x01\01>" +from ranger.ext.safe_path import get_safe_path +from ranger.ext.shell_escape import shell_quote LOG = getLogger(__name__) @@ -242,14 +242,14 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m if cmd.resolve_macros and _MacroTemplate.delimiter in cmd.line: def any_macro(i, char): - return ('any{:d}'.format(i), key_to_string(char)) + return ('any{0:d}'.format(i), key_to_string(char)) def anypath_macro(i, char): try: val = self.fm.bookmarks[key_to_string(char)] except KeyError: val = MACRO_FAIL - return ('any_path{:d}'.format(i), val) + return ('any_path{0:d}'.format(i), val) macros = dict(f(i, char) for f in (any_macro, anypath_macro) for i, char in enumerate(wildcards if wildcards @@ -295,8 +295,8 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m raise ValueError("Could not apply macros to `%s'" % string) return result - def get_macros(self): # pylint: disable=too-many-branches,too-many-statements - macros = {} + def get_macros(self): + macros = MacroDict() macros['rangerdir'] = ranger.RANGERDIR if not ranger.args.clean: @@ -304,57 +304,39 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m macros['datadir'] = self.fm.datapath() macros['space'] = ' ' - if self.fm.thisfile: - macros['f'] = self.fm.thisfile.relative_path - else: - macros['f'] = MACRO_FAIL + macros['f'] = lambda: self.fm.thisfile.relative_path - if self.fm.thistab.get_selection: - macros['p'] = [os.path.join(self.fm.thisdir.path, fl.relative_path) - for fl in self.fm.thistab.get_selection()] - macros['s'] = [fl.relative_path for fl in self.fm.thistab.get_selection()] - else: - macros['p'] = MACRO_FAIL - macros['s'] = MACRO_FAIL + macros['p'] = lambda: [os.path.join(self.fm.thisdir.path, + fl.relative_path) for fl in + self.fm.thistab.get_selection()] + macros['s'] = lambda: [fl.relative_path for fl in + self.fm.thistab.get_selection()] - if self.fm.copy_buffer: - macros['c'] = [fl.path for fl in self.fm.copy_buffer] - else: - macros['c'] = MACRO_FAIL + macros['c'] = lambda: [fl.path for fl in self.fm.copy_buffer] - if self.fm.thisdir.files: - macros['t'] = [fl.relative_path for fl in self.fm.thisdir.files - if fl.realpath in self.fm.tags or []] - else: - macros['t'] = MACRO_FAIL + macros['t'] = lambda: [fl.relative_path for fl in + self.fm.thisdir.files if fl.realpath in + self.fm.tags] - if self.fm.thisdir: - macros['d'] = self.fm.thisdir.path - else: - macros['d'] = '.' + macros['d'] = macro_val(lambda: self.fm.thisdir.path, fallback='.') # define d/f/p/s macros for each tab for i in range(1, 10): + # pylint: disable=cell-var-from-loop try: tab = self.fm.tabs[i] - except KeyError: + tabdir = tab.thisdir + except (KeyError, AttributeError): continue - tabdir = tab.thisdir if not tabdir: continue i = str(i) - macros[i + 'd'] = tabdir.path - if tabdir.get_selection(): - macros[i + 'p'] = [os.path.join(tabdir.path, fl.relative_path) - for fl in tabdir.get_selection()] - macros[i + 's'] = [fl.path for fl in tabdir.get_selection()] - else: - macros[i + 'p'] = MACRO_FAIL - macros[i + 's'] = MACRO_FAIL - if tabdir.pointed_obj: - macros[i + 'f'] = tabdir.pointed_obj.path - else: - macros[i + 'f'] = MACRO_FAIL + macros[i + 'd'] = lambda: tabdir.path + macros[i + 'p'] = lambda: [os.path.join(tabdir.path, + fl.relative_path) for fl in + tabdir.get_selection()] + macros[i + 's'] = lambda: [fl.path for fl in tabdir.get_selection()] + macros[i + 'f'] = lambda: tabdir.pointed_obj.path # define D/F/P/S for the next tab found_current_tab = False @@ -370,25 +352,16 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m found_current_tab = True if found_current_tab and next_tab is None: next_tab = self.fm.tabs[first_tab] - next_tab_dir = next_tab.thisdir + try: + next_tab_dir = next_tab.thisdir + except AttributeError: + return macros - if next_tab_dir: - macros['D'] = str(next_tab_dir.path) - if next_tab.thisfile: - macros['F'] = next_tab.thisfile.path - else: - macros['F'] = MACRO_FAIL - if next_tab_dir.get_selection(): - macros['P'] = [os.path.join(next_tab.path, fl.path) + macros['D'] = lambda: str(next_tab_dir.path) + macros['F'] = lambda: next_tab.thisfile.path + macros['P'] = lambda: [os.path.join(next_tab.path, fl.path) for fl in next_tab.get_selection()] - macros['S'] = [fl.path for fl in next_tab.get_selection()] - else: - macros['P'] = MACRO_FAIL - macros['S'] = MACRO_FAIL - else: - macros['D'] = MACRO_FAIL - macros['F'] = MACRO_FAIL - macros['S'] = MACRO_FAIL + macros['S'] = lambda: [fl.path for fl in next_tab.get_selection()] return macros @@ -399,12 +372,15 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m """ filename = os.path.expanduser(filename) LOG.debug("Sourcing config file '%s'", filename) - with open(filename, 'r') as fobj: + # pylint: disable=unspecified-encoding + with open(filename, 'r', encoding="utf-8") as fobj: for line in fobj: line = line.strip(" \r\n") if line.startswith("#") or not line.strip(): continue try: + if not isinstance(line, str): + line = line.encode("ascii") self.execute_console(line) except Exception as ex: # pylint: disable=broad-except if ranger.args.debug: @@ -429,7 +405,9 @@ 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: - with open(ranger.args.choosefile, 'w') as fobj: + with open( + ranger.args.choosefile, 'w', encoding="utf-8" + ) as fobj: fobj.write(self.fm.thisfile.path) if ranger.args.choosefiles: @@ -440,7 +418,9 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m paths += [fobj.path] paths += [f.path for f in self.fm.thistab.get_selection() if f.path not in paths] - with open(ranger.args.choosefiles, 'w') as fobj: + with open( + ranger.args.choosefiles, 'w', encoding="utf-8" + ) as fobj: fobj.write('\n'.join(paths) + '\n') if ranger.args.choosefile or ranger.args.choosefiles: @@ -961,8 +941,8 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m except IndexError: self.ui.browser.draw_info = [] return - programs = [program for program in self.rifle.list_commands([target.path], None, - skip_ask=True)] + programs = list(self.rifle.list_commands( + [target.path], None, skip_ask=True)) if programs: num_digits = max((len(str(program[0])) for program in programs)) program_info = ['%s | %s' % (str(program[0]).rjust(num_digits), program[1]) @@ -997,14 +977,18 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m pager.set_source(lines) def display_help(self): - manualpath = self.relpath('../doc/ranger.1') - if os.path.exists(manualpath): - process = self.run(['man', manualpath]) - if process.poll() != 16: - return - process = self.run(['man', 'ranger']) - if process.poll() == 16: - self.notify("Could not find manpage.", bad=True) + if 'man' in get_executables(): + manualpath = self.relpath('../doc/ranger.1') + if os.path.exists(manualpath): + process = self.run(['man', manualpath]) + if process.poll() != 16: + return + process = self.run(['man', 'ranger']) + if process.poll() == 16: + self.notify("Could not find manpage.", bad=True) + else: + self.notify("Failed to show man page, check if man is installed", + bad=True) def display_log(self): logs = list(self.get_log()) @@ -1049,13 +1033,16 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m return True @staticmethod - def sha1_encode(path): - if version_info[0] < 3: - return os.path.join(ranger.args.cachedir, sha1(path).hexdigest()) + '.jpg' - return os.path.join(ranger.args.cachedir, - sha1(path.encode('utf-8', 'backslashreplace')).hexdigest()) + '.jpg' - - def get_preview(self, fobj, width, height): # pylint: disable=too-many-return-statements + def sha512_encode(path, inode=None): + if inode is None: + inode = stat(path).st_ino + inode_path = "{0}{1}".format(str(inode), path) + if PY3: + inode_path = inode_path.encode('utf-8', 'backslashreplace') + return '{0}.jpg'.format(sha512(inode_path).hexdigest()) + + def get_preview(self, fobj, width, height): + # pylint: disable=too-many-return-statements,too-many-statements pager = self.ui.get_pager() path = fobj.realpath @@ -1065,6 +1052,9 @@ 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 + # Disable the lint because the preview is read outside the + # local scope. + # pylint: disable=consider-using-with return codecs.open(path, 'r', errors='ignore') # IOError for Python2, OSError for Python3 except (IOError, OSError): @@ -1121,10 +1111,13 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m if not os.path.exists(ranger.args.cachedir): os.makedirs(ranger.args.cachedir) - cacheimg = os.path.join(ranger.args.cachedir, self.sha1_encode(path)) - if self.settings.preview_images and \ - os.path.isfile(cacheimg) and \ - os.path.getmtime(cacheimg) > os.path.getmtime(path): + fobj.load_if_outdated() + cacheimg = os.path.join( + ranger.args.cachedir, + self.sha512_encode(path, inode=fobj.stat.st_ino) + ) + if (self.settings.preview_images and os.path.isfile(cacheimg) + and fobj.stat.st_mtime <= os.path.getmtime(cacheimg)): data['foundpreview'] = True data['imagepreview'] = True pager.set_image(cacheimg) @@ -1224,7 +1217,7 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m try: text = codecs.decode(data, encoding, error_scheme) except UnicodeDecodeError: - pass + return None else: LOG.debug("Guessed encoding of '%s' as %s", path, encoding) return text @@ -1260,6 +1253,9 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m self.signal_emit('tab.change', old=previous_tab, new=self.thistab) self.signal_emit('tab.layoutchange') + def tabopen(self, *args, **kwargs): + return self.tab_open(*args, **kwargs) + def tab_close(self, name=None): if name is None: name = self.current_tab @@ -1275,6 +1271,9 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m self.restorable_tabs.append(tab) self.signal_emit('tab.layoutchange') + def tabclose(self, *args, **kwargs): + return self.tab_close(*args, **kwargs) + def tab_restore(self): # NOTE: The name of the tab is not restored. previous_tab = self.thistab @@ -1290,6 +1289,9 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m self.signal_emit('tab.change', old=previous_tab, new=self.thistab) break + def tabrestore(self, *args, **kwargs): + return self.tab_restore(*args, **kwargs) + def tab_move(self, offset, narg=None): if narg: return self.tab_open(narg) @@ -1301,6 +1303,9 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m self.tab_open(newtab) return None + def tabmove(self, *args, **kwargs): + return self.tab_move(*args, **kwargs) + def tab_new(self, path=None, narg=None): if narg: return self.tab_open(narg, path) @@ -1309,6 +1314,9 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m i += 1 return self.tab_open(i, path) + def tabnew(self, *args, **kwargs): + return self.tab_new(*args, **kwargs) + def tab_shift(self, offset=0, to=None): # pylint: disable=invalid-name """Shift the tab left/right @@ -1354,7 +1362,9 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m self.thistab = oldtab self.ui.titlebar.request_redraw() self.signal_emit('tab.layoutchange') - return None + + def tabshift(self, *args, **kwargs): + return self.tab_shift(*args, **kwargs) def tab_switch(self, path, create_directory=False): """Switches to tab of given path, opening a new tab as necessary. @@ -1400,6 +1410,9 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m if file_selection: self.fm.select_file(file_selection) + def tabswitch(self, *args, **kwargs): + return self.tab_switch(*args, **kwargs) + def get_tab_list(self): assert self.tabs, "There must be at least 1 tab at all times" @@ -1426,6 +1439,8 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m if not contexts: contexts = 'browser', 'console', 'pager', 'taskview' + # Disable lint because TemporaryFiles are removed on close + # pylint: disable=consider-using-with temporary_file = tempfile.NamedTemporaryFile() def write(string): # pylint: disable=redefined-outer-name @@ -1451,6 +1466,8 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m self._run_pager(temporary_file.name) def dump_commands(self): + # Disable lint because TemporaryFiles are removed on close + # pylint: disable=consider-using-with temporary_file = tempfile.NamedTemporaryFile() def write(string): # pylint: disable=redefined-outer-name @@ -1476,6 +1493,8 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m self._run_pager(temporary_file.name) def dump_settings(self): + # Disable lint because TemporaryFiles are removed on close + # pylint: disable=consider-using-with temporary_file = tempfile.NamedTemporaryFile() def write(string): # pylint: disable=redefined-outer-name @@ -1609,7 +1628,7 @@ class Actions( # pylint: disable=too-many-instance-attributes,too-many-public-m # COMPAT: old command.py use fm.delete() without arguments if files is None: files = (fobj.path for fobj in self.thistab.get_selection()) - self.notify("Deleting {}!".format(", ".join(files))) + self.notify("Deleting {fls}!".format(fls=", ".join(files))) files = [os.path.abspath(path) for path in files] for path in files: # Untag the deleted files. diff --git a/ranger/core/filter_stack.py b/ranger/core/filter_stack.py index bd63f2fe..66ab95c3 100644 --- a/ranger/core/filter_stack.py +++ b/ranger/core/filter_stack.py @@ -6,7 +6,6 @@ from __future__ import (absolute_import, division, print_function) import re -import mimetypes # pylint: disable=invalid-name try: from itertools import izip_longest as zip_longest @@ -55,23 +54,23 @@ class NameFilter(BaseFilter): return self.regex.search(fobj.relative_path) def __str__(self): - return "<Filter: name =~ /{}/>".format(self.pattern) + return "<Filter: name =~ /{pat}/>".format(pat=self.pattern) @stack_filter("mime") -class MimeFilter(BaseFilter): +class MimeFilter(BaseFilter, FileManagerAware): def __init__(self, pattern): self.pattern = pattern self.regex = re.compile(pattern) def __call__(self, fobj): - mimetype, _ = mimetypes.guess_type(fobj.relative_path) + mimetype, _ = self.fm.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) + return "<Filter: mimetype =~ /{pat}/>".format(pat=self.pattern) @stack_filter("hash") @@ -97,7 +96,7 @@ class HashFilter(BaseFilter, FileManagerAware): return True def __str__(self): - return "<Filter: hash {}>".format(self.filepath) + return "<Filter: hash {fp}>".format(fp=self.filepath) def group_by_hash(fsobjects): @@ -192,7 +191,7 @@ class TypeFilter(BaseFilter): return self.type_to_function[self.filetype](fobj) def __str__(self): - return "<Filter: type == '{}'>".format(self.filetype) + return "<Filter: type == '{ft}'>".format(ft=self.filetype) @filter_combinator("or") @@ -216,7 +215,8 @@ class OrFilter(BaseFilter): ) def __str__(self): - return "<Filter: {}>".format(" or ".join(map(str, self.subfilters))) + return "<Filter: {comp}>".format( + comp=" or ".join(map(str, self.subfilters))) def decompose(self): return self.subfilters @@ -236,7 +236,8 @@ class AndFilter(BaseFilter): return accept_file(fobj, self.subfilters) def __str__(self): - return "<Filter: {}>".format(" and ".join(map(str, self.subfilters))) + return "<Filter: {comp}>".format( + comp=" and ".join(map(str, self.subfilters))) def decompose(self): return self.subfilters @@ -252,7 +253,7 @@ class NotFilter(BaseFilter): return not self.subfilter(fobj) def __str__(self): - return "<Filter: not {}>".format(str(self.subfilter)) + return "<Filter: not {exp}>".format(exp=str(self.subfilter)) def decompose(self): return [self.subfilter] diff --git a/ranger/core/fm.py b/ranger/core/fm.py index 7d23c9b6..5b221648 100644 --- a/ranger/core/fm.py +++ b/ranger/core/fm.py @@ -5,30 +5,31 @@ from __future__ import (absolute_import, division, print_function) -from time import time -from collections import deque import mimetypes import os.path import pwd import socket import stat import sys +from collections import deque +from io import open +from time import time import ranger.api -from ranger.core.actions import Actions -from ranger.core.tab import Tab from ranger.container import settings -from ranger.container.tags import Tags, TagsDummy -from ranger.gui.ui import UI from ranger.container.bookmarks import Bookmarks +from ranger.container.directory import Directory +from ranger.container.tags import Tags, TagsDummy +from ranger.core.actions import Actions +from ranger.core.loader import Loader +from ranger.core.metadata import MetadataManager from ranger.core.runner import Runner +from ranger.core.tab import Tab +from ranger.ext import logutils from ranger.ext.img_display import get_image_displayer -from ranger.core.metadata import MetadataManager from ranger.ext.rifle import Rifle -from ranger.container.directory import Directory from ranger.ext.signals import SignalDispatcher -from ranger.core.loader import Loader -from ranger.ext import logutils +from ranger.gui.ui import UI class FM(Actions, # pylint: disable=too-many-instance-attributes @@ -49,13 +50,12 @@ class FM(Actions, # pylint: disable=too-many-instance-attributes SignalDispatcher.__init__(self) self.ui = ui if ui is not None else UI() self.start_paths = paths if paths is not None else ['.'] - self.directories = dict() + self.directories = {} self.bookmarks = bookmarks self.current_tab = 1 self.tabs = {} self.tags = tags self.restorable_tabs = deque([], ranger.MAX_RESTORABLE_TABS) - self.py3 = sys.version_info >= (3, ) self.previews = {} self.default_linemodes = deque() self.loader = Loader() @@ -74,11 +74,12 @@ class FM(Actions, # pylint: disable=too-many-instance-attributes self.hostname = socket.gethostname() self.home_path = os.path.expanduser('~') - mimetypes.knownfiles.append(os.path.expanduser('~/.mime.types')) - mimetypes.knownfiles.append(self.relpath('data/mime.types')) - self.mimetypes = mimetypes.MimeTypes() + if not mimetypes.inited: + extra_files = [self.relpath('data/mime.types'), os.path.expanduser("~/.mime.types")] + mimetypes.init(mimetypes.knownfiles + extra_files) + self.mimetypes = mimetypes - def initialize(self): + def initialize(self): # pylint: disable=too-many-statements """If ui/bookmarks are None, they will be initialized here.""" self.tabs = dict((n + 1, Tab(path)) for n, path in enumerate(self.start_paths)) @@ -272,15 +273,15 @@ class FM(Actions, # pylint: disable=too-many-instance-attributes shutil.copy(self.relpath(src), self.confpath(dest)) except OSError as ex: sys.stderr.write(" ERROR: %s\n" % str(ex)) - if which == 'rifle' or which == 'all': + if which in ('rifle', 'all'): copy('config/rifle.conf', 'rifle.conf') - if which == 'commands' or which == 'all': + if which in ('commands', 'all'): copy('config/commands_sample.py', 'commands.py') - if which == 'commands_full' or which == 'all': + if which in ('commands_full', 'all'): copy('config/commands.py', 'commands_full.py') - if which == 'rc' or which == 'all': + if which in ('rc', 'all'): copy('config/rc.conf', 'rc.conf') - if which == 'scope' or which == 'all': + if which in ('scope', 'all'): copy('data/scope.sh', 'scope.sh') os.chmod(self.confpath('scope.sh'), os.stat(self.confpath('scope.sh')).st_mode | stat.S_IXUSR) @@ -329,6 +330,44 @@ class FM(Actions, # pylint: disable=too-many-instance-attributes self.directories[path] = obj return obj + @staticmethod + def group_paths_by_dirname(paths): + """ + Groups the paths into a dictionary with their dirnames as keys and a set of + basenames as entries. + """ + groups = {} + for path in paths: + abspath = os.path.abspath(os.path.expanduser(path)) + dirname, basename = os.path.split(abspath) + groups.setdefault(dirname, set()).add(basename) + return groups + + def get_filesystem_objects(self, paths): + """ + Find FileSystemObjects corresponding to the paths if they are already in + memory and load those that are not. + """ + result = [] + # Grouping the files by dirname avoids the potentially quadratic running time of doing + # a linear search in the directory for each entry name that is supposed to be deleted. + groups = self.group_paths_by_dirname(paths) + for dirname, basenames in groups.items(): + directory = self.fm.get_directory(dirname) + directory.load_content_if_outdated() + for entry in directory.files_all: + if entry.basename in basenames: + result.append(entry) + basenames.remove(entry.basename) + if basenames != set(): + # Abort the operation with an error message if there are entries + # that weren't found. + names = ', '.join(basenames) + self.fm.notify('Error: No such file or directory: {0}'.format( + names), bad=True) + return None + return result + def garbage_collect( self, age, tabs=None): # tabs=None is for COMPATibility pylint: disable=unused-argument @@ -402,14 +441,14 @@ class FM(Actions, # pylint: disable=too-many-instance-attributes if ranger.args.choosedir and self.thisdir and self.thisdir.path: # XXX: UnicodeEncodeError: 'utf-8' codec can't encode character # '\udcf6' in position 42: surrogates not allowed - with open(ranger.args.choosedir, 'w') as fobj: + with open(ranger.args.choosedir, 'w', encoding="utf-8") as fobj: fobj.write(self.thisdir.path) self.bookmarks.remember(self.thisdir) self.bookmarks.save() # Save tabs 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: + with open(self.datapath('tabs'), 'a', encoding="utf-8") 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()) + '\0\0') diff --git a/ranger/core/loader.py b/ranger/core/loader.py index 7d4b21e8..19611c7b 100644 --- a/ranger/core/loader.py +++ b/ranger/core/loader.py @@ -3,14 +3,14 @@ from __future__ import (absolute_import, division, print_function) -from collections import deque -from subprocess import Popen, PIPE -from time import time, sleep +import errno import math import os.path import select -import sys -import errno +from collections import deque +from io import open +from subprocess import Popen, PIPE +from time import time, sleep try: import chardet # pylint: disable=import-error @@ -18,10 +18,11 @@ try: except ImportError: HAVE_CHARDET = False +from ranger import PY3 from ranger.core.shared import FileManagerAware +from ranger.ext.human_readable import human_readable from ranger.ext.safe_path import get_safe_path from ranger.ext.signals import SignalDispatcher -from ranger.ext.human_readable import human_readable class Loadable(object): @@ -170,15 +171,20 @@ class CommandLoader( # pylint: disable=too-many-instance-attributes self.kill_on_pause = kill_on_pause self.popenArgs = popenArgs # pylint: disable=invalid-name - def generate(self): # pylint: disable=too-many-branches,too-many-statements - py3 = sys.version_info[0] >= 3 + def generate(self): + # pylint: disable=too-many-branches,too-many-statements + # TODO: Check whether we can afford to wait for processes and use a + # with-statement for Popen. + # pylint: disable=consider-using-with popenargs = {} if self.popenArgs is None else self.popenArgs popenargs['stdout'] = popenargs['stderr'] = PIPE - popenargs['stdin'] = PIPE if self.input else open(os.devnull, 'r') + popenargs['stdin'] = ( + PIPE if self.input else open(os.devnull, 'r', encoding="utf-8") + ) self.process = process = Popen(self.args, **popenargs) self.signal_emit('before', process=process, loader=self) if self.input: - if py3: + if PY3: import io stdin = io.TextIOWrapper(process.stdin) else: @@ -186,7 +192,7 @@ class CommandLoader( # pylint: disable=too-many-instance-attributes try: stdin.write(self.input) except IOError as ex: - if ex.errno != errno.EPIPE and ex.errno != errno.EINVAL: + if ex.errno not in (errno.EPIPE, errno.EINVAL): raise stdin.close() if self.silent and not self.read: # pylint: disable=too-many-nested-blocks @@ -212,7 +218,7 @@ class CommandLoader( # pylint: disable=too-many-instance-attributes robjs = robjs[0] if robjs == process.stderr: read = robjs.readline() - if py3: + if PY3: read = safe_decode(read) if read: self.fm.notify(read, bad=True) @@ -227,7 +233,7 @@ class CommandLoader( # pylint: disable=too-many-instance-attributes sleep(0.03) if not self.silent: for line in process.stderr: - if py3: + if PY3: line = safe_decode(line) self.fm.notify(line, bad=True) if self.read: @@ -235,7 +241,7 @@ class CommandLoader( # pylint: disable=too-many-instance-attributes if read: read_stdout += read if read_stdout: - if py3: + if PY3: read_stdout = safe_decode(read_stdout) self.stdout_buffer += read_stdout self.finished = True diff --git a/ranger/core/main.py b/ranger/core/main.py index 7322a501..2556b22a 100644 --- a/ranger/core/main.py +++ b/ranger/core/main.py @@ -5,11 +5,14 @@ from __future__ import (absolute_import, division, print_function) -from logging import getLogger +import atexit import locale import os.path +import shutil import sys import tempfile +from io import open +from logging import getLogger from ranger import VERSION @@ -72,14 +75,14 @@ def main( return 1 fm = FM() try: - if sys.version_info[0] >= 3: - fobj = open(fm.datapath('tagged'), 'r', errors='replace') - else: - fobj = open(fm.datapath('tagged'), 'r') + with open( + fm.datapath('tagged'), 'r', encoding="utf-8", errors='replace' + ) as fobj: + lines = fobj.readlines() except OSError as ex: print('Unable to open `tagged` data file: {0}'.format(ex), file=sys.stderr) return 1 - for line in fobj.readlines(): + for line in lines: if len(line) > 2 and line[1] == ':': if line[0] in args.list_tagged_files: sys.stdout.write(line[2:]) @@ -155,11 +158,11 @@ def main( tabs_datapath = fm.datapath('tabs') if fm.settings.save_tabs_on_exit and os.path.exists(tabs_datapath) and not args.paths: try: - with open(tabs_datapath, 'r') as fobj: + with open(tabs_datapath, 'r', encoding="utf-8") as fobj: tabs_saved = fobj.read().partition('\0\0') fm.start_paths += tabs_saved[0].split('\0') if tabs_saved[-1]: - with open(tabs_datapath, 'w') as fobj: + with open(tabs_datapath, 'w', encoding="utf-8") as fobj: fobj.write(tabs_saved[-1]) else: os.remove(tabs_datapath) @@ -242,7 +245,7 @@ https://github.com/ranger/ranger/issues def get_paths(args): if args.paths: - prefix = 'file:///' + prefix = 'file://' prefix_length = len(prefix) paths = [path[prefix_length:] if path.startswith(prefix) else path for path in args.paths] else: @@ -337,6 +340,17 @@ def parse_arguments(): args.cachedir = mkdtemp(suffix='.ranger-cache') args.confdir = None args.datadir = None + + @atexit.register + def cleanup_cachedir(): # pylint: disable=unused-variable + try: + shutil.rmtree(args.cachedir) + except Exception as ex: # pylint: disable=broad-except + sys.stderr.write( + "Error during the temporary cache directory cleanup:\n" + "{ex}\n".format(ex=ex) + ) + else: args.cachedir = path_init('cachedir') args.confdir = path_init('confdir') @@ -377,15 +391,16 @@ def load_settings( # pylint: disable=too-many-locals,too-many-branches,too-many 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 + from importlib import 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 + # pylint: disable=no-value-for-parameter module = SourceFileLoader(name, path).load_module() else: - import imp + import imp # pylint: disable=deprecated-module module = imp.load_source(name, path) # pragma pylint: enable=no-name-in-module,import-error,no-member return module @@ -430,8 +445,11 @@ def load_settings( # pylint: disable=too-many-locals,too-many-branches,too-many if not os.path.exists(fm.confpath('plugins', '__init__.py')): LOG.debug("Creating missing '__init__.py' file in plugin folder") - fobj = open(fm.confpath('plugins', '__init__.py'), 'w') - fobj.close() + with open( + fm.confpath('plugins', '__init__.py'), 'w', encoding="utf-8" + ): + # Create the file if it doesn't exist. + pass ranger.fm = fm for plugin in sorted(plugins): diff --git a/ranger/core/metadata.py b/ranger/core/metadata.py index 75f7ba3c..534c083c 100644 --- a/ranger/core/metadata.py +++ b/ranger/core/metadata.py @@ -14,7 +14,9 @@ The database is contained in a local .metadata.json file. from __future__ import (absolute_import, division, print_function) import copy +from io import open from os.path import join, dirname, exists, basename + from ranger.ext.openstruct import DefaultOpenStruct as ostruct @@ -26,9 +28,9 @@ class MetadataManager(object): def __init__(self): # metadata_cache maps filenames to dicts containing their metadata - self.metadata_cache = dict() + self.metadata_cache = {} # metafile_cache maps .metadata.json filenames to their entries - self.metafile_cache = dict() + self.metafile_cache = {} self.deep_search = DEEP_SEARCH_DEFAULT def reset(self): @@ -84,7 +86,7 @@ class MetadataManager(object): self.metadata_cache[filename] = entry self.metafile_cache[metafile] = entries - with open(metafile, "w") as fobj: + with open(metafile, "w", encoding="utf-8") as fobj: json.dump(entries, fobj, check_circular=True, indent=2) def _get_entry(self, filename): @@ -117,7 +119,7 @@ class MetadataManager(object): return self.metafile_cache[metafile] if exists(metafile): - with open(metafile, "r") as fobj: + with open(metafile, "r", encoding="utf-8") as fobj: try: entries = json.load(fobj) except ValueError: diff --git a/ranger/core/runner.py b/ranger/core/runner.py index d465f070..c5ec697b 100644 --- a/ranger/core/runner.py +++ b/ranger/core/runner.py @@ -27,7 +27,9 @@ from __future__ import (absolute_import, division, print_function) import logging import os import sys +from io import open from subprocess import Popen, PIPE, STDOUT + from ranger.ext.get_executables import get_executables, get_term from ranger.ext.popen_forked import Popen_forked @@ -190,8 +192,10 @@ class Runner(object): # pylint: disable=too-few-public-methods pipe_output = True context.wait = False if 's' in context.flags: - devnull_writable = open(os.devnull, 'w') - devnull_readable = open(os.devnull, 'r') + # Using a with-statement for these is inconvenient. + # pylint: disable=consider-using-with + devnull_writable = open(os.devnull, 'w', encoding="utf-8") + devnull_readable = open(os.devnull, 'r', encoding="utf-8") for key in ('stdout', 'stderr'): popen_kws[key] = devnull_writable toggle_ui = False @@ -231,15 +235,18 @@ class Runner(object): # pylint: disable=too-few-public-methods if toggle_ui: self._activate_ui(False) + + error = None + process = None + try: - error = None - process = None self.fm.signal_emit('runner.execute.before', popen_kws=popen_kws, context=context) try: 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. + # pylint: disable=consider-using-with Popen_forked(**popen_kws) else: process = Popen(**popen_kws) diff --git a/ranger/core/tab.py b/ranger/core/tab.py index 1d5e69d4..3a0fb943 100644 --- a/ranger/core/tab.py +++ b/ranger/core/tab.py @@ -4,9 +4,9 @@ from __future__ import (absolute_import, division, print_function) import os -from os.path import abspath, normpath, join, expanduser, isdir -import sys +from os.path import abspath, normpath, join, expanduser, isdir, dirname +from ranger import PY3 from ranger.container import settings from ranger.container.history import History from ranger.core.shared import FileManagerAware, SettingsAware @@ -19,7 +19,9 @@ class Tab(FileManagerAware, SettingsAware): # pylint: disable=too-many-instance self._thisfile = None # Current File self.history = History(self.settings.max_history_size, unique=False) self.last_search = None - self.pointer = 0 + self._pointer = 0 + self._pointed_obj = None + self.pointed_obj = None self.path = abspath(expanduser(path)) self.pathway = () # NOTE: in the line below, weak=True works only in python3. In python2, @@ -27,15 +29,16 @@ class Tab(FileManagerAware, SettingsAware): # pylint: disable=too-many-instance # "==", and this breaks _set_thisfile_from_signal and _on_tab_change. self.fm.signal_bind('move', self._set_thisfile_from_signal, priority=settings.SIGNAL_PRIORITY_AFTER_SYNC, - weak=(sys.version_info[0] >= 3)) + weak=(PY3)) self.fm.signal_bind('tab.change', self._on_tab_change, - weak=(sys.version_info[0] >= 3)) + weak=(PY3)) def _set_thisfile_from_signal(self, signal): if self == signal.tab: self._thisfile = signal.new if self == self.fm.thistab: self.pointer = self.thisdir.pointer + self.pointed_obj = self.thisdir.pointed_obj def _on_tab_change(self, signal): if self == signal.new and self.thisdir: @@ -53,6 +56,26 @@ class Tab(FileManagerAware, SettingsAware): # pylint: disable=too-many-instance thisfile = property(_get_thisfile, _set_thisfile) + def _get_pointer(self): + try: + if self.thisdir.files[self._pointer] != self._pointed_obj: + try: + self._pointer = self.thisdir.files.index(self._pointed_obj) + except ValueError: + self._set_pointer(self._pointer) + except (TypeError, IndexError): + pass + return self._pointer + + def _set_pointer(self, value): + self._pointer = value + try: + self._pointed_obj = self.thisdir.files[self._pointer] + except (TypeError, IndexError): + pass + + pointer = property(_get_pointer, _set_pointer) + def at_level(self, level): """Returns the FileSystemObject at the given level. @@ -123,9 +146,11 @@ class Tab(FileManagerAware, SettingsAware): # pylint: disable=too-many-instance # get the absolute path path = normpath(join(self.path, expanduser(path))) + selectfile = None if not isdir(path): - return False + selectfile = path + path = dirname(path) new_thisdir = self.fm.get_directory(path) try: @@ -155,6 +180,8 @@ class Tab(FileManagerAware, SettingsAware): # pylint: disable=too-many-instance self.thisdir.sort_directories_first = self.fm.settings.sort_directories_first self.thisdir.sort_reverse = self.fm.settings.sort_reverse self.thisdir.sort_if_outdated() + if selectfile: + self.thisdir.move_to_obj(selectfile) if previous and previous.path != path: self.thisfile = self.thisdir.pointed_obj else: diff --git a/ranger/data/mime.types b/ranger/data/mime.types index c0cfdcdb..b8803836 100644 --- a/ranger/data/mime.types +++ b/ranger/data/mime.types @@ -29,6 +29,8 @@ audio/x-flac flac image/vnd.djvu djvu image/webp webp +message/rfc822 eml + text/x-ruby rb video/ogg ogv ogm diff --git a/ranger/data/scope.sh b/ranger/data/scope.sh index f403ed83..2e9983ee 100755 --- a/ranger/data/scope.sh +++ b/ranger/data/scope.sh @@ -40,12 +40,12 @@ FILE_EXTENSION_LOWER="$(printf "%s" "${FILE_EXTENSION}" | tr '[:upper:]' '[:lowe ## Settings HIGHLIGHT_SIZE_MAX=262143 # 256KiB -HIGHLIGHT_TABWIDTH=${HIGHLIGHT_TABWIDTH:-8} -HIGHLIGHT_STYLE=${HIGHLIGHT_STYLE:-pablo} +HIGHLIGHT_TABWIDTH="${HIGHLIGHT_TABWIDTH:-8}" +HIGHLIGHT_STYLE="${HIGHLIGHT_STYLE:-pablo}" HIGHLIGHT_OPTIONS="--replace-tabs=${HIGHLIGHT_TABWIDTH} --style=${HIGHLIGHT_STYLE} ${HIGHLIGHT_OPTIONS:-}" -PYGMENTIZE_STYLE=${PYGMENTIZE_STYLE:-autumn} -OPENSCAD_IMGSIZE=${RNGR_OPENSCAD_IMGSIZE:-1000,1000} -OPENSCAD_COLORSCHEME=${RNGR_OPENSCAD_COLORSCHEME:-Tomorrow Night} +PYGMENTIZE_STYLE="${PYGMENTIZE_STYLE:-autumn}" +OPENSCAD_IMGSIZE="${RNGR_OPENSCAD_IMGSIZE:-1000,1000}" +OPENSCAD_COLORSCHEME="${RNGR_OPENSCAD_COLORSCHEME:-Tomorrow Night}" handle_extension() { case "${FILE_EXTENSION_LOWER}" in @@ -80,12 +80,16 @@ handle_extension() { exit 1;; ## OpenDocument - odt|ods|odp|sxw) + odt|sxw) ## Preview as text conversion odt2txt "${FILE_PATH}" && exit 5 ## Preview as markdown conversion pandoc -s -t markdown -- "${FILE_PATH}" && exit 5 exit 1;; + ods|odp) + ## Preview as text conversion (unsupported by pandoc for markdown) + odt2txt "${FILE_PATH}" && exit 5 + exit 1;; ## XLSX xlsx) @@ -109,6 +113,14 @@ handle_extension() { python -m json.tool -- "${FILE_PATH}" && exit 5 ;; + ## Jupyter Notebooks + ipynb) + jupyter nbconvert --to markdown "${FILE_PATH}" --stdout | env COLORTERM=8bit bat --color=always --style=plain --language=markdown && exit 5 + jupyter nbconvert --to markdown "${FILE_PATH}" --stdout && exit 5 + jq --color-output . "${FILE_PATH}" && exit 5 + python -m json.tool -- "${FILE_PATH}" && exit 5 + ;; + ## Direct Stream Digital/Transfer (DSDIFF) and wavpack aren't detected ## by file(1). dff|dsf|wv|wvc) @@ -128,9 +140,11 @@ handle_image() { local mimetype="${1}" case "${mimetype}" in ## SVG - # image/svg+xml|image/svg) - # convert -- "${FILE_PATH}" "${IMAGE_CACHE_PATH}" && exit 6 - # exit 1;; + image/svg+xml|image/svg) + rsvg-convert --keep-aspect-ratio --width "${DEFAULT_SIZE%x*}" "${FILE_PATH}" -o "${IMAGE_CACHE_PATH}.png" \ + && mv "${IMAGE_CACHE_PATH}.png" "${IMAGE_CACHE_PATH}" \ + && exit 6 + exit 1;; ## DjVu # image/vnd.djvu) @@ -149,16 +163,24 @@ handle_image() { convert -- "${FILE_PATH}" -auto-orient "${IMAGE_CACHE_PATH}" && exit 6 fi - ## `w3mimgdisplay` will be called for all images (unless overriden + ## `w3mimgdisplay` will be called for all images (unless overridden ## as above), but might fail for unsupported types. exit 7;; ## Video # video/*) - # # Thumbnail + # # Get embedded thumbnail + # ffmpeg -i "${FILE_PATH}" -map 0:v -map -0:V -c copy "${IMAGE_CACHE_PATH}" && exit 6 + # # Get frame 10% into video # ffmpegthumbnailer -i "${FILE_PATH}" -o "${IMAGE_CACHE_PATH}" -s 0 && exit 6 # exit 1;; + ## Audio + # audio/*) + # # Get embedded thumbnail + # ffmpeg -i "${FILE_PATH}" -map 0:v -map -0:V -c copy \ + # "${IMAGE_CACHE_PATH}" && exit 6;; + ## PDF # application/pdf) # pdftoppm -f 1 -l 1 \ @@ -218,7 +240,8 @@ handle_image() { # { [ "$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; \ + # fn=$(echo "$fn" | python -c "from __future__ import print_function; \ + # 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) @@ -247,19 +270,23 @@ handle_image() { # mv "${TMPPNG}" "${IMAGE_CACHE_PATH}" # } - # case "${FILE_EXTENSION_LOWER}" in - # ## 3D models - # ## OpenSCAD only supports png image output, and ${IMAGE_CACHE_PATH} - # ## is hardcoded as jpeg. So we make a tempfile.png and just - # ## move/rename it to jpg. This works because image libraries are - # ## smart enough to handle it. - # csg|scad) - # openscad_image "${FILE_PATH}" && exit 6 - # ;; - # 3mf|amf|dxf|off|stl) - # openscad_image <(echo "import(\"${FILE_PATH}\");") && exit 6 - # ;; - # esac + case "${FILE_EXTENSION_LOWER}" in + ## 3D models + ## OpenSCAD only supports png image output, and ${IMAGE_CACHE_PATH} + ## is hardcoded as jpeg. So we make a tempfile.png and just + ## move/rename it to jpg. This works because image libraries are + ## smart enough to handle it. + # csg|scad) + # openscad_image "${FILE_PATH}" && exit 6 + # ;; + # 3mf|amf|dxf|off|stl) + # openscad_image <(echo "import(\"${FILE_PATH}\");") && exit 6 + # ;; + drawio) + draw.io -x "${FILE_PATH}" -o "${IMAGE_CACHE_PATH}" \ + --width "${DEFAULT_SIZE%x*}" && exit 6 + exit 1;; + esac } handle_mime() { @@ -281,6 +308,12 @@ handle_mime() { pandoc -s -t markdown -- "${FILE_PATH}" && exit 5 exit 1;; + ## E-mails + message/rfc822) + ## Parsing performed by mu: https://github.com/djcb/mu + mu view -- "${FILE_PATH}" && exit 5 + exit 1;; + ## XLS *ms-excel) ## Preview as csv conversion @@ -330,6 +363,11 @@ handle_mime() { mediainfo "${FILE_PATH}" && exit 5 exiftool "${FILE_PATH}" && exit 5 exit 1;; + + ## ELF files (executables and shared objects) + application/x-executable | application/x-pie-executable | application/x-sharedlib) + readelf -WCa "${FILE_PATH}" && exit 5 + exit 1;; esac } diff --git a/ranger/ext/accumulator.py b/ranger/ext/accumulator.py index c2e33b59..c34370d8 100644 --- a/ranger/ext/accumulator.py +++ b/ranger/ext/accumulator.py @@ -3,6 +3,8 @@ from __future__ import (absolute_import, division, print_function) +from abc import abstractmethod + from ranger.ext.direction import Direction @@ -75,8 +77,7 @@ class Accumulator(object): i = 0 if i >= len(lst): i = len(lst) - 1 - if i < 0: - i = 0 + i = max(0, i) self.pointer = i self.pointed_obj = lst[i] @@ -91,8 +92,8 @@ class Accumulator(object): def sync_index(self, **kw): self.move_to_obj(self.pointed_obj, **kw) - @staticmethod - def get_list(): + @abstractmethod + def get_list(self): """OVERRIDE THIS""" return [] diff --git a/ranger/ext/img_display.py b/ranger/ext/img_display.py index ffaa4c07..61e10f96 100644 --- a/ranger/ext/img_display.py +++ b/ranger/ext/img_display.py @@ -30,7 +30,9 @@ from contextlib import contextmanager import codecs from tempfile import NamedTemporaryFile -from ranger.core.shared import FileManagerAware +from ranger import PY3 +from ranger.core.shared import FileManagerAware, SettingsAware +from ranger.ext.popen23 import Popen23 W3MIMGDISPLAY_ENV = "W3MIMGDISPLAY_PATH" W3MIMGDISPLAY_OPTIONS = [] @@ -69,8 +71,14 @@ class ImageDisplayError(Exception): pass -class ImgDisplayUnsupportedException(Exception): - pass +class ImgDisplayUnsupportedException(Exception, SettingsAware): + def __init__(self, message=None): + if message is None: + message = ( + '"{0}" does not appear to be a valid setting for' + ' preview_images_method.' + ).format(self.settings.preview_images_method) + super(ImgDisplayUnsupportedException, self).__init__(message) def fallback_image_displayer(): @@ -107,15 +115,12 @@ class ImageDisplayer(object): def draw(self, path, start_x, start_y, width, height): """Draw an image at the given coordinates.""" - pass def clear(self, start_x, start_y, width, height): """Clear a part of terminal display.""" - pass def quit(self): """Cleanup and close""" - pass @register_image_displayer("w3m") @@ -137,6 +142,8 @@ class W3MImageDisplayer(ImageDisplayer, FileManagerAware): """start w3mimgdisplay""" self.binary_path = None self.binary_path = self._find_w3mimgdisplay_executable() # may crash + # We cannot close the process because that stops the preview. + # pylint: disable=consider-using-with self.process = Popen([self.binary_path] + W3MIMGDISPLAY_OPTIONS, cwd=self.working_dir, stdin=PIPE, stdout=PIPE, universal_newlines=True) self.is_initialized = True @@ -161,8 +168,12 @@ class W3MImageDisplayer(ImageDisplayer, FileManagerAware): fretint = fcntl.ioctl(fd_stdout, termios.TIOCGWINSZ, farg) rows, cols, xpixels, ypixels = struct.unpack("HHHH", fretint) if xpixels == 0 and ypixels == 0: - process = Popen([self.binary_path, "-test"], stdout=PIPE, universal_newlines=True) - output, _ = process.communicate() + with Popen23( + [self.binary_path, "-test"], + stdout=PIPE, + universal_newlines=True, + ) as process: + output, _ = process.communicate() output = output.split() xpixels, ypixels = int(output[0]), int(output[1]) # adjust for misplacement @@ -174,10 +185,11 @@ class W3MImageDisplayer(ImageDisplayer, FileManagerAware): def draw(self, path, start_x, start_y, width, height): if not self.is_initialized or self.process.poll() is not None: self.initialize() - try: - input_gen = self._generate_w3m_input(path, start_x, start_y, width, height) - except ImageDisplayError: - raise + input_gen = self._generate_w3m_input(path, start_x, start_y, width, + height) + self.process.stdin.write(input_gen) + self.process.stdin.flush() + self.process.stdout.readline() # Mitigate the issue with the horizontal black bars when # selecting some images on some systems. 2 milliseconds seems @@ -186,9 +198,7 @@ class W3MImageDisplayer(ImageDisplayer, FileManagerAware): from time import sleep sleep(self.fm.settings.w3m_delay) - self.process.stdin.write(input_gen) - self.process.stdin.flush() - self.process.stdout.readline() + # HACK workaround for w3mimgdisplay memory leak self.quit() self.is_initialized = False @@ -233,7 +243,7 @@ class W3MImageDisplayer(ImageDisplayer, FileManagerAware): # max_height_pixels = (max_height - 1) * fonth - 2 # get image size - cmd = "5;{}\n".format(path) + cmd = "5;{path}\n".format(path=path) self.process.stdin.write(cmd) self.process.stdin.flush() @@ -302,7 +312,7 @@ class ITerm2ImageDisplayer(ImageDisplayer, FileManagerAware): content = self._encode_image_content(path) display_protocol = "\033" close_protocol = "\a" - if "screen" in os.environ['TERM']: + if os.environ["TERM"].startswith(("screen", "tmux")): display_protocol += "Ptmux;\033\033" close_protocol += "\033\\" @@ -344,41 +354,37 @@ class ITerm2ImageDisplayer(ImageDisplayer, FileManagerAware): @staticmethod def _get_image_dimensions(path): """Determine image size using imghdr""" - file_handle = open(path, 'rb') - file_header = file_handle.read(24) - image_type = imghdr.what(path) - if len(file_header) != 24: - file_handle.close() - return 0, 0 - if image_type == 'png': - check = struct.unpack('>i', file_header[4:8])[0] - if check != 0x0d0a1a0a: - file_handle.close() + with open(path, 'rb') as file_handle: + file_header = file_handle.read(24) + image_type = imghdr.what(path) + if len(file_header) != 24: return 0, 0 - width, height = struct.unpack('>ii', file_header[16:24]) - elif image_type == 'gif': - width, height = struct.unpack('<HH', file_header[6:10]) - elif image_type == 'jpeg': - unreadable = IOError if sys.version_info[0] < 3 else OSError - try: - file_handle.seek(0) - size = 2 - ftype = 0 - while not 0xc0 <= ftype <= 0xcf: - file_handle.seek(size, 1) - byte = file_handle.read(1) - while ord(byte) == 0xff: + if image_type == 'png': + check = struct.unpack('>i', file_header[4:8])[0] + if check != 0x0d0a1a0a: + return 0, 0 + width, height = struct.unpack('>ii', file_header[16:24]) + elif image_type == 'gif': + width, height = struct.unpack('<HH', file_header[6:10]) + elif image_type == 'jpeg': + unreadable = OSError if PY3 else IOError + try: + file_handle.seek(0) + size = 2 + ftype = 0 + while not 0xc0 <= ftype <= 0xcf: + file_handle.seek(size, 1) byte = file_handle.read(1) - ftype = ord(byte) - size = struct.unpack('>H', file_handle.read(2))[0] - 2 - file_handle.seek(1, 1) - height, width = struct.unpack('>HH', file_handle.read(4)) - except unreadable: - height, width = 0, 0 - else: - file_handle.close() - return 0, 0 - file_handle.close() + while ord(byte) == 0xff: + byte = file_handle.read(1) + ftype = ord(byte) + size = struct.unpack('>H', file_handle.read(2))[0] - 2 + file_handle.seek(1, 1) + height, width = struct.unpack('>HH', file_handle.read(4)) + except unreadable: + height, width = 0, 0 + else: + return 0, 0 return width, height @@ -434,7 +440,7 @@ class URXVTImageDisplayer(ImageDisplayer, FileManagerAware): def __init__(self): self.display_protocol = "\033" self.close_protocol = "\a" - if "screen" in os.environ['TERM']: + if os.environ["TERM"].startswith(("screen", "tmux")): self.display_protocol += "Ptmux;\033\033" self.close_protocol += "\033\\" self.display_protocol += "]20;" @@ -535,7 +541,7 @@ class KittyImageDisplayer(ImageDisplayer, FileManagerAware): 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: + # differences 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 @@ -590,7 +596,7 @@ class KittyImageDisplayer(ImageDisplayer, FileManagerAware): self.stream = True else: raise ImgDisplayUnsupportedException( - 'kitty replied an unexpected response: {}'.format(resp)) + 'kitty replied an unexpected response: {r}'.format(r=resp)) # get the image manipulation backend try: @@ -616,7 +622,8 @@ class KittyImageDisplayer(ImageDisplayer, FileManagerAware): # 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)) + # sys.stderr.write('{0}-{1}@{2}x{3}\t'.format( + # start_x, start_y, width, height)) # finish initialization if it is the first call if self.needs_late_init: @@ -636,7 +643,7 @@ class KittyImageDisplayer(ImageDisplayer, FileManagerAware): image = image.resize((int(scale * image.width), int(scale * image.height)), self.backend.LANCZOS) - if image.mode != 'RGB' and image.mode != 'RGBA': + if image.mode not in ('RGB', 'RGBA'): image = image.convert('RGB') # start_x += ((box[0] - image.width) // 2) // self.pix_row # start_y += ((box[1] - image.height) // 2) // self.pix_col @@ -673,24 +680,26 @@ class KittyImageDisplayer(ImageDisplayer, FileManagerAware): if b'OK' in resp: return else: - raise ImageDisplayError('kitty replied "{}"'.format(resp)) + raise ImageDisplayError('kitty replied "{r}"'.format(r=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 + # 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 + self.image_id = max(0, self.image_id - 1) self.fm.ui.win.redrawwin() self.fm.ui.win.refresh() 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') + central_blk = ','.join(["{k}={v}".format(k=k, v=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 @@ -706,7 +715,8 @@ class KittyImageDisplayer(ImageDisplayer, FileManagerAware): 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 + # 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: @@ -734,6 +744,8 @@ class UeberzugImageDisplayer(ImageDisplayer): and not self.process.stdin.closed): return + # We cannot close the process because that stops the preview. + # pylint: disable=consider-using-with self.process = Popen(['ueberzug', 'layer', '--silent'], cwd=self.working_dir, stdin=PIPE, universal_newlines=True) self.is_initialized = True diff --git a/ranger/ext/keybinding_parser.py b/ranger/ext/keybinding_parser.py index 8703d458..52e1d506 100644 --- a/ranger/ext/keybinding_parser.py +++ b/ranger/ext/keybinding_parser.py @@ -7,7 +7,8 @@ import sys import copy import curses.ascii -PY3 = sys.version_info[0] >= 3 +from ranger import PY3 + digits = set(range(ord('0'), ord('9') + 1)) # pylint: disable=invalid-name # Arbitrary numbers which are not used with curses.KEY_XYZ @@ -169,7 +170,7 @@ class KeyMaps(dict): self.used_keymap = None def use_keymap(self, keymap_name): - self.keybuffer.keymap = self.get(keymap_name, dict()) + self.keybuffer.keymap = self.get(keymap_name, {}) if self.used_keymap != keymap_name: self.used_keymap = keymap_name self.keybuffer.clear() @@ -178,7 +179,7 @@ class KeyMaps(dict): try: pointer = self[context] except KeyError: - self[context] = pointer = dict() + self[context] = pointer = {} if PY3: keys = keys.encode('utf-8').decode('latin-1') return list(parse_keybinding(keys)), pointer @@ -193,9 +194,9 @@ class KeyMaps(dict): if isinstance(pointer[key], dict): pointer = pointer[key] else: - pointer[key] = pointer = dict() + pointer[key] = pointer = {} except KeyError: - pointer[key] = pointer = dict() + pointer[key] = pointer = {} pointer[last_key] = leaf def copy(self, context, source, target): diff --git a/ranger/ext/logutils.py b/ranger/ext/logutils.py index 38fc7cda..12f7886d 100644 --- a/ranger/ext/logutils.py +++ b/ranger/ext/logutils.py @@ -43,7 +43,7 @@ def setup_logging(debug=True, logfile=None): controlled by the `debug` parameter - debug=False: - a concise log format will be used, debug messsages will be discarded + a concise log format will be used, debug messages will be discarded and only important message will be passed to the `stderr_handler` - debug=True: diff --git a/ranger/ext/macrodict.py b/ranger/ext/macrodict.py new file mode 100644 index 00000000..924fe5a9 --- /dev/null +++ b/ranger/ext/macrodict.py @@ -0,0 +1,82 @@ +from __future__ import absolute_import +import sys + + +MACRO_FAIL = "<\x01\x01MACRO_HAS_NO_VALUE\x01\01>" + + +def macro_val(thunk, fallback=MACRO_FAIL): + try: + return thunk() + except AttributeError: + return fallback + + +try: + from collections.abc import MutableMapping # pylint: disable=no-name-in-module +except ImportError: + from collections import MutableMapping # pylint: disable=deprecated-class + + +class MacroDict(MutableMapping): + """Mapping that returns a fallback value when thunks error + + Macros can be used in scenarios where several attributes aren't + initialized yet. To avoid errors in these cases we have to delay the + evaluation of these attributes using ``lambda``s. This + ``MutableMapping`` evaluates these thunks before returning them + replacing them with a fallback value if necessary. + + For convenience it also catches ``TypeError`` so you can store + non-callable values without thunking. + + >>> m = MacroDict() + >>> o = type("", (object,), {})() + >>> o.existing_attribute = "I exist!" + + >>> m['a'] = "plain value" + >>> m['b'] = lambda: o.non_existent_attribute + >>> m['c'] = lambda: o.existing_attribute + + >>> m['a'] + 'plain value' + >>> m['b'] + '<\\x01\\x01MACRO_HAS_NO_VALUE\\x01\\x01>' + >>> m['c'] + 'I exist!' + """ + + def __init__(self, *args, **kwargs): + super(MacroDict, self).__init__() + self.__dict__.update(*args, **kwargs) + + def __setitem__(self, key, value): + try: + real_val = value() + if real_val is None: + real_val = MACRO_FAIL + except AttributeError: + real_val = MACRO_FAIL + except TypeError: + real_val = value + self.__dict__[key] = real_val + + def __getitem__(self, key): + return self.__dict__[key] + + def __delitem__(self, key): + del self.__dict__[key] + + def __iter__(self): + return iter(self.__dict__) + + def __len__(self): + return len(self.__dict__) + + def __str__(self): + return str(self.__dict__) + + +if __name__ == '__main__': + import doctest + sys.exit(doctest.testmod()[0]) diff --git a/ranger/ext/popen23.py b/ranger/ext/popen23.py new file mode 100644 index 00000000..944ed3dd --- /dev/null +++ b/ranger/ext/popen23.py @@ -0,0 +1,42 @@ +# This file is part of ranger, the console file manager. +# License: GNU GPL version 3, see the file "AUTHORS" for details. + +from __future__ import absolute_import + +from contextlib import contextmanager + +from subprocess import Popen + +try: + from ranger import PY3 +except ImportError: + from sys import version_info + PY3 = version_info[0] >= 3 + + +# COMPAT: Python 2 (and Python <=3.2) subprocess.Popen objects aren't +# context managers. We don't care about early Python 3 but we do want +# to wrap Python 2's Popen. There's no harm in always using this Popen +# but it is only necessary when used with with-statements. This can be +# removed once we ditch Python 2 support. +@contextmanager +def Popen23(*args, **kwargs): # pylint: disable=invalid-name + if PY3: + yield Popen(*args, **kwargs) + return + else: + popen2 = Popen(*args, **kwargs) + try: + yield popen2 + finally: + # From Lib/subprocess.py Popen.__exit__: + if popen2.stdout: + popen2.stdout.close() + if popen2.stderr: + popen2.stderr.close() + try: # Flushing a BufferedWriter may raise an error + if popen2.stdin: + popen2.stdin.close() + finally: + # Wait for the process to terminate, to avoid zombies. + popen2.wait() diff --git a/ranger/ext/popen_forked.py b/ranger/ext/popen_forked.py index bff1818e..40a5b833 100644 --- a/ranger/ext/popen_forked.py +++ b/ranger/ext/popen_forked.py @@ -4,7 +4,8 @@ from __future__ import (absolute_import, division, print_function) import os -import subprocess +from io import open +from subprocess import Popen def Popen_forked(*args, **kwargs): # pylint: disable=invalid-name @@ -18,9 +19,12 @@ def Popen_forked(*args, **kwargs): # pylint: disable=invalid-name return False if pid == 0: os.setsid() - kwargs['stdin'] = open(os.devnull, 'r') - kwargs['stdout'] = kwargs['stderr'] = open(os.devnull, 'w') - subprocess.Popen(*args, **kwargs) + with open(os.devnull, 'r', encoding="utf-8") as null_r, open( + os.devnull, 'w', encoding="utf-8" + ) as null_w: + kwargs['stdin'] = null_r + kwargs['stdout'] = kwargs['stderr'] = null_w + Popen(*args, **kwargs) # pylint: disable=consider-using-with os._exit(0) # pylint: disable=protected-access else: os.wait() diff --git a/ranger/ext/rifle.py b/ranger/ext/rifle.py index a73a188b..fbfe7c5a 100755 --- a/ranger/ext/rifle.py +++ b/ranger/ext/rifle.py @@ -18,10 +18,11 @@ from __future__ import (absolute_import, division, print_function) import os.path import re -from subprocess import Popen, PIPE +from subprocess import PIPE import sys -__version__ = 'rifle 1.9.2' + +__version__ = 'rifle 1.9.3' # Options and constants that a user might want to change: DEFAULT_PAGER = 'less' @@ -70,6 +71,66 @@ except ImportError: try: + from ranger.ext.popen23 import Popen23 +except ImportError: + # COMPAT: Python 2 (and Python <=3.2) subprocess.Popen objects aren't + # context managers. We don't care about early Python 3 but we do + # want to wrap Python 2's Popen. There's no harm in always using + # this Popen but it is only necessary when used with + # with-statements. This can be removed once we ditch Python 2 + # support. + from contextlib import contextmanager + # pylint: disable=ungrouped-imports + from subprocess import Popen, TimeoutExpired + + try: + from ranger import PY3 + except ImportError: + from sys import version_info + PY3 = version_info[0] >= 3 + + @contextmanager + def Popen23(*args, **kwargs): # pylint: disable=invalid-name + if PY3: + yield Popen(*args, **kwargs) + return + else: + popen2 = Popen(*args, **kwargs) + try: + yield popen2 + finally: + # From Lib/subprocess.py Popen.__exit__: + if popen2.stdout: + popen2.stdout.close() + if popen2.stderr: + popen2.stderr.close() + try: # Flushing a BufferedWriter may raise an error + if popen2.stdin: + popen2.stdin.close() + except KeyboardInterrupt: + # https://bugs.python.org/issue25942 + # In the case of a KeyboardInterrupt we assume the SIGINT + # was also already sent to our child processes. We can't + # block indefinitely as that is not user friendly. + # If we have not already waited a brief amount of time in + # an interrupted .wait() or .communicate() call, do so here + # for consistency. + # pylint: disable=protected-access + if popen2._sigint_wait_secs > 0: + try: + # pylint: disable=no-member + popen2._wait(timeout=popen2._sigint_wait_secs) + except TimeoutExpired: + pass + popen2._sigint_wait_secs = 0 # Note that this's been done. + # pylint: disable=lost-exception + return # resume the KeyboardInterrupt + finally: + # Wait for the process to terminate, to avoid zombies. + popen2.wait() + + +try: from ranger.ext.popen_forked import Popen_forked except ImportError: def Popen_forked(*args, **kwargs): # pylint: disable=invalid-name @@ -80,9 +141,13 @@ except ImportError: return False if pid == 0: os.setsid() - kwargs['stdin'] = open(os.devnull, 'r') - kwargs['stdout'] = kwargs['stderr'] = open(os.devnull, 'w') - Popen(*args, **kwargs) + # pylint: disable=unspecified-encoding + with open(os.devnull, "r") as null_r, open( + os.devnull, "w" + ) as null_w: + kwargs["stdin"] = null_r + kwargs["stdout"] = kwargs["stderr"] = null_w + Popen(*args, **kwargs) # pylint: disable=consider-using-with os._exit(0) # pylint: disable=protected-access return True @@ -144,7 +209,6 @@ class Rifle(object): # pylint: disable=too-many-instance-attributes self.config_file = config_file self._app_flags = '' self._app_label = None - self._initialized_mimetypes = False self._mimetype = None self._skip = None self.rules = None @@ -160,20 +224,20 @@ class Rifle(object): # pylint: disable=too-many-instance-attributes """Replace the current configuration with the one in config_file""" if config_file is None: config_file = self.config_file - fobj = open(config_file, 'r') - self.rules = [] - for line in fobj: - line = line.strip() - if line.startswith('#') or line == '': - continue - if self.delimiter1 not in line: - raise ValueError("Line without delimiter") - tests, command = line.split(self.delimiter1, 1) - tests = tests.split(self.delimiter2) - tests = tuple(tuple(f.strip().split(None, 1)) for f in tests) - command = command.strip() - self.rules.append((command, tests)) - fobj.close() + # pylint: disable=unspecified-encoding + with open(config_file, "r") as fobj: + self.rules = [] + for line in fobj: + line = line.strip() + if line.startswith('#') or line == '': + continue + if self.delimiter1 not in line: + raise ValueError("Line without delimiter") + tests, command = line.split(self.delimiter1, 1) + tests = tests.split(self.delimiter2) + tests = tuple(tuple(f.strip().split(None, 1)) for f in tests) + command = command.strip() + self.rules.append((command, tests)) def _eval_condition(self, condition, files, label): # Handle the negation of conditions starting with an exclamation mark, @@ -252,20 +316,24 @@ class Rifle(object): # pylint: disable=too-many-instance-attributes return self._mimetype import mimetypes - for path in self._mimetype_known_files: - if path not in mimetypes.knownfiles: - mimetypes.knownfiles.append(path) + if not mimetypes.inited: + mimetypes.init(mimetypes.knownfiles + self._mimetype_known_files) self._mimetype, _ = mimetypes.guess_type(fname) if not self._mimetype: - process = Popen(["file", "--mime-type", "-Lb", fname], stdout=PIPE, stderr=PIPE) - mimetype, _ = process.communicate() + with Popen23( + ["file", "--mime-type", "-Lb", fname], stdout=PIPE, stderr=PIPE + ) as process: + 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() + with Popen23( + ["mimetype", "--output-format", "%m", fname], + stdout=PIPE, + stderr=PIPE, + ) as process: + mimetype, _ = process.communicate() self._mimetype = mimetype.decode(ENCODING).strip() except OSError: pass @@ -439,8 +507,10 @@ class Rifle(object): # pylint: disable=too-many-instance-attributes if 'f' in flags or 't' in flags: Popen_forked(cmd, env=self.hook_environment(os.environ)) else: - process = Popen(cmd, env=self.hook_environment(os.environ)) - process.wait() + with Popen23( + cmd, env=self.hook_environment(os.environ) + ) as process: + process.wait() finally: self.hook_after_executing(command, self._mimetype, self._app_flags) @@ -475,7 +545,7 @@ def find_conf_path(): def main(): # pylint: disable=too-many-locals - """The main function which is run when you start this program direectly.""" + """The main function which is run when you start this program directly.""" # Evaluate arguments from optparse import OptionParser # pylint: disable=deprecated-module @@ -520,8 +590,8 @@ def main(): # pylint: disable=too-many-locals label = options.p if options.w is not None and not options.l: - process = Popen([options.w] + list(positional)) - process.wait() + with Popen23([options.w] + list(positional)) as process: + process.wait() else: # Start up rifle rifle = Rifle(conf_path) diff --git a/ranger/ext/safe_path.py b/ranger/ext/safe_path.py index b172b577..b2e360ea 100644 --- a/ranger/ext/safe_path.py +++ b/ranger/ext/safe_path.py @@ -1,6 +1,7 @@ # This file is part of ranger, the console file manager. # License: GNU GPL version 3, see the file "AUTHORS" for details. +from __future__ import absolute_import import os SUFFIX = '_' diff --git a/ranger/ext/shell_escape.py b/ranger/ext/shell_escape.py index a652fab1..05bff1df 100644 --- a/ranger/ext/shell_escape.py +++ b/ranger/ext/shell_escape.py @@ -9,6 +9,8 @@ from __future__ import (absolute_import, division, print_function) META_CHARS = (' ', "'", '"', '`', '&', '|', ';', '#', '$', '!', '(', ')', '[', ']', '<', '>', '\t') UNESCAPABLE = set(map(chr, list(range(9)) + list(range(10, 32)) + list(range(127, 256)))) +# pylint: disable=consider-using-dict-comprehension +# COMPAT Dictionary comprehensions didn't exist before 2.7 META_DICT = dict([(mc, '\\' + mc) for mc in META_CHARS]) diff --git a/ranger/ext/shutil_generatorized.py b/ranger/ext/shutil_generatorized.py index a97a4d2d..fe367696 100644 --- a/ranger/ext/shutil_generatorized.py +++ b/ranger/ext/shutil_generatorized.py @@ -15,12 +15,6 @@ __all__ = ["copyfileobj", "copyfile", "copystat", "copy2", "BLOCK_SIZE", BLOCK_SIZE = 16 * 1024 -try: - WindowsError -except NameError: - WindowsError = None # pylint: disable=invalid-name - - if sys.version_info < (3, 3): def copystat(src, dst): """Copy all stat info (mode bits, atime, mtime, flags) from src to dst""" @@ -233,11 +227,7 @@ def copytree(src, dst, # pylint: disable=too-many-locals,too-many-branches try: copystat(src, dst) except OSError as why: - if WindowsError is not None and isinstance(why, WindowsError): - # Copying file access times may fail on Windows - pass - else: - errors.append((src, dst, str(why))) + errors.append((src, dst, str(why))) if errors: raise Error(errors) diff --git a/ranger/ext/signals.py b/ranger/ext/signals.py index 0973249c..8d3f84c9 100644 --- a/ranger/ext/signals.py +++ b/ranger/ext/signals.py @@ -106,14 +106,14 @@ class SignalDispatcher(object): """This abstract class handles the binding and emitting of signals.""" def __init__(self): - self._signals = dict() + self._signals = {} def signal_clear(self): """Remove all signals.""" for handler_list in self._signals.values(): for handler in handler_list: handler.function = None - self._signals = dict() + self._signals = {} def signal_bind(self, signal_name, function, priority=0.5, weak=False, autosort=True): """Bind a function to the signal. diff --git a/ranger/ext/spawn.py b/ranger/ext/spawn.py index dacb3c4a..4bd0b499 100644 --- a/ranger/ext/spawn.py +++ b/ranger/ext/spawn.py @@ -3,8 +3,12 @@ from __future__ import (absolute_import, division, print_function) +from io import open from os import devnull -from subprocess import Popen, PIPE, CalledProcessError +from subprocess import PIPE, CalledProcessError + +from ranger.ext.popen23 import Popen23 + ENCODING = 'utf-8' @@ -28,12 +32,12 @@ def check_output(popenargs, **kwargs): kwargs.setdefault('shell', isinstance(popenargs, str)) if 'stderr' in kwargs: - process = Popen(popenargs, **kwargs) - stdout, _ = process.communicate() - else: - with open(devnull, mode='w') as fd_devnull: - process = Popen(popenargs, stderr=fd_devnull, **kwargs) + with Popen23(popenargs, **kwargs) as process: stdout, _ = process.communicate() + else: + with open(devnull, mode='w', encoding="utf-8") as fd_devnull: + with Popen23(popenargs, stderr=fd_devnull, **kwargs) as process: + stdout, _ = process.communicate() if process.returncode != 0: error = CalledProcessError(process.returncode, popenargs) diff --git a/ranger/ext/vcs/git.py b/ranger/ext/vcs/git.py index e5f74ee1..c39ab3eb 100644 --- a/ranger/ext/vcs/git.py +++ b/ranger/ext/vcs/git.py @@ -177,7 +177,7 @@ class Git(Vcs): if head is None: return 'detached' - match = re.match('refs/heads/([^/]+)', head) + match = re.match('refs/heads/(.+)', head) return match.group(1) if match else None def data_info(self, rev=None): diff --git a/ranger/ext/vcs/vcs.py b/ranger/ext/vcs/vcs.py index e2838f8d..93fb1d0b 100644 --- a/ranger/ext/vcs/vcs.py +++ b/ranger/ext/vcs/vcs.py @@ -9,6 +9,7 @@ import os import subprocess import threading import time +from io import open from ranger.ext import spawn @@ -21,7 +22,6 @@ except ImportError: class VcsError(Exception): """VCS exception""" - pass class Vcs(object): # pylint: disable=too-many-instance-attributes @@ -77,8 +77,9 @@ class Vcs(object): # pylint: disable=too-many-instance-attributes ) self.root, self.repodir, self.repotype, self.links = self._find_root(self.path) - self.is_root = True if self.obj.path == self.root else False - self.is_root_link = True if self.obj.is_link and self.obj.realpath == self.root else False + self.is_root = self.obj.path == self.root + self.is_root_link = ( + self.obj.is_link and self.obj.realpath == self.root) self.is_root_pointer = self.is_root or self.is_root_link self.in_repodir = False self.rootvcs = None @@ -87,6 +88,7 @@ class Vcs(object): # pylint: disable=too-many-instance-attributes if self.root: if self.is_root: self.rootvcs = self + # pylint: disable=invalid-class-object self.__class__ = globals()[self.REPOTYPES[self.repotype]['class'] + 'Root'] if not os.access(self.repodir, os.R_OK): @@ -100,6 +102,7 @@ class Vcs(object): # pylint: disable=too-many-instance-attributes if self.rootvcs is None or self.rootvcs.root is None: return self.rootvcs.links |= self.links + # pylint: disable=invalid-class-object self.__class__ = globals()[self.REPOTYPES[self.repotype]['class']] self.track = self.rootvcs.track @@ -127,7 +130,7 @@ class Vcs(object): # pylint: disable=too-many-instance-attributes return output[:-1] return output else: - with open(os.devnull, mode='w') as fd_devnull: + with open(os.devnull, mode='w', encoding="utf-8") as fd_devnull: subprocess.check_call(cmd, cwd=path, stdout=fd_devnull, stderr=fd_devnull) return None except (subprocess.CalledProcessError, OSError): @@ -460,10 +463,10 @@ class VcsThread(threading.Thread): # pylint: disable=too-many-instance-attribut self.paused.set() self._advance.wait() self._awoken.wait() - if self.__stop.isSet(): + if self.__stop.is_set(): self.stopped.set() return - if not self._advance.isSet(): + if not self._advance.is_set(): continue self._awoken.clear() self.paused.clear() @@ -488,7 +491,7 @@ class VcsThread(threading.Thread): # pylint: disable=too-many-instance-attribut self._advance.set() self._awoken.set() self.stopped.wait(1) - return self.stopped.isSet() + return self.stopped.is_set() def pause(self): """Pause thread""" @@ -513,19 +516,15 @@ from .svn import SVN # NOQA pylint: disable=wrong-import-position class BzrRoot(VcsRoot, Bzr): """Bzr root""" - pass class GitRoot(VcsRoot, Git): """Git root""" - pass class HgRoot(VcsRoot, Hg): """Hg root""" - pass class SVNRoot(VcsRoot, SVN): """SVN root""" - pass diff --git a/ranger/ext/widestring.py b/ranger/ext/widestring.py index 390da639..a209e5fe 100644 --- a/ranger/ext/widestring.py +++ b/ranger/ext/widestring.py @@ -7,7 +7,8 @@ from __future__ import (absolute_import, division, print_function) import sys from unicodedata import east_asian_width -PY3 = sys.version_info[0] >= 3 +from ranger import PY3 + ASCIIONLY = set(chr(c) for c in range(1, 128)) NARROW = 1 WIDE = 2 diff --git a/ranger/gui/ansi.py b/ranger/gui/ansi.py index ce735317..3d33688d 100644 --- a/ranger/gui/ansi.py +++ b/ranger/gui/ansi.py @@ -42,23 +42,23 @@ def text_with_fg_bg_attr(ansi_text): # pylint: disable=too-many-branches,too-ma for x256fg, x256bg, arg in codesplit_re.findall(attr_args + ';'): # first handle xterm256 codes try: - if x256fg: # xterm256 foreground + if x256fg: # xterm256 foreground fg = int(x256fg) continue - elif x256bg: # xterm256 background + elif x256bg: # xterm256 background bg = int(x256bg) continue - elif arg: # usual ansi code + elif arg: # usual ansi code n = int(arg) - else: # empty code means reset + else: # empty code means reset n = 0 except ValueError: continue - if n == 0: # reset colors and attributes + if n == 0: # reset colors and attributes fg, bg, attr = -1, -1, 0 - elif n == 1: # enable attribute + elif n == 1: # enable attribute attr |= color.bold elif n == 4: attr |= color.underline @@ -69,7 +69,7 @@ def text_with_fg_bg_attr(ansi_text): # pylint: disable=too-many-branches,too-ma elif n == 8: attr |= color.invisible - elif n == 22: # disable attribute + elif n == 22: # disable attribute attr &= not color.bold elif n == 24: attr &= not color.underline @@ -80,21 +80,21 @@ def text_with_fg_bg_attr(ansi_text): # pylint: disable=too-many-branches,too-ma elif n == 28: attr &= not color.invisible - elif n >= 30 and n <= 37: # 8 ansi foreground and background colors + elif 30 <= n <= 37: # 8 ansi foreground and background colors fg = n - 30 elif n == 39: fg = -1 - elif n >= 40 and n <= 47: + elif 40 <= n <= 47: bg = n - 40 elif n == 49: bg = -1 # 8 aixterm high intensity colors (light but not bold) - elif n >= 90 and n <= 97: + elif 90 <= n <= 97: fg = n - 90 + 8 elif n == 99: fg = -1 - elif n >= 100 and n <= 107: + elif 100 <= n <= 107: bg = n - 100 + 8 elif n == 109: bg = -1 @@ -159,7 +159,7 @@ def char_slice(ansi_text, start, length): pos += len(chunk) if pos <= start: pass # seek - elif old_pos < start and pos >= start: + elif old_pos < start <= pos: chunks.append(last_color) chunks.append(str(chunk[start - old_pos:start - old_pos + length])) elif pos > length + start: diff --git a/ranger/gui/bar.py b/ranger/gui/bar.py index 3f4b891c..d65ef4bc 100644 --- a/ranger/gui/bar.py +++ b/ranger/gui/bar.py @@ -3,14 +3,10 @@ from __future__ import (absolute_import, division, print_function) -import sys - +from ranger import PY3 from ranger.ext.widestring import WideString, utf_char_width -PY3 = sys.version_info[0] >= 3 - - class Bar(object): left = None right = None @@ -38,7 +34,7 @@ class Bar(object): rightsize = self.right.sumsize() sumsize = leftsize + rightsize - # remove elemets from the left until it fits + # remove elements from the left until it fits if sumsize > wid: while self.left: leftsize -= len(self.left.pop(-1)) @@ -46,7 +42,7 @@ class Bar(object): break sumsize = leftsize + rightsize - # remove elemets from the right until it fits + # remove elements from the right until it fits if sumsize > wid: while self.right: rightsize -= len(self.right.pop(0)) diff --git a/ranger/gui/color.py b/ranger/gui/color.py index 8f6439c7..81d8b44b 100644 --- a/ranger/gui/color.py +++ b/ranger/gui/color.py @@ -49,27 +49,27 @@ def get_color(fg, bg): return COLOR_PAIRS[key] -# pylint: disable=invalid-name,bad-whitespace -black = curses.COLOR_BLACK -blue = curses.COLOR_BLUE -cyan = curses.COLOR_CYAN -green = curses.COLOR_GREEN -magenta = curses.COLOR_MAGENTA -red = curses.COLOR_RED -white = curses.COLOR_WHITE -yellow = curses.COLOR_YELLOW -default = -1 - -normal = curses.A_NORMAL -bold = curses.A_BOLD -blink = curses.A_BLINK -reverse = curses.A_REVERSE -underline = curses.A_UNDERLINE -invisible = curses.A_INVIS +# pylint: disable=invalid-name +black = curses.COLOR_BLACK +blue = curses.COLOR_BLUE +cyan = curses.COLOR_CYAN +green = curses.COLOR_GREEN +magenta = curses.COLOR_MAGENTA +red = curses.COLOR_RED +white = curses.COLOR_WHITE +yellow = curses.COLOR_YELLOW +default = -1 + +normal = curses.A_NORMAL +bold = curses.A_BOLD +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 +# pylint: enable=invalid-name curses.setupterm() # Adding BRIGHT to a color achieves what `bold` was used for. diff --git a/ranger/gui/colorscheme.py b/ranger/gui/colorscheme.py index 01862957..22ff053f 100644 --- a/ranger/gui/colorscheme.py +++ b/ranger/gui/colorscheme.py @@ -27,7 +27,9 @@ set colorscheme yourschemename from __future__ import (absolute_import, division, print_function) import os.path +from abc import abstractmethod from curses import color_pair +from io import open import ranger from ranger.gui.color import get_color @@ -71,8 +73,8 @@ class ColorScheme(object): fg, bg, attr = self.get(*flatten(keys)) return attr | color_pair(get_color(fg, bg)) - @staticmethod - def use(_): + @abstractmethod + def use(self, context): """Use the colorscheme to determine the (fg, bg, attr) tuple. Override this method in your own colorscheme. @@ -108,7 +110,9 @@ def _colorscheme_name_to_class(signal): # pylint: disable=too-many-branches if os.path.exists(signal.fm.confpath('colorschemes')): initpy = signal.fm.confpath('colorschemes', '__init__.py') if not os.path.exists(initpy): - open(initpy, 'a').close() + with open(initpy, "a", encoding="utf-8"): + # Just create the file + pass if usecustom and \ exists(signal.fm.confpath('colorschemes', scheme_name)): diff --git a/ranger/gui/context.py b/ranger/gui/context.py index 96849686..202d6a53 100644 --- a/ranger/gui/context.py +++ b/ranger/gui/context.py @@ -16,7 +16,8 @@ CONTEXT_KEYS = [ 'good', 'bad', 'space', 'permissions', 'owner', 'group', 'mtime', 'nlink', 'scroll', 'all', 'bot', 'top', 'percentage', 'filter', - 'flat', 'marked', 'tagged', 'tag_marker', 'cut', 'copied', 'frozen', + 'flat', 'marked', 'tagged', 'tag_marker', 'line_number', + 'cut', 'copied', 'frozen', 'help_markup', # COMPAT 'seperator', 'key', 'special', 'border', # COMPAT 'title', 'text', 'highlight', 'bars', 'quotes', 'tab', 'loaded', diff --git a/ranger/gui/displayable.py b/ranger/gui/displayable.py index 1c3fb3e4..67128f30 100644 --- a/ranger/gui/displayable.py +++ b/ranger/gui/displayable.py @@ -8,6 +8,12 @@ import curses from ranger.core.shared import FileManagerAware from ranger.gui.curses_shortcuts import CursesShortcuts +try: + from bidi.algorithm import get_display # pylint: disable=import-error + HAVE_BIDI = True +except ImportError: + HAVE_BIDI = False + class Displayable( # pylint: disable=too-many-instance-attributes FileManagerAware, CursesShortcuts): @@ -95,7 +101,7 @@ class Displayable( # pylint: disable=too-many-instance-attributes return self.contains_point(y, x) def draw(self): - """Draw the oject. + """Draw the object. Called on every main iteration if visible. Containers should call draw() on their contained objects here. Override this! @@ -110,22 +116,20 @@ class Displayable( # pylint: disable=too-many-instance-attributes x and y should be absolute coordinates. """ - return (x >= self.x and x < self.x + self.wid) and \ - (y >= self.y and y < self.y + self.hei) + return (self.x <= x < self.x + self.wid) and \ + (self.y <= y < self.y + self.hei) def click(self, event): """Called when a mouse key is pressed and self.focused is True. Override this! """ - pass def press(self, key): """Called when a key is pressed and self.focused is True. Override this! """ - pass def poke(self): """Called before drawing, even if invisible""" @@ -141,7 +145,6 @@ class Displayable( # pylint: disable=too-many-instance-attributes Override this! """ - pass def resize(self, y, x, hei=None, wid=None): """Resize the widget""" @@ -212,6 +215,11 @@ class Displayable( # pylint: disable=too-many-instance-attributes def __str__(self): return self.__class__.__name__ + def bidi_transpose(self, text): + if self.settings.bidi_support and HAVE_BIDI: + return get_display(text) + return text + class DisplayableContainer(Displayable): """DisplayableContainers are Displayables which contain other Displayables. @@ -243,7 +251,7 @@ class DisplayableContainer(Displayable): Displayable.__init__(self, win) - # ------------------------------------ extended or overidden methods + # ------------------------------------ extended or overridden methods def poke(self): """Recursively called on objects in container""" diff --git a/ranger/gui/ui.py b/ranger/gui/ui.py index d2dbb759..db12d6dc 100644 --- a/ranger/gui/ui.py +++ b/ranger/gui/ui.py @@ -9,6 +9,7 @@ import threading import curses from subprocess import CalledProcessError +from ranger.ext.get_executables import get_executables from ranger.ext.keybinding_parser import KeyBuffer, KeyMaps, ALT_KEY from ranger.ext.lazy_property import lazy_property from ranger.ext.signals import Signal @@ -43,12 +44,22 @@ def _setup_mouse(signal): # starts curses again, (e.g. running a ## file by clicking on its # preview) and the next key is another mouse click, the bstate of this # mouse event will be invalid. (atm, invalid bstates are recognized - # as scroll-down, so this avoids an errorneous scroll-down action) + # as scroll-down, so this avoids an erroneous scroll-down action) curses.ungetmouse(0, 0, 0, 0, 0) else: curses.mousemask(0) +def _in_tmux(): + return (os.environ.get("TMUX", "") + and 'tmux' in get_executables()) + + +def _in_screen(): + return ('screen' in os.environ.get("TERM", "") + and 'screen' in get_executables()) + + class UI( # pylint: disable=too-many-instance-attributes,too-many-public-methods DisplayableContainer): ALLOWED_VIEWMODES = 'miller', 'multipane' @@ -73,8 +84,7 @@ class UI( # pylint: disable=too-many-instance-attributes,too-many-public-method self.multiplexer = None self._draw_title = None self._tmux_automatic_rename = None - self._tmux_title = None - self._screen_title = None + self._multiplexer_title = None self.browser = None if fm is not None: @@ -229,7 +239,7 @@ class UI( # pylint: disable=too-many-instance-attributes,too-many-public-method key = self.win.getch() if key == curses.KEY_ENTER: key = ord('\n') - if key == 27 or (key >= 128 and key < 256): + if key == 27 or (128 <= key < 256): # Handle special keys like ALT+X or unicode here: keys = [key] previous_load_mode = self.load_mode @@ -469,58 +479,63 @@ class UI( # pylint: disable=too-many-instance-attributes,too-many-public-method # Handles window renaming behaviour of the terminal multiplexers # GNU Screen and Tmux def handle_multiplexer(self): - if self.settings.update_tmux_title: - if 'TMUX' in os.environ: - # Stores the automatic-rename setting - # prints out a warning if the allow-rename in tmux is not set - tmux_allow_rename = check_output( - ['tmux', 'show-window-options', '-v', - 'allow-rename']).strip() - if tmux_allow_rename == 'off': - self.fm.notify('Warning: allow-rename not set in Tmux!', - bad=True) - elif self._tmux_title is None: - self._tmux_title = check_output( - ['tmux', 'display-message', '-p', '#W']).strip() - else: + if (self.settings.update_tmux_title and not self._multiplexer_title): + try: + if _in_tmux(): + # Stores the automatic-rename setting + # prints out a warning if allow-rename isn't set in tmux try: + tmux_allow_rename = check_output( + ['tmux', 'show-window-options', '-v', + 'allow-rename']).strip() + except CalledProcessError: + tmux_allow_rename = 'off' + if tmux_allow_rename == 'off': + self.fm.notify('Warning: allow-rename not set in Tmux!', + bad=True) + else: + self._multiplexer_title = check_output( + ['tmux', 'display-message', '-p', '#W']).strip() self._tmux_automatic_rename = check_output( ['tmux', 'show-window-options', '-v', 'automatic-rename']).strip() if self._tmux_automatic_rename == 'on': check_output(['tmux', 'set-window-option', 'automatic-rename', 'off']) - except CalledProcessError: - pass - elif 'screen' in os.environ['TERM'] and self._screen_title is None: - # Stores the screen window name before renaming it - # gives out a warning if $TERM is not "screen" - try: - self._screen_title = check_output( + elif _in_screen(): + # Stores the screen window name before renaming it + # gives out a warning if $TERM is not "screen" + self._multiplexer_title = check_output( ['screen', '-Q', 'title']).strip() - except CalledProcessError: - self._screen_title = None + except CalledProcessError: + self.fm.notify("Couldn't access previous multiplexer window" + " name, won't be able to restore.", + bad=False) + if not self._multiplexer_title: + self._multiplexer_title = os.path.basename( + os.environ.get("SHELL", "shell")) sys.stdout.write("\033kranger\033\\") sys.stdout.flush() # Restore window name def restore_multiplexer_name(self): - try: - if 'TMUX' in os.environ: - if self._tmux_automatic_rename: - check_output(['tmux', 'set-window-option', - 'automatic-rename', - self._tmux_automatic_rename]) - else: - check_output(['tmux', 'set-window-option', '-u', - 'automatic-rename']) - if self._tmux_title: - check_output(['tmux', 'rename-window', self._tmux_title]) - elif 'screen' in os.environ['TERM'] and self._screen_title: - check_output(['screen', '-X', 'title', self._screen_title]) - except CalledProcessError: - self.fm.notify("Could not restore window-name!", bad=True) + if self._multiplexer_title: + try: + if _in_tmux(): + if self._tmux_automatic_rename: + check_output(['tmux', 'set-window-option', + 'automatic-rename', + self._tmux_automatic_rename]) + else: + check_output(['tmux', 'set-window-option', '-u', + 'automatic-rename']) + except CalledProcessError: + self.fm.notify("Could not restore multiplexer window name!", + bad=True) + + sys.stdout.write("\033k{0}\033\\".format(self._multiplexer_title)) + sys.stdout.flush() def hint(self, text=None): self.status.hint = text diff --git a/ranger/gui/widgets/browsercolumn.py b/ranger/gui/widgets/browsercolumn.py index 1412ef6a..54149e0c 100644 --- a/ranger/gui/widgets/browsercolumn.py +++ b/ranger/gui/widgets/browsercolumn.py @@ -10,12 +10,6 @@ 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 @@ -99,7 +93,10 @@ class BrowserColumn(Pager): # pylint: disable=too-many-instance-attributes self.fm.thisdir.move_to_obj(clicked_file) self.fm.execute_file(clicked_file) elif self.target.is_file: - self.scrollbit(direction) + if event.pressed(3): + self.fm.execute_file(self.target) + else: + self.scrollbit(direction) else: if self.level > 0 and not direction: self.fm.move(right=0) @@ -215,7 +212,7 @@ class BrowserColumn(Pager): # pylint: disable=too-many-instance-attributes def _format_line_number(self, linum_format, i, selected_i): line_number = i - if self.settings.line_numbers == 'relative': + if self.settings.line_numbers.lower() == 'relative': line_number = abs(selected_i - i) if not self.settings.relative_current_zero and line_number == 0: if self.settings.one_indexed: @@ -276,14 +273,29 @@ class BrowserColumn(Pager): # pylint: disable=too-many-instance-attributes copied = [f.path for f in self.fm.copy_buffer] + selected_i = self._get_index_of_selected_file() + # Set the size of the linum text field to the number of digits in the # visible files in directory. - linum_text_len = len(str(self.scroll_begin + self.hei)) + def nr_of_digits(number): + return len(str(number)) + + scroll_end = self.scroll_begin + min(self.hei, len(self.target)) - 1 + distance_to_top = selected_i - self.scroll_begin + distance_to_bottom = scroll_end - selected_i + one_indexed_offset = 1 if self.settings.one_indexed else 0 + + if self.settings.line_numbers.lower() == "relative": + linum_text_len = nr_of_digits(max(distance_to_top, + distance_to_bottom)) + if not self.settings.relative_current_zero: + linum_text_len = max(nr_of_digits(selected_i + + one_indexed_offset), + linum_text_len) + else: + linum_text_len = nr_of_digits(scroll_end + one_indexed_offset) linum_format = "{0:>" + str(linum_text_len) + "}" - # add separator between line number and tag - linum_format += " " - selected_i = self._get_index_of_selected_file() for line in range(self.hei): i = line + self.scroll_begin @@ -312,12 +324,15 @@ class BrowserColumn(Pager): # pylint: disable=too-many-instance-attributes drawn.path in copied, tagged_marker, drawn.infostring, drawn.vcsstatus, drawn.vcsremotestatus, self.target.has_vcschild, self.fm.do_cut, current_linemode.name, metakey, active_pane, - self.settings.line_numbers) + self.settings.line_numbers.lower(), linum_text_len) # Check if current line has not already computed and cached if key in drawn.display_data: # Recompute line numbers because they can't be reliably cached. - if self.main_column and self.settings.line_numbers != 'false': + if ( + self.main_column + and self.settings.line_numbers.lower() != 'false' + ): line_number_text = self._format_line_number(linum_format, i, selected_i) @@ -342,17 +357,19 @@ class BrowserColumn(Pager): # pylint: disable=too-many-instance-attributes space = self.wid # line number field - if self.settings.line_numbers != 'false': + if self.settings.line_numbers.lower() != 'false': if self.main_column and space - linum_text_len > 2: line_number_text = self._format_line_number(linum_format, i, selected_i) - predisplay_left.append([line_number_text, []]) + predisplay_left.append([line_number_text, ['line_number']]) space -= linum_text_len # Delete one additional character for space separator # between the line number and the tag space -= 1 + # add separator between line number and tag + predisplay_left.append([' ', []]) # selection mark tagmark = self._draw_tagged_display(tagged, tagged_marker) @@ -374,15 +391,16 @@ class BrowserColumn(Pager): # pylint: disable=too-many-instance-attributes try: infostringdata = current_linemode.infostring(drawn, metadata) if infostringdata: - infostring.append([" " + infostringdata + " ", + infostring.append([" " + infostringdata, ["infostring"]]) except NotImplementedError: infostring = self._draw_infostring_display(drawn, space) if infostring: infostringlen = self._total_len(infostring) if space - infostringlen > 2: - predisplay_right = infostring + predisplay_right - space -= infostringlen + sep = [[" ", []]] if predisplay_right else [] + predisplay_right = infostring + sep + predisplay_right + space -= infostringlen + len(sep) textstring = self._draw_text_display(text, space) textstringlen = self._total_len(textstring) @@ -413,7 +431,7 @@ class BrowserColumn(Pager): # pylint: disable=too-many-instance-attributes self.color_reset() def _get_index_of_selected_file(self): - if self.fm.ui.viewmode == 'multipane' and self.tab: + if self.fm.ui.viewmode == 'multipane' and self.tab != self.fm.thistab: return self.tab.pointer return self.target.pointer @@ -421,13 +439,8 @@ 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): - bidi_text = self._bidi_transpose(text) + bidi_text = self.bidi_transpose(text) wtext = WideString(bidi_text) wext = WideString(splitext(bidi_text)[1]) wellip = WideString(self.ellipsis[self.settings.unicode_ellipsis]) @@ -453,7 +466,7 @@ class BrowserColumn(Pager): # pylint: disable=too-many-instance-attributes infostring_display = [] if self.display_infostring and drawn.infostring \ and self.settings.display_size_in_main_column: - infostring = str(drawn.infostring) + " " + infostring = str(drawn.infostring) if len(infostring) <= space: infostring_display.append([infostring, ['infostring']]) return infostring_display @@ -539,7 +552,7 @@ class BrowserColumn(Pager): # pylint: disable=too-many-instance-attributes self.target.scroll_begin = dirsize - winsize return self._get_scroll_begin() - if projected < upper_limit and projected > lower_limit: + if lower_limit < projected < upper_limit: return original if projected > upper_limit: diff --git a/ranger/gui/widgets/console.py b/ranger/gui/widgets/console.py index d796058c..f6d3461f 100644 --- a/ranger/gui/widgets/console.py +++ b/ranger/gui/widgets/console.py @@ -9,7 +9,9 @@ import curses import os import re from collections import deque +from io import open +from ranger import PY3 from ranger.gui.widgets import Widget from ranger.ext.direction import Direction from ranger.ext.widestring import uwid, WideString @@ -43,17 +45,20 @@ class Console(Widget): # pylint: disable=too-many-instance-attributes,too-many- self.historypath = self.fm.datapath('history') if os.path.exists(self.historypath): try: - fobj = open(self.historypath, 'r') - except OSError as ex: - self.fm.notify('Failed to read history file', bad=True, exception=ex) - else: - try: - for line in fobj: - self.history.add(line[:-1]) - except UnicodeDecodeError as ex: - self.fm.notify('Failed to parse corrupt history file', - bad=True, exception=ex) - fobj.close() + with open(self.historypath, "r", encoding="utf-8") as fobj: + try: + for line in fobj: + self.history.add(line[:-1]) + except UnicodeDecodeError as ex: + self.fm.notify( + "Failed to parse corrupt history file", + bad=True, + exception=ex, + ) + except (OSError, IOError) as ex: + self.fm.notify( + "Failed to read history file", bad=True, exception=ex + ) self.history_backup = History(self.history) # NOTE: the console is considered in the "question mode" when the @@ -75,16 +80,16 @@ class Console(Widget): # pylint: disable=too-many-instance-attributes,too-many- return if self.historypath: try: - fobj = open(self.historypath, 'w') - except OSError as ex: - self.fm.notify('Failed to write history file', bad=True, exception=ex) - else: - for entry in self.history_backup: - try: - fobj.write(entry + '\n') - except UnicodeEncodeError: - pass - fobj.close() + with open(self.historypath, 'w', encoding="utf-8") as fobj: + for entry in self.history_backup: + try: + fobj.write(entry + '\n') + except UnicodeEncodeError: + pass + except (OSError, IOError) as ex: + self.fm.notify( + "Failed to write history file", bad=True, exception=ex + ) Widget.destroy(self) def _calculate_offset(self): @@ -216,7 +221,8 @@ class Console(Widget): # pylint: disable=too-many-instance-attributes,too-many- self.unicode_buffer, self.line, self.pos = result self.on_line_change() - def _add_character(self, key, unicode_buffer, line, pos): + @staticmethod + def _add_character(key, unicode_buffer, line, pos): # Takes the pressed key, a string "unicode_buffer" containing a # potentially incomplete unicode character, the current line and the # position of the cursor inside the line. @@ -228,7 +234,7 @@ class Console(Widget): # pylint: disable=too-many-instance-attributes,too-many- except ValueError: return unicode_buffer, line, pos - if self.fm.py3: + if PY3: if len(unicode_buffer) >= 4: unicode_buffer = "" if ord(key) in range(0, 256): @@ -280,7 +286,7 @@ class Console(Widget): # pylint: disable=too-many-instance-attributes,too-many- direction = Direction(keywords) if direction.horizontal(): # Ensure that the pointer is moved utf-char-wise - if self.fm.py3: + if PY3: if self.question_queue: umax = len(self.question_queue[0][0]) + 1 - self.wid else: @@ -317,13 +323,13 @@ class Console(Widget): # pylint: disable=too-many-instance-attributes,too-many- """ Returns a new position by moving word-wise in the line - >>> import sys - >>> if sys.version_info < (3, ): + >>> from ranger import PY3 + >>> if PY3: + ... line = "\\u30AA\\u30CF\\u30E8\\u30A6 world, this is dog" + ... else: ... # Didn't get the unicode test to work on python2, even though ... # it works fine in ranger, even with unicode input... ... line = "ohai world, this is dog" - ... else: - ... line = "\\u30AA\\u30CF\\u30E8\\u30A6 world, this is dog" >>> Console.move_by_word(line, 0, -1) 0 >>> Console.move_by_word(line, 0, 1) @@ -425,7 +431,7 @@ class Console(Widget): # pylint: disable=too-many-instance-attributes,too-many- self.close(trigger_cancel_function=False) return # Delete utf-char-wise - if self.fm.py3: + if PY3: left_part = self.line[:self.pos + mod] self.pos = len(left_part) self.line = left_part + self.line[self.pos + 1:] @@ -437,6 +443,78 @@ class Console(Widget): # pylint: disable=too-many-instance-attributes,too-many- self.line = left_part + ''.join(uchar[upos + 1:]).encode('utf-8', 'ignore') self.on_line_change() + def transpose_subr(self, line, x, y): + # Transpose substrings x & y of line + # x & y are tuples of length two containing positions of endpoints + if not 0 <= x[0] < x[1] <= y[0] < y[1] <= len(line): + self.fm.notify("Tried to transpose invalid regions.", bad=True) + return line + + line_begin = line[:x[0]] + word_x = line[x[0]:x[1]] + line_middle = line[x[1]:y[0]] + word_y = line[y[0]:y[1]] + line_end = line[y[1]:] + + line = line_begin + word_y + line_middle + word_x + line_end + return line + + def transpose_chars(self): + if self.pos == 0: + return + elif self.pos == len(self.line): + x = max(0, self.pos - 2), max(0, self.pos - 1) + y = max(0, self.pos - 1), self.pos + else: + x = max(0, self.pos - 1), self.pos + y = self.pos, min(len(self.line), self.pos + 1) + self.line = self.transpose_subr(self.line, x, y) + self.pos = y[1] + self.on_line_change() + + def transpose_words(self): + # Interchange adjacent words at the console with Alt-t + # like in Emacs and many terminal emulators + if self.line: + # If before the first word, interchange next two words + if not re.search(r'[\w\d]', self.line[:self.pos], re.UNICODE): + self.pos = self.move_by_word(self.line, self.pos, 1) + + # If in/after last word, interchange last two words + if (re.match(r'[\w\d]*\s*$', self.line[self.pos:], re.UNICODE) + and (re.match(r'[\w\d]', self.line[self.pos - 1], re.UNICODE) + if self.pos - 1 >= 0 else True)): + self.pos = self.move_by_word(self.line, self.pos, -1) + + # Util function to increment position until out of word/whitespace + def _traverse(line, pos, regex): + while pos < len(line) and re.match( + regex, line[pos], re.UNICODE): + pos += 1 + return pos + + # Calculate endpoints of target words and pass them to + # 'self.transpose_subr' + x_begin = self.move_by_word(self.line, self.pos, -1) + x_end = _traverse(self.line, x_begin, r'[\w\d]') + x = x_begin, x_end + + y_begin = self.pos + + # If in middle of word, move to end + if re.match(r'[\w\d]', self.line[self.pos - 1], re.UNICODE): + y_begin = _traverse(self.line, y_begin, r'[\w\d]') + + # Traverse whitespace to beginning of next word + y_begin = _traverse(self.line, y_begin, r'\s') + + y_end = _traverse(self.line, y_begin, r'[\w\d]') + y = y_begin, y_end + + self.line = self.transpose_subr(self.line, x, y) + self.pos = y[1] + self.on_line_change() + def execute(self, cmd=None): if self.question_queue and cmd is None: question = self.question_queue[0] diff --git a/ranger/gui/widgets/statusbar.py b/ranger/gui/widgets/statusbar.py index fd44613e..4873c07a 100644 --- a/ranger/gui/widgets/statusbar.py +++ b/ranger/gui/widgets/statusbar.py @@ -42,6 +42,8 @@ class StatusBar(Widget): # pylint: disable=too-many-instance-attributes self.column = column self.settings.signal_bind('setopt.display_size_in_status_bar', self.request_redraw, weak=True) + self.fm.signal_bind('tab.layoutchange', self.request_redraw, weak=True) + self.fm.signal_bind('setop.viewmode', self.request_redraw, weak=True) def request_redraw(self): self.need_redraw = True @@ -52,9 +54,13 @@ class StatusBar(Widget): # pylint: disable=too-many-instance-attributes def clear_message(self): self.msg = None - def draw(self): + def draw(self): # pylint: disable=too-many-branches """Draw the statusbar""" + if self.column != self.fm.ui.browser.main_column: + self.column = self.fm.ui.browser.main_column + self.need_redraw = True + if self.hint and isinstance(self.hint, str): if self.old_hint != self.hint: self.need_redraw = True diff --git a/ranger/gui/widgets/titlebar.py b/ranger/gui/widgets/titlebar.py index 765c1248..e9eb1b8b 100644 --- a/ranger/gui/widgets/titlebar.py +++ b/ranger/gui/widgets/titlebar.py @@ -114,12 +114,14 @@ class TitleBar(Widget): else: clr = 'directory' - bar.add(path.basename, clr, directory=path) + bidi_basename = self.bidi_transpose(path.basename) + bar.add(bidi_basename, clr, directory=path) bar.add('/', clr, fixed=True, directory=path) if self.fm.thisfile is not None and \ self.settings.show_selection_in_titlebar: - bar.add(self.fm.thisfile.relative_path, 'file') + bidi_file_path = self.bidi_transpose(self.fm.thisfile.relative_path) + bar.add(bidi_file_path, 'file') def _get_right_part(self, bar): # TODO: fix that pressed keys are cut off when chaining CTRL keys diff --git a/ranger/gui/widgets/view_multipane.py b/ranger/gui/widgets/view_multipane.py index 9661d31e..cef8d9a8 100644 --- a/ranger/gui/widgets/view_multipane.py +++ b/ranger/gui/widgets/view_multipane.py @@ -3,6 +3,7 @@ from __future__ import (absolute_import, division, print_function) +import curses from ranger.gui.widgets.view_base import ViewBase from ranger.gui.widgets.browsercolumn import BrowserColumn @@ -16,6 +17,17 @@ class ViewMultipane(ViewBase): # pylint: disable=too-many-ancestors self.fm.signal_bind('tab.change', self._tabchange_handler) self.rebuild() + self.old_draw_borders = self._draw_borders_setting() + + def _draw_borders_setting(self): + # If draw_borders_multipane has not been set, it defaults to `None` + # and we fallback to using draw_borders. Important to note: + # `None` is different from the string "none" referring to no borders + if self.settings.draw_borders_multipane is not None: + return self.settings.draw_borders_multipane + else: + return self.settings.draw_borders + def _layoutchange_handler(self): if self.fm.ui.browser == self: self.rebuild() @@ -43,13 +55,101 @@ class ViewMultipane(ViewBase): # pylint: disable=too-many-ancestors self.add_child(column) self.resize(self.y, self.x, self.hei, self.wid) + def draw(self): + if self.need_clear: + self.win.erase() + self.need_redraw = True + self.need_clear = False + + ViewBase.draw(self) + + if self._draw_borders_setting(): + draw_borders = self._draw_borders_setting() + 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: + self._draw_hints() + elif self.draw_info: + self._draw_info(self.draw_info) + + def _draw_border_rectangle(self, left_start, right_end): + win = self.win + 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) + win.vline(1, right_end, curses.ACS_VLINE, self.hei - 2) + # Draw the four corners + 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) + + def _draw_borders(self, border_types): + # Referenced from ranger.gui.widgets.view_miller + win = self.win + self.color('in_browser', 'border') + + left_start = 0 + right_end = self.wid - 1 + + # Draw the outline borders + if 'active-pane' not in border_types: + if 'outline' in border_types: + try: + self._draw_border_rectangle(left_start, right_end) + except curses.error: + pass + + # Draw the column separators + if 'separators' in border_types: + for child in self.columns[:-1]: + x = child.x + child.wid + y = self.hei - 1 + try: + 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) + except curses.error: + pass + else: + bordered_column = self.main_column + left_start = max(bordered_column.x, 0) + right_end = min(left_start + bordered_column.wid, self.wid - 1) + try: + self._draw_border_rectangle(left_start, right_end) + except curses.error: + pass + def resize(self, y, x, hei=None, wid=None): ViewBase.resize(self, y, x, hei, wid) + + border_type = self._draw_borders_setting() + if border_type in ['outline', 'both', 'true', 'active-pane']: + # 'true' for backwards compat., no height pad needed for 'separators' + pad = 1 + else: + pad = 0 column_width = int((wid - len(self.columns) + 1) / len(self.columns)) left = 0 top = 0 for column in self.columns: - column.resize(top, left, hei, max(1, column_width)) + column.resize(top + pad, left, hei - pad * 2, max(1, column_width)) left += column_width + 1 column.need_redraw = True self.need_redraw = True + + def poke(self): + ViewBase.poke(self) + + if self.old_draw_borders != self._draw_borders_setting(): + self.resize(self.y, self.x, self.hei, self.wid) + self.old_draw_borders = self._draw_borders_setting() |